Blog #1

Merged
Dave merged 22 commits from blog into main 2026-09-30 16:48:38 +02:00
39 changed files with 1212 additions and 96 deletions
Showing only changes of commit 356ccedc01 - Show all commits

View File

@@ -15,6 +15,7 @@
"css": {
"lint": {
"propertyIgnoredDueToDisplay": "ignore",
"unknownAtRules": "ignore",
},
},
},

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -16,7 +16,6 @@ const { collection, id, status, diagnostics = [] } = Astro.props
const NOTICE: Partial<Record<Status, string>> = {
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

View File

@@ -14,11 +14,54 @@ const { sidenotes } = Astro.props
style={`position-anchor: --sn-${sidenote.n}`}
role="note"
>
<a class="sidenote__marker" href={`#snref-${sidenote.n}`} aria-label="Back to text">
<a class="marker" href={`#snref-${sidenote.n}`} aria-label="Back to the text">
{sidenote.marker}
</a>
<div class="sidenote__note">
<p class="note">
{sidenote.type !== 'default' && <span class="kind">{sidenote.type} ⟫ </span>}
<Node node={sidenote.children} />
</div>
</p>
</aside>
))}
<style>
.sidenote {
@mixin typo_body-small;
@mixin my var(--space-8);
scroll-margin-top: calc(var(--layout-header-height) + var(--size-8));
}
/* The marker hangs: it lines the note up with the text it belongs to. */
.marker {
@mixin me var(--size-2);
float: inline-start;
font-family: var(--font-mono);
}
.note {
@mixin ma var(--size-0);
}
/* Stacked under the article: a list, separated by rules. */
@media (--bp-tablet-down) {
.sidenote {
@mixin pt var(--space-4);
@mixin border-t var(--size-1), solid, var(--color-border-subtle);
}
}
@media (--bp-desktop) {
@supports (anchor-name: --x) {
.sidenote {
@mixin my var(--size-0);
position: absolute;
top: anchor(top);
left: anchor(--margin-column left);
width: anchor-size(--margin-column width);
}
}
}
</style>

View File

@@ -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 ? (
<Image src={image} alt={alt} />
<Image
src={image}
alt={alt}
widths={widths}
sizes={sizes}
class:list={[fit && 'fit']}
loading={eager ? 'eager' : 'lazy'}
fetchpriority={eager ? 'high' : undefined}
/>
) : (
<span class="missing-image">Missing image: {src}</span>
)}

View File

@@ -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<FigureSize, { widths: number[]; sizes: string }> = {
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)
---
<figure class:list={['figure', `figure--${size}`]}>
<ContentImage src={image} alt={alt} />
<figure
class:list={[
'figure',
`figure--${size}`,
effect !== 'none' && `effect--${effect}`,
ratio !== 'original' && `ratio--${ratio}`,
className,
]}
style={resolved && `--figure-src: url(${resolved.src})`}
>
<ContentImage src={image} alt={alt} eager={eager} {...LAYOUT[size]} />
{(caption || credit) && (
<figcaption>
<figcaption class="figcaption dark">
{caption && <span class="caption">{caption}</span>}
{credit && <span class="credit">{credit}</span>}
</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

@@ -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 && (
<section class="cards">
{title && <h2 class="title">{title}</h2>}
<div class="grid">
{articles.map((article) => (
<ArticleCardItem article={article} />
))}
</div>
</section>
)}

View File

@@ -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
---
<article class="card">
<a class="link" href={article.path}>
{cover.image && (
<div class="cover">
<ContentImage src={cover.image} alt={cover.alt} fit />
</div>
)}
<h3 class="title">{article.title}</h3>
</a>
{excerpt && <p class="excerpt">{excerpt}</p>}
{date && (
<time class="date" datetime={date.toISOString()}>
{toMilitaryDTG(date)}
</time>
)}
</article>

View File

@@ -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
---
<header>
<Overline article={article} />
<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

@@ -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 && <Figure image={image} {...rest} size="full" eager />}

View File

@@ -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)
---
<dl class="meta">
<div class="section">
<dt class="label">Author</dt>
<dd class="item">Dave Damage</dd>
</div>
{tags.length > 0 && (
<div class="section">
<dt class="label">Tags</dt>
<dd>
<ul class="tags">
{tags.map((tag) => (
<li class="tag">
<a href={tag.path} rel="tag">
{tag.title}
</a>
</li>
))}
</ul>
</dd>
</div>
)}
{display && (
<div class="section">
<dt class="label">{label}</dt>
<dd>
<time class="item" datetime={machine}>
{display}
</time>
</dd>
</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

@@ -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)
---
<div class="overline dark">
<div class="wrapper">
{trail.length > 1 && (
<nav aria-label="Breadcrumb" class="breadcrumbs">
<ol>
{trail.map((step, index) => (
<li>
{index === trail.length - 1 ? (
<span aria-current="page">{step.title}</span>
) : (
<a href={step.path}>{step.title}</a>
)}
</li>
))}
</ol>
</nav>
)}
{article.published ? (
<time class="publicationdate" datetime={article.published.toISOString()}>
{toMilitaryDTG(article.published)}
</time>
) : (
<span class="publicationdate">No publication date set</span>
)}
</div>
</div>
<style>
.overline {
font-family: var(--font-mono);
font-size: var(--type--1);
line-height: 1;
color: var(--color-text-secondary);
letter-spacing: var(--tracking-0);
background-color: var(--color-surface-elevated-2);
}
.wrapper {
@mixin layout-wrapper;
@mixin py var(--size-4);
display: flex;
gap: var(--size-4);
align-items: baseline;
justify-content: space-between; /* breadcrumbs left, date right */
}
.breadcrumbs {
& ol {
display: flex;
gap: var(--size-2);
margin: 0;
padding: 0;
list-style: none;
& li {
&:not(:last-child) {
&::after {
content: '⟫' / '';
margin-inline-start: var(--size-2);
}
}
}
}
}
.publicationdate {
font-weight: 700;
&::before {
content: '⟫' / '';
margin-inline-end: var(--size-2);
}
}
</style>

View File

@@ -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
---
<header class="header">
{eyebrow && <p class="eyebrow">{eyebrow}</p>}
<h1 class="title">{title}</h1>
{standfirst && <p class="standfirst">{standfirst}</p>}
<slot />
</header>

View File

@@ -0,0 +1,38 @@
---
type Props = {
title: string
subtitle?: string
}
const { title, subtitle } = Astro.props
---
<div class="wrapper content">
<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>

View File

@@ -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 && (
<nav class="toc" aria-labelledby="toc-title">
<h2 class="title" id="toc-title">
Contents
</h2>
<ol>
{entries.map((heading) => (
<li class:list={['entry', `entry--${heading.level}`]}>
<a href={`#${heading.id}`}>{heading.text}</a>
</li>
))}
</ol>
</nav>
)}

View File

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

View File

@@ -0,0 +1,3 @@
{
"title": "Alchemical Materialism"
}

View File

@@ -0,0 +1,3 @@
{
"title": "Genesis"
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
---
<BaseLayout {...meta}>
<article class="article">
<ArticleHeader article={article} />
<div class="frame">
<div class="body content prose">
<Node node={rendered.tree} />
</div>
<aside class="margin">
<slot name="margin" />
<SidenoteList sidenotes={rendered.sidenotes} />
</aside>
</div>
<FootnoteList footnotes={rendered.footnotes} />
</article>
<DevBanner
collection={article.collection}
id={article.id}
status={article.status}
diagnostics={rendered.diagnostics}
/>
<article class="article">
<h1>{article.title}</h1>
{article.summary.cover.image && (
<Figure
image={article.summary.cover.image}
alt={article.summary.cover.alt}
caption={article.summary.cover.caption}
credit={article.summary.cover.credit}
size="wide"
/>
)}
<div class="prose">
<Node node={rendered.tree} />
</div>
<SidenoteList sidenotes={rendered.sidenotes} />
<FootnoteList footnotes={rendered.footnotes} />
</article>
</BaseLayout>
<style>
.frame {
@mixin layout-wrapper;
position: relative;
@media (--bp-desktop) {
display: grid;
grid-template-columns: var(--layout-content-width) var(--layout-margin-width);
gap: var(--layout-gutter);
align-items: start;
}
}
.body,
.margin {
min-width: 0;
}
</style>

View File

@@ -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
---
<BaseLayout title={`Tagged: ${tag.title}`} description={tag.description || undefined}>
<DevBanner collection={tag.collection} id={tag.id} />
<section class="tag">
<h1>{tag.title}</h1>
{tag.description && <p>{tag.description}</p>}
<ul>
{tag.articles.map((article) => (
<li>
<a href={article.path}>{article.title}</a>
<p>{article.summary.excerpt}</p>
</li>
))}
</ul>
<SimpleHeader eyebrow="Tag" title={tag.title} standfirst={tag.description || undefined}>
<p class="count">
{count} {count === 1 ? 'article' : 'articles'}
</p>
</SimpleHeader>
<ArticleCardGrid articles={tag.articles} />
</section>
</BaseLayout>

View File

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

View File

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

View File

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

View File

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

27
src/pages/404.astro Normal file
View File

@@ -0,0 +1,27 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro'
import { getSite } from '@/lib/site'
const site = await getSite()
const roots = site.roots
---
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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