This commit is contained in:
2026-09-25 11:32:07 +02:00
parent 356ccedc01
commit d06f0293ce
15 changed files with 369 additions and 282 deletions

View File

@@ -55,17 +55,3 @@ const resolved = resolveImage(image)
</figcaption>
)}
</figure>
<style>
.figcaption {
@mixin py var(--size-4);
font-family: var(--font-mono);
font-size: var(--type--1);
line-height: 1;
color: var(--color-text-secondary);
text-align: center;
letter-spacing: var(--tracking-0);
background-color: var(--color-surface-base);
}
</style>

View File

@@ -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
---
<header>
<Overline article={article} />
<Overline
crumbs={crumbs}
stamp={published ? toMilitaryDTG(published) : 'No publication date set'}
datetime={published?.toISOString()}
/>
<Title title={article.title} subtitle={article.subtitle} />
<Cover cover={article.summary.cover} />
<Meta article={article} />
</header>
<style>
.body,
.margin {
min-width: 0;
}
.margin {
anchor-name: --margin-column;
}
</style>

View File

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

View File

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

View File

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

35
src/lib/site/directory.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
@layer components {
.prose {
@mixin typo_body;
@mixin py var(--space-8);
}
}

View File

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