diff --git a/.zed/settings.json b/.zed/settings.json
index e18b72e..bcf98bb 100644
--- a/.zed/settings.json
+++ b/.zed/settings.json
@@ -15,6 +15,7 @@
"css": {
"lint": {
"propertyIgnoredDueToDisplay": "ignore",
+ "unknownAtRules": "ignore",
},
},
},
diff --git a/astro.config.mjs b/astro.config.mjs
index 2b54224..a9b90ce 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -29,11 +29,13 @@ export default defineConfig({
provider: fontProviders.google(),
name: 'Geist',
cssVariable: '--font-geist',
+ weights: ['100 900'],
},
{
provider: fontProviders.google(),
name: 'Geist Mono',
cssVariable: '--font-geist-mono',
+ weights: ['100 900'],
},
{
provider: fontProviders.google(),
diff --git a/src/assets/content/articles/prima-materia/summary/cover/image.jpg b/src/assets/content/articles/prima-materia/summary/cover/image.jpg
new file mode 100644
index 0000000..03d0afc
Binary files /dev/null and b/src/assets/content/articles/prima-materia/summary/cover/image.jpg differ
diff --git a/src/components/dev/DevBanner.astro b/src/components/dev/DevBanner.astro
index 23951e3..201bb4c 100644
--- a/src/components/dev/DevBanner.astro
+++ b/src/components/dev/DevBanner.astro
@@ -16,7 +16,6 @@ const { collection, id, status, diagnostics = [] } = Astro.props
const NOTICE: Partial> = {
draft: 'Draft – not built in production',
unlisted: 'Unlisted – has page, but appears in no list',
- archived: 'Archived – has page, but appears in no list',
}
const site = import.meta.env.DEV ? await getSite() : null
diff --git a/src/components/markdoc/blocks/general/SidenoteList.astro b/src/components/markdoc/blocks/general/SidenoteList.astro
index 3c736db..f474147 100644
--- a/src/components/markdoc/blocks/general/SidenoteList.astro
+++ b/src/components/markdoc/blocks/general/SidenoteList.astro
@@ -14,11 +14,54 @@ const { sidenotes } = Astro.props
style={`position-anchor: --sn-${sidenote.n}`}
role="note"
>
-
+
{sidenote.marker}
-
+
+ {sidenote.type !== 'default' && {sidenote.type} ⟫ }
-
+
))}
+
+
diff --git a/src/components/media/ContentImage.astro b/src/components/media/ContentImage.astro
index b8298ec..9d1b909 100644
--- a/src/components/media/ContentImage.astro
+++ b/src/components/media/ContentImage.astro
@@ -6,15 +6,27 @@ import { resolveImage } from '@/lib/images/resolve'
type Props = {
src: string
alt: string
+ eager?: boolean
+ widths?: number[]
+ sizes?: string
+ fit?: boolean
}
-const { src, alt } = Astro.props
+const { src, alt, eager, widths, sizes, fit } = Astro.props
const image = resolveImage(src)
if (!image && import.meta.env.PROD) throw new Error(`Image not found: ${src}`)
---
{image ? (
-
+
) : (
Missing image: {src}
)}
diff --git a/src/components/media/Figure.astro b/src/components/media/Figure.astro
index 210706f..8c88706 100644
--- a/src/components/media/Figure.astro
+++ b/src/components/media/Figure.astro
@@ -1,5 +1,6 @@
---
-import type { FigureSize } from '@/lib/constants/figure'
+import type { AspectRatio, FigureSize, ImageEffect } from '@/lib/constants/figure'
+import { resolveImage } from '@/lib/images/resolve'
import ContentImage from './ContentImage.astro'
@@ -9,17 +10,62 @@ type Props = {
caption?: string
credit?: string
size?: FigureSize
+ effect?: ImageEffect
+ ratio?: AspectRatio
+ eager?: boolean
+ class?: string
}
-const { image, alt, caption, credit, size = 'inline' } = Astro.props
+const LAYOUT: Record = {
+ inline: { widths: [400, 800, 1200], sizes: '(min-width: 60rem) 40rem, 100vw' },
+ wide: { widths: [600, 1200, 1800], sizes: '(min-width: 80rem) 60rem, 100vw' },
+ full: { widths: [600, 900, 1280, 1600, 1920], sizes: '100vw' },
+}
+
+const {
+ image,
+ alt,
+ caption,
+ credit,
+ eager,
+ size = 'inline',
+ effect = 'none',
+ ratio = 'original',
+ class: className,
+} = Astro.props
+
+const resolved = resolveImage(image)
---
-
-
+
+
{(caption || credit) && (
-
+
{caption && {caption}}
{credit && {credit}}
)}
+
+
diff --git a/src/components/post/ArticleCard/ArticleCardGrid.astro b/src/components/post/ArticleCard/ArticleCardGrid.astro
new file mode 100644
index 0000000..bc8f4d0
--- /dev/null
+++ b/src/components/post/ArticleCard/ArticleCardGrid.astro
@@ -0,0 +1,23 @@
+---
+import type { Article } from '@/lib/site/types'
+
+import ArticleCardItem from './ArticleCardItem.astro'
+
+type Props = {
+ articles: Article[]
+ title?: string
+}
+
+const { articles, title } = Astro.props
+---
+
+{articles.length > 0 && (
+
+ {title && {title}
}
+
+ {articles.map((article) => (
+
+ ))}
+
+
+)}
diff --git a/src/components/post/ArticleCard/ArticleCardItem.astro b/src/components/post/ArticleCard/ArticleCardItem.astro
new file mode 100644
index 0000000..d7219e5
--- /dev/null
+++ b/src/components/post/ArticleCard/ArticleCardItem.astro
@@ -0,0 +1,31 @@
+---
+import ContentImage from '@/components/media/ContentImage.astro'
+import { toMilitaryDTG } from '@/lib/content/dates'
+import type { Article } from '@/lib/site/types'
+
+type Props = {
+ article: Article
+}
+
+const { article } = Astro.props
+
+const { cover, excerpt } = article.summary
+const date = article.published
+---
+
+
+
+ {cover.image && (
+
+
+
+ )}
+ {article.title}
+
+ {excerpt && {excerpt}
}
+ {date && (
+
+ )}
+
diff --git a/src/components/post/Header/ArticleHeader.astro b/src/components/post/Header/ArticleHeader.astro
new file mode 100644
index 0000000..8116953
--- /dev/null
+++ b/src/components/post/Header/ArticleHeader.astro
@@ -0,0 +1,32 @@
+---
+import type { Article } from '@/lib/site/types'
+
+import Cover from './Cover.astro'
+import Meta from './Meta.astro'
+import Overline from './Overline.astro'
+import Title from './Title.astro'
+
+type Props = {
+ article: Article
+}
+
+const { article } = Astro.props
+---
+
+
+
+
diff --git a/src/components/post/Header/Cover.astro b/src/components/post/Header/Cover.astro
new file mode 100644
index 0000000..8d26eb8
--- /dev/null
+++ b/src/components/post/Header/Cover.astro
@@ -0,0 +1,10 @@
+---
+import Figure from '@/components/media/Figure.astro'
+import type { Article } from '@/lib/site/types'
+
+type Props = { cover: Article['summary']['cover'] }
+
+const { image, ...rest } = Astro.props.cover
+---
+
+{image && }
diff --git a/src/components/post/Header/Meta.astro b/src/components/post/Header/Meta.astro
new file mode 100644
index 0000000..d93ec83
--- /dev/null
+++ b/src/components/post/Header/Meta.astro
@@ -0,0 +1,117 @@
+---
+import { toMilitaryDTG } from '@/lib/content/dates'
+import { getSite } from '@/lib/site'
+import type { Article } from '@/lib/site/types'
+
+type Props = {
+ article: Article
+}
+
+const { article } = Astro.props
+const site = await getSite()
+const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined)
+const date = article.updated ?? article.published
+const label = article.updated ? 'Updated' : 'Published'
+const machine = date?.toISOString()
+const display = date && toMilitaryDTG(date)
+---
+
+
+
+
- Author
+ - Dave Damage
+
+ {tags.length > 0 && (
+
+ )}
+ {display && (
+
+
- {label}
+ -
+
+
+
+ )}
+
+
+
diff --git a/src/components/post/Header/Overline.astro b/src/components/post/Header/Overline.astro
new file mode 100644
index 0000000..44ff9b4
--- /dev/null
+++ b/src/components/post/Header/Overline.astro
@@ -0,0 +1,92 @@
+---
+import { toMilitaryDTG } from '@/lib/content/dates'
+import { trailOf } from '@/lib/site/navigation'
+import type { Article } from '@/lib/site/types'
+
+type Props = {
+ article: Article
+}
+
+const { article } = Astro.props
+
+const trail = trailOf(article)
+---
+
+
+
+ {trail.length > 1 && (
+
+ )}
+ {article.published ? (
+
+ ) : (
+
No publication date set
+ )}
+
+
+
+
diff --git a/src/components/post/Header/SimpleHeader.astro b/src/components/post/Header/SimpleHeader.astro
new file mode 100644
index 0000000..b19d0c1
--- /dev/null
+++ b/src/components/post/Header/SimpleHeader.astro
@@ -0,0 +1,17 @@
+---
+type Props = {
+ title: string
+ /** Small label above the title: "Page", "Tag". */
+ eyebrow?: string
+ standfirst?: string
+}
+
+const { title, eyebrow, standfirst } = Astro.props
+---
+
+
diff --git a/src/components/post/Header/Title.astro b/src/components/post/Header/Title.astro
new file mode 100644
index 0000000..9fbc253
--- /dev/null
+++ b/src/components/post/Header/Title.astro
@@ -0,0 +1,38 @@
+---
+type Props = {
+ title: string
+ subtitle?: string
+}
+
+const { title, subtitle } = Astro.props
+---
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
+
diff --git a/src/components/post/ToC.astro b/src/components/post/ToC.astro
new file mode 100644
index 0000000..a5f28e1
--- /dev/null
+++ b/src/components/post/ToC.astro
@@ -0,0 +1,27 @@
+---
+import type { Heading } from '@/lib/markdoc/types'
+
+type Props = {
+ headings: Heading[]
+ min?: number
+ level: number
+}
+
+const { headings, min = 3, level = 3 } = Astro.props
+const entries = headings.filter((heading) => heading.level <= level)
+---
+
+{entries.length >= min && (
+
+)}
diff --git a/src/content/general/articles/prima-materia.mdoc b/src/content/general/articles/prima-materia.mdoc
index 691708f..0fa86b6 100644
--- a/src/content/general/articles/prima-materia.mdoc
+++ b/src/content/general/articles/prima-materia.mdoc
@@ -1,13 +1,120 @@
---
title: Prima Materia
+subtitle: Where we lay the ground
status: draft
noindex: false
route:
parent: stages
summary:
- cover: {}
+ cover:
+ image: /src/assets/content/articles/prima-materia/summary/cover/image.jpg
+ alt: The Great Day of His Wrath
+ credit: John Martin – The Great Day of His Wrath
+ effect: xeroxed
+ ratio: 21-9
dates:
published: 2026-09-21T12:35:00.000Z
-tags: []
+tags:
+ - the-crucible
+ - alchemical-materialism
+ - genesis
---
-## I AM JUST A TEST IN A TESTY WORLD
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. This first paragraph is the lead, so it should be larger and carry the drop cap.
+
+Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. A sentence with *emphasis*, **strong text**, `inline code`, ~~struck text~~ and an [external link](https://example.com).
+
+## Heading two, long enough to wrap onto a second line
+
+Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur{% footnote note="A footnote with *emphasis* and a [link](https://example.com)." /%}. Excepteur sint occaecat cupidatat non proident{% sidenote marker="†" note="A sidenote in the margin, beside the line it belongs to." /%}.
+
+### Heading three
+
+Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam.
+
+#### Heading four
+
+At vero eos et accusamus et iusto odio dignissimos ducimus.
+
+## Lists
+
+- First item
+- Second item, with enough text to run past the end of a line and wrap underneath the marker
+- Third item
+ - Nested item
+ - Another nested item
+ - Third level
+
+1. First step
+2. Second step
+ 1. Sub-step
+ 2. Another sub-step
+3. Third step
+
+## Quotes and rules
+
+> A plain blockquote, no attribution.
+>
+> With a second paragraph.
+
+{% quote cite="Paracelsus" source="Die dritte Defension" href="https://example.com" %}
+All things are poison, and nothing is without poison; the dosage alone makes it so a thing is not a poison.
+{% /quote %}
+
+---
+
+## Callouts
+
+{% callout type="default" title="Note" %}
+A default callout with a [link](https://example.com) and `code` inside it.
+{% /callout %}
+
+{% callout type="example" title="Worked example" %}
+An example callout containing a list:
+
+- one
+- two
+{% /callout %}
+
+## Code
+
+```js
+const crucible = { stage: 'prima-materia', heat: 3 }
+
+for (const [key, value] of Object.entries(crucible)) {
+ console.log(`${key}: ${value}`)
+}
+```
+
+## Tables
+
+{% tableWrapper variant="default" caption="A wrapped table with a caption" %}
+{% table %}
+- Stage
+- Heat
+- Notes
+---
+- Prima Materia
+- 3
+- Longer cell text to see how wrapping behaves in a data cell
+---
+- Solutio
+- 5
+- Short
+{% /table %}
+{% /tableWrapper %}
+
+{% table %}
+- Column A
+- Column B
+---
+- A bare table
+- No wrapper
+{% /table %}
+
+## Images
+
+{% figure image="/src/assets/content/articles/type-specimen/example.png" alt="A placeholder image" caption="An inline figure with a caption" credit="Dave Damage" size="inline" ratio="16-9" effect="none" /%}
+
+Aliquam erat volutpat. Fusce sed velit at arcu tincidunt vulputate{% sidenote marker="‡" type="example" note="A second sidenote, typed as an example." /%}. Nam vitae lacus a nisl ultrices dictum.
+
+Finally, a link to another article: {% xref reference="the-crucible" %}The Crucible{% /xref %}.
diff --git a/src/content/general/tags/alchemical-materialism.json b/src/content/general/tags/alchemical-materialism.json
new file mode 100644
index 0000000..6e8c4d4
--- /dev/null
+++ b/src/content/general/tags/alchemical-materialism.json
@@ -0,0 +1,3 @@
+{
+ "title": "Alchemical Materialism"
+}
diff --git a/src/content/general/tags/genesis.json b/src/content/general/tags/genesis.json
new file mode 100644
index 0000000..c650233
--- /dev/null
+++ b/src/content/general/tags/genesis.json
@@ -0,0 +1,3 @@
+{
+ "title": "Genesis"
+}
diff --git a/src/keystatic/blocks/general/figure.ts b/src/keystatic/blocks/general/figure.ts
index 93bde77..8eabf32 100644
--- a/src/keystatic/blocks/general/figure.ts
+++ b/src/keystatic/blocks/general/figure.ts
@@ -1,26 +1,16 @@
import { fields } from '@keystatic/core'
import { block } from '@keystatic/core/content-components'
+import { imageFields } from '@/keystatic/fields/content/image'
import { FIGURE_SIZES } from '@/lib/constants/figure'
-import { assets } from '@/lib/images/storage'
export const figure = (collection: string) =>
block({
label: 'Figure',
description: 'An image with alt text, and optionally a caption and credit.',
schema: {
- image: fields.image({
- label: 'Image',
- ...assets(collection),
- validation: {
- isRequired: true,
- },
- }),
- alt: fields.text({
- label: 'Caption',
- }),
- credit: fields.text({
- label: 'Credit',
+ ...imageFields(collection, {
+ required: true,
}),
size: fields.select({
label: 'Size',
diff --git a/src/keystatic/fields/content/base.ts b/src/keystatic/fields/content/base.ts
index dbc932f..71ea4d7 100644
--- a/src/keystatic/fields/content/base.ts
+++ b/src/keystatic/fields/content/base.ts
@@ -23,6 +23,10 @@ export const title = fields.slug({
},
})
+export const subtitle = fields.text({
+ label: 'Subtitle',
+})
+
export const status = fields.select({
label: 'Status',
options: STATUS_OPTIONS,
@@ -35,6 +39,7 @@ export const noindex = fields.checkbox({
export const base = {
title,
+ subtitle,
status,
noindex,
}
diff --git a/src/keystatic/fields/content/cover.ts b/src/keystatic/fields/content/cover.ts
index d454dbd..f7fec0f 100644
--- a/src/keystatic/fields/content/cover.ts
+++ b/src/keystatic/fields/content/cover.ts
@@ -1,25 +1,6 @@
import { fields } from '@keystatic/core'
-import { assets } from '@/lib/images/storage'
+import { imageFields } from './image'
export const cover = (collection: string) =>
- fields.object(
- {
- image: fields.image({
- label: 'Image',
- ...assets(collection),
- }),
- alt: fields.text({
- label: 'Alt text',
- }),
- caption: fields.text({
- label: 'Caption',
- }),
- credit: fields.text({
- label: 'Credit',
- }),
- },
- {
- label: 'Cover',
- },
- )
+ fields.object(imageFields(collection), { label: 'Cover' })
diff --git a/src/keystatic/fields/content/image.ts b/src/keystatic/fields/content/image.ts
new file mode 100644
index 0000000..ccc5c4a
--- /dev/null
+++ b/src/keystatic/fields/content/image.ts
@@ -0,0 +1,36 @@
+import { fields } from '@keystatic/core'
+
+import { ASPECT_RATIOS, IMAGE_EFFECTS } from '@/lib/constants/figure'
+import { assets } from '@/lib/images/storage'
+
+export const imageFields = (collection: string, { required = false } = {}) => ({
+ image: fields.image({
+ label: 'Image',
+ ...assets(collection),
+ validation: {
+ isRequired: required,
+ },
+ }),
+ alt: fields.text({
+ label: 'Alt text',
+ validation: {
+ isRequired: required,
+ },
+ }),
+ caption: fields.text({
+ label: 'Caption',
+ }),
+ credit: fields.text({
+ label: 'Credit',
+ }),
+ effect: fields.select({
+ label: 'Effect',
+ options: IMAGE_EFFECTS,
+ defaultValue: 'none',
+ }),
+ ratio: fields.select({
+ label: 'Crop',
+ options: ASPECT_RATIOS,
+ defaultValue: 'original',
+ }),
+})
diff --git a/src/layouts/general/ArticleLayout.astro b/src/layouts/general/ArticleLayout.astro
index b83223d..be291d6 100644
--- a/src/layouts/general/ArticleLayout.astro
+++ b/src/layouts/general/ArticleLayout.astro
@@ -3,7 +3,7 @@ import DevBanner from '@/components/dev/DevBanner.astro'
import FootnoteList from '@/components/markdoc/blocks/general/FootnoteList.astro'
import SidenoteList from '@/components/markdoc/blocks/general/SidenoteList.astro'
import Node from '@/components/markdoc/Node.astro'
-import Figure from '@/components/media/Figure.astro'
+import ArticleHeader from '@/components/post/Header/ArticleHeader.astro'
import BaseLayout from '@/layouts/BaseLayout.astro'
import type { Rendered } from '@/lib/markdoc/types'
import { metaOf } from '@/lib/site/meta'
@@ -19,27 +19,43 @@ const meta = metaOf(article)
---
+
+
+
+
+
-
- {article.title}
- {article.summary.cover.image && (
-
- )}
-
-
-
-
-
-
+
+
diff --git a/src/layouts/general/TagLayout.astro b/src/layouts/general/TagLayout.astro
index ca72db2..dd9367a 100644
--- a/src/layouts/general/TagLayout.astro
+++ b/src/layouts/general/TagLayout.astro
@@ -1,25 +1,24 @@
---
import DevBanner from '@/components/dev/DevBanner.astro'
+import ArticleCardGrid from '@/components/post/ArticleCard/ArticleCardGrid.astro'
+import SimpleHeader from '@/components/post/Header/SimpleHeader.astro'
import BaseLayout from '@/layouts/BaseLayout.astro'
import type { Tag } from '@/lib/site/types'
type Props = { tag: Tag }
const { tag } = Astro.props
+const count = tag.articles.length
---
- {tag.title}
- {tag.description && {tag.description}
}
-
+
+
+ {count} {count === 1 ? 'article' : 'articles'}
+
+
+
diff --git a/src/lib/constants/figure.ts b/src/lib/constants/figure.ts
index 59a965c..d27e796 100644
--- a/src/lib/constants/figure.ts
+++ b/src/lib/constants/figure.ts
@@ -1,16 +1,24 @@
export const FIGURE_SIZES = [
- {
- label: 'Inline',
- value: 'inline',
- },
- {
- label: 'Wide',
- value: 'wide',
- },
- {
- label: 'Full',
- value: 'full',
- },
-]
+ { label: 'Inline', value: 'inline' },
+ { label: 'Wide', value: 'wide' },
+ { label: 'Full', value: 'full' },
+] as const
+
+export const IMAGE_EFFECTS = [
+ { label: 'None', value: 'none' },
+ { label: 'Xeroxed', value: 'xeroxed' },
+] as const
+
+export const ASPECT_RATIOS = [
+ { label: 'Original', value: 'original' },
+ { label: 'Panorama (21:9)', value: '21-9' },
+ { label: 'Wide (16:9)', value: '16-9' },
+ { label: 'Photo (3:2)', value: '3-2' },
+ { label: 'Classic (4:3)', value: '4-3' },
+ { label: 'Square (1:1)', value: '1-1' },
+ { label: 'Portrait (4:5)', value: '4-5' },
+] as const
export type FigureSize = (typeof FIGURE_SIZES)[number]['value']
+export type ImageEffect = (typeof IMAGE_EFFECTS)[number]['value']
+export type AspectRatio = (typeof ASPECT_RATIOS)[number]['value']
diff --git a/src/lib/constants/status.ts b/src/lib/constants/status.ts
index 9646ebd..c9355f3 100644
--- a/src/lib/constants/status.ts
+++ b/src/lib/constants/status.ts
@@ -11,10 +11,6 @@ export const STATUS_OPTIONS = [
label: 'Unlisted',
value: 'unlisted',
},
- {
- label: 'Archived',
- value: 'archived',
- },
] as const
export type Status = (typeof STATUS_OPTIONS)[number]['value']
diff --git a/src/lib/content/dates.ts b/src/lib/content/dates.ts
index a8e9829..6514407 100644
--- a/src/lib/content/dates.ts
+++ b/src/lib/content/dates.ts
@@ -5,6 +5,21 @@ const DISPLAY = new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
})
+const MONTHS = [
+ 'JAN',
+ 'FEB',
+ 'MAR',
+ 'APR',
+ 'MAY',
+ 'JUN',
+ 'JUL',
+ 'AUG',
+ 'SEP',
+ 'OCT',
+ 'NOV',
+ 'DEC',
+] as const
+
export function parseDate(value: string | null): Date | null {
if (!value) return null
const date = new Date(/(z|[+-]\d\d:\d\d)$/i.test(value) ? value : `${value}Z`)
@@ -14,3 +29,14 @@ export function parseDate(value: string | null): Date | null {
export const formatDate = (date: Date): string => DISPLAY.format(date)
export const isoDate = (date: Date): string => date.toISOString().slice(0, 10)
+
+export const toMilitaryDTG = (datestring: Date, timeZone = 'A'): string => {
+ const d = new Date(datestring)
+ const day = d.getUTCDate().toString().padStart(2, '0')
+ const hours = d.getUTCHours().toString().padStart(2, '0')
+ const minutes = d.getUTCMinutes().toString().padStart(2, '0')
+ const month = MONTHS[d.getUTCMonth()]
+ const year = d.getUTCFullYear().toString().slice(-2)
+
+ return `${day}${hours}${minutes}${timeZone}${month}${year}`
+}
diff --git a/src/lib/site/navigation.ts b/src/lib/site/navigation.ts
new file mode 100644
index 0000000..3e2faad
--- /dev/null
+++ b/src/lib/site/navigation.ts
@@ -0,0 +1,7 @@
+import type { Article } from './types'
+
+export function trailOf(article: Article): Article[] {
+ const trail: Article[] = []
+ for (let node: Article | null = article; node; node = node.parent) trail.unshift(node)
+ return trail
+}
diff --git a/src/pages/404.astro b/src/pages/404.astro
new file mode 100644
index 0000000..562a306
--- /dev/null
+++ b/src/pages/404.astro
@@ -0,0 +1,27 @@
+---
+import BaseLayout from '@/layouts/BaseLayout.astro'
+import { getSite } from '@/lib/site'
+
+const site = await getSite()
+const roots = site.roots
+---
+
+
+
+ 404
+ Nothing here
+
+ That page does not exist, or it moved. Try one of these, or
+ start at the beginning.
+
+ {roots.length > 0 && (
+
+ )}
+
+
diff --git a/src/styles/base/colors.css b/src/styles/base/colors.css
index 0a8bd86..7abc95a 100644
--- a/src/styles/base/colors.css
+++ b/src/styles/base/colors.css
@@ -111,4 +111,44 @@
--color-skeleton-base: var(--color-surface-elevated-2);
--color-skeleton-shimmer: var(--color-surface-elevated-1);
}
+
+ .dark {
+ /* == Text == */
+ --color-text-primary: var(--color-palette-paper);
+ --color-text-secondary: var(--color-palette-stock);
+ --color-text-tertiary: var(--color-palette-parchment);
+ --color-text-quarternary: var(--color-palette-bone);
+ --color-text-quiet: var(--color-palette-sand);
+ --color-text-disabled: var(--color-palette-stone);
+ --color-text-inverse: var(--color-palette-black-ink);
+
+ /* == Surfaces: elevation still means lighter == */
+ --color-surface-base: var(--color-palette-black-ink);
+ --color-surface-elevated-1: var(--color-palette-charcoal);
+ --color-surface-elevated-2: var(--color-palette-slate);
+ --color-surface-elevated-3: var(--color-palette-graphite);
+ --color-surface-inverse: var(--color-palette-paper);
+ --color-surface-selected: var(--color-palette-slate);
+ --color-surface-hover: var(--color-palette-charcoal);
+
+ /* == Borders == */
+ --color-border-hairline: var(--color-palette-charcoal);
+ --color-border-subtle: var(--color-palette-slate);
+ --color-border-normal: var(--color-palette-graphite);
+ --color-border-strong: var(--color-palette-paper);
+
+ /* == Apparatus: ink is invisible on a dark page == */
+ --color-rule: var(--color-palette-slate);
+ --color-rule-strong: var(--color-palette-paper);
+
+ /* == Overlays tint with light, not ink == */
+ --color-overlay-light: oklch(98.8% 0.005 85deg / 8%);
+ --color-overlay-medium: oklch(98.8% 0.005 85deg / 30%);
+ --color-overlay-heavy: oklch(98.8% 0.005 85deg / 60%);
+
+ /* == Shadow == */
+ --shadow-card: var(--size-1) var(--size-1) 0 var(--color-palette-paper);
+ --shadow-raised: var(--size-2) var(--size-2) 0 var(--color-palette-paper);
+ --shadow-pressed: 0 0 0 var(--color-palette-paper);
+ }
}
diff --git a/src/styles/base/primitives.css b/src/styles/base/primitives.css
index 49fa0ba..7af363e 100644
--- a/src/styles/base/primitives.css
+++ b/src/styles/base/primitives.css
@@ -58,6 +58,7 @@
--tracking-eyebrow: 0.16em; /* section eyebrows, zone labels */
--tracking-plate: 0.08em; /* plate numbers */
--tracking-display: 0.01em; /* Blaka h1 */
+ --tracking-subtitle: 0.0875em; /* standfirst under a title */
}
.content {
@@ -108,7 +109,6 @@
--tracking-8: -0.0225em;
--tracking-9: -0.0237em;
--tracking-10: -0.0247em;
- --leading-prose: 1.6;
}
:root {
@@ -122,7 +122,7 @@
--font-weight-body: 400;
--font-weight-emphasis: 700;
--font-weight-label: 600;
- --font-weight-heading: 800;
+ --font-weight-heading: 900;
/* === SIZE === */
--size-0: 0;
@@ -189,7 +189,7 @@
/* ═══ LAYOUT ═══ */
--layout-max-width: 74rem; /* the tool shell */
- --layout-content-width: minmax(0, 78ch); /* article measure */
+ --layout-content-width: minmax(0, 62ch); /* article measure */
--layout-margin-width: minmax(10ch, 1fr);
--layout-step-columns: 2;
--layout-gutter: var(--size-6); /* between step cards */
diff --git a/src/styles/components/figure.css b/src/styles/components/figure.css
new file mode 100644
index 0000000..1e7dc81
--- /dev/null
+++ b/src/styles/components/figure.css
@@ -0,0 +1,117 @@
+@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
new file mode 100644
index 0000000..858e276
--- /dev/null
+++ b/src/styles/components/prose.css
@@ -0,0 +1,6 @@
+@layer components {
+ .prose {
+ @mixin typo_body;
+ @mixin py var(--space-8);
+ }
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index ce88068..38ec98f 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -3,6 +3,8 @@
@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 {
diff --git a/src/styles/mixins/borders.css b/src/styles/mixins/borders.css
new file mode 100644
index 0000000..52a4d20
--- /dev/null
+++ b/src/styles/mixins/borders.css
@@ -0,0 +1,60 @@
+@define-mixin border-l $width: var(--size-1), $style: solid,
+ $color: var(--color-surface-inverse) {
+ border-left: $width $style $color;
+}
+
+@define-mixin border-r $width: var(--size-1), $style: solid,
+ $color: var(--color-surface-inverse) {
+ border-right: $width $style $color;
+}
+
+@define-mixin border-t $width: var(--size-1), $style: solid,
+ $color: var(--color-surface-inverse) {
+ border-top: $width $style $color;
+}
+
+@define-mixin border-b $width: var(--size-1), $style: solid,
+ $color: var(--color-surface-inverse) {
+ border-bottom: $width $style $color;
+}
+
+@define-mixin border-x $width: var(--size-1), $style: solid,
+ $color: var(--color-surface-inverse) {
+ border-right: $width $style $color;
+ border-left: $width $style $color;
+}
+
+@define-mixin border-y $width: var(--size-1), $style: solid,
+ $color: var(--color-surface-inverse) {
+ border-top: $width $style $color;
+ border-bottom: $width $style $color;
+}
+
+@define-mixin rounded-top $radius {
+ border-top-left-radius: $radius;
+ border-top-right-radius: $radius;
+}
+
+@define-mixin rounded-bottom $radius {
+ border-bottom-right-radius: $radius;
+ border-bottom-left-radius: $radius;
+}
+
+@define-mixin rounded-left $radius {
+ border-top-left-radius: $radius;
+ border-bottom-left-radius: $radius;
+}
+
+@define-mixin rounded-right $radius {
+ border-top-left-radius: $radius;
+ border-bottom-left-radius: $radius;
+}
+
+@define-mixin circle {
+ border-radius: 50%;
+}
+
+@define-mixin focus-ring $width: 2px, $color: var(--color-focus-ring), $offset: 2px {
+ outline: $width solid $color;
+ outline-offset: $offset;
+}
diff --git a/src/styles/mixins/helpers.css b/src/styles/mixins/helpers.css
new file mode 100644
index 0000000..19e69f0
--- /dev/null
+++ b/src/styles/mixins/helpers.css
@@ -0,0 +1,11 @@
+@define-mixin layout-wrapper {
+ @mixin mx auto;
+ @mixin px var(--layout-page-margin);
+
+ width: 100%;
+ max-width: var(--layout-max-width);
+
+ @media screen and (--bp-desktop) {
+ @mixin px var(--space-0);
+ }
+}
diff --git a/src/styles/mixins/spacing.css b/src/styles/mixins/spacing.css
new file mode 100644
index 0000000..e0c0f25
--- /dev/null
+++ b/src/styles/mixins/spacing.css
@@ -0,0 +1,85 @@
+@define-mixin ma $spacing {
+ margin: $spacing;
+}
+
+@define-mixin mt $spacing {
+ margin-top: $spacing;
+}
+
+@define-mixin mb $spacing {
+ margin-bottom: $spacing;
+}
+
+@define-mixin ml $spacing {
+ margin-left: $spacing;
+}
+
+@define-mixin mr $spacing {
+ margin-right: $spacing;
+}
+
+@define-mixin my $spacing {
+ margin-block: $spacing;
+}
+
+@define-mixin mx $spacing {
+ margin-inline: $spacing;
+}
+
+@define-mixin ms $spacing {
+ margin-line-start: $spacing;
+}
+
+@define-mixin me $spacing {
+ margin-inline-end: $spacing;
+}
+
+@define-mixin space-x $spacing $reverse: 0 {
+ & > :not(:last-child) {
+ margin-inline-start: calc($spacing * $reverse);
+ margin-inline-end: calc($spacing * (1 - $reverse));
+ }
+}
+
+@define-mixin pa $spacing {
+ padding: $spacing;
+}
+
+@define-mixin pt $spacing {
+ padding-top: $spacing;
+}
+
+@define-mixin pb $spacing {
+ padding-bottom: $spacing;
+}
+
+@define-mixin pl $spacing {
+ padding-left: $spacing;
+}
+
+@define-mixin pr $spacing {
+ padding-right: $spacing;
+}
+
+@define-mixin py $spacing {
+ padding-block: $spacing;
+}
+
+@define-mixin px $spacing {
+ padding-inline: $spacing;
+}
+
+@define-mixin ps $spacing {
+ padding-inline-start: $spacing;
+}
+
+@define-mixin pe $spacing {
+ padding-inline-end: $spacing;
+}
+
+@define-mixin space-y $spacing, $reverse: 0 {
+ & > :not(:last-child) {
+ margin-block-start: calc($spacing * $reverse);
+ margin-block-end: calc($spacing * (1 - $reverse));
+ }
+}
diff --git a/src/styles/mixins/typography.css b/src/styles/mixins/typography.css
new file mode 100644
index 0000000..c02f236
--- /dev/null
+++ b/src/styles/mixins/typography.css
@@ -0,0 +1,101 @@
+@define-mixin typo_body {
+ font-family: var(--font-body);
+ font-size: var(--type-0);
+ font-weight: var(--font-weight-body);
+ line-height: var(--leading-prose);
+ letter-spacing: var(--tracking-0);
+}
+
+@define-mixin typo_heading-2 {
+ font-family: var(--font-header);
+ font-size: var(--type-7);
+ font-weight: var(--font-weight-heading);
+ line-height: var(--leading-7);
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-7);
+}
+
+@define-mixin typo_heading-3 {
+ font-family: var(--font-header);
+ font-size: var(--type-6);
+ font-weight: var(--font-weight-heading);
+ line-height: var(--leading-6);
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-6);
+}
+
+@define-mixin typo_heading-4 {
+ font-family: var(--font-header);
+ font-size: var(--type-5);
+ font-weight: var(--font-weight-emphasis);
+ line-height: var(--leading-5);
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-5);
+}
+
+@define-mixin typo_code {
+ font-family: var(--font-mono);
+ font-size: var(--type--1);
+ font-weight: var(--font-weight-body);
+ line-height: var(--leading--1);
+ letter-spacing: var(--tracking-0);
+}
+
+@define-mixin typo_table-header {
+ font-family: var(--font-header);
+ font-size: var(--type--1);
+ font-weight: var(--font-weight-emphasis);
+ line-height: var(--leading-0);
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-0);
+}
+
+@define-mixin typo_table-data {
+ font-family: var(--font-mono);
+ font-size: var(--type--1);
+ font-weight: var(--font-weight-body);
+ line-height: var(--leading--1);
+ letter-spacing: var(--tracking-0);
+}
+
+@define-mixin typo_definition-header {
+ font-family: var(--font-header);
+ font-size: var(--type-0);
+ font-weight: var(--font-weight-emphasis);
+ line-height: var(--leading-0);
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-0);
+}
+
+@define-mixin typo_definition-data {
+ font-family: var(--font-mono);
+ font-size: var(--type--1);
+ font-weight: var(--font-weight-body);
+ line-height: var(--leading--1);
+ letter-spacing: var(--tracking-0);
+}
+
+@define-mixin typo_fine {
+ font-family: var(--font-body);
+ font-size: var(--type--3);
+ font-weight: var(--font-weight-body);
+ line-height: var(--leading--3);
+ letter-spacing: var(--tracking--3);
+}
+
+@define-mixin typo_label {
+ font-family: var(--font-header);
+ font-size: var(--type--1);
+ font-weight: var(--font-weight-label);
+ line-height: var(--leading--1);
+ text-transform: uppercase;
+ letter-spacing: var(--tracking-label);
+}
+
+@define-mixin typo_body-small {
+ font-family: var(--font-body);
+ font-size: var(--type--1);
+ font-weight: var(--font-weight-body);
+ line-height: var(--leading-prose-tight);
+ letter-spacing: var(--tracking--1);
+}