PreStyling Layouts & Components
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"css": {
|
"css": {
|
||||||
"lint": {
|
"lint": {
|
||||||
"propertyIgnoredDueToDisplay": "ignore",
|
"propertyIgnoredDueToDisplay": "ignore",
|
||||||
|
"unknownAtRules": "ignore",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ export default defineConfig({
|
|||||||
provider: fontProviders.google(),
|
provider: fontProviders.google(),
|
||||||
name: 'Geist',
|
name: 'Geist',
|
||||||
cssVariable: '--font-geist',
|
cssVariable: '--font-geist',
|
||||||
|
weights: ['100 900'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: fontProviders.google(),
|
provider: fontProviders.google(),
|
||||||
name: 'Geist Mono',
|
name: 'Geist Mono',
|
||||||
cssVariable: '--font-geist-mono',
|
cssVariable: '--font-geist-mono',
|
||||||
|
weights: ['100 900'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: fontProviders.google(),
|
provider: fontProviders.google(),
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
@@ -16,7 +16,6 @@ const { collection, id, status, diagnostics = [] } = Astro.props
|
|||||||
const NOTICE: Partial<Record<Status, string>> = {
|
const NOTICE: Partial<Record<Status, string>> = {
|
||||||
draft: 'Draft – not built in production',
|
draft: 'Draft – not built in production',
|
||||||
unlisted: 'Unlisted – has page, but appears in no list',
|
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
|
const site = import.meta.env.DEV ? await getSite() : null
|
||||||
|
|||||||
@@ -14,11 +14,54 @@ const { sidenotes } = Astro.props
|
|||||||
style={`position-anchor: --sn-${sidenote.n}`}
|
style={`position-anchor: --sn-${sidenote.n}`}
|
||||||
role="note"
|
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}
|
{sidenote.marker}
|
||||||
</a>
|
</a>
|
||||||
<div class="sidenote__note">
|
<p class="note">
|
||||||
|
{sidenote.type !== 'default' && <span class="kind">{sidenote.type} ⟫ </span>}
|
||||||
<Node node={sidenote.children} />
|
<Node node={sidenote.children} />
|
||||||
</div>
|
</p>
|
||||||
</aside>
|
</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>
|
||||||
|
|||||||
@@ -6,15 +6,27 @@ import { resolveImage } from '@/lib/images/resolve'
|
|||||||
type Props = {
|
type Props = {
|
||||||
src: string
|
src: string
|
||||||
alt: 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)
|
const image = resolveImage(src)
|
||||||
if (!image && import.meta.env.PROD) throw new Error(`Image not found: ${src}`)
|
if (!image && import.meta.env.PROD) throw new Error(`Image not found: ${src}`)
|
||||||
---
|
---
|
||||||
|
|
||||||
{image ? (
|
{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>
|
<span class="missing-image">Missing image: {src}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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'
|
import ContentImage from './ContentImage.astro'
|
||||||
|
|
||||||
@@ -9,17 +10,62 @@ type Props = {
|
|||||||
caption?: string
|
caption?: string
|
||||||
credit?: string
|
credit?: string
|
||||||
size?: FigureSize
|
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}`]}>
|
<figure
|
||||||
<ContentImage src={image} alt={alt} />
|
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) && (
|
{(caption || credit) && (
|
||||||
<figcaption>
|
<figcaption class="figcaption dark">
|
||||||
{caption && <span class="caption">{caption}</span>}
|
{caption && <span class="caption">{caption}</span>}
|
||||||
{credit && <span class="credit">{credit}</span>}
|
{credit && <span class="credit">{credit}</span>}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
)}
|
)}
|
||||||
</figure>
|
</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>
|
||||||
|
|||||||
23
src/components/post/ArticleCard/ArticleCardGrid.astro
Normal file
23
src/components/post/ArticleCard/ArticleCardGrid.astro
Normal 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>
|
||||||
|
)}
|
||||||
31
src/components/post/ArticleCard/ArticleCardItem.astro
Normal file
31
src/components/post/ArticleCard/ArticleCardItem.astro
Normal 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>
|
||||||
32
src/components/post/Header/ArticleHeader.astro
Normal file
32
src/components/post/Header/ArticleHeader.astro
Normal 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>
|
||||||
10
src/components/post/Header/Cover.astro
Normal file
10
src/components/post/Header/Cover.astro
Normal 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 />}
|
||||||
117
src/components/post/Header/Meta.astro
Normal file
117
src/components/post/Header/Meta.astro
Normal 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>
|
||||||
92
src/components/post/Header/Overline.astro
Normal file
92
src/components/post/Header/Overline.astro
Normal 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>
|
||||||
17
src/components/post/Header/SimpleHeader.astro
Normal file
17
src/components/post/Header/SimpleHeader.astro
Normal 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>
|
||||||
38
src/components/post/Header/Title.astro
Normal file
38
src/components/post/Header/Title.astro
Normal 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>
|
||||||
27
src/components/post/ToC.astro
Normal file
27
src/components/post/ToC.astro
Normal 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>
|
||||||
|
)}
|
||||||
@@ -1,13 +1,120 @@
|
|||||||
---
|
---
|
||||||
title: Prima Materia
|
title: Prima Materia
|
||||||
|
subtitle: Where we lay the ground
|
||||||
status: draft
|
status: draft
|
||||||
noindex: false
|
noindex: false
|
||||||
route:
|
route:
|
||||||
parent: stages
|
parent: stages
|
||||||
summary:
|
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:
|
dates:
|
||||||
published: 2026-09-21T12:35:00.000Z
|
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 %}.
|
||||||
|
|||||||
3
src/content/general/tags/alchemical-materialism.json
Normal file
3
src/content/general/tags/alchemical-materialism.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "Alchemical Materialism"
|
||||||
|
}
|
||||||
3
src/content/general/tags/genesis.json
Normal file
3
src/content/general/tags/genesis.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "Genesis"
|
||||||
|
}
|
||||||
@@ -1,26 +1,16 @@
|
|||||||
import { fields } from '@keystatic/core'
|
import { fields } from '@keystatic/core'
|
||||||
import { block } from '@keystatic/core/content-components'
|
import { block } from '@keystatic/core/content-components'
|
||||||
|
|
||||||
|
import { imageFields } from '@/keystatic/fields/content/image'
|
||||||
import { FIGURE_SIZES } from '@/lib/constants/figure'
|
import { FIGURE_SIZES } from '@/lib/constants/figure'
|
||||||
import { assets } from '@/lib/images/storage'
|
|
||||||
|
|
||||||
export const figure = (collection: string) =>
|
export const figure = (collection: string) =>
|
||||||
block({
|
block({
|
||||||
label: 'Figure',
|
label: 'Figure',
|
||||||
description: 'An image with alt text, and optionally a caption and credit.',
|
description: 'An image with alt text, and optionally a caption and credit.',
|
||||||
schema: {
|
schema: {
|
||||||
image: fields.image({
|
...imageFields(collection, {
|
||||||
label: 'Image',
|
required: true,
|
||||||
...assets(collection),
|
|
||||||
validation: {
|
|
||||||
isRequired: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
alt: fields.text({
|
|
||||||
label: 'Caption',
|
|
||||||
}),
|
|
||||||
credit: fields.text({
|
|
||||||
label: 'Credit',
|
|
||||||
}),
|
}),
|
||||||
size: fields.select({
|
size: fields.select({
|
||||||
label: 'Size',
|
label: 'Size',
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ export const title = fields.slug({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const subtitle = fields.text({
|
||||||
|
label: 'Subtitle',
|
||||||
|
})
|
||||||
|
|
||||||
export const status = fields.select({
|
export const status = fields.select({
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
options: STATUS_OPTIONS,
|
options: STATUS_OPTIONS,
|
||||||
@@ -35,6 +39,7 @@ export const noindex = fields.checkbox({
|
|||||||
|
|
||||||
export const base = {
|
export const base = {
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
status,
|
status,
|
||||||
noindex,
|
noindex,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,6 @@
|
|||||||
import { fields } from '@keystatic/core'
|
import { fields } from '@keystatic/core'
|
||||||
|
|
||||||
import { assets } from '@/lib/images/storage'
|
import { imageFields } from './image'
|
||||||
|
|
||||||
export const cover = (collection: string) =>
|
export const cover = (collection: string) =>
|
||||||
fields.object(
|
fields.object(imageFields(collection), { label: 'Cover' })
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
36
src/keystatic/fields/content/image.ts
Normal file
36
src/keystatic/fields/content/image.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@ import DevBanner from '@/components/dev/DevBanner.astro'
|
|||||||
import FootnoteList from '@/components/markdoc/blocks/general/FootnoteList.astro'
|
import FootnoteList from '@/components/markdoc/blocks/general/FootnoteList.astro'
|
||||||
import SidenoteList from '@/components/markdoc/blocks/general/SidenoteList.astro'
|
import SidenoteList from '@/components/markdoc/blocks/general/SidenoteList.astro'
|
||||||
import Node from '@/components/markdoc/Node.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 BaseLayout from '@/layouts/BaseLayout.astro'
|
||||||
import type { Rendered } from '@/lib/markdoc/types'
|
import type { Rendered } from '@/lib/markdoc/types'
|
||||||
import { metaOf } from '@/lib/site/meta'
|
import { metaOf } from '@/lib/site/meta'
|
||||||
@@ -19,27 +19,43 @@ const meta = metaOf(article)
|
|||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout {...meta}>
|
<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
|
<DevBanner
|
||||||
collection={article.collection}
|
collection={article.collection}
|
||||||
id={article.id}
|
id={article.id}
|
||||||
status={article.status}
|
status={article.status}
|
||||||
diagnostics={rendered.diagnostics}
|
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>
|
</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>
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
---
|
---
|
||||||
import DevBanner from '@/components/dev/DevBanner.astro'
|
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 BaseLayout from '@/layouts/BaseLayout.astro'
|
||||||
import type { Tag } from '@/lib/site/types'
|
import type { Tag } from '@/lib/site/types'
|
||||||
|
|
||||||
type Props = { tag: Tag }
|
type Props = { tag: Tag }
|
||||||
|
|
||||||
const { tag } = Astro.props
|
const { tag } = Astro.props
|
||||||
|
const count = tag.articles.length
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`Tagged: ${tag.title}`} description={tag.description || undefined}>
|
<BaseLayout title={`Tagged: ${tag.title}`} description={tag.description || undefined}>
|
||||||
<DevBanner collection={tag.collection} id={tag.id} />
|
<DevBanner collection={tag.collection} id={tag.id} />
|
||||||
<section class="tag">
|
<section class="tag">
|
||||||
<h1>{tag.title}</h1>
|
<SimpleHeader eyebrow="Tag" title={tag.title} standfirst={tag.description || undefined}>
|
||||||
{tag.description && <p>{tag.description}</p>}
|
<p class="count">
|
||||||
<ul>
|
{count} {count === 1 ? 'article' : 'articles'}
|
||||||
{tag.articles.map((article) => (
|
</p>
|
||||||
<li>
|
</SimpleHeader>
|
||||||
<a href={article.path}>{article.title}</a>
|
<ArticleCardGrid articles={tag.articles} />
|
||||||
<p>{article.summary.excerpt}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
export const FIGURE_SIZES = [
|
export const FIGURE_SIZES = [
|
||||||
{
|
{ label: 'Inline', value: 'inline' },
|
||||||
label: 'Inline',
|
{ label: 'Wide', value: 'wide' },
|
||||||
value: 'inline',
|
{ label: 'Full', value: 'full' },
|
||||||
},
|
] as const
|
||||||
{
|
|
||||||
label: 'Wide',
|
export const IMAGE_EFFECTS = [
|
||||||
value: 'wide',
|
{ label: 'None', value: 'none' },
|
||||||
},
|
{ label: 'Xeroxed', value: 'xeroxed' },
|
||||||
{
|
] as const
|
||||||
label: 'Full',
|
|
||||||
value: 'full',
|
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 FigureSize = (typeof FIGURE_SIZES)[number]['value']
|
||||||
|
export type ImageEffect = (typeof IMAGE_EFFECTS)[number]['value']
|
||||||
|
export type AspectRatio = (typeof ASPECT_RATIOS)[number]['value']
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ export const STATUS_OPTIONS = [
|
|||||||
label: 'Unlisted',
|
label: 'Unlisted',
|
||||||
value: 'unlisted',
|
value: 'unlisted',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Archived',
|
|
||||||
value: 'archived',
|
|
||||||
},
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type Status = (typeof STATUS_OPTIONS)[number]['value']
|
export type Status = (typeof STATUS_OPTIONS)[number]['value']
|
||||||
|
|||||||
@@ -5,6 +5,21 @@ const DISPLAY = new Intl.DateTimeFormat('en-GB', {
|
|||||||
year: 'numeric',
|
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 {
|
export function parseDate(value: string | null): Date | null {
|
||||||
if (!value) return null
|
if (!value) return null
|
||||||
const date = new Date(/(z|[+-]\d\d:\d\d)$/i.test(value) ? value : `${value}Z`)
|
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 formatDate = (date: Date): string => DISPLAY.format(date)
|
||||||
|
|
||||||
export const isoDate = (date: Date): string => date.toISOString().slice(0, 10)
|
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}`
|
||||||
|
}
|
||||||
|
|||||||
7
src/lib/site/navigation.ts
Normal file
7
src/lib/site/navigation.ts
Normal 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
27
src/pages/404.astro
Normal 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>
|
||||||
@@ -111,4 +111,44 @@
|
|||||||
--color-skeleton-base: var(--color-surface-elevated-2);
|
--color-skeleton-base: var(--color-surface-elevated-2);
|
||||||
--color-skeleton-shimmer: var(--color-surface-elevated-1);
|
--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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
--tracking-eyebrow: 0.16em; /* section eyebrows, zone labels */
|
--tracking-eyebrow: 0.16em; /* section eyebrows, zone labels */
|
||||||
--tracking-plate: 0.08em; /* plate numbers */
|
--tracking-plate: 0.08em; /* plate numbers */
|
||||||
--tracking-display: 0.01em; /* Blaka h1 */
|
--tracking-display: 0.01em; /* Blaka h1 */
|
||||||
|
--tracking-subtitle: 0.0875em; /* standfirst under a title */
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -108,7 +109,6 @@
|
|||||||
--tracking-8: -0.0225em;
|
--tracking-8: -0.0225em;
|
||||||
--tracking-9: -0.0237em;
|
--tracking-9: -0.0237em;
|
||||||
--tracking-10: -0.0247em;
|
--tracking-10: -0.0247em;
|
||||||
--leading-prose: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
--font-weight-body: 400;
|
--font-weight-body: 400;
|
||||||
--font-weight-emphasis: 700;
|
--font-weight-emphasis: 700;
|
||||||
--font-weight-label: 600;
|
--font-weight-label: 600;
|
||||||
--font-weight-heading: 800;
|
--font-weight-heading: 900;
|
||||||
|
|
||||||
/* === SIZE === */
|
/* === SIZE === */
|
||||||
--size-0: 0;
|
--size-0: 0;
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
|
|
||||||
/* ═══ LAYOUT ═══ */
|
/* ═══ LAYOUT ═══ */
|
||||||
--layout-max-width: 74rem; /* the tool shell */
|
--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-margin-width: minmax(10ch, 1fr);
|
||||||
--layout-step-columns: 2;
|
--layout-step-columns: 2;
|
||||||
--layout-gutter: var(--size-6); /* between step cards */
|
--layout-gutter: var(--size-6); /* between step cards */
|
||||||
|
|||||||
117
src/styles/components/figure.css
Normal file
117
src/styles/components/figure.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/styles/components/prose.css
Normal file
6
src/styles/components/prose.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@layer components {
|
||||||
|
.prose {
|
||||||
|
@mixin typo_body;
|
||||||
|
@mixin py var(--space-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
@import url("base/foundation.css");
|
@import url("base/foundation.css");
|
||||||
@import url("base/colors.css");
|
@import url("base/colors.css");
|
||||||
@import url("base/primitives.css");
|
@import url("base/primitives.css");
|
||||||
|
@import url("components/figure.css");
|
||||||
|
@import url("components/prose.css");
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
|
|||||||
60
src/styles/mixins/borders.css
Normal file
60
src/styles/mixins/borders.css
Normal 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;
|
||||||
|
}
|
||||||
11
src/styles/mixins/helpers.css
Normal file
11
src/styles/mixins/helpers.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/styles/mixins/spacing.css
Normal file
85
src/styles/mixins/spacing.css
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/styles/mixins/typography.css
Normal file
101
src/styles/mixins/typography.css
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user