Styling and astro refactor of sidenotes

This commit is contained in:
2026-09-28 16:55:18 +02:00
parent 672044cbe7
commit a704c51f75
14 changed files with 409 additions and 125 deletions

View File

@@ -12,10 +12,12 @@ const { footnotes } = Astro.props
<ol>
{footnotes.map((footnote) => (
<li id={`fn-${footnote.n}`}>
<p class="footnotes__note">
<Node node={footnote.children} />{' '}
<a href={`#fnref-${footnote.n}`} class="footnotes__back" aria-label="Back to text">
↩
</a>
</p>
</li>
))}
</ol>

View File

@@ -1,67 +0,0 @@
---
import Node from '@/components/markdoc/Node.astro'
import type { Sidenote } from '@/lib/markdoc/types'
type Props = { sidenotes: Sidenote[] }
const { sidenotes } = Astro.props
---
{sidenotes.map((sidenote) => (
<aside
class:list={['sidenote', `sidenote--${sidenote.type}`]}
id={`sn-${sidenote.n}`}
style={`position-anchor: --sn-${sidenote.n}`}
role="note"
>
<a class="marker" href={`#snref-${sidenote.n}`} aria-label="Back to the text">
{sidenote.marker}
</a>
<p class="note">
{sidenote.type !== 'default' && <span class="kind">{sidenote.type} ⟫ </span>}
<Node node={sidenote.children} />
</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

@@ -98,30 +98,43 @@ for (const [key, value] of Object.entries(crucible)) {
## Tables
{% tableWrapper variant="default" caption="A wrapped table with a caption" %}
{% tableWrapper
variant="default"
caption="Stages of the Work"
columns=["rowheader", "key", "num", "num", "text"] %}
{% table %}
- Stage
- No
- Heat
- Hours
- Notes
---
- Prima Materia
- I
- 3
- Longer cell text to see how wrapping behaves in a data cell
- 0.25
- The undifferentiated ground; nothing is named yet.
---
- Calcination
- II
- 7
- 1.50
- Burned down to what will not burn.
---
- Solutio
- III
- 5
- Short
- 12
- Dissolved back into flow; forms loosen.
---
- Tincture
- VII
- 10
- 120.75
- The finished colour, carried into everything it touches.
{% /table %}
{% /tableWrapper %}
{% table %}
- Column A
- Column B
---
- A bare table
- No wrapper
{% /table %}
## Images
{% figure

View File

@@ -1,7 +1,7 @@
import { fields } from '@keystatic/core'
import { wrapper } from '@keystatic/core/content-components'
import { TABLE_VARIANTS } from '@/lib/constants/blocks/table'
import { COLUMN_TYPES, TABLE_VARIANTS } from '@/lib/constants/blocks/table'
export const tableWrapper = wrapper({
label: 'Table Wrapper',
@@ -15,5 +15,17 @@ export const tableWrapper = wrapper({
caption: fields.text({
label: 'Caption',
}),
columns: fields.array(
fields.select({
label: 'Type',
options: COLUMN_TYPES,
defaultValue: 'default',
}),
{
label: 'Columns',
description: 'One per column, left to right.',
itemLabel: (item) => item.value,
},
),
},
})

View File

@@ -4,13 +4,34 @@ export const TABLE_VARIANTS = [
value: 'default',
},
{
label: 'Random',
value: 'random',
label: 'Matrix',
value: 'matrix',
},
{
label: 'Icon',
value: 'icon',
},
]
] as const
export type TableVariant = (typeof TABLE_VARIANTS)[number]['value']
export const COLUMN_TYPES = [
{
label: 'Default',
value: 'default',
},
{
label: 'Number',
value: 'num',
},
{
label: 'Key',
value: 'key',
},
{
label: 'Sentence',
value: 'text',
},
{
label: 'Row header',
value: 'rowheader',
},
] as const
export type ColumnType = (typeof COLUMN_TYPES)[number]['value']

View File

@@ -20,7 +20,7 @@ export async function render(
headings: [],
ids: new Set(),
footnotes: [],
sidenotes: [],
sidenoteCount: 0,
markers: new Set(),
problems: [],
}
@@ -41,7 +41,6 @@ export async function render(
tree,
headings: state.headings,
footnotes: state.footnotes,
sidenotes: state.sidenotes,
diagnostics: problems.list,
}
}

View File

@@ -95,9 +95,8 @@ export function createTags(state: NodeState, resolveLink: ResolveLink): Record<s
message: `Two sidenotes use the marker "${marker}".`,
})
state.markers.add(marker)
const n = state.sidenotes.length + 1
const n = (state.sidenoteCount += 1)
const children = inlineOf(text(node.attributes.note))
state.sidenotes.push({ n, marker, type: noteType, children })
return [
new Tag('a', { class: 'sidenote-ref', id: `snref-${n}`, href: `#sn-${n}` }, [marker]),
new Tag(
@@ -136,15 +135,33 @@ export function createTags(state: NodeState, resolveLink: ResolveLink): Record<s
},
tableWrapper: {
...generated.tableWrapper,
/* Keystatic makes an array attribute required; a table without column
types is the normal case. */
attributes: {
...generated.tableWrapper?.attributes,
columns: { ...generated.tableWrapper?.attributes?.columns, required: false },
},
transform(node, config) {
const { columns, ...attributes } = node.transformAttributes(config) as {
columns?: string[]
}
const children = node.transformChildren(config)
if (columns?.length) applyColumns(children, columns, state)
return new Tag('TableWrapper', attributes, children)
},
/* The children of a Keystatic wrapper are free-form block content, so
"exactly one table" can only be a build-time check. */
validate(node) {
const tables = node.children.filter((child) => child.tag === 'table').length
const tables = node.children.filter(
(child) => child.type === 'table' || child.tag === 'table',
).length
return tables === 1
? []
: [
{
id: 'table-wrapper-children',
level: 'warning' as const,
message: `A table wrapper should hold exactly one table, this one holds ${tables}.`,
level: 'error' as const,
message: `A table wrapper holds exactly one table, this one holds ${tables}.`,
},
]
},

View File

@@ -13,13 +13,6 @@ export type Footnote = {
children: RenderableTreeNode[]
}
export type Sidenote = {
n: number
marker: string
type: string
children: RenderableTreeNode[]
}
export type Problem = {
level: Level
message: string
@@ -29,7 +22,7 @@ export type NodeState = {
headings: Heading[]
ids: Set<string>
footnotes: Footnote[]
sidenotes: Sidenote[]
sidenoteCount: number
markers: Set<string>
problems: Problem[]
}
@@ -42,7 +35,6 @@ export type Rendered = {
tree: RenderableTreeNode
headings: Heading[]
footnotes: Footnote[]
sidenotes: Sidenote[]
diagnostics: Diagnostic[]
}

View File

@@ -0,0 +1,109 @@
@layer components.blocks {
.footnote-ref {
font-family: var(--font-mono);
font-size: 0.7em;
font-weight: var(--font-weight-emphasis);
line-height: 1;
& a {
@mixin px var(--size-1);
color: var(--color-text-primary);
text-decoration: none;
&:hover {
color: var(--color-primary);
}
&:focus-visible {
@mixin focus-ring;
}
}
}
.footnotes {
--footnote-marker: 1.5rem;
@mixin mt var(--space-10);
@mixin pt var(--space-6);
@mixin border-t var(--border-boundary), solid, var(--color-border-strong);
counter-reset: fn;
& .footnotes__title {
@mixin typo_label;
@mixin mt var(--size-0);
@mixin mb var(--size-5);
}
& ol {
@mixin ma var(--size-0);
@mixin pl var(--size-0);
display: flex;
flex-direction: column;
gap: var(--size-4);
list-style: none;
& li {
@mixin typo_body-small;
counter-increment: fn;
scroll-margin-block-start: calc(var(--layout-header-height) + var(--size-8));
display: grid;
grid-template-columns: var(--footnote-marker) minmax(0, 1fr);
column-gap: var(--slot-prose-gap);
color: var(--color-text-secondary);
&::before {
content: counter(fn);
font-family: var(--font-mono);
font-weight: var(--font-weight-emphasis);
color: var(--color-text-primary);
}
&:target {
color: var(--color-text-primary);
&::before {
color: var(--color-primary);
}
}
}
}
& .footnotes__note {
@mixin ma var(--size-0);
}
& a {
color: var(--color-text-link);
text-decoration: underline;
text-decoration-thickness: var(--size-1);
text-underline-offset: var(--size-2);
&:visited {
color: var(--color-text-link-visited);
}
&:hover {
color: var(--color-text-link-hover);
}
&:focus-visible {
@mixin focus-ring;
}
}
& a.footnotes__back {
color: var(--color-text-quiet);
text-decoration: none;
&:hover {
color: var(--color-text-primary);
}
}
}
}

View File

@@ -3,3 +3,6 @@
@import url("lead.css");
@import url("callout.css");
@import url("reveal.css");
@import url("table-wrapper.css");
@import url("footnote.css");
@import url("sidenote.css");

View File

@@ -335,23 +335,5 @@
@mixin border-t var(--border-card), solid, var(--color-border-strong);
}
}
& .num {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
& .key {
font-weight: var(--font-weight-emphasis);
}
td.text {
font-family: var(--font-body);
font-size: 1.077em;
line-height: 1.55;
color: var(--color-text-secondary);
text-wrap: pretty;
}
}
}

View File

@@ -0,0 +1,80 @@
@layer components.blocks {
.sidenote-ref {
@mixin px var(--size-1);
font-family: var(--font-mono);
font-size: 0.75em;
font-weight: var(--font-weight-emphasis);
color: var(--color-text-primary);
text-decoration: none;
&:hover {
color: var(--color-primary);
}
&:focus-visible {
@mixin focus-ring;
}
}
.sidenote {
@mixin typo_body-small;
@mixin pt var(--size-3);
@mixin border-t var(--border-card), solid, var(--color-border-strong);
scroll-margin-block-start: calc(var(--layout-header-height) + var(--size-8));
color: var(--color-text-secondary);
/* Hangs, so the first line starts against the marker. */
& .sidenote__marker {
@mixin me var(--size-4);
float: inline-start;
font-family: var(--font-mono);
font-weight: var(--font-weight-emphasis);
color: var(--color-text-primary);
}
&:target {
color: var(--color-text-primary);
& .sidenote__marker {
color: var(--color-primary);
}
}
}
.sidenote--optional {
@mixin border-t var(--border-hairline), solid, var(--color-border-normal);
color: var(--color-text-tertiary);
& .sidenote__marker {
color: var(--color-text-quiet);
}
}
.sidenote--example {
& .sidenote__marker {
color: var(--color-primary);
}
}
@media (--bp-tablet-down) {
.sidenote {
@mixin my var(--space-6);
display: block;
}
}
@media (--bp-desktop) {
.sidenote {
@mixin my var(--size-0);
position: absolute;
inset-inline-start: calc(100% + var(--layout-gutter));
width: var(--layout-margin-width);
}
}
}

View File

@@ -0,0 +1,121 @@
@layer components.blocks {
.table-wrapper {
/* The header treatment. Ink by default: a standalone wrapper is a
citable object. Position overrides these — nothing is authored. */
--table-header-fg: var(--color-text-inverse);
--table-header-bg: var(--color-surface-inverse);
--table-header-rule: 0;
--table-header-edge: var(--color-border-normal);
@mixin my var(--space-10);
counter-increment: tbl;
& figcaption {
@mixin typo_label;
@mixin mb var(--size-3);
display: flex;
gap: var(--size-5);
color: var(--color-text-secondary);
&::before {
content: "Table " counter(tbl, upper-roman) " ⟫";
font-weight: var(--font-weight-emphasis);
color: var(--color-text-primary);
white-space: nowrap;
}
}
& table {
@mixin my var(--size-0);
& thead {
& th {
border-block-end: var(--table-header-rule);
color: var(--table-header-fg);
background-color: var(--table-header-bg);
}
}
& td {
&.text {
font-family: var(--font-body);
font-size: 1.077em;
line-height: 1.55;
color: var(--color-text-secondary);
text-wrap: pretty;
}
}
}
& .num {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
& .key {
font-weight: var(--font-weight-emphasis);
}
}
/* Underline header: a table introduced by a heading, or one inside a
block, is part of the argument rather than a reference. A block with
its own surface redeclares --table-header-bg so the sticky first
column on mobile paints the right colour. */
.prose :is(h2, h3, h4) + .table-wrapper,
:is(.callout, .reveal) .table-wrapper {
--table-header-fg: inherit;
--table-header-bg: var(--color-surface-base);
--table-header-rule: var(--border-card) solid var(--color-border-strong);
--table-header-edge: var(--color-border-strong);
}
/* === MATRIX ===
A correspondence table: the cells carry hits, not values, so it has to
read as a grid — rules on both axes, everything centred, and a miss
stated rather than left looking like an oversight. */
.table-wrapper--matrix {
& :is(th, td) {
@mixin px var(--space-4);
@mixin border-r var(--border-hairline), solid, var(--color-border-subtle);
text-align: center;
vertical-align: middle;
}
& :is(th, td):last-child {
border-inline-end: 0;
}
/* The first column is the subject; it keeps its reading direction. */
& :is(thead, tbody) :is(th, td):first-child {
@mixin border-r var(--border-card), solid, var(--color-border-strong);
text-align: left;
}
/* Axis labels are short. Let them wrap instead of widening a column. */
& thead th {
text-wrap: balance;
}
/* A hit: the cell itself is the mark. */
& tbody td.is-set {
background-color: var(--color-text-primary);
}
/* A miss, said out loud. */
& tbody td:empty::before {
content: "·";
color: var(--color-text-quiet);
}
}
@media (--bp-mobile) {
.table-wrapper .table-scroll thead th:first-child {
border-inline-end-color: var(--table-header-edge);
}
}
}

View File

@@ -27,7 +27,7 @@
}
@define-mixin ms $spacing {
margin-line-start: $spacing;
margin-inline-start: $spacing;
}
@define-mixin me $spacing {