Added PageComponents

This commit is contained in:
2026-09-30 16:20:40 +02:00
parent c6023cf857
commit e57e2da09d
37 changed files with 1408 additions and 62 deletions

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://dave-dmg.de/sitemap.xml

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
---
import { STAGE_OPTIONS, type Stage } from '@/lib/constants/stage'
type props = {
type Props = {
stage: Stage
}

View File

@@ -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 {

View 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>

View 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>

View 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>

View 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: []
---

View File

@@ -0,0 +1,7 @@
---
title: Colophon
status: draft
noindex: false
route: {}
summary: {}
---

View File

@@ -0,0 +1,7 @@
---
title: Privacy Policy
status: draft
noindex: false
route: {}
summary: {}
---

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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
View 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
View 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)
}

View File

@@ -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
View 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' } })
}

View File

@@ -1,5 +1,6 @@
@layer tokens {
:root {
:root,
.light {
/* === PALETTE === */
/* == Ink == */

View File

@@ -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);

View File

@@ -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. */

View File

@@ -1,3 +1,4 @@
@import url("prose/index.css");
@import url("blocks/index.css");
@import url("post/index.css");
@import url("site/index.css");

View 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;
}
}

View 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);
}
}
}

View File

@@ -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 {

View File

@@ -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");

View 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;
}
}

View 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;
}
}
}

View File

@@ -0,0 +1,3 @@
@import url("site-header.css");
@import url("finder.css");
@import url("site-footer.css");

View 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);
}
}
}

View 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);
}
}
}

View File

@@ -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%);
}

View File

@@ -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);
}