From e57e2da09d9664ccb36b87354aab623e467ba9e4 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 30 Sep 2026 16:20:40 +0200 Subject: [PATCH] Added PageComponents --- public/robots.txt | 4 + .../post/ArticleCard/ArticleCardGrid.astro | 13 +- .../post/ArticleCard/ArticleCardItem.astro | 42 ++- src/components/post/Header/SimpleHeader.astro | 25 +- src/components/post/Header/StageBadge.astro | 2 +- src/components/post/ToC.astro | 2 +- src/components/site/Finder.astro | 140 ++++++++ src/components/site/SiteFooter.astro | 39 +++ src/components/site/SiteHeader.astro | 26 ++ .../general/articles/chainbreaker.mdoc | 15 + src/content/general/pages/colophon.mdoc | 7 + src/content/general/pages/privacy-policy.mdoc | 7 + src/layouts/BaseLayout.astro | 13 +- src/layouts/general/ArticleLayout.astro | 2 +- src/layouts/general/PageLayout.astro | 31 +- src/layouts/general/TagLayout.astro | 16 +- src/lib/constants/collections.ts | 6 + src/lib/content/load.ts | 15 +- src/lib/finder/element.ts | 191 +++++++++++ src/lib/finder/search.ts | 61 ++++ src/lib/site/navigation.ts | 20 ++ src/pages/sitemap.xml.ts | 39 +++ src/styles/base/colors.css | 3 +- src/styles/components/blocks/footnote.css | 1 - src/styles/components/blocks/sidenote.css | 1 - src/styles/components/index.css | 1 + .../components/post/article-card-grid.css | 34 ++ src/styles/components/post/article-card.css | 84 +++++ src/styles/components/post/article-header.css | 7 +- src/styles/components/post/index.css | 7 +- src/styles/components/post/simple-header.css | 87 +++++ src/styles/components/site/finder.css | 318 ++++++++++++++++++ src/styles/components/site/index.css | 3 + src/styles/components/site/site-footer.css | 72 ++++ src/styles/components/site/site-header.css | 111 ++++++ src/styles/mixins/helpers.css | 16 + src/styles/mixins/typography.css | 9 + 37 files changed, 1408 insertions(+), 62 deletions(-) create mode 100644 public/robots.txt create mode 100644 src/components/site/Finder.astro create mode 100644 src/components/site/SiteFooter.astro create mode 100644 src/components/site/SiteHeader.astro create mode 100644 src/content/general/articles/chainbreaker.mdoc create mode 100644 src/content/general/pages/colophon.mdoc create mode 100644 src/content/general/pages/privacy-policy.mdoc create mode 100644 src/lib/finder/element.ts create mode 100644 src/lib/finder/search.ts create mode 100644 src/pages/sitemap.xml.ts create mode 100644 src/styles/components/post/article-card-grid.css create mode 100644 src/styles/components/post/article-card.css create mode 100644 src/styles/components/post/simple-header.css create mode 100644 src/styles/components/site/finder.css create mode 100644 src/styles/components/site/index.css create mode 100644 src/styles/components/site/site-footer.css create mode 100644 src/styles/components/site/site-header.css diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..995d64d --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://dave-dmg.de/sitemap.xml diff --git a/src/components/post/ArticleCard/ArticleCardGrid.astro b/src/components/post/ArticleCard/ArticleCardGrid.astro index bc8f4d0..9494644 100644 --- a/src/components/post/ArticleCard/ArticleCardGrid.astro +++ b/src/components/post/ArticleCard/ArticleCardGrid.astro @@ -9,15 +9,18 @@ type Props = { } const { articles, title } = Astro.props +const level = title ? 3 : 2 --- {articles.length > 0 && ( -
- {title &&

{title}

} -
+
+ {title &&

{title}

} +
    {articles.map((article) => ( - +
  • + +
  • ))} -
+
)} diff --git a/src/components/post/ArticleCard/ArticleCardItem.astro b/src/components/post/ArticleCard/ArticleCardItem.astro index b583838..c7527a7 100644 --- a/src/components/post/ArticleCard/ArticleCardItem.astro +++ b/src/components/post/ArticleCard/ArticleCardItem.astro @@ -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' --- -
- - {cover.image && ( -
- -
- )} -

{article.title}

-
- {excerpt &&

{excerpt}

} - {date && ( - +
+ {cover.image && ( +
+ +
)} +
+ + + {article.title} + + + {excerpt &&

{excerpt}

} + {date && ( + + )} +
diff --git a/src/components/post/Header/SimpleHeader.astro b/src/components/post/Header/SimpleHeader.astro index b19d0c1..e4f8049 100644 --- a/src/components/post/Header/SimpleHeader.astro +++ b/src/components/post/Header/SimpleHeader.astro @@ -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 --- -
- {eyebrow &&

{eyebrow}

} -

{title}

- {standfirst &&

{standfirst}

} - +
+
+
+ {eyebrow &&

{eyebrow}

} +

{title}

+ {standfirst &&

{standfirst}

} +
+ {count !== undefined && ( +

+ + + {count} {count === 1 ? 'entry' : 'entries'} + +

+ )} +
diff --git a/src/components/post/Header/StageBadge.astro b/src/components/post/Header/StageBadge.astro index 95837fa..1daed1d 100644 --- a/src/components/post/Header/StageBadge.astro +++ b/src/components/post/Header/StageBadge.astro @@ -1,7 +1,7 @@ --- import { STAGE_OPTIONS, type Stage } from '@/lib/constants/stage' -type props = { +type Props = { stage: Stage } diff --git a/src/components/post/ToC.astro b/src/components/post/ToC.astro index 58a6f95..ffcba8a 100644 --- a/src/components/post/ToC.astro +++ b/src/components/post/ToC.astro @@ -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 { diff --git a/src/components/site/Finder.astro b/src/components/site/Finder.astro new file mode 100644 index 0000000..659c63f --- /dev/null +++ b/src/components/site/Finder.astro @@ -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) +--- + + + + + +
+

+ RegisterQuery +

+ +
+ +
+ + +
+ +
+

+ Context + + {article?.title ?? 'Index'} + +

+

+ {String(count).padStart(2, '0')} entries +

+
+ +
+
+ {groups.map((group, g) => { + const heading = group.heading?.title ?? 'Index' + return ( +
+ + {group.entries.map((entry, i) => ( + + {COLLECTION_LABELS[entry.collection]} + {entry.title} + {entry.path} + + ))} +
+ ) + })} +
+ + + + +
+ + +
+
+ diff --git a/src/components/site/SiteFooter.astro b/src/components/site/SiteFooter.astro new file mode 100644 index 0000000..60834d1 --- /dev/null +++ b/src/components/site/SiteFooter.astro @@ -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() +--- + +
+ +
diff --git a/src/components/site/SiteHeader.astro b/src/components/site/SiteHeader.astro new file mode 100644 index 0000000..b508f23 --- /dev/null +++ b/src/components/site/SiteHeader.astro @@ -0,0 +1,26 @@ +--- +import type { Article } from '@/lib/site/types' + +import Finder from './Finder.astro' + +type Props = { + article?: Article +} + +const { article } = Astro.props +--- + + diff --git a/src/content/general/articles/chainbreaker.mdoc b/src/content/general/articles/chainbreaker.mdoc new file mode 100644 index 0000000..7b3aafd --- /dev/null +++ b/src/content/general/articles/chainbreaker.mdoc @@ -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: [] +--- diff --git a/src/content/general/pages/colophon.mdoc b/src/content/general/pages/colophon.mdoc new file mode 100644 index 0000000..0ecd882 --- /dev/null +++ b/src/content/general/pages/colophon.mdoc @@ -0,0 +1,7 @@ +--- +title: Colophon +status: draft +noindex: false +route: {} +summary: {} +--- diff --git a/src/content/general/pages/privacy-policy.mdoc b/src/content/general/pages/privacy-policy.mdoc new file mode 100644 index 0000000..bed757b --- /dev/null +++ b/src/content/general/pages/privacy-policy.mdoc @@ -0,0 +1,7 @@ +--- +title: Privacy Policy +status: draft +noindex: false +route: {} +summary: {} +--- diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index af7a97a..9d96066 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -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 - + +
+ +
+ diff --git a/src/layouts/general/ArticleLayout.astro b/src/layouts/general/ArticleLayout.astro index 6fa3823..4e5b653 100644 --- a/src/layouts/general/ArticleLayout.astro +++ b/src/layouts/general/ArticleLayout.astro @@ -21,7 +21,7 @@ const meta = metaOf(article) const sequence = sequenceOf(article) --- - +
diff --git a/src/layouts/general/PageLayout.astro b/src/layouts/general/PageLayout.astro index b02a66b..f10a0f6 100644 --- a/src/layouts/general/PageLayout.astro +++ b/src/layouts/general/PageLayout.astro @@ -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} />
-

{page.title}

-
- + +
+
+ +
+ + diff --git a/src/layouts/general/TagLayout.astro b/src/layouts/general/TagLayout.astro index dd9367a..ff122cc 100644 --- a/src/layouts/general/TagLayout.astro +++ b/src/layouts/general/TagLayout.astro @@ -8,17 +8,15 @@ import type { Tag } from '@/lib/site/types' type Props = { tag: Tag } const { tag } = Astro.props -const count = tag.articles.length --- -
- -

- {count} {count === 1 ? 'article' : 'articles'} -

-
- -
+ +
diff --git a/src/lib/constants/collections.ts b/src/lib/constants/collections.ts index 801cb9c..a87064c 100644 --- a/src/lib/constants/collections.ts +++ b/src/lib/constants/collections.ts @@ -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' diff --git a/src/lib/content/load.ts b/src/lib/content/load.ts index afd1730..b83abc2 100644 --- a/src/lib/content/load.ts +++ b/src/lib/content/load.ts @@ -19,18 +19,21 @@ async function readAll( collection: string, problems: Diagnostics, ): Promise> { - const entries = new Map() - 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() + for (const [id, entry] of entries) if (entry) map.set(id, entry) + return map } export async function loadContent(): Promise { diff --git a/src/lib/finder/element.ts b/src/lib/finder/element.ts new file mode 100644 index 0000000..16c974b --- /dev/null +++ b/src/lib/finder/element.ts @@ -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 = (root: ParentNode, selector: string): T => { + const element = root.querySelector(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[]> | 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('[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 + }) + .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('[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(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) diff --git a/src/lib/finder/search.ts b/src/lib/finder/search.ts new file mode 100644 index 0000000..145b706 --- /dev/null +++ b/src/lib/finder/search.ts @@ -0,0 +1,61 @@ +import { slugify } from '@/lib/content/slug' + +type Searchable = { + title: string + path: string +} + +export type Prepared = { + entry: T + title: string + words: string[] + path: string +} + +const termsOf = (text: string) => slugify(text).split('-').filter(Boolean) + +export function prepare(entries: T[]): Prepared[] { + return entries.map((entry) => ({ + entry, + title: slugify(entry.title), + words: termsOf(entry.title), + path: entry.path.toLowerCase(), + })) +} + +function scoreTerm(item: Prepared, 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( + items: Prepared[], + 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) +} diff --git a/src/lib/site/navigation.ts b/src/lib/site/navigation.ts index bd6f1b4..9938799 100644 --- a/src/lib/site/navigation.ts +++ b/src/lib/site/navigation.ts @@ -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) +} diff --git a/src/pages/sitemap.xml.ts b/src/pages/sitemap.xml.ts new file mode 100644 index 0000000..64fa5fb --- /dev/null +++ b/src/pages/sitemap.xml.ts @@ -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 = [ + '', + '', + ...urls.map(({ path, modified }) => { + const lastmod = modified ? `${isoDate(modified)}` : '' + return ` ${new URL(path, origin)}${lastmod}` + }), + '', + ].join('\n') + + return new Response(body, { headers: { 'Content-Type': 'application/xml' } }) +} diff --git a/src/styles/base/colors.css b/src/styles/base/colors.css index c3893e4..c6fa4fb 100644 --- a/src/styles/base/colors.css +++ b/src/styles/base/colors.css @@ -1,5 +1,6 @@ @layer tokens { - :root { + :root, + .light { /* === PALETTE === */ /* == Ink == */ diff --git a/src/styles/components/blocks/footnote.css b/src/styles/components/blocks/footnote.css index 1eff1c0..f9e076d 100644 --- a/src/styles/components/blocks/footnote.css +++ b/src/styles/components/blocks/footnote.css @@ -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); diff --git a/src/styles/components/blocks/sidenote.css b/src/styles/components/blocks/sidenote.css index cfa9cb1..e5957e2 100644 --- a/src/styles/components/blocks/sidenote.css +++ b/src/styles/components/blocks/sidenote.css @@ -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. */ diff --git a/src/styles/components/index.css b/src/styles/components/index.css index 4316afe..35d880c 100644 --- a/src/styles/components/index.css +++ b/src/styles/components/index.css @@ -1,3 +1,4 @@ @import url("prose/index.css"); @import url("blocks/index.css"); @import url("post/index.css"); +@import url("site/index.css"); diff --git a/src/styles/components/post/article-card-grid.css b/src/styles/components/post/article-card-grid.css new file mode 100644 index 0000000..88838aa --- /dev/null +++ b/src/styles/components/post/article-card-grid.css @@ -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; + } +} diff --git a/src/styles/components/post/article-card.css b/src/styles/components/post/article-card.css new file mode 100644 index 0000000..c49805a --- /dev/null +++ b/src/styles/components/post/article-card.css @@ -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); + } + } +} diff --git a/src/styles/components/post/article-header.css b/src/styles/components/post/article-header.css index eda91fb..ea70cc8 100644 --- a/src/styles/components/post/article-header.css +++ b/src/styles/components/post/article-header.css @@ -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 { diff --git a/src/styles/components/post/index.css b/src/styles/components/post/index.css index c827ac9..7bb7148 100644 --- a/src/styles/components/post/index.css +++ b/src/styles/components/post/index.css @@ -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"); diff --git a/src/styles/components/post/simple-header.css b/src/styles/components/post/simple-header.css new file mode 100644 index 0000000..2ba3050 --- /dev/null +++ b/src/styles/components/post/simple-header.css @@ -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; + } +} diff --git a/src/styles/components/site/finder.css b/src/styles/components/site/finder.css new file mode 100644 index 0000000..9a1a35f --- /dev/null +++ b/src/styles/components/site/finder.css @@ -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; + } + } +} diff --git a/src/styles/components/site/index.css b/src/styles/components/site/index.css new file mode 100644 index 0000000..2c4d309 --- /dev/null +++ b/src/styles/components/site/index.css @@ -0,0 +1,3 @@ +@import url("site-header.css"); +@import url("finder.css"); +@import url("site-footer.css"); diff --git a/src/styles/components/site/site-footer.css b/src/styles/components/site/site-footer.css new file mode 100644 index 0000000..3d2162e --- /dev/null +++ b/src/styles/components/site/site-footer.css @@ -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); + } + } +} diff --git a/src/styles/components/site/site-header.css b/src/styles/components/site/site-header.css new file mode 100644 index 0000000..b0ccdb2 --- /dev/null +++ b/src/styles/components/site/site-header.css @@ -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); + } + } +} diff --git a/src/styles/mixins/helpers.css b/src/styles/mixins/helpers.css index 5b7e84e..e85a66c 100644 --- a/src/styles/mixins/helpers.css +++ b/src/styles/mixins/helpers.css @@ -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%); +} diff --git a/src/styles/mixins/typography.css b/src/styles/mixins/typography.css index 51123bb..c54e4bc 100644 --- a/src/styles/mixins/typography.css +++ b/src/styles/mixins/typography.css @@ -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); +}