@juwel-development/design-system 1.1.0 → 2.1.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 (67) hide show
  1. package/README.md +19 -3
  2. package/dist/design-system.js +560 -43
  3. package/dist/index.css +1 -1
  4. package/dist/types/Display/Brandmark/Brandmark.d.ts +50 -0
  5. package/dist/types/Display/Checklist/Checklist.d.ts +26 -0
  6. package/dist/types/Display/DefinitionList/DefinitionList.d.ts +41 -0
  7. package/dist/types/Display/Figure/Figure.d.ts +52 -0
  8. package/dist/types/Display/Rail/Rail.d.ts +54 -0
  9. package/dist/types/Display/Table/Table.d.ts +61 -0
  10. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +26 -0
  11. package/dist/types/Display/Typography/H1/H1.d.ts +27 -0
  12. package/dist/types/Display/Typography/H2/H2.d.ts +24 -0
  13. package/dist/types/Display/Typography/H3/H3.d.ts +23 -0
  14. package/dist/types/Display/Typography/H4/H4.d.ts +22 -0
  15. package/dist/types/Display/Typography/H5/H5.d.ts +23 -0
  16. package/dist/types/Display/Typography/H6/H6.d.ts +23 -0
  17. package/dist/types/Display/Typography/P/P.d.ts +25 -0
  18. package/dist/types/Display/Typography/Prose/Prose.d.ts +42 -0
  19. package/dist/types/Interaction/Button/Button.d.ts +7 -3
  20. package/dist/types/Interaction/Input/Input.d.ts +31 -0
  21. package/dist/types/Interaction/Link/Link.d.ts +27 -0
  22. package/dist/types/Interaction/TextArea/TextArea.d.ts +27 -0
  23. package/dist/types/Layout/Footer/Footer.d.ts +34 -0
  24. package/dist/types/Layout/Form/Form.d.ts +31 -0
  25. package/dist/types/Layout/Header/Header.d.ts +41 -0
  26. package/dist/types/Layout/Hero/Hero.d.ts +46 -0
  27. package/dist/types/Layout/PageHead/PageHead.d.ts +36 -0
  28. package/dist/types/Layout/Section/Section.d.ts +38 -0
  29. package/dist/types/Theme/Palette.d.ts +25 -6
  30. package/dist/types/Theme/renderTokens.d.ts +16 -3
  31. package/dist/types/index.d.ts +25 -1
  32. package/package.json +5 -1
  33. package/src/Display/.gitkeep +0 -0
  34. package/src/Display/Brandmark/Brandmark.tsx +97 -0
  35. package/src/Display/Checklist/Checklist.tsx +75 -0
  36. package/src/Display/DefinitionList/DefinitionList.tsx +90 -0
  37. package/src/Display/Figure/Figure.tsx +116 -0
  38. package/src/Display/Rail/Rail.tsx +108 -0
  39. package/src/Display/Table/Table.tsx +191 -0
  40. package/src/Display/Typography/Eyebrow/Eyebrow.tsx +46 -0
  41. package/src/Display/Typography/H1/H1.tsx +46 -0
  42. package/src/Display/Typography/H2/H2.tsx +43 -0
  43. package/src/Display/Typography/H3/H3.tsx +41 -0
  44. package/src/Display/Typography/H4/H4.tsx +40 -0
  45. package/src/Display/Typography/H5/H5.tsx +41 -0
  46. package/src/Display/Typography/H6/H6.tsx +41 -0
  47. package/src/Display/Typography/P/P.tsx +38 -0
  48. package/src/Display/Typography/Prose/Prose.tsx +91 -0
  49. package/src/Interaction/Button/Button.tsx +15 -10
  50. package/src/Interaction/Input/Input.tsx +120 -0
  51. package/src/Interaction/Link/Link.tsx +70 -0
  52. package/src/Interaction/TextArea/TextArea.tsx +110 -0
  53. package/src/Layout/.gitkeep +0 -0
  54. package/src/Layout/Footer/Footer.tsx +60 -0
  55. package/src/Layout/Form/Form.tsx +102 -0
  56. package/src/Layout/Header/Header.tsx +84 -0
  57. package/src/Layout/Hero/Hero.tsx +73 -0
  58. package/src/Layout/PageHead/PageHead.tsx +79 -0
  59. package/src/Layout/Section/Section.tsx +79 -0
  60. package/src/Theme/Palette.ts +38 -12
  61. package/src/Theme/renderTokens.ts +255 -18
  62. package/src/index.ts +25 -1
  63. package/src/styles.dark.css +9 -0
  64. package/src/styles.light.css +9 -0
  65. package/src/tokens.css +118 -9
  66. package/src/tokens.dark.css +169 -0
  67. package/src/tokens.light.css +169 -0
@@ -23,29 +23,49 @@ export type PaletteTokens = {
23
23
  muted: string;
24
24
  /** Hairlines and dividers. */
25
25
  border: string;
26
+ /** The boundary of a control with no fill of its own, so it is the only thing separating the
27
+ * control from the surface. Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against `surface`
28
+ * in the same theme. */
29
+ controlBorder: string;
30
+ /** The structural rule that marks where a block's structure is, weightier than `border`'s hairline
31
+ * dividers and lighter than `controlBorder`'s control edge. It serves three consumers: a table's
32
+ * heavier top line above the first row, a checklist's tick, and a page section's join to the
33
+ * section before it. A mid-neutral, so it structures without boxing. Constraint: at least 3:1
34
+ * against `surface` in the same theme. */
35
+ rule: string;
36
+ /** The plate behind content that has not painted - an image still loading, or one that failed and
37
+ * is showing its alt text. Constraint: at least 4.5:1 against `foreground` in the same theme,
38
+ * because a failed image renders its alt text on this plate and that text must stay legible. That
39
+ * keeps it near `surface` rather than a mid grey. */
40
+ backing: string;
26
41
 
27
42
  /** The main call-to-action fill. */
28
43
  primary: string;
29
44
  primaryHover: string;
30
45
  /** Text and icons drawn on top of `primary`. */
31
46
  primaryForeground: string;
32
- /** Focus ring for primary surfaces - lighter than the fill so it reads against it. */
33
- primaryRing: string;
34
47
 
35
48
  /** The alternative action fill, for choices that sit beside a primary one. */
36
49
  secondary: string;
37
50
  secondaryHover: string;
38
51
  secondaryForeground: string;
39
- secondaryRing: string;
40
52
 
41
- /** Fill for controls that cannot be interacted with. */
53
+ /** The tone a control takes when it cannot be interacted with - its fill, or its border when
54
+ * the fill is transparent. */
42
55
  disabled: string;
43
56
  /** Kept distinct from `disabled` so a disabled control still absorbs hover rather than
44
57
  * appearing to respond to it. */
45
58
  disabledHover: string;
46
59
 
47
- /** Focus ring for surfaces that have no fill of their own, such as a ghost button. */
48
- ring: string;
60
+ /** The one focus ring, drawn by every focusable primitive regardless of variant - a focus ring
61
+ * states keyboard position, not the control's importance. Constraint (WCAG 2.2 SC 1.4.11): at
62
+ * least 3:1 against `surface` in the same theme. */
63
+ focusRing: string;
64
+
65
+ /** The colour of a prose link. A link is told apart by its underline, never by hue, so this
66
+ * carries the text threshold, not the 3:1 the ring takes. Constraint (WCAG 2.2 SC 1.4.3): at
67
+ * least 4.5:1 against `surface` in the same theme. */
68
+ link: string;
49
69
 
50
70
  /** Status colours. Not yet consumed by a component - they complete the role set so the
51
71
  * first Alert or Toast has names to reach for instead of inventing them. */
@@ -60,21 +80,24 @@ export const light: PaletteTokens = {
60
80
  foreground: '#0f172a',
61
81
  muted: '#64748b',
62
82
  border: '#e2e8f0',
83
+ controlBorder: '#64748b',
84
+ rule: '#808fa3',
85
+ backing: '#f1f5f9',
63
86
 
64
87
  primary: '#8b5cf6',
65
88
  primaryHover: '#7c3aed',
66
89
  primaryForeground: '#f8fafc',
67
- primaryRing: '#a78bfa',
68
90
 
69
91
  secondary: '#0ea5e9',
70
92
  secondaryHover: '#0284c7',
71
93
  secondaryForeground: '#f8fafc',
72
- secondaryRing: '#38bdf8',
73
94
 
74
95
  disabled: '#94a3b8',
75
96
  disabledHover: '#64748b',
76
97
 
77
- ring: '#94a3b8',
98
+ focusRing: '#475569',
99
+
100
+ link: '#2563eb',
78
101
 
79
102
  success: '#10b981',
80
103
  warning: '#f59e0b',
@@ -93,21 +116,24 @@ export const dark: PaletteTokens = {
93
116
  foreground: '#f8fafc',
94
117
  muted: '#94a3b8',
95
118
  border: '#334155',
119
+ controlBorder: '#94a3b8',
120
+ rule: '#5b6a80',
121
+ backing: '#1e293b',
96
122
 
97
123
  primary: '#7c3aed',
98
124
  primaryHover: '#8b5cf6',
99
125
  primaryForeground: '#f8fafc',
100
- primaryRing: '#a78bfa',
101
126
 
102
127
  secondary: '#0284c7',
103
128
  secondaryHover: '#0ea5e9',
104
129
  secondaryForeground: '#f8fafc',
105
- secondaryRing: '#38bdf8',
106
130
 
107
131
  disabled: '#475569',
108
132
  disabledHover: '#334155',
109
133
 
110
- ring: '#64748b',
134
+ focusRing: '#cbd5e1',
135
+
136
+ link: '#60a5fa',
111
137
 
112
138
  success: '#34d399',
113
139
  warning: '#fbbf24',
@@ -7,6 +7,18 @@ const GENERATED_HEADER = `/* GENERATED from src/Theme/Palette.ts by \`npm run bu
7
7
  * values inside @theme directly would freeze them at build time and the .dark overrides would
8
8
  * never reach the utilities. */`;
9
9
 
10
+ const GENERATED_LIGHT_HEADER = `/* GENERATED from src/Theme/Palette.ts by \`npm run build:tokens\` - do not edit by hand.
11
+ *
12
+ * The light-only variant (issue #62): the light values live in :root and the @theme inline block
13
+ * maps each onto a Tailwind colour. It carries no dark rule, so a light-only consumer imports it
14
+ * instead of tokens.css and bundles no second theme it cannot remove. */`;
15
+
16
+ const GENERATED_DARK_HEADER = `/* GENERATED from src/Theme/Palette.ts by \`npm run build:tokens\` - do not edit by hand.
17
+ *
18
+ * The dark-only variant (issue #62): the dark values live in :root, ungated, and the @theme inline
19
+ * block maps each onto a Tailwind colour. A dark-only consumer imports it instead of tokens.css; it
20
+ * is the only theme, so no class toggle or variant is needed to select it. */`;
21
+
10
22
  /* The library performs exactly one motion - a colour transition - so its motion contract is this
11
23
  single token. 150ms matches Tailwind's default, so it changes nothing visually; its job is to
12
24
  have a name a consumer theme and the reduced-motion query can reach.
@@ -23,6 +35,153 @@ const MOTION = `:root {
23
35
  }
24
36
  }`;
25
37
 
38
+ /* The ring's width and offset are not colours: like motion they live in :root only, never in
39
+ @theme inline, so a consumer can re-point them. Constraint: both > 0 - a zero width is no
40
+ indicator, a zero offset drops the ring onto the fill where the 3:1-against-surface rule fails.
41
+ See docs/adr/0002-focus-ring-token-contract.md. */
42
+ const FOCUS_RING = `:root {
43
+ --focus-ring-width: 3px;
44
+ --focus-ring-offset: 2px;
45
+ }`;
46
+
47
+ /* Radius is not a colour: like motion and the focus-ring dimensions it lives in :root only, never
48
+ in @theme inline. One token names the corner every control reads; 0.5rem is exactly what
49
+ rounded-lg resolved to, so nothing changes visually while it keeps tracking the root font size.
50
+ No structure radius, no value constraint - both deliberate; see docs/adr/0003-radius-token-contract.md. */
51
+ const RADIUS = `:root {
52
+ --radius-control: 0.5rem;
53
+ }`;
54
+
55
+ /* The control's minimum width is not a colour: like radius it lives in :root only, never in
56
+ @theme inline, so a consumer theme can re-point it. One token names the floor primary and
57
+ secondary share; 10.5rem is exactly what min-w-42 resolved to, so nothing changes visually while
58
+ it keeps tracking the root font size. ghost owns a distinct zero - an intentional role, not this
59
+ token set to 0 - so it keeps its literal min-w-0 and does not read this. */
60
+ const CONTROL_MIN_WIDTH = `:root {
61
+ --control-min-width: 10.5rem;
62
+ }`;
63
+
64
+ /* The library's underline is not a colour: like the focus-ring dimensions it lives in :root only,
65
+ never @theme inline. Not prose's alone - every Link treatment that draws a line draws it from these.
66
+ Constraint: --underline-thickness-hover > --underline-thickness (the thicken on hover is the only
67
+ cue telling a prose link apart, never hue) and both > 0. The pair names a rest state and a thickened
68
+ one, so only prose - whose line is always on screen - reads the -hover token; a line that merely
69
+ appears on hover takes --underline-thickness. Either way the change is instant, decoration being off
70
+ the transition allowlist. See docs/adr/0006-link-treatment-contract.md. */
71
+ const UNDERLINE = `:root {
72
+ --underline-offset: 0.18em;
73
+ --underline-thickness: 1px;
74
+ --underline-thickness-hover: 2px;
75
+ }`;
76
+
77
+ /* Typography is not a colour and, unlike the blocks above, not :root-only: Tailwind 4's --font-*,
78
+ --text-*, --leading-* and --tracking-* are theme namespaces, so its own @theme block generates the
79
+ font-primary/text-display/leading-body/tracking-label utilities a sealed recipe reaches. No
80
+ Tailwind built-in is re-pointed; only new role names. See docs/adr/0004-typography-token-contract.md. */
81
+ const TYPOGRAPHY = `@theme {
82
+ /* Both faces default to inherit, so the library ships no @font-face and no face and a one-face
83
+ consumer renders as today. --font-primary is carried by content, --font-secondary by labels; a
84
+ component sets font-variant-numeric: tabular-nums only on a --font-primary carrying tnum and
85
+ font-variant-caps: small-caps only on a --font-secondary carrying smcp - both fail silently otherwise. */
86
+ --font-primary: inherit;
87
+ --font-secondary: inherit;
88
+
89
+ /* Type roles, not a scale: one role per job, so a component asks for what its text is, never a rung.
90
+ display > title > subtitle are the three heading steps H1-H3 bind to; H4-H6 share body and split
91
+ by weight, so there is no fourth role. display is the hero and must out-scale every subpage head. */
92
+ --text-display: clamp(3rem, 7vw, 6rem);
93
+ --leading-display: 1.05;
94
+ --text-title: clamp(2.25rem, 5vw, 4.25rem);
95
+ --leading-title: 1.1;
96
+ --text-subtitle: clamp(1.5rem, 3vw, 2.25rem);
97
+ --leading-subtitle: 1.2;
98
+
99
+ /* The reading block's opening paragraph (Prose #21): sized above body and below subtitle, led
100
+ tighter than body's 1.6 but not as tight as a heading's 1.2 - it is running text, not a head, so
101
+ it keeps running-text leading. A role, not a rung: one lede per block, nothing to choose. Reusing
102
+ subtitle was rejected in ADR 0004 - it welds the lede's size to H3's and its leading to a head's. */
103
+ --text-lede: 1.25rem; /* 20px */
104
+ --leading-lede: 1.4;
105
+ --text-body: 1.0625rem; /* 17px */
106
+ --leading-body: 1.6;
107
+ --text-small: 0.9375rem; /* 15px floor: below it tabular figures stop comparing column to column */
108
+ --text-label: 0.8125rem; /* 13px floor: below it the tracking reads as damage, not a device */
109
+
110
+ /* Two quantities on one property that must not collapse: --tracking-label is a fixed letter-spaced
111
+ style, --tracking-optical a correction that varies with size. The names say which is which, so the
112
+ distinction survives without the ADR in hand. Scope: the title role and above - H1, H2 and the page
113
+ head's h1 - and never below it, where the correction would read as damage. See the ADR. */
114
+ --tracking-label: 0.14em;
115
+ --tracking-optical: -0.02em;
116
+ }`;
117
+
118
+ /* The reading measure is in ch, not rem, so the character count stays held when the body size moves
119
+ under it; --measure-display is narrower because bigger type wants fewer characters per line, and
120
+ --measure-wide slightly wider - the standfirst of a page head (PageHead #18) is an opening statement,
121
+ not a reading column, so it runs a little past the reading measure by design. All three in ch and,
122
+ having no Tailwind namespace, in :root beside radius, read as max-w-[var(--measure)]. */
123
+ const MEASURE = `:root {
124
+ --measure: 66ch;
125
+ --measure-display: 36ch;
126
+ --measure-wide: 72ch;
127
+ }`;
128
+
129
+ /* Three spacing roles, not a ladder: --space-stack is the sibling gap in a stack, --space-region the air
130
+ around a region of a page - a form's region groups and the shell's bars, above and below the header and
131
+ the footer and between the header's nav items (#14/#15) - and --space-band the vertical air inside a page
132
+ section (#9). All three in em so they track the type ramp, not a fixed length. Three names a reader could
133
+ line up stack < region < band is exactly where "roles, not rungs" is tested - and it holds: each is bound
134
+ to one job with nothing to choose between, not to a step on a ramp a component picks from. A numbered
135
+ scale (--space-1..4) was rejected for that reason - ADR 0003/0004. em has no Tailwind namespace, so like
136
+ --measure these sit in :root. The gutter is a spacing role too but not a --space-* one and not in em -
137
+ see GUTTER below. */
138
+ const SPACING = `:root {
139
+ --space-stack: 0.5em;
140
+ --space-region: 1.5em;
141
+ --space-band: 4em;
142
+ }`;
143
+
144
+ /* The gutter is the horizontal inset holding content off the viewport edge (#9), and the one spacing role
145
+ measured against the screen rather than the type: it answers to how much room there is, not how large
146
+ the words are, so it is in rem/vw and never em. A clamp() lets it grow with the viewport with no
147
+ breakpoint, the device --text-display already uses. Not a --space-* role and no Tailwind namespace, so
148
+ it sits in :root beside the space roles, read as px-[var(--gutter)]. */
149
+ const GUTTER = `:root {
150
+ --gutter: clamp(1.5rem, 5vw, 4rem);
151
+ }`;
152
+
153
+ /* The fold is the least height a first screen takes (CONTEXT.md): measured against the screen not the
154
+ type, so in vh/rem and never em, in :root beside the gutter with no Tailwind namespace, read as
155
+ min-h-[var(--fold-height)]. Constraint: the vh component is < 100vh - a fold that exactly fills the
156
+ screen guarantees top-heaviness, so the next section's hairline must sit just inside it, and the
157
+ floor is enforced against the library's own value (ADR 0004), never a consumer's. */
158
+ const FOLD = `:root {
159
+ --fold-height: min(70vh, 40rem);
160
+ }`;
161
+
162
+ /* Four aspect roles in a @theme block like typography: --aspect-* is a Tailwind 4 namespace, so it
163
+ generates the aspect-portrait utilities Figure reaches rather than a :root value. Named shapes, not
164
+ a ladder - ADR 0003/0004 rejected a radius and spacing scale (see SPACING) because a scale lets a
165
+ component pick a rung with no rule saying which; four shapes with distinct jobs and no ordering do not. */
166
+ const ASPECT = `@theme {
167
+ --aspect-portrait: 4 / 5;
168
+ --aspect-square: 1 / 1;
169
+ --aspect-landscape: 3 / 2;
170
+ --aspect-wide: 16 / 9;
171
+ }`;
172
+
173
+ /* The checklist tick's two dimensions are not colours: like the underline and focus-ring dimensions
174
+ they live in :root only, never @theme inline, so a brand can re-point them. They are named rather
175
+ than recipe literals because of ADR 0004's test - whether the library sets the property. The tick is
176
+ drawn as a `::before` pseudo-element, the strongest possible case of an element a consumer cannot
177
+ reach: no `className` gets near it, so a value overriding it would have to fight a utility class on
178
+ an element that is not in the tree at all, and the value therefore needs a name. With `rule` that is
179
+ three names a brand re-points to get a different marker, and no member of Checklist gains a prop. */
180
+ const TICK = `:root {
181
+ --tick-length: 0.9rem;
182
+ --tick-thickness: 1px;
183
+ }`;
184
+
26
185
  const toKebabCase = (name: string): string =>
27
186
  name.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
28
187
 
@@ -34,21 +193,76 @@ const declarations = (
34
193
  .map((name) => ` --color-${toKebabCase(name)}: ${value(name)};`)
35
194
  .join('\n');
36
195
 
196
+ // The library no longer declares --color-ring but still reads it as a fallback, so a consumer's
197
+ // existing value keeps working; the plain hex in the palette is the default and what the contrast
198
+ // test checks. See docs/adr/0002-focus-ring-token-contract.md.
199
+ const raw = (tokens: PaletteTokens) => (name: string) => {
200
+ const value = tokens[name as keyof PaletteTokens];
201
+ return name === 'focusRing' ? `var(--color-ring, ${value})` : value;
202
+ };
203
+ const reference = () => (name: string) => `var(--color-${toKebabCase(name)})`;
204
+
205
+ // Only the combined tokens.css carries the dark set; this gates it behind .dark and defines dark:.
206
+ const DARK_VARIANT = `/* Dark mode is driven by a \`.dark\` class rather than prefers-color-scheme, so a story or a
207
+ screenshot can be taken under either theme on demand. */
208
+ @custom-variant dark (&:where(.dark, .dark *));`;
209
+
210
+ // The @theme inline colour map and every non-colour token block: identical across all three
211
+ // variants, since the map references the role variables :root declares rather than any hex.
212
+ const colourMapAndTail = (): string => `@theme inline {
213
+ ${declarations(light, reference())}
214
+ }
215
+
216
+ /* Motion is not a colour: it is carried in :root only, never mapped into @theme inline. */
217
+ ${MOTION}
218
+
219
+ /* The focus ring's dimensions are not colours either, and sit in :root beside the motion block. */
220
+ ${FOCUS_RING}
221
+
222
+ /* Radius is not a colour either: one token for the corner every control reads, beside the others. */
223
+ ${RADIUS}
224
+
225
+ /* The control minimum width is not a colour either, and sits in :root beside radius. */
226
+ ${CONTROL_MIN_WIDTH}
227
+
228
+ /* The library's underline dimensions are not colours either, and sit in :root beside radius. They are
229
+ not prose's alone: every Link treatment that draws a line draws it from these (docs/adr/0006). */
230
+ ${UNDERLINE}
231
+
232
+ /* Typography is a set of Tailwind theme namespaces, not colours: its own @theme block generates the
233
+ utilities, so it is not carried in @theme inline with the palette. */
234
+ ${TYPOGRAPHY}
235
+
236
+ /* The aspect roles are Tailwind theme namespaces like typography, so they take their own @theme block
237
+ after it and are not carried in @theme inline with the palette. */
238
+ ${ASPECT}
239
+
240
+ /* The reading measure has no Tailwind namespace, so it sits in :root beside radius. */
241
+ ${MEASURE}
242
+
243
+ /* Spacing has no Tailwind namespace either - --spacing is a single base multiplier ADR 0004 forbids
244
+ re-pointing - so the three roles sit in :root beside the measure. */
245
+ ${SPACING}
246
+
247
+ /* The gutter has no Tailwind namespace either, so it sits in :root beside the space roles. */
248
+ ${GUTTER}
249
+
250
+ /* The fold height has no Tailwind namespace either, so it sits in :root beside the gutter. */
251
+ ${FOLD}
252
+
253
+ /* The checklist tick's dimensions are not colours either, and sit in :root beside the underline block. */
254
+ ${TICK}
255
+ `;
256
+
37
257
  /**
38
- * Renders the stylesheet form of the palette. Shared by the build script and the test that pins
39
- * `src/tokens.css` to it, so a palette edit that was never regenerated fails the suite instead of
40
- * silently shipping stale colours.
258
+ * Renders the combined stylesheet form of the palette: the light set in `:root`, the dark set under
259
+ * `.dark`, the `dark:` custom variant, then the shared colour map and non-colour tokens. This is
260
+ * the default `src/tokens.css`, unchanged by issue #62. Shared by the build script and the test that
261
+ * pins `src/tokens.css` to it, so a palette edit that was never regenerated fails the suite.
41
262
  */
42
- export const renderTokens = (): string => {
43
- const raw = (tokens: PaletteTokens) => (name: string) =>
44
- tokens[name as keyof PaletteTokens];
45
- const reference = () => (name: string) => `var(--color-${toKebabCase(name)})`;
263
+ export const renderTokens = (): string => `${GENERATED_HEADER}
46
264
 
47
- return `${GENERATED_HEADER}
48
-
49
- /* Dark mode is driven by a \`.dark\` class rather than prefers-color-scheme, so a story or a
50
- screenshot can be taken under either theme on demand. */
51
- @custom-variant dark (&:where(.dark, .dark *));
265
+ ${DARK_VARIANT}
52
266
 
53
267
  :root {
54
268
  ${declarations(light, raw(light))}
@@ -58,11 +272,34 @@ ${declarations(light, raw(light))}
58
272
  ${declarations(dark, raw(dark))}
59
273
  }
60
274
 
61
- @theme inline {
62
- ${declarations(light, reference())}
275
+ ${colourMapAndTail()}`;
276
+
277
+ /* The single-theme variants (issue #62) share one shape: the chosen set unconditionally in `:root`,
278
+ then the shared colour map and non-colour tokens - no `.dark` rule and no `dark:` variant. They
279
+ differ only in header and which palette set lands in `:root`, so both render through here. */
280
+ const renderSingleTheme = (
281
+ header: string,
282
+ tokens: PaletteTokens,
283
+ ): string => `${header}
284
+
285
+ :root {
286
+ ${declarations(tokens, raw(tokens))}
63
287
  }
64
288
 
65
- /* Motion is not a colour: it is carried in :root only, never mapped into @theme inline. */
66
- ${MOTION}
67
- `;
68
- };
289
+ ${colourMapAndTail()}`;
290
+
291
+ /**
292
+ * Renders the light-only variant (issue #62): the light set in `:root`, no `.dark` rule and no
293
+ * `dark:` variant, so a light-only consumer's bundle carries no dark theme it cannot remove. Pinned
294
+ * to `src/tokens.light.css` by the same regeneration test that pins {@link renderTokens}.
295
+ */
296
+ export const renderLightTokens = (): string =>
297
+ renderSingleTheme(GENERATED_LIGHT_HEADER, light);
298
+
299
+ /**
300
+ * Renders the dark-only variant (issue #62): the dark set in `:root`, ungated since it is the only
301
+ * theme. A dark-only consumer imports this instead of the default. Pinned to `src/tokens.dark.css`
302
+ * by the same regeneration test that pins {@link renderTokens}.
303
+ */
304
+ export const renderDarkTokens = (): string =>
305
+ renderSingleTheme(GENERATED_DARK_HEADER, dark);
package/src/index.ts CHANGED
@@ -1,6 +1,30 @@
1
1
  import './styles.css';
2
2
 
3
- export type { IButtonProps } from 'Interaction/Button/Button';
3
+ export { Brandmark } from 'Display/Brandmark/Brandmark';
4
+ export { Checklist } from 'Display/Checklist/Checklist';
5
+ export { DefinitionList } from 'Display/DefinitionList/DefinitionList';
6
+ export { Figure } from 'Display/Figure/Figure';
7
+ export { Rail } from 'Display/Rail/Rail';
8
+ export { Table } from 'Display/Table/Table';
9
+ export { Eyebrow } from 'Display/Typography/Eyebrow/Eyebrow';
10
+ export { H1 } from 'Display/Typography/H1/H1';
11
+ export { H2 } from 'Display/Typography/H2/H2';
12
+ export { H3 } from 'Display/Typography/H3/H3';
13
+ export { H4 } from 'Display/Typography/H4/H4';
14
+ export { H5 } from 'Display/Typography/H5/H5';
15
+ export { H6 } from 'Display/Typography/H6/H6';
16
+ export { P } from 'Display/Typography/P/P';
17
+ export { Prose } from 'Display/Typography/Prose/Prose';
4
18
  export { Button } from 'Interaction/Button/Button';
19
+ export { Input } from 'Interaction/Input/Input';
20
+ export { Link } from 'Interaction/Link/Link';
21
+ export { TextArea } from 'Interaction/TextArea/TextArea';
22
+ export { Footer } from 'Layout/Footer/Footer';
23
+ export type { FormState } from 'Layout/Form/Form';
24
+ export { Form } from 'Layout/Form/Form';
25
+ export { Header } from 'Layout/Header/Header';
26
+ export { Hero } from 'Layout/Hero/Hero';
27
+ export { PageHead } from 'Layout/PageHead/PageHead';
28
+ export { Section } from 'Layout/Section/Section';
5
29
  export type { PaletteTokens } from 'Theme/Palette';
6
30
  export { dark, light } from 'Theme/Palette';
@@ -0,0 +1,9 @@
1
+ /* Component-level styles for the design system, dark theme only (issue #62). */
2
+ @import "tailwindcss";
3
+ @import "./tokens.dark.css";
4
+
5
+ /* Scan the component sources explicitly so their utilities are generated no matter
6
+ which host imports this stylesheet. Tailwind's automatic content detection is
7
+ anchored to the consumer and skips node_modules, so a host pulling the components
8
+ in through a package would otherwise render them unstyled. */
9
+ @source ".";
@@ -0,0 +1,9 @@
1
+ /* Component-level styles for the design system, light theme only (issue #62). */
2
+ @import "tailwindcss";
3
+ @import "./tokens.light.css";
4
+
5
+ /* Scan the component sources explicitly so their utilities are generated no matter
6
+ which host imports this stylesheet. Tailwind's automatic content detection is
7
+ anchored to the consumer and skips node_modules, so a host pulling the components
8
+ in through a package would otherwise render them unstyled. */
9
+ @source ".";
package/src/tokens.css CHANGED
@@ -14,17 +14,19 @@
14
14
  --color-foreground: #0f172a;
15
15
  --color-muted: #64748b;
16
16
  --color-border: #e2e8f0;
17
+ --color-control-border: #64748b;
18
+ --color-rule: #808fa3;
19
+ --color-backing: #f1f5f9;
17
20
  --color-primary: #8b5cf6;
18
21
  --color-primary-hover: #7c3aed;
19
22
  --color-primary-foreground: #f8fafc;
20
- --color-primary-ring: #a78bfa;
21
23
  --color-secondary: #0ea5e9;
22
24
  --color-secondary-hover: #0284c7;
23
25
  --color-secondary-foreground: #f8fafc;
24
- --color-secondary-ring: #38bdf8;
25
26
  --color-disabled: #94a3b8;
26
27
  --color-disabled-hover: #64748b;
27
- --color-ring: #94a3b8;
28
+ --color-focus-ring: var(--color-ring, #475569);
29
+ --color-link: #2563eb;
28
30
  --color-success: #10b981;
29
31
  --color-warning: #f59e0b;
30
32
  --color-error: #d63384;
@@ -36,17 +38,19 @@
36
38
  --color-foreground: #f8fafc;
37
39
  --color-muted: #94a3b8;
38
40
  --color-border: #334155;
41
+ --color-control-border: #94a3b8;
42
+ --color-rule: #5b6a80;
43
+ --color-backing: #1e293b;
39
44
  --color-primary: #7c3aed;
40
45
  --color-primary-hover: #8b5cf6;
41
46
  --color-primary-foreground: #f8fafc;
42
- --color-primary-ring: #a78bfa;
43
47
  --color-secondary: #0284c7;
44
48
  --color-secondary-hover: #0ea5e9;
45
49
  --color-secondary-foreground: #f8fafc;
46
- --color-secondary-ring: #38bdf8;
47
50
  --color-disabled: #475569;
48
51
  --color-disabled-hover: #334155;
49
- --color-ring: #64748b;
52
+ --color-focus-ring: var(--color-ring, #cbd5e1);
53
+ --color-link: #60a5fa;
50
54
  --color-success: #34d399;
51
55
  --color-warning: #fbbf24;
52
56
  --color-error: #f48fb1;
@@ -58,17 +62,19 @@
58
62
  --color-foreground: var(--color-foreground);
59
63
  --color-muted: var(--color-muted);
60
64
  --color-border: var(--color-border);
65
+ --color-control-border: var(--color-control-border);
66
+ --color-rule: var(--color-rule);
67
+ --color-backing: var(--color-backing);
61
68
  --color-primary: var(--color-primary);
62
69
  --color-primary-hover: var(--color-primary-hover);
63
70
  --color-primary-foreground: var(--color-primary-foreground);
64
- --color-primary-ring: var(--color-primary-ring);
65
71
  --color-secondary: var(--color-secondary);
66
72
  --color-secondary-hover: var(--color-secondary-hover);
67
73
  --color-secondary-foreground: var(--color-secondary-foreground);
68
- --color-secondary-ring: var(--color-secondary-ring);
69
74
  --color-disabled: var(--color-disabled);
70
75
  --color-disabled-hover: var(--color-disabled-hover);
71
- --color-ring: var(--color-ring);
76
+ --color-focus-ring: var(--color-focus-ring);
77
+ --color-link: var(--color-link);
72
78
  --color-success: var(--color-success);
73
79
  --color-warning: var(--color-warning);
74
80
  --color-error: var(--color-error);
@@ -87,3 +93,106 @@
87
93
  --motion-duration-color: 0ms;
88
94
  }
89
95
  }
96
+
97
+ /* The focus ring's dimensions are not colours either, and sit in :root beside the motion block. */
98
+ :root {
99
+ --focus-ring-width: 3px;
100
+ --focus-ring-offset: 2px;
101
+ }
102
+
103
+ /* Radius is not a colour either: one token for the corner every control reads, beside the others. */
104
+ :root {
105
+ --radius-control: 0.5rem;
106
+ }
107
+
108
+ /* The control minimum width is not a colour either, and sits in :root beside radius. */
109
+ :root {
110
+ --control-min-width: 10.5rem;
111
+ }
112
+
113
+ /* The library's underline dimensions are not colours either, and sit in :root beside radius. They are
114
+ not prose's alone: every Link treatment that draws a line draws it from these (docs/adr/0006). */
115
+ :root {
116
+ --underline-offset: 0.18em;
117
+ --underline-thickness: 1px;
118
+ --underline-thickness-hover: 2px;
119
+ }
120
+
121
+ /* Typography is a set of Tailwind theme namespaces, not colours: its own @theme block generates the
122
+ utilities, so it is not carried in @theme inline with the palette. */
123
+ @theme {
124
+ /* Both faces default to inherit, so the library ships no @font-face and no face and a one-face
125
+ consumer renders as today. --font-primary is carried by content, --font-secondary by labels; a
126
+ component sets font-variant-numeric: tabular-nums only on a --font-primary carrying tnum and
127
+ font-variant-caps: small-caps only on a --font-secondary carrying smcp - both fail silently otherwise. */
128
+ --font-primary: inherit;
129
+ --font-secondary: inherit;
130
+
131
+ /* Type roles, not a scale: one role per job, so a component asks for what its text is, never a rung.
132
+ display > title > subtitle are the three heading steps H1-H3 bind to; H4-H6 share body and split
133
+ by weight, so there is no fourth role. display is the hero and must out-scale every subpage head. */
134
+ --text-display: clamp(3rem, 7vw, 6rem);
135
+ --leading-display: 1.05;
136
+ --text-title: clamp(2.25rem, 5vw, 4.25rem);
137
+ --leading-title: 1.1;
138
+ --text-subtitle: clamp(1.5rem, 3vw, 2.25rem);
139
+ --leading-subtitle: 1.2;
140
+
141
+ /* The reading block's opening paragraph (Prose #21): sized above body and below subtitle, led
142
+ tighter than body's 1.6 but not as tight as a heading's 1.2 - it is running text, not a head, so
143
+ it keeps running-text leading. A role, not a rung: one lede per block, nothing to choose. Reusing
144
+ subtitle was rejected in ADR 0004 - it welds the lede's size to H3's and its leading to a head's. */
145
+ --text-lede: 1.25rem; /* 20px */
146
+ --leading-lede: 1.4;
147
+ --text-body: 1.0625rem; /* 17px */
148
+ --leading-body: 1.6;
149
+ --text-small: 0.9375rem; /* 15px floor: below it tabular figures stop comparing column to column */
150
+ --text-label: 0.8125rem; /* 13px floor: below it the tracking reads as damage, not a device */
151
+
152
+ /* Two quantities on one property that must not collapse: --tracking-label is a fixed letter-spaced
153
+ style, --tracking-optical a correction that varies with size. The names say which is which, so the
154
+ distinction survives without the ADR in hand. Scope: the title role and above - H1, H2 and the page
155
+ head's h1 - and never below it, where the correction would read as damage. See the ADR. */
156
+ --tracking-label: 0.14em;
157
+ --tracking-optical: -0.02em;
158
+ }
159
+
160
+ /* The aspect roles are Tailwind theme namespaces like typography, so they take their own @theme block
161
+ after it and are not carried in @theme inline with the palette. */
162
+ @theme {
163
+ --aspect-portrait: 4 / 5;
164
+ --aspect-square: 1 / 1;
165
+ --aspect-landscape: 3 / 2;
166
+ --aspect-wide: 16 / 9;
167
+ }
168
+
169
+ /* The reading measure has no Tailwind namespace, so it sits in :root beside radius. */
170
+ :root {
171
+ --measure: 66ch;
172
+ --measure-display: 36ch;
173
+ --measure-wide: 72ch;
174
+ }
175
+
176
+ /* Spacing has no Tailwind namespace either - --spacing is a single base multiplier ADR 0004 forbids
177
+ re-pointing - so the three roles sit in :root beside the measure. */
178
+ :root {
179
+ --space-stack: 0.5em;
180
+ --space-region: 1.5em;
181
+ --space-band: 4em;
182
+ }
183
+
184
+ /* The gutter has no Tailwind namespace either, so it sits in :root beside the space roles. */
185
+ :root {
186
+ --gutter: clamp(1.5rem, 5vw, 4rem);
187
+ }
188
+
189
+ /* The fold height has no Tailwind namespace either, so it sits in :root beside the gutter. */
190
+ :root {
191
+ --fold-height: min(70vh, 40rem);
192
+ }
193
+
194
+ /* The checklist tick's dimensions are not colours either, and sit in :root beside the underline block. */
195
+ :root {
196
+ --tick-length: 0.9rem;
197
+ --tick-thickness: 1px;
198
+ }