diff --git a/src/components/media/Figure.astro b/src/components/media/Figure.astro index 8c88706..4dcb9cb 100644 --- a/src/components/media/Figure.astro +++ b/src/components/media/Figure.astro @@ -55,17 +55,3 @@ const resolved = resolveImage(image) )} - - diff --git a/src/components/post/Header/ArticleHeader.astro b/src/components/post/Header/ArticleHeader.astro index 8116953..a7c95a2 100644 --- a/src/components/post/Header/ArticleHeader.astro +++ b/src/components/post/Header/ArticleHeader.astro @@ -1,4 +1,6 @@ --- +import { toMilitaryDTG } from '@/lib/content/dates' +import { trailOf } from '@/lib/site/navigation' import type { Article } from '@/lib/site/types' import Cover from './Cover.astro' @@ -11,22 +13,18 @@ type Props = { } const { article } = Astro.props + +const crumbs = trailOf(article) +const published = article.published ---
- + <Cover cover={article.summary.cover} /> <Meta article={article} /> </header> - -<style> - .body, - .margin { - min-width: 0; - } - - .margin { - anchor-name: --margin-column; - } -</style> diff --git a/src/components/post/Header/Meta.astro b/src/components/post/Header/Meta.astro index d93ec83..f11ddd6 100644 --- a/src/components/post/Header/Meta.astro +++ b/src/components/post/Header/Meta.astro @@ -48,70 +48,3 @@ const display = date && toMilitaryDTG(date) </div> )} </dl> - -<style> - .meta { - @mixin layout-wrapper; - - display: flex; - flex-wrap: wrap; - gap: var(--size-8); - align-items: baseline; - } - - .section { - @mixin py var(--size-4); - - flex: 1 0 auto; - min-width: 12ch; - } - - .item { - font-family: var(--font-mono); - font-size: var(--type-0); - text-transform: uppercase; - letter-spacing: var(--tracking--3); - } - - .label { - @mixin mb var(--size-2); - @mixin pb var(--size-2); - @mixin border-b 2px, solid, var(--color-border-strong); - - display: block; - - font-family: var(--font-header); - font-size: var(--type--2); - font-weight: 500; - text-transform: uppercase; - letter-spacing: var(--tracking--2); - } - - .tags { - display: flex; - flex-wrap: wrap; - gap: var(--size-2); - } - - .tag a { - @mixin py var(--size-1); - @mixin px var(--size-3); - - display: block; - - font-family: var(--font-mono); - font-size: var(--type--1); - color: var(--color-text-inverse); - text-decoration: none; - text-transform: uppercase; - letter-spacing: var(--tracking--3); - - background-color: var(--color-surface-inverse); - } - - .tag a:hover, - .tag a:focus-visible { - color: var(--color-surface-inverse); - background-color: var(--color-primary); - } -</style> diff --git a/src/components/post/Header/Overline.astro b/src/components/post/Header/Overline.astro index 44ff9b4..c9dbdab 100644 --- a/src/components/post/Header/Overline.astro +++ b/src/components/post/Header/Overline.astro @@ -1,40 +1,43 @@ --- -import { toMilitaryDTG } from '@/lib/content/dates' -import { trailOf } from '@/lib/site/navigation' -import type { Article } from '@/lib/site/types' - -type Props = { - article: Article +export type Crumb = { + title: string + path?: string } -const { article } = Astro.props +type Props = { + crumbs: Crumb[] + stamp?: string + datetime?: string +} -const trail = trailOf(article) +const { crumbs, stamp, datetime } = Astro.props --- <div class="overline dark"> <div class="wrapper"> - {trail.length > 1 && ( + {crumbs.length > 1 && ( <nav aria-label="Breadcrumb" class="breadcrumbs"> <ol> - {trail.map((step, index) => ( + {crumbs.map((crumb, index) => ( <li> - {index === trail.length - 1 ? ( - <span aria-current="page">{step.title}</span> + {crumb.path && index < crumbs.length - 1 ? ( + <a href={crumb.path}>{crumb.title}</a> ) : ( - <a href={step.path}>{step.title}</a> + <span aria-current="page">{crumb.title}</span> )} </li> ))} </ol> </nav> )} - {article.published ? ( - <time class="publicationdate" datetime={article.published.toISOString()}> - {toMilitaryDTG(article.published)} + {datetime ? ( + <time class="stamp" datetime={datetime}> + <slot name="stamp">{stamp}</slot> </time> ) : ( - <span class="publicationdate">No publication date set</span> + <span class="stamp"> + <slot name="stamp">{stamp}</slot> + </span> )} </div> </div> @@ -57,7 +60,7 @@ const trail = trailOf(article) display: flex; gap: var(--size-4); align-items: baseline; - justify-content: space-between; /* breadcrumbs left, date right */ + justify-content: space-between; /* breadcrumbs left, stamp right */ } .breadcrumbs { @@ -81,7 +84,7 @@ const trail = trailOf(article) } } - .publicationdate { + .stamp { font-weight: 700; &::before { diff --git a/src/components/post/Header/Title.astro b/src/components/post/Header/Title.astro index 9fbc253..14b6249 100644 --- a/src/components/post/Header/Title.astro +++ b/src/components/post/Header/Title.astro @@ -11,28 +11,3 @@ const { title, subtitle } = Astro.props <h1 class="title">{title}</h1> {subtitle && <p class="subtitle">{subtitle}</p>} </div> - -<style> - .wrapper { - @mixin layout-wrapper; - @mixin py var(--space-7); - } - - .title { - @mixin my var(--space-2); - font-family: var(--font-display); - font-size: var(--type-10); - line-height: var(--leading-10); - letter-spacing: var(--tracking-10); - } - - .subtitle { - @mixin mt var(--size-4); - font-family: var(--font-header); - font-weight: 300; - line-height: var(--leading-0); - color: var(--color-text-tertiary); - text-transform: uppercase; - letter-spacing: 0.0875em; - } -</style> diff --git a/src/lib/site/directory.ts b/src/lib/site/directory.ts new file mode 100644 index 0000000..8c2972d --- /dev/null +++ b/src/lib/site/directory.ts @@ -0,0 +1,35 @@ +import type { DirectoryEntry, Site } from './types' + +const depthOf = (path: string) => path.split('/').filter(Boolean).length + +const byPath = (a: DirectoryEntry, b: DirectoryEntry) => a.path.localeCompare(b.path) + +export function directoryOf(site: Site): DirectoryEntry[] { + const articles = [...site.articles.values()] + .filter((article) => article.listed) + .map((article) => ({ + title: article.title, + path: article.path, + collection: article.collection, + parent: article.parent?.title ?? null, + depth: depthOf(article.path), + })) + + const pages = [...site.pages.values()].map((page) => ({ + title: page.title, + path: page.path, + collection: page.collection, + parent: null, + depth: depthOf(page.path), + })) + + const tags = [...site.tags.values()].map((tag) => ({ + title: tag.title, + path: tag.path, + collection: tag.collection, + parent: null, + depth: depthOf(tag.path), + })) + + return [...articles, ...pages, ...tags].sort(byPath) +} diff --git a/src/lib/site/rules.ts b/src/lib/site/rules.ts index f6afafa..b9b2597 100644 --- a/src/lib/site/rules.ts +++ b/src/lib/site/rules.ts @@ -15,7 +15,7 @@ export const isListed = (status: Status, { drafts }: Visibility) => export const segmentOf = (entry: { title: string; route: { segment: string } }) => entry.route.segment || slugify(entry.title) -const RESERVED = [TAGS, 'keystatic'] +const RESERVED = [TAGS, 'keystatic', 'api'] export function segmentProblem(segment: string, atRoot: boolean): string | null { if (!segment) return 'The title has no URL-safe character. Set an URL segment.' diff --git a/src/lib/site/types.ts b/src/lib/site/types.ts index 9b4a039..5a852ad 100644 --- a/src/lib/site/types.ts +++ b/src/lib/site/types.ts @@ -37,3 +37,11 @@ export type Site = { roots: Article[] diagnostics: Diagnostic[] } + +export type DirectoryEntry = { + title: string + path: string + collection: typeof ARTICLES | typeof PAGES | typeof TAGS + parent: string | null + depth: number +} diff --git a/src/pages/404.astro b/src/pages/404.astro index 562a306..7ed37e4 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -1,27 +1,292 @@ --- +import Overline from '@/components/post/Header/Overline.astro' import BaseLayout from '@/layouts/BaseLayout.astro' +import { toMilitaryDTG } from '@/lib/content/dates' import { getSite } from '@/lib/site' const site = await getSite() -const roots = site.roots + +const stamp = toMilitaryDTG(new Date()) + +const disposition = [ + { + title: 'Return to index', + path: '/', + }, + ...site.roots.map((root) => ({ + title: `Browser ${root.title}`, + path: root.path, + })), +] --- -<BaseLayout title="Not found" noindex> - <section class="notfound"> - <p class="code">404</p> - <h1 class="title">Nothing here</h1> - <p class="text"> - That page does not exist, or it moved. Try one of these, or - <a href="/">start at the beginning</a>. - </p> - {roots.length > 0 && ( - <ul class="sections"> - {roots.map((root) => ( - <li> - <a href={root.path}>{root.title}</a> - </li> - ))} - </ul> - )} - </section> +<BaseLayout title="Not on record" noindex> + <div class="notfound dark"> + <Overline crumbs={[{ title: 'Index', path: '/' }, { title: 'Not on record' }]}> + <span slot="stamp" data-stamp> + {stamp} + </span> + </Overline> + + <div class="inner"> + <p class="glyph" aria-hidden="true"> + 404 + </p> + <h1 class="title">Not on record</h1> + <p class="standfirst"> + There is no entry under this address. It was never filed, or it has been struck from the + register. + </p> + + <div class="record dark"> + <div class="cell"> + <p class="label">01 ⟫ Requested</p> + <p class="value path"> + <span class="found" data-found></span> + <span class="struck" data-struck> + — + </span> + </p> + </div> + + <div class="cell"> + <p class="label">02 ⟫ Status</p> + <p class="status">✖ Not found</p> + </div> + + <div class="cell"> + <p class="label">03 ⟫ Checked</p> + <p class="value" data-stamp> + {stamp} + </p> + </div> + + <div class="cell"> + <p class="label">04 ⟫ Disposition</p> + <ul class="links" data-disposition> + {disposition.map((link) => ( + <li> + <a href={link.path}>{link.title}</a> + </li> + ))} + </ul> + </div> + </div> + </div> + </div> </BaseLayout> + +<script> + import { toMilitaryDTG } from '@/lib/content/dates' + + type Link = { title: string; path: string } + + const readIndex = async (): Promise<Record<string, string>> => { + try { + const response = await fetch('/api/site-index.json') + if (!response.ok) return {} + const entries: { title: string; path: string }[] = await response.json() + return Object.fromEntries(entries.map((entry) => [entry.path, entry.title])) + } catch { + return {} + } + } + + const ancestorsOf = (pathname: string, index: Record<string, string>): Link[] => { + const segments = pathname.split('/').filter(Boolean) + const found: Link[] = [] + for (let depth = segments.length; depth > 0; depth--) { + const path = `/${segments.slice(0, depth).join('/')}` + const title = index[path] + if (title) found.push({ title: `Browse ${title}`, path }) + } + return found + } + + const render = (list: Element, links: Link[]) => { + const template = list.querySelector('li') + if (!template) return + list.replaceChildren( + ...links.map(({ title, path }) => { + const item = template.cloneNode(true) as HTMLLIElement + const anchor = item.querySelector('a') + if (anchor) { + anchor.href = path + anchor.textContent = title + } + return item + }), + ) + } + + const now = toMilitaryDTG(new Date()) + for (const element of document.querySelectorAll('[data-stamp]')) element.textContent = now + + const requested = location.pathname + location.search + const found = document.querySelector('[data-found]') + const struck = document.querySelector('[data-struck]') + if (struck) struck.textContent = requested + + const ancestors = ancestorsOf(location.pathname, await readIndex()) + + const known = ancestors[0]?.path ?? '' + if (known) { + if (found) found.textContent = `${known}/` + if (struck) struck.textContent = requested.slice(known.length + 1) + } + + const disposition = document.querySelector('[data-disposition]') + if (disposition && ancestors.length > 0) + render(disposition, [...ancestors, { title: 'Return to index', path: '/' }]) +</script> + +<style> + .notfound { + display: flex; + flex-direction: column; + + min-height: 100dvh; + + color: var(--color-text-primary); + + background-color: var(--color-surface-base); + } + + .inner { + @mixin layout-wrapper; + @mixin py var(--size-14); + } + + .glyph { + @mixin my var(--size-0); + + font-family: var(--font-display); + font-size: var(--type-slab); + font-weight: var(--font-weight-display); + line-height: 0.9; + color: var(--color-primary); + text-shadow: var(--size-4) var(--size-4) 0 var(--color-text-primary); + } + + .title { + @mixin mt var(--size-10); + @mixin mb var(--size-9); + + font-family: var(--font-header); + font-size: var(--type-9); + font-weight: var(--font-weight-heading); + line-height: 1; + text-transform: uppercase; + letter-spacing: var(--tracking-9); + } + + .standfirst { + @mixin typo_body; + @mixin mt var(--size-0); + @mixin mb var(--size-12); + + max-width: 52ch; + color: var(--color-text-secondary); + } + + .record { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--border-card); + + max-width: 52rem; + border: var(--border-card) solid var(--color-border-strong); + + background-color: var(--color-border-strong); + + @media (--bp-mobile) { + grid-template-columns: minmax(0, 1fr); + } + } + + .cell { + @mixin py var(--size-8); + @mixin px var(--size-9); + + display: flex; + flex-direction: column; + gap: var(--size-5); + background-color: var(--color-surface-base); + } + + .label { + @mixin my var(--size-0); + + font-family: var(--font-mono); + font-size: var(--type--3); + color: var(--color-text-quarternary); + text-transform: uppercase; + } + + .value { + @mixin my var(--size-0); + + font-family: var(--font-mono); + font-size: var(--type-0); + font-weight: var(--font-weight-emphasis); + overflow-wrap: anywhere; + } + + .path { + display: flex; + flex-wrap: wrap; + } + + .found { + color: var(--color-text-secondary); + } + + .struck { + color: var(--color-text-quiet); + text-decoration: line-through; + } + + .status { + @mixin my var(--size-0); + @mixin py var(--size-px); + @mixin px var(--size-4); + align-self: flex-start; + + font-family: var(--font-mono); + font-size: var(--type-0); + color: var(--color-text-on-accent); + text-transform: uppercase; + + background-color: var(--color-primary); + } + + .links { + @mixin ma var(--size-0); + @mixin pl var(--size-0); + + display: flex; + flex-direction: column; + gap: var(--size-3); + list-style: none; + + a { + font-family: var(--font-mono); + font-size: var(--type-0); + color: var(--color-text-primary); + text-decoration: none; + text-transform: uppercase; + + transition: color var(--motion-fast) var(--ease-out); + + &::before { + content: '⟫ ' / ''; + color: var(--color-text-quiet); + } + + &:hover, + &:focus-visible { + color: var(--color-primary); + } + } + } +</style> diff --git a/src/pages/api/site-index.json.ts b/src/pages/api/site-index.json.ts new file mode 100644 index 0000000..d759cb7 --- /dev/null +++ b/src/pages/api/site-index.json.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro' + +import { getSite } from '@/lib/site' +import { directoryOf } from '@/lib/site/directory' + +export const GET: APIRoute = async () => Response.json(directoryOf(await getSite())) diff --git a/src/styles/base/colors.css b/src/styles/base/colors.css index 7abc95a..c3893e4 100644 --- a/src/styles/base/colors.css +++ b/src/styles/base/colors.css @@ -45,6 +45,7 @@ --color-text-quiet: var(--color-palette-ash); --color-text-disabled: var(--color-palette-stone); --color-text-inverse: var(--color-palette-paper); + --color-text-on-accent: var(--color-palette-black-ink); /* == Surfaces == */ --color-surface-base: var(--color-palette-stock); @@ -121,6 +122,7 @@ --color-text-quiet: var(--color-palette-sand); --color-text-disabled: var(--color-palette-stone); --color-text-inverse: var(--color-palette-black-ink); + --color-text-on-accent: var(--color-palette-black-ink); /* == Surfaces: elevation still means lighter == */ --color-surface-base: var(--color-palette-black-ink); diff --git a/src/styles/base/primitives.css b/src/styles/base/primitives.css index 7af363e..92f6fa3 100644 --- a/src/styles/base/primitives.css +++ b/src/styles/base/primitives.css @@ -15,6 +15,7 @@ --type-8: 2.1875rem; /* 35px */ --type-9: 2.5rem; /* 40px */ --type-10: 2.75rem; /* 44px */ + --type-slab: clamp(4rem, 9vw, 7.5rem); /* == Line heights == */ --leading--3: 1.6; diff --git a/src/styles/components/figure.css b/src/styles/components/figure.css deleted file mode 100644 index 1e7dc81..0000000 --- a/src/styles/components/figure.css +++ /dev/null @@ -1,117 +0,0 @@ -@layer components { - [class*="ratio--"] img { - width: 100%; - height: 100%; - object-fit: cover; - object-position: center; - } - - .ratio--21-9 img { - aspect-ratio: 21 / 9; - } - - .ratio--16-9 img { - aspect-ratio: 16 / 9; - } - - .ratio--3-2 img { - aspect-ratio: 3 / 2; - } - - .ratio--4-3 img { - aspect-ratio: 4 / 3; - } - - .ratio--1-1 img { - aspect-ratio: 1 / 1; - } - - .ratio--4-5 img { - aspect-ratio: 4 / 5; - } - - .effect--xeroxed { - background-color: var(--color-surface-elevated-1); - } - - .effect--xeroxed img { - display: block; - filter: grayscale(1) brightness(1.1) contrast(30); - mix-blend-mode: multiply; - } - - .effect--glitched { - isolation: isolate; - position: relative; - } - - .effect--glitched img { - display: block; - filter: grayscale(1) contrast(1.1); - } - - .effect--glitched::before, - .effect--glitched::after { - pointer-events: none; - content: ""; - - position: absolute; - inset: 0; - - opacity: 0; - background: var(--figure-src) center / cover no-repeat; - mix-blend-mode: screen; - } - - .effect--glitched:hover::before { - opacity: 0.8; - background-color: oklch(63% 0.2 25deg); - background-blend-mode: multiply; - animation: glitch-x 320ms steps(2, end) infinite; - } - - .effect--glitched:hover::after { - opacity: 0.8; - background-color: oklch(70% 0.15 200deg); - background-blend-mode: multiply; - animation: glitch-x 260ms steps(3, end) infinite reverse; - } - - .effect--glitched:hover img { - filter: grayscale(0.15) contrast(1.3); - } - - @keyframes glitch-x { - 0% { - transform: translate(0, 0); - clip-path: inset(0 0 72% 0); - } - - 25% { - transform: translate(-3px, 1px); - clip-path: inset(38% 0 34% 0); - } - - 50% { - transform: translate(3px, -1px); - clip-path: inset(66% 0 8% 0); - } - - 75% { - transform: translate(-2px, 0); - clip-path: inset(12% 0 60% 0); - } - - 100% { - transform: translate(0, 0); - clip-path: inset(0 0 72% 0); - } - } - - @media (prefers-reduced-motion: reduce) { - .effect--glitched:hover::before, - .effect--glitched:hover::after { - animation: none; - } - } -} diff --git a/src/styles/components/prose.css b/src/styles/components/prose.css deleted file mode 100644 index 858e276..0000000 --- a/src/styles/components/prose.css +++ /dev/null @@ -1,6 +0,0 @@ -@layer components { - .prose { - @mixin typo_body; - @mixin py var(--space-8); - } -} diff --git a/src/styles/global.css b/src/styles/global.css index 38ec98f..ce88068 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3,8 +3,6 @@ @import url("base/foundation.css"); @import url("base/colors.css"); @import url("base/primitives.css"); -@import url("components/figure.css"); -@import url("components/prose.css"); @layer base { body {