Blog #1
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://dave-dmg.de/sitemap.xml
|
||||
@@ -9,15 +9,18 @@ type Props = {
|
||||
}
|
||||
|
||||
const { articles, title } = Astro.props
|
||||
const level = title ? 3 : 2
|
||||
---
|
||||
|
||||
{articles.length > 0 && (
|
||||
<section class="cards">
|
||||
{title && <h2 class="title">{title}</h2>}
|
||||
<div class="grid">
|
||||
<section class="article-card-grid">
|
||||
{title && <h2 class="article-card-grid__title">{title}</h2>}
|
||||
<ul class="article-card-grid__list" role="list">
|
||||
{articles.map((article) => (
|
||||
<ArticleCardItem article={article} />
|
||||
<li class="article-card-grid__item">
|
||||
<ArticleCardItem article={article} level={level} />
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -5,27 +5,37 @@ import type { Article } from '@/lib/site/types'
|
||||
|
||||
type Props = {
|
||||
article: Article
|
||||
level?: 2 | 3
|
||||
}
|
||||
|
||||
const { article } = Astro.props
|
||||
|
||||
const { article, level = 2 } = Astro.props
|
||||
const { cover, excerpt } = article.summary
|
||||
const date = article.publishedAt
|
||||
const Heading = level === 2 ? 'h2' : 'h3'
|
||||
---
|
||||
|
||||
<article class="card">
|
||||
<a class="link" href={article.path}>
|
||||
{cover.image && (
|
||||
<div class="cover">
|
||||
<ContentImage src={cover.image} alt={cover.alt} fit />
|
||||
</div>
|
||||
)}
|
||||
<h3 class="title">{article.title}</h3>
|
||||
</a>
|
||||
{excerpt && <p class="excerpt">{excerpt}</p>}
|
||||
{date && (
|
||||
<time class="date" datetime={date.toISOString()}>
|
||||
{toMilitaryDTG(date)}
|
||||
</time>
|
||||
<article class:list={['article-card', cover.effect !== 'none' && `effect--${cover.effect}`]}>
|
||||
{cover.image && (
|
||||
<div class="article-card__cover">
|
||||
<ContentImage
|
||||
src={cover.image}
|
||||
alt=""
|
||||
widths={[400, 800, 1200]}
|
||||
sizes="(min-width: 80rem) 24rem, (min-width: 48rem) 50vw, 100vw"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div class="article-card__body">
|
||||
<Heading class="article-card__title">
|
||||
<a class="article-card__link" href={article.path}>
|
||||
{article.title}
|
||||
</a>
|
||||
</Heading>
|
||||
{excerpt && <p class="article-card__excerpt">{excerpt}</p>}
|
||||
{date && (
|
||||
<time class="article-card__date" datetime={date.toISOString()}>
|
||||
{toMilitaryDTG(date)}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
---
|
||||
type Props = {
|
||||
title: string
|
||||
/** Small label above the title: "Page", "Tag". */
|
||||
eyebrow?: string
|
||||
standfirst?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
const { title, eyebrow, standfirst } = Astro.props
|
||||
const { title, eyebrow, standfirst, count } = Astro.props
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
|
||||
<h1 class="title">{title}</h1>
|
||||
{standfirst && <p class="standfirst">{standfirst}</p>}
|
||||
<slot />
|
||||
<header class="simple-header dark">
|
||||
<div class="simple-header__inner">
|
||||
<div class="simple-header__text">
|
||||
{eyebrow && <p class="simple-header__eyebrow">{eyebrow}</p>}
|
||||
<h1 class="simple-header__title">{title}</h1>
|
||||
{standfirst && <p class="simple-header__standfirst">{standfirst}</p>}
|
||||
</div>
|
||||
{count !== undefined && (
|
||||
<p class="simple-header__count">
|
||||
<span aria-hidden="true">{String(count).padStart(2, '0')}</span>
|
||||
<span class="simple-header__hidden">
|
||||
{count} {count === 1 ? 'entry' : 'entries'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import { STAGE_OPTIONS, type Stage } from '@/lib/constants/stage'
|
||||
|
||||
type props = {
|
||||
type Props = {
|
||||
stage: Stage
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const entries = headings.filter((heading) => heading.level <= level)
|
||||
@mixin border-t var(--border-card), solid, var(--color-border-strong);
|
||||
counter-reset: toc;
|
||||
position: sticky;
|
||||
top: var(--size-10);
|
||||
top: calc(var(--layout-header-height) + var(--size-10));
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
140
src/components/site/Finder.astro
Normal file
140
src/components/site/Finder.astro
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
import { COLLECTION_LABELS } from '@/lib/constants/collections'
|
||||
import { toRoman } from '@/lib/content/numerals'
|
||||
import { getSite } from '@/lib/site'
|
||||
import { contextOf } from '@/lib/site/navigation'
|
||||
import type { Article } from '@/lib/site/types'
|
||||
|
||||
type Props = {
|
||||
article?: Article
|
||||
}
|
||||
|
||||
const { article } = Astro.props
|
||||
const site = await getSite()
|
||||
const groups = contextOf(article ?? null, site.roots)
|
||||
const count = groups.reduce((sum, group) => sum + group.entries.length, 0)
|
||||
---
|
||||
|
||||
<site-finder class="finder">
|
||||
<button
|
||||
class="finder__trigger light"
|
||||
type="button"
|
||||
commandfor="finder"
|
||||
command="show-modal"
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span class="finder__prompt" aria-hidden="true">
|
||||
❯
|
||||
</span>
|
||||
<span class="finder__placeholder">Navigate…</span>
|
||||
<kbd class="finder__key" data-finder-shortcut>
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<dialog id="finder" class="finder__dialog" aria-labelledby="finder-title" closedby="any">
|
||||
<div class="finder__bar">
|
||||
<h2 id="finder-title" class="finder__title">
|
||||
Register<span class="finder__crumb">Query</span>
|
||||
</h2>
|
||||
<button
|
||||
class="finder__close"
|
||||
type="button"
|
||||
commandfor="finder"
|
||||
command="close"
|
||||
aria-label="Close"
|
||||
>
|
||||
Esc
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="finder__field">
|
||||
<span class="finder__prompt" aria-hidden="true">
|
||||
❯
|
||||
</span>
|
||||
<input
|
||||
class="finder__input"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-label="Search articles"
|
||||
aria-controls="finder-results"
|
||||
aria-expanded="true"
|
||||
aria-autocomplete="list"
|
||||
placeholder="Where do you want to go?"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-finder-input
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="finder__status">
|
||||
<p class="finder__scope" data-finder-scope>
|
||||
<span data-finder-mode>Context</span>
|
||||
<span class="finder__crumb" data-finder-subject>
|
||||
{article?.title ?? 'Index'}
|
||||
</span>
|
||||
</p>
|
||||
<p class="finder__count" data-finder-count>
|
||||
{String(count).padStart(2, '0')} entries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="finder-results" class="finder__results" role="listbox" aria-label="Articles">
|
||||
<div data-finder-context>
|
||||
{groups.map((group, g) => {
|
||||
const heading = group.heading?.title ?? 'Index'
|
||||
return (
|
||||
<div class="finder__group" role="group" aria-label={heading}>
|
||||
<p class="finder__heading" aria-hidden="true">
|
||||
<span class="finder__section">§ {toRoman(g + 1)}</span>
|
||||
{heading}
|
||||
</p>
|
||||
{group.entries.map((entry, i) => (
|
||||
<a
|
||||
id={`finder-option-${g}-${i}`}
|
||||
class="finder__row"
|
||||
href={entry.path}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<span class="finder__type">{COLLECTION_LABELS[entry.collection]}</span>
|
||||
<span class="finder__label">{entry.title}</span>
|
||||
<span class="finder__path">{entry.path}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div data-finder-matches hidden></div>
|
||||
<p class="finder__empty" data-finder-empty hidden>
|
||||
No entries found
|
||||
</p>
|
||||
|
||||
<template data-finder-row>
|
||||
<a class="finder__row" role="option" tabindex="-1">
|
||||
<span class="finder__type" data-field="type"></span>
|
||||
<span class="finder__label" data-field="label"></span>
|
||||
<span class="finder__path" data-field="path"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="finder__footer" aria-hidden="true">
|
||||
<span class="finder__hint">
|
||||
<kbd>↑</kbd>
|
||||
<kbd>↓</kbd> Move
|
||||
</span>
|
||||
<span class="finder__hint">
|
||||
<kbd>↵</kbd> Open
|
||||
</span>
|
||||
<span class="finder__hint">
|
||||
<kbd data-finder-shortcut>⌘K</kbd> Toggle
|
||||
</span>
|
||||
</div>
|
||||
</dialog>
|
||||
</site-finder>
|
||||
<script>
|
||||
import '@/lib/finder/element'
|
||||
</script>
|
||||
39
src/components/site/SiteFooter.astro
Normal file
39
src/components/site/SiteFooter.astro
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
import { toMilitaryDTG } from '@/lib/content/dates'
|
||||
import { getSite } from '@/lib/site'
|
||||
|
||||
const LINKED_PAGES = ['imprint', 'privacy-policy', 'colophon']
|
||||
|
||||
const site = await getSite()
|
||||
const release = site.releases[0]
|
||||
const pages = LINKED_PAGES.map((id) => site.pages.get(id)).filter((page) => page !== undefined)
|
||||
const year = new Date().getUTCFullYear()
|
||||
---
|
||||
|
||||
<footer class="site-footer dark">
|
||||
<div class="site-footer__inner">
|
||||
<p class="site-footer__copyright">© {year} Dave Damage</p>
|
||||
{release && (
|
||||
<p class="site-footer__release">
|
||||
<span class="site-footer__version">v{release.version}</span>
|
||||
<time datetime={release.date.toISOString()}>{toMilitaryDTG(release.date)}</time>
|
||||
</p>
|
||||
)}
|
||||
<nav class="site-footer__nav" aria-label="Site">
|
||||
<ul class="site-footer__links">
|
||||
{pages.map((page) => (
|
||||
<li>
|
||||
<a class="site-footer__link" href={page.path}>
|
||||
{page.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<a class="site-footer__link site-footer__top" href="#top">
|
||||
Top
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
26
src/components/site/SiteHeader.astro
Normal file
26
src/components/site/SiteHeader.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import type { Article } from '@/lib/site/types'
|
||||
|
||||
import Finder from './Finder.astro'
|
||||
|
||||
type Props = {
|
||||
article?: Article
|
||||
}
|
||||
|
||||
const { article } = Astro.props
|
||||
---
|
||||
|
||||
<header class="site-header dark">
|
||||
<div class="site-header__inner">
|
||||
<a class="site-header__home" href="/" aria-label="dave dmg, home">
|
||||
<span class="site-header__logo" aria-hidden="true">
|
||||
◬
|
||||
</span>
|
||||
<span class="site-header__name">
|
||||
dave<span class="site-header__bracket">[</span>dmg
|
||||
<span class="site-header__bracket">]</span>
|
||||
</span>
|
||||
</a>
|
||||
<Finder article={article} />
|
||||
</div>
|
||||
</header>
|
||||
15
src/content/general/articles/chainbreaker.mdoc
Normal file
15
src/content/general/articles/chainbreaker.mdoc
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Chainbreaker
|
||||
subtitle: We're gonna break down the walls
|
||||
status: draft
|
||||
noindex: false
|
||||
stage: stub
|
||||
route:
|
||||
sequence: false
|
||||
summary:
|
||||
cover:
|
||||
effect: none
|
||||
ratio: original
|
||||
published: 2026-09-30T14:27:00.000Z
|
||||
tags: []
|
||||
---
|
||||
7
src/content/general/pages/colophon.mdoc
Normal file
7
src/content/general/pages/colophon.mdoc
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Colophon
|
||||
status: draft
|
||||
noindex: false
|
||||
route: {}
|
||||
summary: {}
|
||||
---
|
||||
7
src/content/general/pages/privacy-policy.mdoc
Normal file
7
src/content/general/pages/privacy-policy.mdoc
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Privacy Policy
|
||||
status: draft
|
||||
noindex: false
|
||||
route: {}
|
||||
summary: {}
|
||||
---
|
||||
@@ -1,8 +1,12 @@
|
||||
---
|
||||
import { Font } from 'astro:assets'
|
||||
|
||||
import type { Article } from '@/lib/site/types'
|
||||
|
||||
import '@/styles/global.css'
|
||||
|
||||
import SiteFooter from '@/components/site/SiteFooter.astro'
|
||||
import SiteHeader from '@/components/site/SiteHeader.astro'
|
||||
import { resolveImage } from '@/lib/images/resolve'
|
||||
|
||||
interface Props {
|
||||
@@ -10,9 +14,10 @@ interface Props {
|
||||
description?: string
|
||||
image?: string
|
||||
noindex?: boolean
|
||||
article?: Article
|
||||
}
|
||||
|
||||
const { title, description, image, noindex } = Astro.props
|
||||
const { title, description, image, noindex, article } = Astro.props
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site)
|
||||
const preview = image ? resolveImage(image) : undefined
|
||||
const previewUrl = preview ? new URL(preview.src, Astro.site) : undefined
|
||||
@@ -47,6 +52,10 @@ const previewUrl = preview ? new URL(preview.src, Astro.site) : undefined
|
||||
</style>
|
||||
</head>
|
||||
<body class="ui">
|
||||
<slot />
|
||||
<SiteHeader article={article} />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,7 +21,7 @@ const meta = metaOf(article)
|
||||
const sequence = sequenceOf(article)
|
||||
---
|
||||
|
||||
<BaseLayout {...meta}>
|
||||
<BaseLayout {...meta} article={article}>
|
||||
<article class="article">
|
||||
<ArticleHeader article={article} />
|
||||
<div class="frame">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import DevBanner from '@/components/dev/DevBanner.astro'
|
||||
import Node from '@/components/markdoc/Node.astro'
|
||||
import SimpleHeader from '@/components/post/Header/SimpleHeader.astro'
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro'
|
||||
import type { Rendered } from '@/lib/markdoc/types'
|
||||
import { metaOf } from '@/lib/site/meta'
|
||||
@@ -23,9 +24,33 @@ const meta = metaOf(page)
|
||||
diagnostics={rendered.diagnostics}
|
||||
/>
|
||||
<article class="page">
|
||||
<h1>{page.title}</h1>
|
||||
<div class="prose">
|
||||
<Node node={rendered.tree} />
|
||||
<SimpleHeader eyebrow="Page" title={page.title} standfirst={page.subtitle || undefined} />
|
||||
<div class="frame">
|
||||
<div class="body content prose">
|
||||
<Node node={rendered.tree} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.frame {
|
||||
@mixin layout-wrapper;
|
||||
@mixin my var(--size-12);
|
||||
|
||||
@media (--bp-desktop) {
|
||||
@mixin layout-columns;
|
||||
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
|
||||
@media (--bp-desktop) {
|
||||
grid-column: body-start / body-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,17 +8,15 @@ import type { Tag } from '@/lib/site/types'
|
||||
type Props = { tag: Tag }
|
||||
|
||||
const { tag } = Astro.props
|
||||
const count = tag.articles.length
|
||||
---
|
||||
|
||||
<BaseLayout title={`Tagged: ${tag.title}`} description={tag.description || undefined}>
|
||||
<DevBanner collection={tag.collection} id={tag.id} />
|
||||
<section class="tag">
|
||||
<SimpleHeader eyebrow="Tag" title={tag.title} standfirst={tag.description || undefined}>
|
||||
<p class="count">
|
||||
{count} {count === 1 ? 'article' : 'articles'}
|
||||
</p>
|
||||
</SimpleHeader>
|
||||
<ArticleCardGrid articles={tag.articles} />
|
||||
</section>
|
||||
<SimpleHeader
|
||||
eyebrow="Tag"
|
||||
title={tag.title}
|
||||
standfirst={tag.description || undefined}
|
||||
count={tag.articles.length}
|
||||
/>
|
||||
<ArticleCardGrid articles={tag.articles} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,5 +4,11 @@ export const ARTICLES = 'articles'
|
||||
export const PAGES = 'pages'
|
||||
export const TAGS = 'tags'
|
||||
|
||||
export const COLLECTION_LABELS = {
|
||||
[ARTICLES]: 'Article',
|
||||
[PAGES]: 'Page',
|
||||
[TAGS]: 'Tag',
|
||||
} as const
|
||||
|
||||
/* === SITE COLLECTIONS === */
|
||||
export const RELEASES = 'releases'
|
||||
|
||||
@@ -19,18 +19,21 @@ async function readAll<T>(
|
||||
collection: string,
|
||||
problems: Diagnostics,
|
||||
): Promise<Map<string, T>> {
|
||||
const entries = new Map<string, T>()
|
||||
await Promise.all(
|
||||
(await reader.list()).map(async (id) => {
|
||||
const ids = (await reader.list()).sort()
|
||||
const entries = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
const entry = await reader.read(id)
|
||||
if (entry) entries.set(id, entry)
|
||||
return [id, await reader.read(id)] as const
|
||||
} catch (error) {
|
||||
problems.error(collection, id, reason(error))
|
||||
return [id, null] as const
|
||||
}
|
||||
}),
|
||||
)
|
||||
return entries
|
||||
|
||||
const map = new Map<string, T>()
|
||||
for (const [id, entry] of entries) if (entry) map.set(id, entry)
|
||||
return map
|
||||
}
|
||||
|
||||
export async function loadContent(): Promise<Content> {
|
||||
|
||||
191
src/lib/finder/element.ts
Normal file
191
src/lib/finder/element.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { ARTICLES, COLLECTION_LABELS } from '@/lib/constants/collections'
|
||||
import { prepare, search, type Prepared } from '@/lib/finder/search'
|
||||
import type { DirectoryEntry } from '@/lib/site/types'
|
||||
|
||||
const LIMIT = 30
|
||||
const INDEX_URL = '/api/site-index.json'
|
||||
|
||||
const pick = <T extends Element>(root: ParentNode, selector: string): T => {
|
||||
const element = root.querySelector<T>(selector)
|
||||
if (!element) throw new Error(`Finder: missing ${selector}`)
|
||||
return element
|
||||
}
|
||||
|
||||
const countOf = (total: number) => `${String(total).padStart(2, '0')} entries`
|
||||
|
||||
class SiteFinder extends HTMLElement {
|
||||
#dialog!: HTMLDialogElement
|
||||
#input!: HTMLInputElement
|
||||
#results!: HTMLElement
|
||||
#context!: HTMLElement
|
||||
#matches!: HTMLElement
|
||||
#empty!: HTMLElement
|
||||
#mode!: HTMLElement
|
||||
#subject!: HTMLElement
|
||||
#count!: HTMLElement
|
||||
#row!: HTMLTemplateElement
|
||||
|
||||
#index: Promise<Prepared<DirectoryEntry>[]> | null = null
|
||||
#active = -1
|
||||
#initial = { subject: '', count: '' }
|
||||
|
||||
connectedCallback() {
|
||||
this.#dialog = pick(this, 'dialog')
|
||||
this.#input = pick(this, '[data-finder-input]')
|
||||
this.#results = pick(this, '[role="listbox"]')
|
||||
this.#context = pick(this, '[data-finder-context]')
|
||||
this.#matches = pick(this, '[data-finder-matches]')
|
||||
this.#empty = pick(this, '[data-finder-empty]')
|
||||
this.#mode = pick(this, '[data-finder-mode]')
|
||||
this.#subject = pick(this, '[data-finder-subject]')
|
||||
this.#count = pick(this, '[data-finder-count]')
|
||||
this.#row = pick(this, 'template[data-finder-row]')
|
||||
this.#initial = {
|
||||
subject: this.#subject.textContent ?? '',
|
||||
count: this.#count.textContent ?? '',
|
||||
}
|
||||
|
||||
if (!/Mac|iPhone|iPad/.test(navigator.userAgent))
|
||||
for (const key of this.querySelectorAll('[data-finder-shortcut]'))
|
||||
key.textContent = 'Ctrl K'
|
||||
|
||||
document.addEventListener('keydown', this.#onShortcut)
|
||||
this.#input.addEventListener('input', this.#update)
|
||||
this.#input.addEventListener('keydown', this.#onKey)
|
||||
this.#results.addEventListener('pointermove', this.#onPointer)
|
||||
this.#dialog.addEventListener('click', this.#onBackdrop)
|
||||
this.#dialog.addEventListener('close', this.#reset)
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('keydown', this.#onShortcut)
|
||||
}
|
||||
|
||||
#onShortcut = (event: KeyboardEvent) => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return
|
||||
event.preventDefault()
|
||||
if (this.#dialog.open) this.#dialog.close()
|
||||
else this.#dialog.showModal()
|
||||
}
|
||||
|
||||
#onBackdrop = (event: MouseEvent) => {
|
||||
if (event.target !== this.#dialog) return
|
||||
const box = this.#dialog.getBoundingClientRect()
|
||||
const inside =
|
||||
event.clientX >= box.left &&
|
||||
event.clientX <= box.right &&
|
||||
event.clientY >= box.top &&
|
||||
event.clientY <= box.bottom
|
||||
if (!inside) this.#dialog.close()
|
||||
}
|
||||
|
||||
#onKey = (event: KeyboardEvent) => {
|
||||
const options = this.#options()
|
||||
if (!options.length) return
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
this.#select((this.#active + 1) % options.length)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
this.#select(this.#active <= 0 ? options.length - 1 : this.#active - 1)
|
||||
} else if (event.key === 'Enter') {
|
||||
const option =
|
||||
options[this.#active] ?? (this.#input.value.trim() ? options[0] : undefined)
|
||||
if (!option) return
|
||||
event.preventDefault()
|
||||
option.click()
|
||||
}
|
||||
}
|
||||
|
||||
#onPointer = (event: PointerEvent) => {
|
||||
const option = (event.target as Element).closest<HTMLAnchorElement>('[role="option"]')
|
||||
if (!option) return
|
||||
const index = this.#options().indexOf(option)
|
||||
if (index !== this.#active) this.#select(index, false)
|
||||
}
|
||||
|
||||
#update = async () => {
|
||||
const query = this.#input.value.trim()
|
||||
this.#select(-1)
|
||||
|
||||
if (!query) {
|
||||
this.#show(false)
|
||||
this.#mode.textContent = 'Context'
|
||||
this.#subject.textContent = this.#initial.subject
|
||||
this.#count.textContent = this.#initial.count
|
||||
return
|
||||
}
|
||||
|
||||
const items = await this.#load()
|
||||
if (this.#input.value.trim() !== query) return
|
||||
|
||||
const found = search(items, query, LIMIT)
|
||||
this.#matches.replaceChildren(...found.map((entry, i) => this.#render(entry, i)))
|
||||
this.#empty.hidden = found.length > 0
|
||||
this.#show(true)
|
||||
this.#mode.textContent = 'Query'
|
||||
this.#subject.textContent = query
|
||||
this.#count.textContent = countOf(found.length)
|
||||
}
|
||||
|
||||
#reset = () => {
|
||||
this.#input.value = ''
|
||||
void this.#update()
|
||||
}
|
||||
|
||||
#load() {
|
||||
this.#index ??= fetch(INDEX_URL)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(response.statusText)
|
||||
return response.json() as Promise<DirectoryEntry[]>
|
||||
})
|
||||
.then((entries) => prepare(entries.filter((entry) => entry.collection === ARTICLES)))
|
||||
.catch(() => {
|
||||
this.#index = null
|
||||
return []
|
||||
})
|
||||
return this.#index
|
||||
}
|
||||
|
||||
#show(searching: boolean) {
|
||||
this.#context.hidden = searching
|
||||
this.#matches.hidden = !searching
|
||||
if (!searching) this.#empty.hidden = true
|
||||
}
|
||||
|
||||
#options(): HTMLAnchorElement[] {
|
||||
const visible = this.#matches.hidden ? this.#context : this.#matches
|
||||
return [...visible.querySelectorAll<HTMLAnchorElement>('[role="option"]')]
|
||||
}
|
||||
|
||||
#select(index: number, scroll = true) {
|
||||
for (const option of this.#results.querySelectorAll('[aria-selected]'))
|
||||
option.removeAttribute('aria-selected')
|
||||
|
||||
this.#active = index
|
||||
const option = this.#options()[index]
|
||||
if (!option) {
|
||||
this.#input.removeAttribute('aria-activedescendant')
|
||||
return
|
||||
}
|
||||
|
||||
option.setAttribute('aria-selected', 'true')
|
||||
this.#input.setAttribute('aria-activedescendant', option.id)
|
||||
if (scroll) option.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
#render(entry: DirectoryEntry, index: number) {
|
||||
const row = pick<HTMLAnchorElement>(this.#row.content, 'a').cloneNode(
|
||||
true,
|
||||
) as HTMLAnchorElement
|
||||
row.id = `finder-match-${index}`
|
||||
row.href = entry.path
|
||||
pick(row, '[data-field="type"]').textContent = COLLECTION_LABELS[entry.collection]
|
||||
pick(row, '[data-field="label"]').textContent = entry.title
|
||||
pick(row, '[data-field="path"]').textContent = entry.path
|
||||
return row
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('site-finder')) customElements.define('site-finder', SiteFinder)
|
||||
61
src/lib/finder/search.ts
Normal file
61
src/lib/finder/search.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { slugify } from '@/lib/content/slug'
|
||||
|
||||
type Searchable = {
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type Prepared<T extends Searchable> = {
|
||||
entry: T
|
||||
title: string
|
||||
words: string[]
|
||||
path: string
|
||||
}
|
||||
|
||||
const termsOf = (text: string) => slugify(text).split('-').filter(Boolean)
|
||||
|
||||
export function prepare<T extends Searchable>(entries: T[]): Prepared<T>[] {
|
||||
return entries.map((entry) => ({
|
||||
entry,
|
||||
title: slugify(entry.title),
|
||||
words: termsOf(entry.title),
|
||||
path: entry.path.toLowerCase(),
|
||||
}))
|
||||
}
|
||||
|
||||
function scoreTerm(item: Prepared<Searchable>, term: string): number {
|
||||
if (item.words.includes(term)) return 80
|
||||
if (item.words.some((word) => word.startsWith(term))) return 60
|
||||
if (item.title.includes(term)) return 40
|
||||
if (item.path.includes(term)) return 10
|
||||
return 0
|
||||
}
|
||||
|
||||
export function search<T extends Searchable>(
|
||||
items: Prepared<T>[],
|
||||
query: string,
|
||||
limit: number,
|
||||
): T[] {
|
||||
const terms = termsOf(query)
|
||||
if (!terms.length) return []
|
||||
const exact = terms.join('-')
|
||||
|
||||
const scored: { entry: T; score: number }[] = []
|
||||
for (const item of items) {
|
||||
let score = item.title === exact ? 100 : 0
|
||||
for (const term of terms) {
|
||||
const points = scoreTerm(item, term)
|
||||
if (!points) {
|
||||
score = 0
|
||||
break
|
||||
}
|
||||
score += points
|
||||
}
|
||||
if (score) scored.push({ entry: item.entry, score })
|
||||
}
|
||||
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title))
|
||||
.slice(0, limit)
|
||||
.map(({ entry }) => entry)
|
||||
}
|
||||
@@ -30,3 +30,23 @@ export function trailOf(article: Article): Article[] {
|
||||
for (let node: Article | null = article; node; node = node.parent) trail.unshift(node)
|
||||
return trail
|
||||
}
|
||||
|
||||
export type ContextGroup = {
|
||||
heading: Article | null
|
||||
entries: Article[]
|
||||
}
|
||||
|
||||
export function contextOf(article: Article | null, roots: Article[]): ContextGroup[] {
|
||||
if (!article) return [{ heading: null, entries: roots }]
|
||||
|
||||
const { parent } = article
|
||||
const siblings = (parent ? parent.children : roots).filter((entry) => entry !== article)
|
||||
|
||||
const groups: ContextGroup[] = [
|
||||
{ heading: parent?.parent ?? null, entries: parent?.listed ? [parent] : [] },
|
||||
{ heading: parent, entries: siblings },
|
||||
{ heading: article, entries: article.children },
|
||||
]
|
||||
|
||||
return groups.filter((group) => group.entries.length > 0)
|
||||
}
|
||||
|
||||
39
src/pages/sitemap.xml.ts
Normal file
39
src/pages/sitemap.xml.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
import { ARTICLES } from '@/lib/constants/collections'
|
||||
import { isoDate } from '@/lib/content/dates'
|
||||
import { getSite } from '@/lib/site'
|
||||
import type { Routed } from '@/lib/site/types'
|
||||
|
||||
type Url = {
|
||||
path: string
|
||||
modified: Date | null
|
||||
}
|
||||
|
||||
const modifiedOf = (node: Routed): Date | null =>
|
||||
node.collection === ARTICLES ? (node.changes[0]?.release.date ?? node.publishedAt) : null
|
||||
|
||||
export const GET: APIRoute = async ({ site: origin }) => {
|
||||
if (!origin) throw new Error('sitemap.xml needs `site` in astro.config.mjs')
|
||||
|
||||
const site = await getSite()
|
||||
const urls: Url[] = [
|
||||
{ path: '/', modified: null },
|
||||
...[...site.byPath.values()]
|
||||
.filter((node) => node.status === 'published' && !node.noindex)
|
||||
.map((node) => ({ path: node.path, modified: modifiedOf(node) })),
|
||||
...[...site.tags.values()].map((tag) => ({ path: tag.path, modified: null })),
|
||||
]
|
||||
|
||||
const body = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...urls.map(({ path, modified }) => {
|
||||
const lastmod = modified ? `<lastmod>${isoDate(modified)}</lastmod>` : ''
|
||||
return ` <url><loc>${new URL(path, origin)}</loc>${lastmod}</url>`
|
||||
}),
|
||||
'</urlset>',
|
||||
].join('\n')
|
||||
|
||||
return new Response(body, { headers: { 'Content-Type': 'application/xml' } })
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@layer tokens {
|
||||
:root {
|
||||
:root,
|
||||
.light {
|
||||
/* === PALETTE === */
|
||||
|
||||
/* == Ink == */
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
@mixin typo_body-small;
|
||||
|
||||
counter-increment: fn;
|
||||
scroll-margin-block-start: calc(var(--layout-header-height) + var(--size-8));
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--footnote-marker) minmax(0, 1fr);
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
@mixin pt var(--size-3);
|
||||
@mixin border-t var(--border-card), solid, var(--color-border-strong);
|
||||
|
||||
scroll-margin-block-start: calc(var(--layout-header-height) + var(--size-8));
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
/* Hangs, so the first line starts against the marker. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import url("prose/index.css");
|
||||
@import url("blocks/index.css");
|
||||
@import url("post/index.css");
|
||||
@import url("site/index.css");
|
||||
|
||||
34
src/styles/components/post/article-card-grid.css
Normal file
34
src/styles/components/post/article-card-grid.css
Normal file
@@ -0,0 +1,34 @@
|
||||
@layer components.blocks {
|
||||
.article-card-grid {
|
||||
@mixin layout-wrapper;
|
||||
@mixin my var(--size-12);
|
||||
}
|
||||
|
||||
.article-card-grid__title {
|
||||
@mixin typo_heading-2;
|
||||
@mixin mt var(--size-0);
|
||||
@mixin mb var(--size-8);
|
||||
}
|
||||
|
||||
.article-card-grid__list {
|
||||
@mixin ma var(--size-0);
|
||||
@mixin pa var(--size-0);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--size-10);
|
||||
list-style: none;
|
||||
|
||||
@media (--bp-tablet-up) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (--bp-desktop) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.article-card-grid__item {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
84
src/styles/components/post/article-card.css
Normal file
84
src/styles/components/post/article-card.css
Normal file
@@ -0,0 +1,84 @@
|
||||
@layer components.blocks {
|
||||
.article-card {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border: var(--border-card) solid var(--color-border-strong);
|
||||
|
||||
background-color: var(--color-surface-elevated-1);
|
||||
box-shadow: var(--shadow-card);
|
||||
|
||||
transition: box-shadow var(--motion-fast) var(--ease-out);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
&:has(.article-card__link:focus-visible) {
|
||||
@mixin focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.article-card__cover {
|
||||
@mixin border-b var(--border-card), solid, var(--color-border-strong);
|
||||
|
||||
& img {
|
||||
display: block;
|
||||
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.article-card__body {
|
||||
@mixin pa var(--size-9);
|
||||
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--size-6);
|
||||
}
|
||||
|
||||
.article-card__title {
|
||||
@mixin typo_heading-2;
|
||||
@mixin ma var(--size-0);
|
||||
}
|
||||
|
||||
.article-card__link {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.article-card__excerpt {
|
||||
@mixin typo_body-small;
|
||||
@mixin ma var(--size-0);
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.article-card__date {
|
||||
@mixin typo_tag;
|
||||
@mixin mt auto;
|
||||
@mixin pt var(--size-5);
|
||||
@mixin border-t var(--border-hairline), solid, var(--color-border-subtle);
|
||||
|
||||
&::before {
|
||||
content: "❯" / "";
|
||||
margin-inline-end: var(--size-3);
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text-quiet);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,15 +50,10 @@
|
||||
}
|
||||
|
||||
.article-header__subtitle {
|
||||
@mixin typo_standfirst;
|
||||
@mixin ma var(--size-0);
|
||||
|
||||
font-family: var(--font-header);
|
||||
font-size: var(--type-1);
|
||||
font-weight: var(--font-weight-body);
|
||||
line-height: var(--leading-1);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-subtitle);
|
||||
}
|
||||
|
||||
.article-header__meta {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@import url("article-card.css");
|
||||
@import url("article-card-grid.css");
|
||||
@import url("article-header.css");
|
||||
@import url("stage-badge.css");
|
||||
@import url("article-footer.css");
|
||||
@import url("sequence-nav.css");
|
||||
@import url("revision-card.css");
|
||||
@import url("sequence-nav.css");
|
||||
@import url("simple-header.css");
|
||||
@import url("stage-badge.css");
|
||||
|
||||
87
src/styles/components/post/simple-header.css
Normal file
87
src/styles/components/post/simple-header.css
Normal file
@@ -0,0 +1,87 @@
|
||||
@layer components.blocks {
|
||||
.simple-header {
|
||||
@mixin border-t var(--border-hairline), solid, var(--color-border-subtle);
|
||||
@mixin border-b var(--border-hairline), solid, var(--color-border-normal);
|
||||
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-surface-base);
|
||||
}
|
||||
|
||||
.simple-header__inner {
|
||||
@mixin layout-wrapper;
|
||||
@mixin py var(--size-14);
|
||||
|
||||
display: grid;
|
||||
gap: var(--size-10);
|
||||
|
||||
@media (--bp-desktop) {
|
||||
@mixin layout-columns;
|
||||
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.simple-header__text {
|
||||
@media (--bp-desktop) {
|
||||
grid-column: body-start / margin-end;
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.simple-header__eyebrow {
|
||||
@mixin typo_label;
|
||||
@mixin ma var(--size-0);
|
||||
|
||||
color: var(--color-text-quiet);
|
||||
}
|
||||
|
||||
.simple-header__title {
|
||||
@mixin mt var(--size-6);
|
||||
@mixin mb var(--size-0);
|
||||
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(var(--type-9), 6vw, var(--size-15));
|
||||
font-weight: var(--font-weight-display);
|
||||
line-height: 1;
|
||||
letter-spacing: var(--tracking-display);
|
||||
}
|
||||
|
||||
.simple-header__standfirst {
|
||||
@mixin typo_standfirst;
|
||||
@mixin mt var(--size-8);
|
||||
@mixin mb var(--size-0);
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.simple-header__count {
|
||||
@mixin ma var(--size-0);
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
order: -1;
|
||||
|
||||
aspect-ratio: 1;
|
||||
width: var(--size-14);
|
||||
|
||||
font-family: var(--font-header);
|
||||
font-size: var(--type-7);
|
||||
font-weight: var(--font-weight-heading);
|
||||
line-height: 1;
|
||||
color: var(--color-text-on-accent);
|
||||
|
||||
background-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-raised);
|
||||
|
||||
@media (--bp-desktop) {
|
||||
grid-column: rail-start / rail-end;
|
||||
grid-row: 1;
|
||||
width: var(--size-16);
|
||||
font-size: var(--type-10);
|
||||
}
|
||||
}
|
||||
|
||||
.simple-header__hidden {
|
||||
@mixin visually-hidden;
|
||||
}
|
||||
}
|
||||
318
src/styles/components/site/finder.css
Normal file
318
src/styles/components/site/finder.css
Normal file
@@ -0,0 +1,318 @@
|
||||
@layer components.blocks {
|
||||
.finder {
|
||||
display: block;
|
||||
flex: 0 1 22rem;
|
||||
min-width: 0;
|
||||
|
||||
& kbd {
|
||||
@mixin typo_tag;
|
||||
@mixin py var(--size-1);
|
||||
@mixin px var(--size-3);
|
||||
|
||||
color: var(--color-text-inverse);
|
||||
background-color: var(--color-surface-inverse);
|
||||
}
|
||||
}
|
||||
|
||||
/* === TRIGGER === */
|
||||
|
||||
.finder__trigger {
|
||||
@mixin py var(--size-3);
|
||||
@mixin ps var(--size-5);
|
||||
@mixin pe var(--size-3);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: var(--size-5);
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
border: var(--border-hairline) solid var(--color-border-subtle);
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type-0);
|
||||
line-height: 1;
|
||||
color: var(--color-text-quiet);
|
||||
|
||||
background-color: var(--color-surface-elevated-1);
|
||||
|
||||
transition:
|
||||
color var(--motion-fast) var(--ease-out),
|
||||
border-color var(--motion-fast) var(--ease-out);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-normal);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@mixin focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.finder__prompt {
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.finder__placeholder {
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.finder__key {
|
||||
@media (--bp-mobile) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* === DIALOG === */
|
||||
|
||||
.finder__dialog {
|
||||
@mixin pa var(--size-0);
|
||||
|
||||
width: min(40rem, 100% - 2 * var(--layout-page-margin));
|
||||
max-width: none;
|
||||
max-height: min(40rem, 76dvh);
|
||||
margin: 12dvh auto auto;
|
||||
border: var(--border-card) solid var(--color-border-strong);
|
||||
|
||||
color: var(--color-text-primary);
|
||||
|
||||
background-color: var(--color-surface-elevated-1);
|
||||
box-shadow: var(--size-4) var(--size-4) 0 var(--color-primary);
|
||||
|
||||
&::backdrop {
|
||||
background-color: var(--color-overlay-heavy);
|
||||
}
|
||||
|
||||
&[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.finder__bar {
|
||||
@mixin py var(--size-4);
|
||||
@mixin px var(--size-8);
|
||||
|
||||
display: flex;
|
||||
gap: var(--size-6);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
background-color: var(--color-surface-inverse);
|
||||
}
|
||||
|
||||
.finder__title {
|
||||
@mixin typo_eyebrow;
|
||||
@mixin ma var(--size-0);
|
||||
|
||||
font-weight: var(--font-weight-emphasis);
|
||||
}
|
||||
|
||||
.finder__crumb {
|
||||
&::before {
|
||||
content: "⟫" / "";
|
||||
margin-inline: var(--size-2);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
}
|
||||
|
||||
.finder__close {
|
||||
@mixin typo_tag;
|
||||
@mixin py var(--size-1);
|
||||
@mixin px var(--size-3);
|
||||
|
||||
cursor: pointer;
|
||||
border: var(--border-hairline) solid currentcolor;
|
||||
|
||||
&:focus-visible {
|
||||
@mixin focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.finder__field {
|
||||
@mixin px var(--size-8);
|
||||
@mixin border-b var(--border-card), solid, var(--color-border-strong);
|
||||
|
||||
display: flex;
|
||||
gap: var(--size-6);
|
||||
align-items: center;
|
||||
|
||||
& .finder__prompt {
|
||||
font-size: var(--type-3);
|
||||
}
|
||||
}
|
||||
|
||||
.finder__input {
|
||||
@mixin py var(--size-8);
|
||||
|
||||
flex: 1;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type-3);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
background: transparent;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-quiet);
|
||||
}
|
||||
}
|
||||
|
||||
.finder__status {
|
||||
@mixin py var(--size-5);
|
||||
@mixin px var(--size-8);
|
||||
@mixin border-b var(--border-hairline), solid, var(--color-border-normal);
|
||||
|
||||
display: flex;
|
||||
gap: var(--size-6);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.finder__scope,
|
||||
.finder__count {
|
||||
@mixin typo_eyebrow;
|
||||
@mixin ma var(--size-0);
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* === RESULTS === */
|
||||
|
||||
.finder__results {
|
||||
@mixin px var(--size-8);
|
||||
@mixin pb var(--size-6);
|
||||
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.finder__heading {
|
||||
@mixin typo_eyebrow;
|
||||
@mixin mt var(--size-8);
|
||||
@mixin mb var(--size-0);
|
||||
@mixin pb var(--size-3);
|
||||
@mixin border-b var(--border-card), solid, var(--color-border-strong);
|
||||
|
||||
display: flex;
|
||||
gap: var(--size-6);
|
||||
font-weight: var(--font-weight-emphasis);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.finder__section {
|
||||
font-weight: var(--font-weight-body);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.finder__row {
|
||||
@mixin mx calc(-1 * var(--size-8));
|
||||
@mixin py var(--size-6);
|
||||
@mixin px var(--size-8);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--size-8);
|
||||
align-items: center;
|
||||
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&::after {
|
||||
content: "❯" / "";
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text-quiet);
|
||||
}
|
||||
|
||||
&[aria-selected="true"] {
|
||||
color: var(--color-text-inverse);
|
||||
background-color: var(--color-surface-inverse);
|
||||
|
||||
&::after {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
& .finder__type,
|
||||
& .finder__path {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
@media (--bp-tablet-up) {
|
||||
grid-template-columns: 6rem minmax(0, 1fr) minmax(0, 14rem) auto;
|
||||
}
|
||||
}
|
||||
|
||||
.finder__type {
|
||||
@mixin typo_eyebrow;
|
||||
|
||||
color: var(--color-text-quiet);
|
||||
}
|
||||
|
||||
.finder__label {
|
||||
@mixin typo_heading-3;
|
||||
}
|
||||
|
||||
.finder__path {
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type--1);
|
||||
color: var(--color-text-quiet);
|
||||
text-align: end;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (--bp-tablet-up) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.finder__empty {
|
||||
@mixin typo_eyebrow;
|
||||
@mixin ma var(--size-0);
|
||||
@mixin py var(--size-12);
|
||||
@mixin px var(--size-8);
|
||||
|
||||
color: var(--color-text-quiet);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* === FOOTER === */
|
||||
|
||||
.finder__footer {
|
||||
@mixin typo_eyebrow;
|
||||
@mixin py var(--size-5);
|
||||
@mixin px var(--size-8);
|
||||
@mixin border-t var(--border-card), solid, var(--color-border-strong);
|
||||
|
||||
display: flex;
|
||||
gap: var(--size-8);
|
||||
align-items: center;
|
||||
color: var(--color-text-primary);
|
||||
|
||||
@media (--bp-mobile) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.finder__hint {
|
||||
display: inline-flex;
|
||||
gap: var(--size-2);
|
||||
align-items: center;
|
||||
|
||||
&:last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/styles/components/site/index.css
Normal file
3
src/styles/components/site/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import url("site-header.css");
|
||||
@import url("finder.css");
|
||||
@import url("site-footer.css");
|
||||
72
src/styles/components/site/site-footer.css
Normal file
72
src/styles/components/site/site-footer.css
Normal file
@@ -0,0 +1,72 @@
|
||||
@layer components.blocks {
|
||||
.site-footer {
|
||||
@mixin mt var(--size-16);
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
background-color: var(--color-surface-base);
|
||||
}
|
||||
|
||||
.site-footer__inner {
|
||||
@mixin layout-wrapper;
|
||||
@mixin typo_eyebrow;
|
||||
@mixin py var(--size-8);
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-6) var(--layout-gutter);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.site-footer__copyright,
|
||||
.site-footer__release {
|
||||
@mixin ma var(--size-0);
|
||||
}
|
||||
|
||||
.site-footer__version {
|
||||
font-weight: var(--font-weight-emphasis);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&::after {
|
||||
content: "·" / "";
|
||||
margin-inline: var(--size-3);
|
||||
font-weight: var(--font-weight-body);
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer__links {
|
||||
@mixin ma var(--size-0);
|
||||
@mixin pa var(--size-0);
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-2) var(--size-3);
|
||||
list-style: none;
|
||||
|
||||
& li + li {
|
||||
&::before {
|
||||
content: "·" / "";
|
||||
margin-inline-end: var(--size-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer__link {
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@mixin focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer__top {
|
||||
&::before {
|
||||
content: "↑" / "";
|
||||
margin-inline-end: var(--size-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/styles/components/site/site-header.css
Normal file
111
src/styles/components/site/site-header.css
Normal file
@@ -0,0 +1,111 @@
|
||||
@layer components.blocks {
|
||||
html {
|
||||
scroll-padding-block-start: calc(var(--layout-header-height) + var(--size-8));
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
|
||||
color: var(--color-text-primary);
|
||||
|
||||
background-color: var(--color-surface-base);
|
||||
}
|
||||
|
||||
.site-header__inner {
|
||||
@mixin layout-wrapper;
|
||||
|
||||
display: flex;
|
||||
gap: var(--layout-gutter);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
height: var(--layout-header-height);
|
||||
}
|
||||
|
||||
.site-header__home {
|
||||
display: inline-flex;
|
||||
gap: var(--size-4);
|
||||
align-items: center;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type-1);
|
||||
line-height: var(--leading-1);
|
||||
|
||||
&:focus-visible {
|
||||
@mixin focus-ring;
|
||||
}
|
||||
}
|
||||
|
||||
.site-header__logo {
|
||||
display: inline-block;
|
||||
font-family: var(--font-body);
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation:
|
||||
site-header-pulse 5s cubic-bezier(0.4, 0, 0.6, 1) infinite,
|
||||
site-header-glitch 13s step-end infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.site-header__bracket {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
@keyframes site-header-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
25%,
|
||||
75% {
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.33;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes site-header-glitch {
|
||||
0%,
|
||||
84%,
|
||||
91%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
85% {
|
||||
transform: translate(-2px, 0);
|
||||
filter: brightness(0.4);
|
||||
}
|
||||
|
||||
86% {
|
||||
transform: translate(1px, -1px);
|
||||
filter: brightness(0.2);
|
||||
}
|
||||
|
||||
87% {
|
||||
transform: translate(-1px, 1px);
|
||||
filter: brightness(0.7);
|
||||
}
|
||||
|
||||
88% {
|
||||
transform: translate(2px, 0);
|
||||
filter: brightness(0.1);
|
||||
}
|
||||
|
||||
89% {
|
||||
transform: translate(-2px, 1px);
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate(0, -1px);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,3 +23,19 @@
|
||||
[body-end margin-start] var(--layout-margin-width) [margin-end];
|
||||
column-gap: var(--layout-gutter);
|
||||
}
|
||||
|
||||
@define-mixin visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
white-space: nowrap;
|
||||
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
|
||||
@@ -117,3 +117,12 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-tag);
|
||||
}
|
||||
|
||||
@define-mixin typo_standfirst {
|
||||
font-family: var(--font-header);
|
||||
font-size: var(--type-1);
|
||||
font-weight: var(--font-weight-body);
|
||||
line-height: var(--leading-1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-subtitle);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user