Added RevisionCard

This commit is contained in:
2026-09-30 14:04:20 +02:00
parent 8ef3899782
commit c6023cf857
40 changed files with 431 additions and 72 deletions

View File

@@ -1,12 +1,21 @@
import { config } from '@keystatic/core' import { config } from '@keystatic/core'
import { contentCollections } from '@/keystatic/collections/general' import { contentCollections } from '@/keystatic/collections/general'
import { siteCollections } from '@/keystatic/collections/site'
import { ARTICLES, PAGES, RELEASES, TAGS } from '@/lib/constants/collections'
export default config({ export default config({
storage: { storage: {
kind: 'local', kind: 'local',
}, },
ui: {
navigation: {
Content: [ARTICLES, PAGES, TAGS],
Site: [RELEASES],
},
},
collections: { collections: {
...contentCollections, ...contentCollections,
...siteCollections,
}, },
}) })

View File

@@ -10,7 +10,7 @@ type Props = {
const { article } = Astro.props const { article } = Astro.props
const { cover, excerpt } = article.summary const { cover, excerpt } = article.summary
const date = article.published const date = article.publishedAt
--- ---
<article class="card"> <article class="card">

View File

@@ -1,17 +1,22 @@
--- ---
import type { Sequence } from '@/lib/site/navigation' import type { Sequence } from '@/lib/site/navigation'
import type { Article } from '@/lib/site/types'
import RevisionCard from './RevisionCard.astro'
import SequenceNav from './SequenceNav.astro' import SequenceNav from './SequenceNav.astro'
type Props = { type Props = {
article: Article
sequence: Sequence | null sequence: Sequence | null
} }
const { sequence } = Astro.props const { article, sequence } = Astro.props
const { title, changes } = article
--- ---
{sequence && ( {(changes.length > 0 || sequence) && (
<footer class="article-footer"> <footer class="article-footer">
<SequenceNav sequence={sequence} /> {changes.length > 0 && <RevisionCard title={title} changes={changes} />}
{sequence && <SequenceNav sequence={sequence} />}
</footer> </footer>
)} )}

View File

@@ -0,0 +1,38 @@
---
import { CHANGE_TYPES } from '@/lib/constants/changelog'
import { toMilitaryDTG } from '@/lib/content/dates'
import type { Change } from '@/lib/site/types'
type Props = {
title: string
changes: Change[]
}
const LIMIT = 5
const { title, changes } = Astro.props
const labelOf = (kind: Change['kind']) =>
CHANGE_TYPES.find((option) => option.value === kind)?.label ?? kind
---
<section class="revision-card" aria-labelledby="revisions-heading">
<div class="revision-card__head">
<h2 id="revisions-heading" class="revision-card__heading">
Revisions
</h2>
<p class="revision-card__subject">{title}</p>
</div>
<ol class="revision-card__list">
{changes.slice(0, LIMIT).map(({ kind, text, release }) => (
<li class="revision-card__row">
<span class="revision-card__version">v{release.version}</span>
<time class="revision-card__date" datetime={release.date.toISOString()}>
{toMilitaryDTG(release.date)}
</time>
<span class="revision-card__kind">{labelOf(kind)}</span>
<p class="revision-card__text">{text}</p>
</li>
))}
</ol>
</section>

View File

@@ -15,7 +15,7 @@ type Props = {
const { article } = Astro.props const { article } = Astro.props
const crumbs = trailOf(article) const crumbs = trailOf(article)
const published = article.published const published = article.publishedAt
--- ---
<header class="article-header"> <header class="article-header">

View File

@@ -11,8 +11,10 @@ type Props = {
const { article } = Astro.props const { article } = Astro.props
const site = await getSite() const site = await getSite()
const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined) const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined)
const date = article.updated ?? article.published const latest = article.changes[0]?.release.date
const dateLabel = article.updated ? 'Updated' : 'Published' const updated = latest && article.publishedAt && latest > article.publishedAt ? latest : null
const date = updated ?? article.publishedAt
const dateLabel = updated ? 'Updated' : 'Published'
--- ---
<dl class="article-header__meta"> <dl class="article-header__meta">

View File

@@ -12,7 +12,6 @@ summary:
cover: cover:
effect: none effect: none
ratio: original ratio: original
dates:
published: 2026-09-30T12:27:00.000Z published: 2026-09-30T12:27:00.000Z
tags: tags:
- alchemical-materialism - alchemical-materialism

View File

@@ -12,7 +12,6 @@ summary:
cover: cover:
effect: none effect: none
ratio: original ratio: original
dates:
published: 2026-09-30T12:27:00.000Z published: 2026-09-30T12:27:00.000Z
tags: tags:
- alchemical-materialism - alchemical-materialism

View File

@@ -12,7 +12,6 @@ summary:
cover: cover:
effect: none effect: none
ratio: original ratio: original
dates:
published: 2026-09-30T12:27:00.000Z published: 2026-09-30T12:27:00.000Z
tags: tags:
- alchemical-materialism - alchemical-materialism

View File

@@ -12,7 +12,6 @@ summary:
cover: cover:
effect: none effect: none
ratio: original ratio: original
dates:
published: 2026-09-30T12:27:00.000Z published: 2026-09-30T12:27:00.000Z
tags: tags:
- alchemical-materialism - alchemical-materialism

View File

@@ -12,7 +12,6 @@ summary:
cover: cover:
effect: none effect: none
ratio: original ratio: original
dates:
published: 2026-09-30T12:27:00.000Z published: 2026-09-30T12:27:00.000Z
tags: tags:
- alchemical-materialism - alchemical-materialism

View File

@@ -15,7 +15,6 @@ summary:
credit: John Martin – The Great Day of His Wrath credit: John Martin – The Great Day of His Wrath
effect: xeroxed effect: xeroxed
ratio: 21-9 ratio: 21-9
dates:
published: 2026-09-21T12:35:00.000Z published: 2026-09-21T12:35:00.000Z
tags: tags:
- the-crucible - the-crucible

View File

@@ -10,7 +10,6 @@ summary:
cover: cover:
effect: none effect: none
ratio: original ratio: original
dates:
published: 2026-09-21T12:34:00.000Z published: 2026-09-21T12:34:00.000Z
tags: [] tags: []
--- ---

View File

@@ -12,7 +12,6 @@ summary:
cover: cover:
effect: none effect: none
ratio: original ratio: original
dates:
published: 2026-09-30T12:27:00.000Z published: 2026-09-30T12:27:00.000Z
tags: tags:
- alchemical-materialism - alchemical-materialism

View File

@@ -8,7 +8,6 @@ summary:
Where we explore the crucible – a materialistic alchemist Worldbuilding Where we explore the crucible – a materialistic alchemist Worldbuilding
Framework Framework
cover: {} cover: {}
dates:
published: 2026-09-21T12:33:00.000Z published: 2026-09-21T12:33:00.000Z
tags: tags:
- the-crucible - the-crucible

View File

@@ -0,0 +1,6 @@
version: '0.12'
date: 2026-09-30T13:00:00.000Z
changes:
- kind: added
article: prima-materia
text: Section on the ground state; first table of correspondences

View File

@@ -0,0 +1,12 @@
version: '0.13'
date: 2026-09-30T13:00:00.000Z
changes:
- kind: added
article: calcination
text: Test
- kind: added
article: fermentation
text: Big test with a longer sentence
- kind: changed
article: coagulation
text: Lorem ipsum, blablabla ipbsum whorum

View File

@@ -0,0 +1,19 @@
version: '0.14'
date: 2026-09-30T13:00:00.000Z
changes:
- kind: added
article: coagulation
text: >-
s the raw release exactly as Keystatic reads it, inferred from the schema
like the other three
- kind: changed
article: prima-materia
text: >-
releases are loaded, resolved and attached to articles. Six files; nothing
renders yet.
- kind: fixed
article: fermentation
text: We don't burn the priests anymore, we burn the gods
- kind: removed
article: the-crucible
text: AND EVERYTHING GOES BOOOOOOOM

View File

@@ -1,8 +1,7 @@
import { collection, fields } from '@keystatic/core' import { collection, fields } from '@keystatic/core'
import { base, stage } from '@/keystatic/fields/content/base' import { base, published, stage } from '@/keystatic/fields/content/base'
import { content } from '@/keystatic/fields/content/content' import { content } from '@/keystatic/fields/content/content'
import { dates } from '@/keystatic/fields/content/dates'
import { articleRoute } from '@/keystatic/fields/content/route' import { articleRoute } from '@/keystatic/fields/content/route'
import { articleSummary } from '@/keystatic/fields/content/summary' import { articleSummary } from '@/keystatic/fields/content/summary'
import { ARTICLES, TAGS } from '@/lib/constants/collections' import { ARTICLES, TAGS } from '@/lib/constants/collections'
@@ -21,7 +20,7 @@ export const articles = collection({
stage, stage,
route: articleRoute, route: articleRoute,
summary: articleSummary(ARTICLES), summary: articleSummary(ARTICLES),
dates, published,
tags: fields.multiRelationship({ tags: fields.multiRelationship({
label: 'Tags', label: 'Tags',
collection: TAGS, collection: TAGS,

View File

@@ -0,0 +1,7 @@
import { RELEASES } from '@/lib/constants/collections'
import { releases } from './releases'
export const siteCollections = {
[RELEASES]: releases,
}

View File

@@ -0,0 +1,66 @@
import { collection, fields } from '@keystatic/core'
import { CHANGE_TYPES } from '@/lib/constants/changelog'
import { ARTICLES, RELEASES } from '@/lib/constants/collections'
const VERSION = /^\d+\.\d+(\.\d+)?$/
export const releases = collection({
label: 'Releases',
slugField: 'version',
path: `src/content/site/${RELEASES}/*`,
columns: ['version', 'date'],
schema: {
version: fields.slug({
name: {
label: 'Version',
description: 'Without the v: 0.12, 1.0, 1.0.3',
validation: {
isRequired: true,
pattern: {
regex: VERSION,
message: 'Numbers separated by dots, like 0.12 or 1.0.3',
},
},
},
slug: {
label: 'File ID',
generate: (version) => `v${version.replaceAll('.', '-')}`,
},
}),
date: fields.datetime({
label: 'Date',
defaultValue: {
kind: 'now',
},
validation: {
isRequired: true,
},
}),
changes: fields.array(
fields.object({
kind: fields.select({
label: 'Kind',
options: CHANGE_TYPES,
defaultValue: 'added',
}),
article: fields.relationship({
label: 'Article',
description: 'Empty for site-wide changes',
collection: ARTICLES,
}),
text: fields.text({
label: 'Text',
validation: {
isRequired: true,
},
}),
}),
{
label: 'Changes',
itemLabel: (props) =>
`${props.fields.kind.value} · ${props.fields.article.value ?? 'site'} · ${props.fields.text.value}`,
},
),
},
})

View File

@@ -45,6 +45,13 @@ export const noindex = fields.checkbox({
label: 'Hide from search engines', label: 'Hide from search engines',
}) })
export const published = fields.datetime({
label: 'Published',
defaultValue: {
kind: 'now',
},
})
export const base = { export const base = {
title, title,
subtitle, subtitle,

View File

@@ -1,18 +0,0 @@
import { fields } from '@keystatic/core'
export const dates = fields.object(
{
published: fields.datetime({
label: 'Published',
defaultValue: {
kind: 'now',
},
}),
updated: fields.datetime({
label: 'Updated',
}),
},
{
label: 'Dates',
},
)

View File

@@ -38,7 +38,7 @@ const sequence = sequenceOf(article)
<FootnoteList footnotes={rendered.footnotes} /> <FootnoteList footnotes={rendered.footnotes} />
</div> </div>
</div> </div>
<ArticleFooter sequence={sequence} /> <ArticleFooter sequence={sequence} article={article} />
</article> </article>
<DevBanner <DevBanner
collection={article.collection} collection={article.collection}
@@ -58,12 +58,10 @@ const sequence = sequenceOf(article)
gap: var(--layout-gutter); gap: var(--layout-gutter);
@media (--bp-desktop) { @media (--bp-desktop) {
@mixin layout-columns;
display: grid; display: grid;
grid-template-columns: row-gap: var(--layout-gutter);
[rail-start] minmax(var(--layout-rail-width), 1fr)
[rail-end body-start] var(--layout-content-width)
[body-end margin-start] var(--layout-margin-width) [margin-end];
gap: var(--layout-gutter);
align-items: start; align-items: start;
} }
} }

View File

@@ -0,0 +1,20 @@
export const CHANGE_TYPES = [
{
label: 'Added',
value: 'added',
},
{
label: 'Changed',
value: 'changed',
},
{
label: 'Fixed',
value: 'fixed',
},
{
label: 'Removed',
value: 'removed',
},
] as const
export type ChangeType = (typeof CHANGE_TYPES)[number]['value']

View File

@@ -3,3 +3,6 @@
export const ARTICLES = 'articles' export const ARTICLES = 'articles'
export const PAGES = 'pages' export const PAGES = 'pages'
export const TAGS = 'tags' export const TAGS = 'tags'
/* === SITE COLLECTIONS === */
export const RELEASES = 'releases'

View File

@@ -1,8 +1,8 @@
import { ARTICLES, PAGES, TAGS } from '../constants/collections' import { ARTICLES, PAGES, RELEASES, TAGS } from '../constants/collections'
import { createDiagnostics } from '../diagnostics' import { createDiagnostics } from '../diagnostics'
import type { Diagnostics } from '../diagnostics/types' import type { Diagnostics } from '../diagnostics/types'
import { createContentReader } from './reader' import { createContentReader } from './reader'
import type { ArticleEntry, Content, PageEntry, TagEntry } from './types' import type { ArticleEntry, Content, PageEntry, ReleaseEntry, TagEntry } from './types'
interface CollectionReader<T> { interface CollectionReader<T> {
list(): Promise<string[]> list(): Promise<string[]>
@@ -36,10 +36,11 @@ async function readAll<T>(
export async function loadContent(): Promise<Content> { export async function loadContent(): Promise<Content> {
const { collections } = createContentReader() const { collections } = createContentReader()
const problems = createDiagnostics() const problems = createDiagnostics()
const [articles, pages, tags] = await Promise.all([ const [articles, pages, tags, releases] = await Promise.all([
readAll<ArticleEntry>(collections.articles, ARTICLES, problems), readAll<ArticleEntry>(collections.articles, ARTICLES, problems),
readAll<PageEntry>(collections.pages, PAGES, problems), readAll<PageEntry>(collections.pages, PAGES, problems),
readAll<TagEntry>(collections.tags, TAGS, problems), readAll<TagEntry>(collections.tags, TAGS, problems),
readAll<ReleaseEntry>(collections.releases, RELEASES, problems),
]) ])
return { articles, pages, tags, diagnostics: problems.list } return { articles, pages, tags, releases, diagnostics: problems.list }
} }

View File

@@ -9,10 +9,12 @@ type Collections = typeof config.collections
export type ArticleEntry = Entry<Collections['articles']> export type ArticleEntry = Entry<Collections['articles']>
export type PageEntry = Entry<Collections['pages']> export type PageEntry = Entry<Collections['pages']>
export type TagEntry = Entry<Collections['tags']> export type TagEntry = Entry<Collections['tags']>
export type ReleaseEntry = Entry<Collections['releases']>
export type Content = { export type Content = {
articles: Map<string, ArticleEntry> articles: Map<string, ArticleEntry>
pages: Map<string, PageEntry> pages: Map<string, PageEntry>
tags: Map<string, TagEntry> tags: Map<string, TagEntry>
releases: Map<string, ReleaseEntry>
diagnostics: Diagnostic[] diagnostics: Diagnostic[]
} }

View File

@@ -3,6 +3,7 @@ import { loadContent } from '../content/load'
import type { Content } from '../content/types' import type { Content } from '../content/types'
import { createDiagnostics } from '../diagnostics' import { createDiagnostics } from '../diagnostics'
import type { Diagnostics } from '../diagnostics/types' import type { Diagnostics } from '../diagnostics/types'
import { collectReleases } from './releases'
import { import {
isBuilt, isBuilt,
newestFirst, newestFirst,
@@ -58,6 +59,7 @@ export function buildSite(content: Content, visibility: Visibility): Site {
const articles = placeArticles(content, visibility, problems) const articles = placeArticles(content, visibility, problems)
const pages = placePages(content, visibility, problems) const pages = placePages(content, visibility, problems)
const tags = collectTags(content, articles) const tags = collectTags(content, articles)
const releases = collectReleases(content, articles, problems)
validate(articles.values(), content, problems) validate(articles.values(), content, problems)
const byPath = indexUrls([...articles.values(), ...pages.values()], problems) const byPath = indexUrls([...articles.values(), ...pages.values()], problems)
const roots = [...articles.values()] const roots = [...articles.values()]
@@ -68,6 +70,7 @@ export function buildSite(content: Content, visibility: Visibility): Site {
articles, articles,
pages, pages,
tags, tags,
releases,
byPath, byPath,
known: new Set(content.articles.keys()), known: new Set(content.articles.keys()),
roots, roots,

59
src/lib/site/releases.ts Normal file
View File

@@ -0,0 +1,59 @@
import { RELEASES } from '../constants/collections'
import { parseDate } from '../content/dates'
import type { Content } from '../content/types'
import type { Diagnostics } from '../diagnostics/types'
import type { Article, Release } from './types'
const partsOf = (version: string) => version.split('.').map(Number)
function compareVersions(a: string, b: string): number {
const [left, right] = [partsOf(a), partsOf(b)]
for (let i = 0; i < Math.max(left.length, right.length); i++) {
const difference = (left[i] ?? 0) - (right[i] ?? 0)
if (difference) return difference
}
return 0
}
const releaseOrder = (a: Release, b: Release) => compareVersions(b.version, a.version)
export function collectReleases(
content: Content,
articles: Map<string, Article>,
problems: Diagnostics,
): Release[] {
const releases: Release[] = []
for (const [id, entry] of content.releases) {
const date = parseDate(entry.date)
if (!date) {
problems.error(RELEASES, id, 'Needs a date')
continue
}
const release: Release = { id, version: entry.version, date, changes: [] }
for (const { kind, article: target, text } of entry.changes) {
const article = target ? articles.get(target) : null
if (target && !article) {
if (!content.articles.has(target))
problems.error(RELEASES, id, `Article "${target}" does not exist`)
continue
}
release.changes.push({ kind, article: article ?? null, text, release })
}
if (release.changes.length) releases.push(release)
}
releases.sort(releaseOrder)
releases.forEach((release, i) => {
const older = releases[i + 1]
if (older && release.date < older.date)
problems.warning(RELEASES, release.id, `Dated before v${older.version}`)
})
for (const release of releases)
for (const change of release.changes) change.article?.changes.push(change)
return releases
}

View File

@@ -27,7 +27,7 @@ export function segmentProblem(segment: string, atRoot: boolean): string | null
const time = (date: Date | null) => date?.getTime() ?? 0 const time = (date: Date | null) => date?.getTime() ?? 0
const UNINDEXED = Number.MAX_SAFE_INTEGER const UNINDEXED = Number.MAX_SAFE_INTEGER
export const newestFirst = (a: Article, b: Article) => time(b.published) - time(a.published) export const newestFirst = (a: Article, b: Article) => time(b.publishedAt) - time(a.publishedAt)
export const siblingOrder = (a: Article, b: Article) => export const siblingOrder = (a: Article, b: Article) =>
(a.route.index ?? UNINDEXED) - (b.route.index ?? UNINDEXED) || (a.route.index ?? UNINDEXED) - (b.route.index ?? UNINDEXED) ||

View File

@@ -54,10 +54,10 @@ export function placeArticles(
id, id,
path: `${parent?.path ?? ''}/${segment}`, path: `${parent?.path ?? ''}/${segment}`,
listed: isListed(entry.status, visibility), listed: isListed(entry.status, visibility),
published: parseDate(entry.dates.published), publishedAt: parseDate(entry.published),
updated: parseDate(entry.dates.updated),
parent, parent,
children: [], children: [],
changes: [],
} }
placed.set(id, article) placed.set(id, article)
return article return article

View File

@@ -1,3 +1,4 @@
import type { ChangeType } from '../constants/changelog'
import { ARTICLES, PAGES, TAGS } from '../constants/collections' import { ARTICLES, PAGES, TAGS } from '../constants/collections'
import type { ArticleEntry, PageEntry, TagEntry } from '../content/types' import type { ArticleEntry, PageEntry, TagEntry } from '../content/types'
import type { Diagnostic } from '../diagnostics/types' import type { Diagnostic } from '../diagnostics/types'
@@ -7,10 +8,10 @@ export type Article = ArticleEntry & {
id: string id: string
path: string path: string
listed: boolean listed: boolean
published: Date | null publishedAt: Date | null
updated: Date | null
parent: Article | null parent: Article | null
children: Article[] children: Article[]
changes: Change[]
} }
export type Page = PageEntry & { export type Page = PageEntry & {
@@ -26,12 +27,27 @@ export type Tag = TagEntry & {
articles: Article[] articles: Article[]
} }
export type Release = {
id: string
version: string
date: Date
changes: Change[]
}
export type Change = {
kind: ChangeType
article: Article | null
text: string
release: Release
}
export type Routed = Article | Page export type Routed = Article | Page
export type Site = { export type Site = {
articles: Map<string, Article> articles: Map<string, Article>
pages: Map<string, Page> pages: Map<string, Page>
tags: Map<string, Tag> tags: Map<string, Tag>
releases: Release[]
byPath: Map<string, Routed> byPath: Map<string, Routed>
known: Set<string> known: Set<string>
roots: Article[] roots: Article[]

View File

@@ -4,17 +4,16 @@ import type { Diagnostics } from '../diagnostics/types'
import type { Article } from './types' import type { Article } from './types'
export function validate(articles: Iterable<Article>, content: Content, problems: Diagnostics) { export function validate(articles: Iterable<Article>, content: Content, problems: Diagnostics) {
for (const { id, status, summary, published, updated, tags } of articles) { for (const { id, status, summary, publishedAt, tags } of articles) {
const { excerpt, cover } = summary const { excerpt, cover } = summary
const error = (message: string) => problems.error(ARTICLES, id, message) const error = (message: string) => problems.error(ARTICLES, id, message)
const warn = (message: string) => problems.warning(ARTICLES, id, message) const warn = (message: string) => problems.warning(ARTICLES, id, message)
if (status !== 'draft' && !excerpt) error('Needs an excerpt') if (status !== 'draft' && !excerpt) error('Needs an excerpt')
if (status !== 'draft' && !published) error('Needs a published date') if (status !== 'draft' && !publishedAt) error('Needs a published date')
if (cover.image && !cover.alt) error('Cover image needs alt text') if (cover.image && !cover.alt) error('Cover image needs alt text')
if (!cover.image && (cover.alt || cover.caption || cover.credit)) if (!cover.image && (cover.alt || cover.caption || cover.credit))
warn('The cover has text but not image!') warn('The cover has text but not image!')
if (published && updated && updated < published) warn('Updated before it was published')
for (const tag of tags) if (!content.tags.has(tag)) error(`Tag "${tag}" does not exist`) for (const tag of tags) if (!content.tags.has(tag)) error(`Tag "${tag}" does not exist`)
} }
} }

View File

@@ -5,5 +5,17 @@
display: grid; display: grid;
gap: var(--size-10); gap: var(--size-10);
@media (--bp-desktop) {
@mixin layout-columns;
& > .revision-card {
grid-column: body-start / body-end;
}
& > .sequence-nav {
grid-column: rail-start / margin-end;
}
}
} }
} }

View File

@@ -109,17 +109,10 @@
.article-header__tag { .article-header__tag {
@mixin py var(--size-1); @mixin py var(--size-1);
@mixin px var(--size-3); @mixin px var(--size-3);
@mixin typo_tag;
display: block; display: block;
font-size: var(--type--2);
font-weight: var(--font-weight-emphasis);
line-height: var(--leading--2);
color: var(--color-text-inverse); color: var(--color-text-inverse);
letter-spacing: var(--tracking-tag);
background-color: var(--color-surface-inverse); background-color: var(--color-surface-inverse);
transition: background-color var(--motion-fast) var(--ease-out); transition: background-color var(--motion-fast) var(--ease-out);
&:hover { &:hover {

View File

@@ -2,3 +2,4 @@
@import url("stage-badge.css"); @import url("stage-badge.css");
@import url("article-footer.css"); @import url("article-footer.css");
@import url("sequence-nav.css"); @import url("sequence-nav.css");
@import url("revision-card.css");

View File

@@ -0,0 +1,92 @@
@layer components.blocks {
.revision-card {
@mixin border-t var(--border-boundary), solid, var(--color-border-strong);
@mixin pt var(--size-8);
}
.revision-card__head {
@mixin mb var(--size-6);
display: flex;
flex-wrap: wrap;
gap: var(--size-2) var(--size-6);
align-items: baseline;
justify-content: space-between;
}
.revision-card__heading {
@mixin typo_heading-2;
@mixin ma var(--size-0);
}
.revision-card__subject {
@mixin typo_eyebrow;
@mixin ma var(--size-0);
color: var(--color-text-secondary);
}
.revision-card__list {
@mixin ma var(--size-0);
@mixin pa var(--size-0);
list-style: none;
@media (--bp-tablet-up) {
display: grid;
grid-template-columns: auto auto auto minmax(0, 1fr);
column-gap: var(--size-10);
}
}
.revision-card__row {
@mixin border-b var(--border-hairline), solid, var(--color-border-subtle);
@mixin py var(--size-6);
display: grid;
grid-template-columns: auto auto minmax(0, 1fr);
gap: var(--size-2) var(--size-8);
align-items: baseline;
font-family: var(--font-mono);
font-size: var(--type-0);
line-height: var(--leading-0);
@media (--bp-tablet-up) {
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
}
.revision-card__version {
font-weight: var(--font-weight-emphasis);
color: var(--color-text-primary);
}
.revision-card__date {
color: var(--color-text-quiet);
}
.revision-card__kind {
@mixin typo_tag;
@mixin py var(--size-1);
@mixin px var(--size-3);
justify-self: start;
color: var(--color-text-inverse);
background-color: var(--color-surface-inverse);
}
.revision-card__text {
@mixin ma var(--size-0);
grid-column: 1 / -1;
font-family: var(--font-body);
line-height: var(--leading-prose-tight);
color: var(--color-text-primary);
@media (--bp-tablet-up) {
grid-column: auto;
}
}
}

View File

@@ -15,3 +15,11 @@
width: min(100% - 2 * var(--layout-page-margin), var(--layout-wrapper-width)); width: min(100% - 2 * var(--layout-page-margin), var(--layout-wrapper-width));
} }
@define-mixin layout-columns {
grid-template-columns:
[rail-start] minmax(var(--layout-rail-width), 1fr)
[rail-end body-start] var(--layout-content-width)
[body-end margin-start] var(--layout-margin-width) [margin-end];
column-gap: var(--layout-gutter);
}

View File

@@ -108,3 +108,12 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: var(--tracking-eyebrow); letter-spacing: var(--tracking-eyebrow);
} }
@define-mixin typo_tag {
font-family: var(--font-mono);
font-size: var(--type--2);
font-weight: var(--font-weight-emphasis);
line-height: var(--leading--2);
text-transform: uppercase;
letter-spacing: var(--tracking-tag);
}