diff --git a/src/components/markdoc/blocks/general/FootnoteList.astro b/src/components/markdoc/blocks/general/FootnoteList.astro
index 58764f0..e4170b7 100644
--- a/src/components/markdoc/blocks/general/FootnoteList.astro
+++ b/src/components/markdoc/blocks/general/FootnoteList.astro
@@ -12,10 +12,12 @@ const { footnotes } = Astro.props
{footnotes.map((footnote) => (
))}
diff --git a/src/components/markdoc/blocks/general/SidenoteList.astro b/src/components/markdoc/blocks/general/SidenoteList.astro
deleted file mode 100644
index f474147..0000000
--- a/src/components/markdoc/blocks/general/SidenoteList.astro
+++ /dev/null
@@ -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) => (
-
-))}
-
-
diff --git a/src/content/general/articles/prima-materia.mdoc b/src/content/general/articles/prima-materia.mdoc
index 0c9b635..a453055 100644
--- a/src/content/general/articles/prima-materia.mdoc
+++ b/src/content/general/articles/prima-materia.mdoc
@@ -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
diff --git a/src/keystatic/blocks/general/table-wrapper.ts b/src/keystatic/blocks/general/table-wrapper.ts
index 1423843..b0a0a01 100644
--- a/src/keystatic/blocks/general/table-wrapper.ts
+++ b/src/keystatic/blocks/general/table-wrapper.ts
@@ -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,
+ },
+ ),
},
})
diff --git a/src/lib/constants/blocks/table.ts b/src/lib/constants/blocks/table.ts
index 4fc37ed..1cf6168 100644
--- a/src/lib/constants/blocks/table.ts
+++ b/src/lib/constants/blocks/table.ts
@@ -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']
diff --git a/src/lib/markdoc/index.ts b/src/lib/markdoc/index.ts
index 66441ff..fa40517 100644
--- a/src/lib/markdoc/index.ts
+++ b/src/lib/markdoc/index.ts
@@ -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,
}
}
diff --git a/src/lib/markdoc/tags.ts b/src/lib/markdoc/tags.ts
index 1e00e30..8dc8a61 100644
--- a/src/lib/markdoc/tags.ts
+++ b/src/lib/markdoc/tags.ts
@@ -95,9 +95,8 @@ export function createTags(state: NodeState, resolveLink: ResolveLink): Record 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}.`,
},
]
},
diff --git a/src/lib/markdoc/types.ts b/src/lib/markdoc/types.ts
index 9a118e5..79217d7 100644
--- a/src/lib/markdoc/types.ts
+++ b/src/lib/markdoc/types.ts
@@ -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
footnotes: Footnote[]
- sidenotes: Sidenote[]
+ sidenoteCount: number
markers: Set
problems: Problem[]
}
@@ -42,7 +35,6 @@ export type Rendered = {
tree: RenderableTreeNode
headings: Heading[]
footnotes: Footnote[]
- sidenotes: Sidenote[]
diagnostics: Diagnostic[]
}
diff --git a/src/styles/components/footnote.css b/src/styles/components/footnote.css
new file mode 100644
index 0000000..1eff1c0
--- /dev/null
+++ b/src/styles/components/footnote.css
@@ -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);
+ }
+ }
+ }
+}
diff --git a/src/styles/components/index.css b/src/styles/components/index.css
index 21d6e13..af70898 100644
--- a/src/styles/components/index.css
+++ b/src/styles/components/index.css
@@ -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");
diff --git a/src/styles/components/prose.css b/src/styles/components/prose.css
index 450d1cb..6631ba4 100644
--- a/src/styles/components/prose.css
+++ b/src/styles/components/prose.css
@@ -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;
- }
}
}
diff --git a/src/styles/components/sidenote.css b/src/styles/components/sidenote.css
new file mode 100644
index 0000000..cfa9cb1
--- /dev/null
+++ b/src/styles/components/sidenote.css
@@ -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);
+ }
+ }
+}
diff --git a/src/styles/components/table-wrapper.css b/src/styles/components/table-wrapper.css
new file mode 100644
index 0000000..1049547
--- /dev/null
+++ b/src/styles/components/table-wrapper.css
@@ -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);
+ }
+ }
+}
diff --git a/src/styles/mixins/spacing.css b/src/styles/mixins/spacing.css
index e0c0f25..c199ac7 100644
--- a/src/styles/mixins/spacing.css
+++ b/src/styles/mixins/spacing.css
@@ -27,7 +27,7 @@
}
@define-mixin ms $spacing {
- margin-line-start: $spacing;
+ margin-inline-start: $spacing;
}
@define-mixin me $spacing {