shelving 1.228.0 → 1.229.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/package.json +1 -1
  2. package/ui/README.md +223 -0
  3. package/ui/app/App.d.ts +4 -4
  4. package/ui/app/App.js +4 -14
  5. package/ui/app/App.tsx +5 -14
  6. package/ui/block/Address.d.ts +6 -2
  7. package/ui/block/Address.js +7 -2
  8. package/ui/block/Address.module.css +23 -15
  9. package/ui/block/Address.tsx +20 -3
  10. package/ui/block/Block.d.ts +8 -8
  11. package/ui/block/Block.js +8 -5
  12. package/ui/block/Block.module.css +60 -73
  13. package/ui/block/Block.tsx +25 -10
  14. package/ui/block/Blockquote.d.ts +7 -2
  15. package/ui/block/Blockquote.js +8 -2
  16. package/ui/block/Blockquote.module.css +23 -16
  17. package/ui/block/Blockquote.tsx +28 -3
  18. package/ui/block/Card.d.ts +10 -9
  19. package/ui/block/Card.js +9 -8
  20. package/ui/block/Card.module.css +65 -68
  21. package/ui/block/Card.tsx +25 -16
  22. package/ui/block/Definitions.d.ts +7 -1
  23. package/ui/block/Definitions.js +8 -2
  24. package/ui/block/Definitions.module.css +50 -47
  25. package/ui/block/Definitions.tsx +30 -3
  26. package/ui/block/Divider.d.ts +4 -5
  27. package/ui/block/Divider.js +5 -2
  28. package/ui/block/Divider.module.css +20 -15
  29. package/ui/block/Divider.tsx +6 -7
  30. package/ui/block/Heading.d.ts +5 -1
  31. package/ui/block/Heading.js +6 -2
  32. package/ui/block/Heading.module.css +27 -19
  33. package/ui/block/Heading.tsx +19 -3
  34. package/ui/block/Image.d.ts +4 -2
  35. package/ui/block/Image.js +5 -2
  36. package/ui/block/Image.module.css +21 -15
  37. package/ui/block/Image.tsx +8 -3
  38. package/ui/block/List.d.ts +6 -2
  39. package/ui/block/List.js +8 -2
  40. package/ui/block/List.module.css +35 -22
  41. package/ui/block/List.tsx +15 -3
  42. package/ui/block/Panel.d.ts +26 -0
  43. package/ui/block/Panel.js +22 -0
  44. package/ui/block/Panel.module.css +32 -0
  45. package/ui/block/Panel.tsx +47 -0
  46. package/ui/block/Paragraph.d.ts +7 -5
  47. package/ui/block/Paragraph.js +7 -5
  48. package/ui/block/Paragraph.module.css +24 -12
  49. package/ui/block/Paragraph.tsx +11 -6
  50. package/ui/block/Preformatted.js +1 -2
  51. package/ui/block/Preformatted.module.css +43 -32
  52. package/ui/block/Preformatted.tsx +1 -2
  53. package/ui/block/Prose.module.css +17 -11
  54. package/ui/block/Subheading.js +6 -2
  55. package/ui/block/Subheading.module.css +25 -19
  56. package/ui/block/Subheading.tsx +18 -2
  57. package/ui/block/Table.d.ts +8 -2
  58. package/ui/block/Table.js +9 -2
  59. package/ui/block/Table.module.css +56 -53
  60. package/ui/block/Table.tsx +23 -3
  61. package/ui/block/Title.js +6 -2
  62. package/ui/block/Title.module.css +25 -19
  63. package/ui/block/Title.tsx +18 -2
  64. package/ui/block/Video.d.ts +3 -5
  65. package/ui/block/Video.js +4 -2
  66. package/ui/block/Video.module.css +86 -84
  67. package/ui/block/Video.tsx +5 -9
  68. package/ui/block/index.d.ts +1 -0
  69. package/ui/block/index.js +1 -0
  70. package/ui/block/index.ts +1 -0
  71. package/ui/dialog/Dialog.module.css +30 -23
  72. package/ui/dialog/Modal.module.css +14 -10
  73. package/ui/docs/DocumentationCard.js +1 -1
  74. package/ui/docs/DocumentationCard.tsx +1 -1
  75. package/ui/docs/DocumentationKind.js +1 -1
  76. package/ui/docs/DocumentationKind.tsx +2 -2
  77. package/ui/docs/DocumentationPage.js +1 -1
  78. package/ui/docs/DocumentationPage.tsx +1 -1
  79. package/ui/form/ArrayInput.js +1 -1
  80. package/ui/form/ArrayInput.tsx +1 -1
  81. package/ui/form/ArrayRadioInputs.js +1 -1
  82. package/ui/form/ArrayRadioInputs.tsx +1 -1
  83. package/ui/form/Button.d.ts +7 -5
  84. package/ui/form/Button.js +5 -8
  85. package/ui/form/Button.module.css +86 -77
  86. package/ui/form/Button.tsx +12 -12
  87. package/ui/form/ButtonInput.d.ts +2 -1
  88. package/ui/form/ButtonInput.js +4 -2
  89. package/ui/form/ButtonInput.tsx +5 -3
  90. package/ui/form/CheckboxInput.d.ts +3 -2
  91. package/ui/form/CheckboxInput.js +5 -3
  92. package/ui/form/CheckboxInput.tsx +6 -3
  93. package/ui/form/ChoiceRadioInputs.js +1 -1
  94. package/ui/form/ChoiceRadioInputs.tsx +1 -1
  95. package/ui/form/DataInput.js +1 -1
  96. package/ui/form/DataInput.tsx +1 -1
  97. package/ui/form/DictionaryInput.js +1 -1
  98. package/ui/form/DictionaryInput.tsx +1 -1
  99. package/ui/form/Field.module.css +44 -37
  100. package/ui/form/Form.module.css +27 -6
  101. package/ui/form/FormFooter.js +1 -1
  102. package/ui/form/FormFooter.tsx +1 -1
  103. package/ui/form/Input.d.ts +6 -9
  104. package/ui/form/Input.js +10 -12
  105. package/ui/form/Input.module.css +190 -170
  106. package/ui/form/Input.tsx +11 -12
  107. package/ui/form/OutputInput.d.ts +3 -2
  108. package/ui/form/OutputInput.js +4 -2
  109. package/ui/form/OutputInput.tsx +6 -4
  110. package/ui/form/Popover.module.css +28 -21
  111. package/ui/form/Progress.d.ts +9 -0
  112. package/ui/form/Progress.js +9 -1
  113. package/ui/form/Progress.module.css +49 -25
  114. package/ui/form/Progress.tsx +31 -2
  115. package/ui/form/RadioInput.d.ts +3 -2
  116. package/ui/form/RadioInput.js +5 -3
  117. package/ui/form/RadioInput.tsx +6 -3
  118. package/ui/form/SelectInput.js +2 -2
  119. package/ui/form/SelectInput.tsx +3 -3
  120. package/ui/form/index.d.ts +0 -1
  121. package/ui/form/index.js +0 -1
  122. package/ui/form/index.ts +0 -1
  123. package/ui/index.d.ts +1 -1
  124. package/ui/index.js +1 -1
  125. package/ui/index.ts +1 -1
  126. package/ui/inline/Code.js +1 -2
  127. package/ui/inline/Code.module.css +26 -18
  128. package/ui/inline/Code.tsx +1 -2
  129. package/ui/inline/Deleted.module.css +11 -7
  130. package/ui/inline/Emphasis.module.css +8 -4
  131. package/ui/inline/Inserted.module.css +11 -7
  132. package/ui/inline/Link.module.css +17 -13
  133. package/ui/inline/Mark.module.css +11 -14
  134. package/ui/inline/Small.module.css +7 -7
  135. package/ui/inline/Strong.module.css +8 -4
  136. package/ui/inline/Subscript.module.css +12 -8
  137. package/ui/inline/Superscript.module.css +12 -8
  138. package/ui/layout/CenteredLayout.js +2 -2
  139. package/ui/layout/CenteredLayout.module.css +15 -11
  140. package/ui/layout/CenteredLayout.tsx +2 -2
  141. package/ui/layout/Layout.d.ts +2 -2
  142. package/ui/layout/Layout.js +4 -2
  143. package/ui/layout/Layout.module.css +48 -46
  144. package/ui/layout/Layout.ts +4 -2
  145. package/ui/layout/README.md +1 -1
  146. package/ui/layout/SidebarLayout.d.ts +0 -2
  147. package/ui/layout/SidebarLayout.js +3 -4
  148. package/ui/layout/SidebarLayout.module.css +89 -79
  149. package/ui/layout/SidebarLayout.tsx +3 -5
  150. package/ui/menu/Menu.module.css +59 -55
  151. package/ui/misc/Catcher.js +1 -1
  152. package/ui/misc/Catcher.tsx +1 -1
  153. package/ui/misc/Loading.module.css +20 -16
  154. package/ui/misc/StatusIcon.d.ts +1 -1
  155. package/ui/misc/StatusIcon.js +1 -1
  156. package/ui/misc/StatusIcon.module.css +29 -25
  157. package/ui/misc/StatusIcon.tsx +2 -2
  158. package/ui/misc/Tag.d.ts +2 -2
  159. package/ui/misc/Tag.js +3 -5
  160. package/ui/misc/Tag.module.css +48 -31
  161. package/ui/misc/Tag.tsx +2 -4
  162. package/ui/misc/index.d.ts +0 -1
  163. package/ui/misc/index.js +0 -1
  164. package/ui/misc/index.tsx +0 -1
  165. package/ui/notice/Message.d.ts +3 -3
  166. package/ui/notice/Message.js +5 -5
  167. package/ui/notice/Message.module.css +10 -6
  168. package/ui/notice/Message.tsx +6 -6
  169. package/ui/notice/Notice.d.ts +4 -6
  170. package/ui/notice/Notice.js +4 -8
  171. package/ui/notice/Notice.module.css +36 -17
  172. package/ui/notice/Notice.tsx +6 -10
  173. package/ui/notice/Notices.d.ts +4 -1
  174. package/ui/notice/Notices.js +3 -3
  175. package/ui/notice/Notices.module.css +13 -9
  176. package/ui/notice/Notices.tsx +6 -4
  177. package/ui/notice/NoticesStore.d.ts +1 -1
  178. package/ui/notice/NoticesStore.ts +1 -1
  179. package/ui/{variant → style}/Align.d.ts +0 -2
  180. package/ui/{variant → style}/Align.js +0 -1
  181. package/ui/style/Align.module.css +17 -0
  182. package/ui/{variant → style}/Align.tsx +0 -2
  183. package/ui/{variant → style}/Color.d.ts +4 -6
  184. package/ui/{variant → style}/Color.js +0 -1
  185. package/ui/style/Color.module.css +70 -0
  186. package/ui/{variant → style}/Color.tsx +4 -6
  187. package/ui/{variant → style}/Flex.d.ts +4 -7
  188. package/ui/{variant → style}/Flex.js +4 -4
  189. package/ui/style/Flex.module.css +76 -0
  190. package/ui/{variant → style}/Flex.tsx +6 -9
  191. package/ui/style/Gap.d.ts +13 -0
  192. package/ui/style/Gap.js +5 -0
  193. package/ui/style/Gap.module.css +30 -0
  194. package/ui/style/Gap.tsx +20 -0
  195. package/ui/style/Padding.d.ts +17 -0
  196. package/ui/style/Padding.js +5 -0
  197. package/ui/style/Padding.module.css +31 -0
  198. package/ui/style/Padding.tsx +24 -0
  199. package/ui/style/Spacing.d.ts +15 -0
  200. package/ui/{variant → style}/Spacing.js +0 -1
  201. package/ui/style/Spacing.module.css +29 -0
  202. package/ui/{variant → style}/Spacing.tsx +9 -5
  203. package/ui/style/Status.module.css +36 -0
  204. package/ui/style/Thickness.d.ts +19 -0
  205. package/ui/style/Thickness.js +5 -0
  206. package/ui/style/Thickness.module.css +35 -0
  207. package/ui/style/Thickness.tsx +26 -0
  208. package/ui/style/Typography.d.ts +41 -0
  209. package/ui/style/Typography.module.css +70 -0
  210. package/ui/style/Typography.tsx +50 -0
  211. package/ui/style/Width.d.ts +11 -0
  212. package/ui/style/Width.js +5 -0
  213. package/ui/style/Width.module.css +19 -0
  214. package/ui/style/Width.tsx +18 -0
  215. package/ui/style/base.css +221 -0
  216. package/ui/style/index.d.ts +10 -0
  217. package/ui/style/index.js +10 -0
  218. package/ui/style/index.tsx +10 -0
  219. package/ui/util/notice.d.ts +1 -1
  220. package/ui/util/notice.ts +1 -1
  221. package/ui/app/App.module.css +0 -147
  222. package/ui/form/SegmentedProgress.d.ts +0 -10
  223. package/ui/form/SegmentedProgress.js +0 -16
  224. package/ui/form/SegmentedProgress.module.css +0 -31
  225. package/ui/form/SegmentedProgress.tsx +0 -34
  226. package/ui/misc/Typography.d.ts +0 -33
  227. package/ui/misc/Typography.module.css +0 -54
  228. package/ui/misc/Typography.tsx +0 -41
  229. package/ui/variant/Align.module.css +0 -13
  230. package/ui/variant/Color.module.css +0 -207
  231. package/ui/variant/Flex.module.css +0 -71
  232. package/ui/variant/Spacing.d.ts +0 -11
  233. package/ui/variant/Spacing.module.css +0 -5
  234. package/ui/variant/Status.module.css +0 -85
  235. package/ui/variant/Surface.d.ts +0 -9
  236. package/ui/variant/Surface.js +0 -11
  237. package/ui/variant/Surface.module.css +0 -41
  238. package/ui/variant/Surface.tsx +0 -12
  239. package/ui/variant/index.d.ts +0 -6
  240. package/ui/variant/index.js +0 -6
  241. package/ui/variant/index.tsx +0 -6
  242. /package/ui/{variant → style}/Status.d.ts +0 -0
  243. /package/ui/{variant → style}/Status.js +0 -0
  244. /package/ui/{variant → style}/Status.tsx +0 -0
  245. /package/ui/{misc → style}/Typography.js +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.228.0",
3
+ "version": "1.229.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
package/ui/README.md CHANGED
@@ -15,6 +15,229 @@ A few conventions run through every component (see also the React Components sec
15
15
  - **Sentence case.** Titles, headings, and button labels capitalise only the first word.
16
16
  - **Theming via CSS variables.** Colour and spacing come from CSS custom properties with fallback chains, so a theme is a small set of variable overrides.
17
17
 
18
+ ## Styling system
19
+
20
+ The styling system has four moving parts, all defined in [style/](./style/). Most components compose them in a predictable shape; consumers theme by overriding CSS custom properties at `:root`.
21
+
22
+ ### Design tokens
23
+
24
+ [`style/base.css`](./style/base.css) defines every design-token constant at `:root` — colours, sizes (`--size-*`), spacing (`--space-*`), radii (`--radius-*`), strokes (`--stroke-*`), shadows (`--shadow-*`), durations (`--duration-*`). Components read these via `var(--token)`; themes override them at `:root` in their own CSS file. No class selectors needed.
25
+
26
+ `base.css` is `@import`ed at the top of every `*.module.css` in the codebase — that's how the design tokens (and the cascade layer order) reach every component automatically, regardless of bundle order.
27
+
28
+ #### The 5-step colour scale
29
+
30
+ Colours are organised as a **5-step scale**: `--color-black`, `--color-dark`, `--color-vivid`, `--color-light`, `--color-white`. The inner three steps (`dark` / `vivid` / `light`) are saturated tones of the active hue and change per variant scope; the extremes (`black` / `white`) are the page foreground/background and stay put unless the theme deliberately inverts them (e.g. dark mode).
31
+
32
+ A useful mental model: **step distance encodes contrast strength**.
33
+
34
+ | Distance | Pairing | Use for |
35
+ |---|---|---|
36
+ | 2 steps | `vivid + white`, `light + dark` | Short text (button labels, tag labels, notices) |
37
+ | 3 steps | `dark + white`, `light + black` | Body text (paragraphs, headings) |
38
+ | 4 steps | `black + white` | Maximum-contrast surfaces (Inputs) |
39
+
40
+ Components pick whichever pair fits their content; variants only ever set the inner three steps, so a component that renders `bg=light` and `text=dark` automatically inherits the right tint when wrapped in `.red`, `.success`, etc.
41
+
42
+ The base palette underneath the scale defines three shades per hue: `--vivid-red`, `--light-red`, `--dark-red` (and the same for orange, yellow, green, aqua, blue, purple, pink, plus `--*-gray` for the default neutrals). The default `:root` value of `--color-vivid` is `var(--vivid-gray)` and so on — grey is just the variant you get when no colour variant is applied.
43
+
44
+ **`--color-black` and `--color-white` are theme-scoped, not literal.** They're the extremes of the active scale. In a dark theme they'd be a deep navy and a soft cream. For literal black or white pixels (neutral hover blends, etc.) use the CSS keywords `black` / `white` directly — they sit outside the scale entirely.
45
+
46
+ ### Cascade layers
47
+
48
+ Order, lowest to highest priority:
49
+
50
+ | Layer | What's in it |
51
+ |---|---|
52
+ | `defaults` | `:root` design tokens, body baseline typography, any low-priority opt-in defaults |
53
+ | `components` | Component-defining CSS — the bulk of the codebase: `.card`, `.button`, `.notice`, `.heading`, etc. |
54
+ | `variants` | Cross-cutting opt-in modifiers (Color, Status, Align, Spacing, Padding, Gap, Thickness, Width, Typography, Flex). Always beat components. |
55
+ | `overrides` | Top-priority structural overrides — `:first-child` / `:last-child` margin collapses, which need to beat variant-set margins |
56
+
57
+ **Unlayered rules beat all layered rules.** A consumer theme that wraps its overrides in `@layer theme { … }` or just sets tokens at `:root` is fine; one that writes raw class selectors without participating in the layer system will silently dominate variants.
58
+
59
+ ### Variant utilities
60
+
61
+ [`style/`](./style/) exports a set of opt-in class utilities. Each has the same shape: a `.module.css` with the variant classes inside `@layer variants`, and a `.tsx` exporting `getXxxClass(props)` + a `XxxVariants` interface that components extend.
62
+
63
+ | Utility | Classes | Purpose |
64
+ |---|---|---|
65
+ | [`Color`](./style/Color.tsx) | `.primary`, `.secondary`, `.red`, `.blue`, `.green`, etc. | Raw colour overrides |
66
+ | [`Status`](./style/Status.tsx) | `.info`, `.success`, `.warning`, `.danger`, `.error`, `.loading` | Semantic status colours |
67
+ | [`Align`](./style/Align.tsx) | `.left`, `.center`, `.right` | `text-align` |
68
+ | [`Spacing`](./style/Spacing.tsx) | `.space-none` … `.space-xxlarge` | `margin-block` (top + bottom) |
69
+ | [`Padding`](./style/Padding.tsx) | `.padding-none` … `.padding-xxlarge` | `padding-block` (top + bottom) |
70
+ | [`Gap`](./style/Gap.tsx) | `.gap-none` … `.gap-xxlarge` | `gap` |
71
+ | [`Thickness`](./style/Thickness.tsx) | `.thickness-none` … `.thickness-xxthick` | Sets `--thickness` for components that paint borders |
72
+ | [`Width`](./style/Width.tsx) | `.narrow`, `.wide`, `.full` | `max-width` |
73
+ | [`Typography`](./style/Typography.tsx) | `.body`, `.monospace`, `.sans`, `.serif`, `.code` + `.size-xxsmall` … `.size-xxlarge` | `font-family` + `font-size` |
74
+ | [`Flex`](./style/Flex.tsx) | `.flex` + `.column`, `.left`, `.wrap`, etc. | Flex layout (composes `Gap`) |
75
+
76
+ A component using variants looks like:
77
+
78
+ ```tsx
79
+ export interface CardProps extends ColorVariants, PaddingVariants, ThicknessVariants, WidthVariants /* … */ {
80
+ status?: Status | undefined;
81
+ }
82
+
83
+ export function Card({ children, status, ...props }: CardProps): ReactElement {
84
+ return (
85
+ <article
86
+ className={getClass(
87
+ getModuleClass(CARD_CSS, "card"),
88
+ status && getStatusClass(status),
89
+ getColorClass(props),
90
+ getPaddingClass(props),
91
+ getThicknessClass(props),
92
+ getWidthClass(props),
93
+ )}
94
+ >
95
+ {children}
96
+ </article>
97
+ );
98
+ }
99
+ ```
100
+
101
+ ### Component theme hooks
102
+
103
+ Each component exposes per-component CSS custom properties for its overridable values. These are read with a `var(--component-hook, default)` fallback chain in the component's CSS, so a consumer can override the hook to retheme a single component without touching the rest of the design system.
104
+
105
+ Naming follows the file-prefix rule from [AGENTS.md](/AGENTS.md): hooks owned by a specific module file start with that file's kebab-case name. So Card owns `--card-color-light`, `--card-color-dark`, `--card-padding`, `--card-radius`, etc. Button owns `--button-color-vivid`, `--button-color-light`, `--button-border`, and so on.
106
+
107
+ Tokens declared at `:root` (in `base.css`, or in `Color`/`Status`'s token blocks) are exempt — they're the global palette, not file-owned.
108
+
109
+ ### The colour rebind pattern
110
+
111
+ Any component that paints a `background-color`, `border-color`, or `color` from the 5-step scale (Card, Button, Notice, Panel, Tag, Code, Mark, Modal, Popover, Preformatted, …) **rebinds all five scale steps on its own scope** before painting:
112
+
113
+ ```css
114
+ .card {
115
+ /* Rebind: per-component theme hook wins; otherwise inherit from variant or page scope. */
116
+ --color-black: var(--card-color-black, inherit);
117
+ --color-dark: var(--card-color-dark, inherit);
118
+ --color-vivid: var(--card-color-vivid, inherit);
119
+ --color-light: var(--card-color-light, inherit);
120
+ --color-white: var(--card-color-white, inherit);
121
+
122
+ /* bg=light + text=dark is a 2-step pair, fine for the short text inside a card body. */
123
+ background-color: var(--color-light);
124
+ border-color: var(--color-vivid);
125
+ color: var(--color-dark);
126
+ }
127
+ ```
128
+
129
+ The rebind serves three jobs at once:
130
+
131
+ 1. **Per-component theme hook.** A consumer setting `--card-color-light: peachpuff` at `:root` repaints the card surface without touching Buttons or Notices.
132
+ 2. **Variant inheritance.** When the card is `.red` (or `.success`, etc.), the variant has already set `--color-dark / --color-vivid / --color-light` at its scope. The rebind's `inherit` fallback picks those up — no explicit `.card.red` rule needed.
133
+ 3. **Identity propagation.** Descendants (like a `<Code>` chip inside the card) inherit the rebound values, so they can compute their own surface relative to the card.
134
+
135
+ For variants on appearance (`.strong`, `.outline`, `.plain` on Button) — just pick a different step pair from the already-rebound scale. No extra hook needed:
136
+
137
+ ```css
138
+ .button { background: var(--color-light); color: var(--color-dark); }
139
+ .button.strong { background: var(--color-vivid); color: var(--color-white); }
140
+ ```
141
+
142
+ **What about components that only paint one colour?** Text-only blocks (Paragraph, Heading, Title, etc.) skip the rebind and read the relevant scale step directly with a single theme-hook fallback:
143
+
144
+ ```css
145
+ .paragraph { color: var(--paragraph-color, var(--color-dark)); }
146
+ .heading { color: var(--heading-color, var(--color-black)); }
147
+ ```
148
+
149
+ **What about Inputs?** Inputs sit outside the variant scope's middle three steps — they always use `bg=white + text=black` (a 4-step pair, maximum contrast) regardless of the surrounding variant. Variant scope still tints their border and validity states, but never the field surface.
150
+
151
+ Other tokens (`--*-padding`, `--*-spacing`, `--*-radius`, `--*-font`, `--*-size`, etc.) are **not** rebound:
152
+
153
+ - `font-*` properties already inherit naturally via CSS, so just setting them is enough — children pick up the value automatically.
154
+ - `padding`, `margin`, `gap`, `border-width`, `border-radius` are non-inheriting CSS properties. Each component sets its own; children should never read a parent's padding.
155
+
156
+ This split is deliberate. The rebind is the right tool when an identity needs to propagate; for everything else, plain CSS inheritance (or no inheritance at all) is the right tool.
157
+
158
+ ### How `:first-child` / `:last-child` margin overrides work
159
+
160
+ Every block-level component zeros its outer margins when it's the first or last child of its container — otherwise a Heading at the top of a Card would leave a strip of unwanted space. These rules live in `@layer overrides`, which beats every other layer including `variants`, so a `<Card space-large>` still collapses its abutting edges correctly.
161
+
162
+ Pattern:
163
+
164
+ ```css
165
+ @layer components {
166
+ .card { margin-block: var(--card-spacing, var(--spacing-paragraph)); }
167
+ }
168
+
169
+ @layer overrides {
170
+ .card {
171
+ &:first-child { margin-block-start: 0; }
172
+ &:last-child { margin-block-end: 0; }
173
+ }
174
+ }
175
+ ```
176
+
177
+ ### Writing a new component
178
+
179
+ A typical new block-level component looks like:
180
+
181
+ ```tsx
182
+ // Address.tsx
183
+ import { type AlignVariants, getAlignClass } from "../style/Align.js";
184
+ import { getSpacingClass, type SpacingVariants } from "../style/Spacing.js";
185
+ import { getTypographyClass, type TypographyVariants } from "../style/Typography.js";
186
+
187
+ export interface AddressProps extends AlignVariants, SpacingVariants, TypographyVariants, ChildProps {}
188
+
189
+ export function Address({ children, ...variants }: AddressProps) {
190
+ return (
191
+ <address
192
+ className={getClass(
193
+ getModuleClass(styles, "address"),
194
+ getAlignClass(variants),
195
+ getSpacingClass(variants),
196
+ getTypographyClass(variants),
197
+ )}
198
+ >
199
+ {children}
200
+ </address>
201
+ );
202
+ }
203
+ ```
204
+
205
+ ```css
206
+ /* Address.module.css */
207
+ @import "../style/base.css";
208
+
209
+ @layer components {
210
+ .address {
211
+ display: block;
212
+ margin-inline: 0;
213
+ margin-block: var(--address-spacing, var(--spacing-paragraph));
214
+
215
+ /* Single-colour text block — read --color-dark directly with a theme-hook fallback. */
216
+ color: var(--address-color, var(--color-dark));
217
+ font-family: var(--address-font, inherit);
218
+ font-size: var(--address-size, inherit);
219
+ text-align: var(--address-align, left);
220
+ }
221
+ }
222
+
223
+ @layer overrides {
224
+ .address {
225
+ &:first-child { margin-block-start: 0; }
226
+ &:last-child { margin-block-end: 0; }
227
+ }
228
+ }
229
+ ```
230
+
231
+ Checklist:
232
+
233
+ - [ ] `@import "../style/base.css";` at the top.
234
+ - [ ] All rules inside `@layer components { … }`.
235
+ - [ ] All custom properties owned by this file start with the file name (`--address-*`, etc.), per [AGENTS.md](/AGENTS.md).
236
+ - [ ] If the component paints a surface (background + border + text), rebind all five scale steps at the top of the rule and pick a step pair for the painted properties.
237
+ - [ ] If the component only paints one colour (a text-only block), skip the rebind and read the step directly with a single theme-hook fallback.
238
+ - [ ] `:first-child` / `:last-child` overrides in a separate `@layer overrides { … }` block.
239
+ - [ ] TSX extends the variant interfaces (`SpacingVariants`, `AlignVariants`, etc.) you want to expose; composes the matching `getXxxClass(props)` calls.
240
+
18
241
  ## Module map
19
242
 
20
243
  ### Content
package/ui/app/App.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { type ReactElement } from "react";
1
+ import type { ReactElement } from "react";
2
+ import "../style/base.css";
2
3
  import type { PossibleMeta } from "../util/index.js";
3
4
  import type { ChildProps } from "../util/props.js";
4
5
  export interface AppProps extends PossibleMeta, ChildProps {
5
6
  }
6
7
  /**
7
- * Root component for an application.
8
- * - Adds the theme CSS class (which sets CSS token variables on `:root`) to `document.body` on mount and removes it on unmount.
9
- * - Provides a `Meta` context to its children so descendants can read or update metadata.
8
+ * Root component for an application. Provides a `Meta` context to its children so descendants can read
9
+ * or update metadata. Design tokens and body baseline typography are set globally via `style/base.css`.
10
10
  */
11
11
  export declare function App({ children, ...meta }: AppProps): ReactElement;
package/ui/app/App.js CHANGED
@@ -1,20 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect } from "react";
3
2
  import { MetaContext, requireMeta } from "../misc/MetaContext.js";
4
- import APP_CSS from "./App.module.css";
5
- const APP_CLASS = APP_CSS.app;
3
+ import "../style/base.css";
6
4
  /**
7
- * Root component for an application.
8
- * - Adds the theme CSS class (which sets CSS token variables on `:root`) to `document.body` on mount and removes it on unmount.
9
- * - Provides a `Meta` context to its children so descendants can read or update metadata.
5
+ * Root component for an application. Provides a `Meta` context to its children so descendants can read
6
+ * or update metadata. Design tokens and body baseline typography are set globally via `style/base.css`.
10
7
  */
11
8
  export function App({ children, ...meta }) {
12
- const merged = requireMeta(meta);
13
- useEffect(() => {
14
- if (!APP_CLASS)
15
- return;
16
- document.body.classList.add(APP_CLASS);
17
- return () => document.body.classList.remove(APP_CLASS);
18
- }, []);
19
- return _jsx(MetaContext, { value: merged, children: children });
9
+ return _jsx(MetaContext, { value: requireMeta(meta), children: children });
20
10
  }
package/ui/app/App.tsx CHANGED
@@ -1,24 +1,15 @@
1
- import { type ReactElement, useEffect } from "react";
1
+ import type { ReactElement } from "react";
2
2
  import { MetaContext, requireMeta } from "../misc/MetaContext.js";
3
+ import "../style/base.css";
3
4
  import type { PossibleMeta } from "../util/index.js";
4
5
  import type { ChildProps } from "../util/props.js";
5
- import APP_CSS from "./App.module.css";
6
6
 
7
7
  export interface AppProps extends PossibleMeta, ChildProps {}
8
8
 
9
- const APP_CLASS = APP_CSS.app;
10
-
11
9
  /**
12
- * Root component for an application.
13
- * - Adds the theme CSS class (which sets CSS token variables on `:root`) to `document.body` on mount and removes it on unmount.
14
- * - Provides a `Meta` context to its children so descendants can read or update metadata.
10
+ * Root component for an application. Provides a `Meta` context to its children so descendants can read
11
+ * or update metadata. Design tokens and body baseline typography are set globally via `style/base.css`.
15
12
  */
16
13
  export function App({ children, ...meta }: AppProps): ReactElement {
17
- const merged = requireMeta(meta);
18
- useEffect(() => {
19
- if (!APP_CLASS) return;
20
- document.body.classList.add(APP_CLASS);
21
- return () => document.body.classList.remove(APP_CLASS);
22
- }, []);
23
- return <MetaContext value={merged}>{children}</MetaContext>;
14
+ return <MetaContext value={requireMeta(meta)}>{children}</MetaContext>;
24
15
  }
@@ -1,8 +1,12 @@
1
1
  import type { ReactElement } from "react";
2
2
  import { type AddressData } from "../../schema/AddressSchema.js";
3
3
  import type { Nullish } from "../../util/null.js";
4
+ import { type AlignVariants } from "../style/Align.js";
5
+ import { type ColorVariants } from "../style/Color.js";
6
+ import { type SpacingVariants } from "../style/Spacing.js";
7
+ import { type TypographyVariants } from "../style/Typography.js";
4
8
  import type { ChildProps } from "../util/props.js";
5
- export interface AddressProps extends ChildProps {
9
+ export interface AddressProps extends AlignVariants, ColorVariants, SpacingVariants, TypographyVariants, ChildProps {
6
10
  }
7
11
  export interface PhysicalAddressProps {
8
12
  name?: Nullish<string>;
@@ -13,7 +17,7 @@ export interface EmailAddressProps {
13
17
  email: Nullish<string>;
14
18
  }
15
19
  /** Show any kind of contact data. */
16
- export declare function Address({ children }: AddressProps): import("react/jsx-runtime").JSX.Element;
20
+ export declare function Address({ children, ...variants }: AddressProps): import("react/jsx-runtime").JSX.Element;
17
21
  /** Show an optional `AddressData` object correctly on screen. */
18
22
  export declare function PhysicalAddress({ name, address }: PhysicalAddressProps): ReactElement;
19
23
  /** Show an optional email address string correctly on screen. */
@@ -2,10 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { formatAddress } from "../../schema/AddressSchema.js";
3
3
  import { Small } from "../inline/Small.js";
4
4
  import { Strong } from "../inline/Strong.js";
5
+ import { getAlignClass } from "../style/Align.js";
6
+ import { getColorClass } from "../style/Color.js";
7
+ import { getSpacingClass } from "../style/Spacing.js";
8
+ import { getTypographyClass } from "../style/Typography.js";
9
+ import { getClass, getModuleClass } from "../util/css.js";
5
10
  import styles from "./Address.module.css";
6
11
  /** Show any kind of contact data. */
7
- export function Address({ children }) {
8
- return _jsx("address", { className: styles.address, children: children });
12
+ export function Address({ children, ...variants }) {
13
+ return (_jsx("address", { className: getClass(getModuleClass(styles, "address"), getColorClass(variants), getAlignClass(variants), getSpacingClass(variants), getTypographyClass(variants)), children: children }));
9
14
  }
10
15
  /** Show an optional `AddressData` object correctly on screen. */
11
16
  export function PhysicalAddress({ name, address }) {
@@ -1,20 +1,28 @@
1
- .address,
2
- .prose address {
3
- /* Box */
4
- display: block;
5
- margin-inline: 0;
6
- margin-block: var(--address-spacing, var(--space-normal));
1
+ @import "../style/base.css";
7
2
 
8
- /* Contents */
9
- white-space: pre-line;
10
- font-style: normal;
11
- text-align: left;
3
+ @layer components {
4
+ .address,
5
+ .prose address {
6
+ display: block;
7
+ margin-inline: 0;
8
+ margin-block: var(--address-spacing, var(--spacing-paragraph));
12
9
 
13
- /* Styles */
14
- &:first-child {
15
- margin-block-start: 0;
10
+ white-space: pre-line;
11
+ font-style: normal;
12
+ font-family: var(--address-font, inherit);
13
+ font-size: var(--address-size, inherit);
14
+ text-align: var(--address-align, left);
16
15
  }
17
- &:last-child {
18
- margin-block-end: 0;
16
+ }
17
+
18
+ @layer overrides {
19
+ .address,
20
+ .prose address {
21
+ &:first-child {
22
+ margin-block-start: 0;
23
+ }
24
+ &:last-child {
25
+ margin-block-end: 0;
26
+ }
19
27
  }
20
28
  }
@@ -3,10 +3,15 @@ import { type AddressData, formatAddress } from "../../schema/AddressSchema.js";
3
3
  import type { Nullish } from "../../util/null.js";
4
4
  import { Small } from "../inline/Small.js";
5
5
  import { Strong } from "../inline/Strong.js";
6
+ import { type AlignVariants, getAlignClass } from "../style/Align.js";
7
+ import { type ColorVariants, getColorClass } from "../style/Color.js";
8
+ import { getSpacingClass, type SpacingVariants } from "../style/Spacing.js";
9
+ import { getTypographyClass, type TypographyVariants } from "../style/Typography.js";
10
+ import { getClass, getModuleClass } from "../util/css.js";
6
11
  import type { ChildProps } from "../util/props.js";
7
12
  import styles from "./Address.module.css";
8
13
 
9
- export interface AddressProps extends ChildProps {}
14
+ export interface AddressProps extends AlignVariants, ColorVariants, SpacingVariants, TypographyVariants, ChildProps {}
10
15
 
11
16
  export interface PhysicalAddressProps {
12
17
  name?: Nullish<string>;
@@ -19,8 +24,20 @@ export interface EmailAddressProps {
19
24
  }
20
25
 
21
26
  /** Show any kind of contact data. */
22
- export function Address({ children }: AddressProps) {
23
- return <address className={styles.address}>{children}</address>;
27
+ export function Address({ children, ...variants }: AddressProps) {
28
+ return (
29
+ <address
30
+ className={getClass(
31
+ getModuleClass(styles, "address"),
32
+ getColorClass(variants),
33
+ getAlignClass(variants),
34
+ getSpacingClass(variants),
35
+ getTypographyClass(variants),
36
+ )}
37
+ >
38
+ {children}
39
+ </address>
40
+ );
24
41
  }
25
42
 
26
43
  /** Show an optional `AddressData` object correctly on screen. */
@@ -1,12 +1,12 @@
1
1
  import type { ReactElement } from "react";
2
+ import { type AlignVariants } from "../style/Align.js";
3
+ import { type ColorVariants } from "../style/Color.js";
4
+ import { type SpacingVariants } from "../style/Spacing.js";
5
+ import { type TypographyVariants } from "../style/Typography.js";
6
+ import { type WidthVariants } from "../style/Width.js";
2
7
  import type { OptionalChildProps } from "../util/props.js";
3
- import { type SpacingVariants } from "../variant/Spacing.js";
4
8
  export declare const BLOCK_CLASS: string | undefined;
5
- export interface BlockProps extends SpacingVariants, OptionalChildProps {
6
- /** Constrain the block to narrow width. */
7
- narrow?: boolean | undefined;
8
- /** Constrain the block to wide width. */
9
- wide?: boolean | undefined;
9
+ export interface BlockProps extends ColorVariants, SpacingVariants, TypographyVariants, WidthVariants, OptionalChildProps {
10
10
  /** Mark as a keyboard-focusable horizontal scroll region — adds `tabindex="0"`, `role="region"`, an `aria-label`, and `overflow-x: auto`. */
11
11
  scrollable?: boolean | undefined;
12
12
  }
@@ -24,7 +24,7 @@ export declare function Nav(props: BlockProps): ReactElement;
24
24
  export declare function Aside(props: BlockProps): ReactElement;
25
25
  /** `<figure>` block with block-level spacing. Pair with `<Caption>` for `<figcaption>` content. */
26
26
  export declare function Figure(props: BlockProps): ReactElement;
27
- export interface CaptionProps extends OptionalChildProps {
27
+ export interface CaptionProps extends AlignVariants, ColorVariants, TypographyVariants, OptionalChildProps {
28
28
  }
29
29
  /** `<figcaption>` block — caption text for a `<Figure>`. */
30
- export declare function Caption({ children }: CaptionProps): ReactElement;
30
+ export declare function Caption({ children, ...variants }: CaptionProps): ReactElement;
package/ui/block/Block.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { getAlignClass } from "../style/Align.js";
3
+ import { getColorClass } from "../style/Color.js";
4
+ import { getSpacingClass } from "../style/Spacing.js";
5
+ import { getTypographyClass } from "../style/Typography.js";
6
+ import { getWidthClass } from "../style/Width.js";
2
7
  import { getClass, getModuleClass } from "../util/css.js";
3
- import { getSpacingClass } from "../variant/Spacing.js";
4
8
  import styles from "./Block.module.css";
5
9
  export const BLOCK_CLASS = getModuleClass(styles, "block");
6
10
  function renderBlock(Component, { children, ...variants }) {
7
- const className = getClass(getModuleClass(styles, "block", variants), //
8
- getSpacingClass(variants));
11
+ const className = getClass(getModuleClass(styles, "block", variants), variants.scrollable && getModuleClass(styles, "scrollable"), getColorClass(variants), getSpacingClass(variants), getTypographyClass(variants), getWidthClass(variants));
9
12
  return variants.scrollable ? (_jsx(Component, { className: className, tabIndex: 0, role: "region", "aria-label": "Scrollable region", children: children })) : (_jsx(Component, { className: className, children: children }));
10
13
  }
11
14
  /** Plain `<div>` block with block-level spacing. Base building block; use a semantic variant (`<Section>`, `<Figure>`, etc.) when the element matters. */
@@ -37,6 +40,6 @@ export function Figure(props) {
37
40
  return renderBlock("figure", props);
38
41
  }
39
42
  /** `<figcaption>` block — caption text for a `<Figure>`. */
40
- export function Caption({ children }) {
41
- return _jsx("figcaption", { className: getModuleClass(styles, "caption"), children: children });
43
+ export function Caption({ children, ...variants }) {
44
+ return (_jsx("figcaption", { className: getClass(getModuleClass(styles, "caption"), getColorClass(variants), getAlignClass(variants), getTypographyClass(variants)), children: children }));
42
45
  }
@@ -1,93 +1,80 @@
1
- .block {
2
- /* Box */
3
- position: relative;
4
- display: block;
5
- margin-inline: 0;
6
- margin-block: var(--block-spacing, var(--spacing-section));
1
+ @import "../style/base.css";
7
2
 
8
- /* Psuedo-classes */
9
- &:first-child {
10
- margin-block-start: 0;
11
- }
12
- &:last-child {
13
- margin-block-end: 0;
3
+ @layer components {
4
+ .block {
5
+ position: relative;
6
+ display: block;
7
+ margin-inline: 0;
8
+ margin-block: var(--block-spacing, var(--spacing-section));
14
9
  }
15
10
 
16
- /* Variants */
17
- &.narrow {
18
- margin-inline: auto;
19
- max-width: var(--block-width-narrow, var(--width-narrow));
11
+ /* Plain semantic blocks inside prose pick up the same spacing as a <Block>. */
12
+ .prose :where(section, article, aside, nav, header, footer, figure) {
13
+ margin-inline: 0;
14
+ margin-block: var(--block-spacing, var(--spacing-section));
20
15
  }
21
- &.wide {
22
- margin-inline: auto;
23
- max-width: var(--block-width-wide, var(--width-wide));
24
- }
25
- }
26
16
 
27
- /* Plain semantic blocks inside prose pick up the same spacing as a <Block>. */
28
- .prose :where(section, article, aside, nav, header, footer, figure) {
29
- margin-inline: 0;
30
- margin-block: var(--block-spacing, var(--spacing-section));
17
+ /* Scrollable region keyboard-focusable horizontal scroll container. */
18
+ .scrollable,
19
+ .prose [role="region"][tabindex="0"] {
20
+ overflow-x: auto;
21
+ overscroll-behavior-x: contain;
31
22
 
32
- &:first-child {
33
- margin-block-start: 0;
34
- }
35
- &:last-child {
36
- margin-block-end: 0;
37
- }
38
- }
39
-
40
- /* Scrollable region — keyboard-focusable horizontal scroll container. Targets both the .scrollable variant and any prose element marked `role="region" tabindex="0"` (e.g. Markdown fenced/table output). */
41
- .scrollable,
42
- .prose [role="region"][tabindex="0"] {
43
- overflow-x: auto;
44
- overscroll-behavior-x: contain;
23
+ &:focus-visible {
24
+ outline: var(--focus-stroke, var(--stroke-thick)) solid var(--focus-color, var(--color-focus));
25
+ outline-offset: var(--focus-offset, var(--space-xxsmall));
26
+ }
45
27
 
46
- &:focus-visible {
47
- outline: var(--focus-stroke, var(--stroke-thick)) solid var(--focus-color, var(--color-accent));
48
- outline-offset: var(--focus-offset, var(--space-xxsmall));
49
- }
28
+ /* Stretch the inner block to fill the scroll container so short content still spans the width. */
29
+ > :is(table, pre) {
30
+ min-width: 100%;
31
+ }
50
32
 
51
- /* Stretch the inner block to fill the scroll container so short content still spans the width. */
52
- > :is(table, pre) {
53
- min-width: 100%;
54
- }
33
+ /* Force long lines so they trigger horizontal scroll instead of wrapping. */
34
+ > pre {
35
+ width: max-content;
36
+ white-space: pre;
37
+ overflow-wrap: normal;
38
+ }
55
39
 
56
- /* Force long lines so they trigger horizontal scroll instead of wrapping; grow the box with the content (`width: max-content`) so the pre's own right-side padding survives at scroll-end. */
57
- > pre {
58
- width: max-content;
59
- white-space: pre;
60
- overflow-wrap: normal;
40
+ /* Caption stays pinned to the left during horizontal scroll. */
41
+ > :is(.caption, figcaption) {
42
+ position: sticky;
43
+ left: 0;
44
+ }
61
45
  }
62
46
 
63
- /* Caption stays pinned to the left during horizontal scroll. Spacing/typography come from the shared figcaption rule below. */
64
- > :is(.caption, figcaption) {
65
- position: sticky;
66
- left: 0;
47
+ .caption,
48
+ .prose figcaption {
49
+ display: block;
50
+ margin-block: var(--caption-gap, var(--space-xsmall));
51
+ font-size: var(--caption-size, var(--size-small));
52
+ text-align: var(--caption-align, left);
67
53
  }
68
- }
69
-
70
- .caption,
71
- .prose figcaption {
72
- display: block;
73
- margin-block: var(--caption-gap, var(--space-xsmall));
74
- font-size: var(--caption-font-size, var(--size-small));
75
- color: var(--caption-color, var(--color-quiet));
76
54
 
77
- &:first-child {
55
+ /* Captions have tighter margins than most block elements, so zero the abutting margin of any */
56
+ /* neighbouring sibling — otherwise margin collapse would let the bigger neighbour margin win, */
57
+ /* drifting the caption away from its content. */
58
+ .caption + *,
59
+ .prose figcaption + * {
78
60
  margin-block-start: 0;
79
61
  }
80
- &:last-child {
62
+ :has(+ .caption),
63
+ .prose :has(+ figcaption) {
81
64
  margin-block-end: 0;
82
65
  }
83
66
  }
84
67
 
85
- /* Captions have tighter margins than most block elements, so zero the abutting margin of any neighbouring sibling — otherwise margin collapse would let the bigger neighbour margin win, drifting the caption away from its content. */
86
- .caption + *,
87
- .prose figcaption + * {
88
- margin-block-start: 0;
89
- }
90
- :has(+ .caption),
91
- .prose :has(+ figcaption) {
92
- margin-block-end: 0;
68
+ @layer overrides {
69
+ .block,
70
+ .prose :where(section, article, aside, nav, header, footer, figure),
71
+ .caption,
72
+ .prose figcaption {
73
+ &:first-child {
74
+ margin-block-start: 0;
75
+ }
76
+ &:last-child {
77
+ margin-block-end: 0;
78
+ }
79
+ }
93
80
  }