@juwel-development/design-system 3.9.1 → 3.11.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 (79) hide show
  1. package/README.md +9 -298
  2. package/dist/design-system.js +1070 -605
  3. package/dist/index.css +1 -1
  4. package/dist/types/Arrangement/ColumnLayout/ColumnLayout.d.ts +92 -0
  5. package/dist/types/Arrangement/ColumnLayout/ColumnLayoutCompositionError.d.ts +3 -0
  6. package/dist/types/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.d.ts +3 -0
  7. package/dist/types/Arrangement/FieldRow/FieldRow.d.ts +87 -0
  8. package/dist/types/Arrangement/FieldRow/FieldRowCompositionError.d.ts +3 -0
  9. package/dist/types/Arrangement/FieldRow/FieldRowConfigurationError.d.ts +3 -0
  10. package/dist/types/Arrangement/FieldRow/IControlEdge.d.ts +6 -0
  11. package/dist/types/Arrangement/FieldRow/alignControlEdges.d.ts +7 -0
  12. package/dist/types/Display/Box/Box.d.ts +41 -0
  13. package/dist/types/Display/Brandmark/Brandmark.d.ts +1 -1
  14. package/dist/types/Display/DefinitionList/DefinitionList.d.ts +59 -9
  15. package/dist/types/Display/DefinitionList/DefinitionListConfigurationError.d.ts +3 -0
  16. package/dist/types/Display/Figure/Figure.d.ts +2 -2
  17. package/dist/types/Display/Icon/Icon.d.ts +45 -0
  18. package/dist/types/Display/Table/Table.d.ts +118 -8
  19. package/dist/types/Display/Table/TableConfigurationError.d.ts +3 -0
  20. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +1 -1
  21. package/dist/types/Display/Typography/H1/H1.d.ts +3 -2
  22. package/dist/types/Display/Typography/H2/H2.d.ts +3 -2
  23. package/dist/types/Display/Typography/H3/H3.d.ts +3 -2
  24. package/dist/types/Display/Typography/H4/H4.d.ts +3 -2
  25. package/dist/types/Display/Typography/H5/H5.d.ts +3 -2
  26. package/dist/types/Display/Typography/H6/H6.d.ts +3 -2
  27. package/dist/types/Display/Typography/Note/Note.d.ts +1 -1
  28. package/dist/types/Display/Typography/P/P.d.ts +3 -2
  29. package/dist/types/Display/Typography/Prose/Prose.d.ts +1 -1
  30. package/dist/types/Interaction/Button/Button.d.ts +25 -3
  31. package/dist/types/Interaction/Tabs/Tabs.d.ts +31 -6
  32. package/dist/types/Layout/Header/Header.d.ts +67 -8
  33. package/dist/types/Layout/ScrollContainer/ScrollContainer.d.ts +37 -0
  34. package/dist/types/Layout/Section/Section.d.ts +1 -1
  35. package/dist/types/Layout/Sidebar/Sidebar.d.ts +4 -0
  36. package/dist/types/Theme/Palette.d.ts +31 -7
  37. package/dist/types/index.d.ts +6 -0
  38. package/package.json +1 -1
  39. package/src/Arrangement/ColumnLayout/ColumnLayout.tsx +249 -0
  40. package/src/Arrangement/ColumnLayout/ColumnLayoutCompositionError.ts +8 -0
  41. package/src/Arrangement/ColumnLayout/ColumnLayoutConfigurationError.ts +6 -0
  42. package/src/Arrangement/FieldRow/FieldRow.tsx +313 -0
  43. package/src/Arrangement/FieldRow/FieldRowCompositionError.ts +6 -0
  44. package/src/Arrangement/FieldRow/FieldRowConfigurationError.ts +6 -0
  45. package/src/Arrangement/FieldRow/IControlEdge.ts +6 -0
  46. package/src/Arrangement/FieldRow/alignControlEdges.ts +35 -0
  47. package/src/Display/Box/Box.tsx +77 -0
  48. package/src/Display/Checklist/Checklist.tsx +1 -1
  49. package/src/Display/DefinitionList/DefinitionList.tsx +164 -22
  50. package/src/Display/DefinitionList/DefinitionListConfigurationError.ts +6 -0
  51. package/src/Display/Icon/Icon.tsx +113 -0
  52. package/src/Display/Table/Table.tsx +434 -44
  53. package/src/Display/Table/TableConfigurationError.ts +6 -0
  54. package/src/Display/Typography/H1/H1.tsx +3 -2
  55. package/src/Display/Typography/H2/H2.tsx +3 -2
  56. package/src/Display/Typography/H3/H3.tsx +3 -2
  57. package/src/Display/Typography/H4/H4.tsx +3 -2
  58. package/src/Display/Typography/H5/H5.tsx +3 -2
  59. package/src/Display/Typography/H6/H6.tsx +3 -2
  60. package/src/Display/Typography/P/P.tsx +3 -2
  61. package/src/Display/Typography/Prose/Prose.tsx +3 -3
  62. package/src/Interaction/Button/Button.tsx +47 -17
  63. package/src/Interaction/Input/Input.tsx +1 -1
  64. package/src/Interaction/MultiSelect/MultiSelect.tsx +1 -1
  65. package/src/Interaction/NumberInput/NumberInput.tsx +1 -1
  66. package/src/Interaction/Select/Select.tsx +1 -1
  67. package/src/Interaction/Tabs/Tabs.tsx +38 -11
  68. package/src/Interaction/TextArea/TextArea.tsx +1 -1
  69. package/src/Layout/Dialog/Dialog.tsx +4 -3
  70. package/src/Layout/Header/Header.tsx +139 -39
  71. package/src/Layout/PageHead/PageHead.tsx +6 -5
  72. package/src/Layout/ScrollContainer/ScrollContainer.tsx +149 -0
  73. package/src/Layout/Sidebar/Sidebar.tsx +8 -4
  74. package/src/Theme/Palette.ts +37 -9
  75. package/src/Theme/renderTokens.ts +101 -5
  76. package/src/index.ts +6 -0
  77. package/src/tokens.css +68 -4
  78. package/src/tokens.dark.css +66 -4
  79. package/src/tokens.light.css +64 -2
@@ -6,7 +6,7 @@ import type { FunctionComponent, ReactNode } from 'react';
6
6
  // inherits - Tailwind's preflight resets h1-h6 to font-weight: inherit, so the sized levels carry no
7
7
  // weight class. Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:`
8
8
  // class.
9
- const h3 = cva('font-primary text-subtitle leading-subtitle', {
9
+ const h3 = cva('font-heading text-subtitle leading-subtitle', {
10
10
  variants: {
11
11
  color: {
12
12
  foreground: 'text-foreground',
@@ -31,7 +31,8 @@ interface IH3Props extends VariantProps<typeof h3> {
31
31
  *
32
32
  * @Guarantees — enforced on every render
33
33
  * - Renders an `h3`; its outline level and the subtitle role are one choice, not two (docs/adr/0005).
34
- * - Reads `--font-primary`, sized by `--text-subtitle` and led by `--leading-subtitle`.
34
+ * - Reads `--font-heading`, the heading family that follows `--font-primary` until a theme
35
+ * re-points it (#120), sized by `--text-subtitle` and led by `--leading-subtitle`.
35
36
  * - `color` selects `foreground`, `muted`, `success`, `warning`, `error` or `info`; nothing else
36
37
  * paints text. A status tone changes colour only and adds no announcement semantics.
37
38
  *
@@ -6,7 +6,7 @@ import type { FunctionComponent, ReactNode } from 'react';
6
6
  // (docs/adr/0005). The `font-bold` is load-bearing - Tailwind's preflight resets h1-h6 to
7
7
  // font-weight: inherit, so without it an h4 renders identically to a paragraph. Colour is a semantic
8
8
  // token re-pointed by `.dark`, so no variant carries a `dark:` class.
9
- const h4 = cva('font-primary text-body leading-body font-bold', {
9
+ const h4 = cva('font-heading text-body leading-body font-bold', {
10
10
  variants: {
11
11
  color: {
12
12
  foreground: 'text-foreground',
@@ -30,7 +30,8 @@ interface IH4Props extends VariantProps<typeof h4> {
30
30
  *
31
31
  * @Guarantees — enforced on every render
32
32
  * - Renders an `h4`; its outline level and the body role are one choice, not two (docs/adr/0005).
33
- * - Reads `--font-primary`, sized by `--text-body`, and is bold so it stands apart from a paragraph.
33
+ * - Reads `--font-heading`, the heading family that follows `--font-primary` until a theme
34
+ * re-points it (#120), sized by `--text-body`, and is bold so it stands apart from a paragraph.
34
35
  * - `color` selects `foreground`, `muted`, `success`, `warning`, `error` or `info`; nothing else
35
36
  * paints text. A status tone changes colour only and adds no announcement semantics.
36
37
  *
@@ -6,7 +6,7 @@ import type { FunctionComponent, ReactNode } from 'react';
6
6
  // (docs/adr/0005). The `font-semibold` is load-bearing - Tailwind's preflight resets h1-h6 to
7
7
  // font-weight: inherit, so without it an h5 renders identically to a paragraph. Colour is a semantic
8
8
  // token re-pointed by `.dark`, so no variant carries a `dark:` class.
9
- const h5 = cva('font-primary text-body leading-body font-semibold', {
9
+ const h5 = cva('font-heading text-body leading-body font-semibold', {
10
10
  variants: {
11
11
  color: {
12
12
  foreground: 'text-foreground',
@@ -31,7 +31,8 @@ interface IH5Props extends VariantProps<typeof h5> {
31
31
  *
32
32
  * @Guarantees — enforced on every render
33
33
  * - Renders an `h5`; its outline level and the body role are one choice, not two (docs/adr/0005).
34
- * - Reads `--font-primary`, sized by `--text-body`, semibold so it stands apart from a paragraph.
34
+ * - Reads `--font-heading`, the heading family that follows `--font-primary` until a theme
35
+ * re-points it (#120), sized by `--text-body`, semibold so it stands apart from a paragraph.
35
36
  * - `color` selects `foreground`, `muted`, `success`, `warning`, `error` or `info`; nothing else
36
37
  * paints text. A status tone changes colour only and adds no announcement semantics.
37
38
  *
@@ -6,7 +6,7 @@ import type { FunctionComponent, ReactNode } from 'react';
6
6
  // (docs/adr/0005). The `font-medium` is load-bearing - Tailwind's preflight resets h1-h6 to
7
7
  // font-weight: inherit, so without it an h6 renders identically to a paragraph. Colour is a semantic
8
8
  // token re-pointed by `.dark`, so no variant carries a `dark:` class.
9
- const h6 = cva('font-primary text-body leading-body font-medium', {
9
+ const h6 = cva('font-heading text-body leading-body font-medium', {
10
10
  variants: {
11
11
  color: {
12
12
  foreground: 'text-foreground',
@@ -31,7 +31,8 @@ interface IH6Props extends VariantProps<typeof h6> {
31
31
  *
32
32
  * @Guarantees — enforced on every render
33
33
  * - Renders an `h6`; its outline level and the body role are one choice, not two (docs/adr/0005).
34
- * - Reads `--font-primary`, sized by `--text-body`, medium so it stands apart from a paragraph.
34
+ * - Reads `--font-heading`, the heading family that follows `--font-primary` until a theme
35
+ * re-points it (#120), sized by `--text-body`, medium so it stands apart from a paragraph.
35
36
  * - `color` selects `foreground`, `muted`, `success`, `warning`, `error` or `info`; nothing else
36
37
  * paints text. A status tone changes colour only and adds no announcement semantics.
37
38
  *
@@ -5,7 +5,7 @@ import type { FunctionComponent, ReactNode } from 'react';
5
5
  // The body role, shared with h4-h6 which add a weight to stand apart from it (docs/adr/0005). Weight
6
6
  // inherits. Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
7
7
  // P owns no reading measure - that belongs to whatever owns the reading column (Prose #21).
8
- const p = cva('font-primary text-body leading-body', {
8
+ const p = cva('font-body text-body leading-body', {
9
9
  variants: {
10
10
  color: {
11
11
  foreground: 'text-foreground',
@@ -29,7 +29,8 @@ interface IPProps extends VariantProps<typeof p> {
29
29
  * table cell — because the reading measure belongs to whatever owns the reading column (Prose #21).
30
30
  *
31
31
  * @Guarantees — enforced on every render
32
- * - Renders a `p`, reading `--font-primary`, sized by `--text-body` and led by `--leading-body`.
32
+ * - Renders a `p`, reading `--font-body` (the body family, which follows `--font-primary` until a
33
+ * theme re-points it, #120), sized by `--text-body` and led by `--leading-body`.
33
34
  * - `color` selects `foreground`, `muted`, `success`, `warning`, `error` or `info`; nothing else
34
35
  * paints text. A status tone changes colour only and adds no announcement semantics.
35
36
  *
@@ -13,12 +13,12 @@ const proseRoot = cva(
13
13
 
14
14
  // The opening paragraph, at the new lede role: larger than body and led tighter (docs/adr/0004), but
15
15
  // still running-text leading, not a heading's. Colour is the foreground token, re-pointed by `.dark`.
16
- const proseLede = cva('font-primary text-lede leading-lede text-foreground');
16
+ const proseLede = cva('font-body text-lede leading-lede text-foreground');
17
17
 
18
18
  // The body role inside the column - the same utilities P emits, restated because the no-cross-import
19
19
  // rule forbids reusing P's recipe. A test pins the two equal so they cannot drift. Colour is a
20
20
  // semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
21
- const proseBody = cva('font-primary text-body leading-body', {
21
+ const proseBody = cva('font-body text-body leading-body', {
22
22
  variants: {
23
23
  color: {
24
24
  foreground: 'text-foreground',
@@ -33,7 +33,7 @@ const proseBody = cva('font-primary text-body leading-body', {
33
33
  });
34
34
 
35
35
  // The stepped-down closing note: the small role, always muted. small carries the enforced 15px floor.
36
- const proseTail = cva('font-primary text-small text-muted');
36
+ const proseTail = cva('font-body text-small text-muted');
37
37
 
38
38
  interface IProseRootProps {
39
39
  children?: ReactNode;
@@ -5,25 +5,33 @@ import type { Subject } from 'rxjs';
5
5
 
6
6
  // No `dark:` classes here by design: every colour below is a semantic token whose value is
7
7
  // re-pointed by the `.dark` class in tokens.css, so one set of classes serves both themes.
8
- // The colour transition is stated once in the base, on the motion token, so no variant can
9
- // disagree with it - see docs/adr/0001-motion-token-contract.md. The one focus ring is in the
10
- // base too: identical across variants, drawn with outline, colour at rest so it never fades in -
11
- // see docs/adr/0002-focus-ring-token-contract.md. The corner is in the base as well, one radius
12
- // token every variant shares, so none can disagree - see docs/adr/0003-radius-token-contract.md.
13
- // The face is in the base for the same reason - see docs/adr/0004-typography-token-contract.md (#90).
14
- // The size is in the base for the same reason, and here it is load-bearing: the recipe fixes
15
- // vertical padding and sets no height, so the font-size is what drives it (docs/adr/0004, #92).
8
+ // Every variant shares colour motion and the focus ring. Faced controls share their typography,
9
+ // radius and label arrangement; plain inherits its surrounding text. Long words wrap within the
10
+ // available width without clipping, and faced minimum widths yield to a narrower holder (#119).
11
+ const face =
12
+ 'font-control text-body text-balance wrap-anywhere rounded-[var(--radius-control)] select-none inline-flex flex-row items-center justify-center gap-2';
16
13
  const button = cva(
17
- 'font-primary text-body transition-colors duration-[var(--motion-duration-color)] rounded-[var(--radius-control)] py-2 sm:py-2 disabled:bg-disabled disabled:hover:bg-disabled-hover cursor-pointer disabled:cursor-not-allowed select-none text-nowrap inline-flex flex-row items-center justify-center gap-2 outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
14
+ 'transition-colors duration-[var(--motion-duration-color)] cursor-pointer disabled:cursor-not-allowed outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
18
15
  {
19
16
  variants: {
20
17
  variant: {
21
- primary:
22
- 'px-4 sm:px-6 min-w-[var(--control-min-width)] bg-primary text-primary-foreground hover:bg-primary-hover',
23
- secondary:
24
- 'px-4 sm:px-6 min-w-[var(--control-min-width)] bg-secondary text-secondary-foreground hover:bg-secondary-hover',
25
- ghost:
26
- 'px-2 min-w-0 bg-transparent text-foreground hover:underline hover:decoration-[length:var(--underline-thickness)] hover:underline-offset-[var(--underline-offset)]',
18
+ // The four faced variants share one floor, `--control-min-width`, so a row of them aligns,
19
+ // and the floor yields to a holder narrower than it (#119): `min()` against the holder's
20
+ // width lets the button shrink and its label wrap instead of forcing the holder to overflow.
21
+ // A holder sized to its content (an auto track, a table cell) has no width to yield to, so
22
+ // there the button takes its content width. Measured: a filled button in forced colours has
23
+ // no boundary at all - the UA forces the fill to ButtonFace, which can equal Canvas, and
24
+ // preflight zeroes the border - so the filled variants draw one there and nowhere else.
25
+ primary: `${face} px-4 sm:px-6 py-2 min-w-[min(var(--control-min-width),100%)] bg-primary text-primary-foreground hover:bg-primary-hover disabled:bg-disabled disabled:hover:bg-disabled-hover forced-colors:border`,
26
+ secondary: `${face} px-4 sm:px-6 py-2 min-w-[min(var(--control-min-width),100%)] bg-secondary text-secondary-foreground hover:bg-secondary-hover disabled:bg-disabled disabled:hover:bg-disabled-hover forced-colors:border`,
27
+ ghost: `${face} px-2 py-2 min-w-0 bg-transparent text-foreground hover:underline hover:decoration-[length:var(--underline-thickness)] hover:underline-offset-[var(--underline-offset)] disabled:bg-disabled disabled:hover:bg-disabled-hover`,
28
+ // Unfilled and identified by its edge (#119). The edge sits outside the inset, so each inset
29
+ // gives back the edge's pixel and the outlined button is exactly a filled one's height.
30
+ // Disabled follows Input: edge and ink go to the disabled tones and no fill appears.
31
+ outlined: `${face} px-[calc(1rem_-_1px)] sm:px-[calc(1.5rem_-_1px)] py-[calc(0.5rem_-_1px)] min-w-[min(var(--control-min-width),100%)] border border-solid border-secondary bg-transparent text-secondary hover:bg-backing disabled:border-disabled disabled:text-muted disabled:hover:bg-transparent`,
32
+ destructive: `${face} px-4 sm:px-6 py-2 min-w-[min(var(--control-min-width),100%)] bg-error text-error-foreground hover:bg-error-hover disabled:bg-disabled disabled:hover:bg-disabled-hover forced-colors:border`,
33
+ plain:
34
+ 'inline p-0 min-w-0 max-w-full whitespace-normal wrap-anywhere bg-transparent [font:inherit] [letter-spacing:inherit] [color:inherit] [text-align:inherit] disabled:text-disabled',
27
35
  },
28
36
  },
29
37
  defaultVariants: {
@@ -51,10 +59,29 @@ interface IButtonProps extends VariantProps<typeof button> {
51
59
  *
52
60
  * @component
53
61
  *
62
+ * @Variants
63
+ * - `primary` and `secondary`: the filled actions
64
+ * - `outlined`: the quiet secondary - `secondary` text and edge on an unfilled surface, for an
65
+ * action beside a primary one that must not compete with it
66
+ * - `destructive`: an action that removes or ends something, filled with the `error` status tone
67
+ * and inked with `errorForeground`. The tone reinforces words it never replaces: the label, or
68
+ * the `ariaLabel` of a symbol-only button, must say what the action does
69
+ * - `ghost`: the padded text action with a hover underline and no floor. With an `ariaLabel` and
70
+ * the bin `Icon` as its only child it is the quiet delete: the unfilled form of a secondary
71
+ * destructive action at the corner of a block of content, where `destructive` is the filled form
72
+ *
73
+ * The four faced variants share the control minimum width and inset, so a row of them aligns, and
74
+ * every one of them shrinks and wraps its label where its holder is narrower than that.
75
+ *
54
76
  * @UXGuidelines
55
77
  * - Use clear, action-oriented text (e.g., "Save" instead of "OK")
56
- * - Keep button text concise (1-3 words)
78
+ * - Keep button text short; a translated label that runs longer wraps where the layout constrains
79
+ * it, so the words stay readable rather than overflowing
57
80
  * - Use primary buttons for main actions, secondary buttons for alternative actions
81
+ * - `plain` is a plain action (CONTEXT.md): an operable button in the typography and colour of the
82
+ * text around it, with no face, padding, corner or hover underline of its own. Reach for it where
83
+ * the action belongs to a line of content - a sortable column header composed with `Icon` - and
84
+ * for `ghost` where a quiet but still button-shaped action is wanted
58
85
  * - Maintain consistent button styling throughout the application
59
86
  * - Provide visual feedback on hover/active states
60
87
  * - Ensure sufficient touch target size (minimum 44x44px) for mobile users
@@ -64,8 +91,11 @@ interface IButtonProps extends VariantProps<typeof button> {
64
91
  *
65
92
  * @Accessibility
66
93
  * - Ensure adequate color contrast (4.5:1 minimum ratio)
67
- * - Provide focus styles for keyboard navigation
94
+ * - Provide focus styles for keyboard navigation: every variant, `plain` included, draws the shared
95
+ * focus ring and nothing else changes on focus
96
+ * - A disabled `plain` button is told apart by the `disabled` text tone and keeps native disabled semantics
68
97
  * - Use appropriate ARIA attributes when needed
98
+ * - Status colour never stands alone: a destructive action is named by its words or symbol
69
99
  */
70
100
  export const Button: FunctionComponent<IButtonProps> = ({
71
101
  children,
@@ -12,7 +12,7 @@ import type { Subject } from 'rxjs';
12
12
  // control's face, and the placeholder that follows it, are docs/adr/0004's (#90); so is its size
13
13
  // (#92) - `body` is the role clearing the 16px below which iOS Safari zooms a focused control.
14
14
  const input = cva(
15
- 'block w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
15
+ 'block w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-control text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
16
16
  {
17
17
  variants: {
18
18
  // text/email/url are visually identical; the axis only selects the control's `type`
@@ -43,7 +43,7 @@ const multiSelectRoot = cva('', {
43
43
  field: [
44
44
  'group/field relative flex items-center gap-1',
45
45
  'rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2',
46
- 'font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)]',
46
+ 'font-control text-body text-foreground transition-colors duration-[var(--motion-duration-color)]',
47
47
  'data-[disabled]:border-disabled data-[disabled]:text-muted',
48
48
  ].join(' '),
49
49
  trigger: `absolute inset-0 flex cursor-pointer items-center justify-end rounded-[var(--radius-control)] bg-transparent px-3 text-muted disabled:cursor-not-allowed ${FOCUS_RING}`,
@@ -7,7 +7,7 @@ import type { Subject } from 'rxjs';
7
7
  // #number-state) - with `inputmode=decimal` as a keyboard hint only. The classes are Input's, for
8
8
  // Input's reasons: tokens re-pointed by `.dark`, the ring of docs/adr/0002, the faces of 0004.
9
9
  const numberInput = cva(
10
- 'block w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
10
+ 'block w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-control text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
11
11
  );
12
12
 
13
13
  export interface INumberInputProps {
@@ -10,7 +10,7 @@ const selectRoot = cva(
10
10
  '[&>span]:font-secondary [&>span]:text-muted [&>span]:text-small',
11
11
  '[&>p]:font-secondary [&>p]:text-small [&>p]:text-muted [&>p[data-error]]:text-error',
12
12
  '[&>select]:block [&>select]:w-full [&>select]:rounded-[var(--radius-control)] [&>select]:border [&>select]:border-solid [&>select]:border-control-border [&>select]:bg-transparent [&>select]:px-3 [&>select]:py-2',
13
- '[&>select]:font-primary [&>select]:text-body [&>select]:text-foreground [&>select]:transition-colors [&>select]:duration-[var(--motion-duration-color)]',
13
+ '[&>select]:font-control [&>select]:text-body [&>select]:text-foreground [&>select]:transition-colors [&>select]:duration-[var(--motion-duration-color)]',
14
14
  '[&>select]:outline-focus-ring [&>select]:outline-offset-[var(--focus-ring-offset)] [&>select]:focus-visible:outline [&>select]:focus-visible:outline-[length:var(--focus-ring-width)]',
15
15
  '[&>select:user-invalid]:border-error [&>select]:aria-[invalid=true]:border-error [&>select]:disabled:cursor-not-allowed [&>select]:disabled:border-disabled [&>select]:disabled:text-muted',
16
16
  ].join(' '),
@@ -11,10 +11,10 @@ import {
11
11
  import type { Subject } from 'rxjs';
12
12
  import { TabsCompositionError } from './TabsCompositionError';
13
13
 
14
- // The row is a single scrolling line, never a wrap: overflow is an accommodation, not a strip. The
15
- // ring room is written from the two focus-ring tokens so it cannot drift from the ring it exists
16
- // for: padding holds the scroll clip off the outline, the negative margin hands the room back to
17
- // the page, and scroll-padding makes a nearest scrollIntoView stop with the ring inside the clip.
14
+ // The row is one scrolling line of controls, never a second row. The ring room is written from the
15
+ // two focus-ring tokens so it cannot drift from the ring it exists for: padding holds the scroll
16
+ // clip off the outline, the negative margin hands the room back to the page, and scroll-padding
17
+ // makes a nearest scrollIntoView stop with the ring inside the clip.
18
18
  const tabsList = cva(
19
19
  [
20
20
  'flex flex-row overflow-x-auto',
@@ -28,12 +28,14 @@ const tabsList = cva(
28
28
  // keyed on aria-selected, so the attribute the device reads is the one the paint follows. The
29
29
  // marker keeps one thickness and flips only colour on the shared motion token, so selection
30
30
  // shifts no geometry. The focus ring sits in the base with its colour at rest, as on Button.
31
+ // The tab sets no flex-shrink and no white-space of its own (#121): as a flex item it shrinks
32
+ // no further than its longest word, so a label wraps inside its tab before the row scrolls.
31
33
  const tabsTab = cva(
32
34
  [
33
35
  'font-secondary text-label tracking-label',
34
36
  'text-muted hover:text-foreground aria-selected:text-foreground',
35
37
  'border-b-[length:var(--tab-marker-thickness)] border-solid border-transparent aria-selected:border-foreground',
36
- 'shrink-0 cursor-pointer select-none text-nowrap px-[var(--tab-inset-inline)] py-[var(--tab-inset-block)]',
38
+ 'cursor-pointer select-none px-[var(--tab-inset-inline)] py-[var(--tab-inset-block)]',
37
39
  'transition-colors duration-[var(--motion-duration-color)]',
38
40
  'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
39
41
  ].join(' '),
@@ -117,8 +119,10 @@ export interface ITabsTabProps {
117
119
  /** The stable identity connecting this tab to its panel and emitted by selection requests.
118
120
  * Not React's `key`, and never inferred from the label or the position. */
119
121
  value: string;
120
- /** The visible text label. Text only - no icons, no per-tab markup. */
121
- children: string;
122
+ /** The label: visible text naming the view - a plain string still works - optionally with an
123
+ * icon, a count or inline emphasis beside it. Decorative parts carry the consumer's own
124
+ * `aria-hidden`; nothing in it is interactive, as the tab is the control. Rendered whole. */
125
+ children: ReactNode;
122
126
  testId?: string;
123
127
  }
124
128
 
@@ -252,15 +256,31 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
252
256
  * immediately; focus stays on the operated tab. Up/Down are left to the browser.
253
257
  * - Roving tabindex: Tab enters the list at the active tab, then the active panel - a consistent
254
258
  * Tab stop whether or not its content is focusable. Inactive panels add no stop.
255
- * - The row scrolls horizontally on overflow - one line, no wrap, no shrink - and holds its own
256
- * ring room, so the focused tab's ring survives the scroll clip.
259
+ * - A label is rendered whole, text and markup alike, however long its translation runs: never
260
+ * clipped, shortened, elided or replaced by a hint. The tab's accessible name is computed from
261
+ * its content, so what the device reads is what the viewer sees, less what the consumer marked
262
+ * `aria-hidden`. A rich label and a plain string are one and the same control.
263
+ * - The row is one line of controls. As it narrows, a label's text wraps inside its tab, down to
264
+ * the tab's longest word; only when the controls still cannot fit does the row scroll
265
+ * horizontally - the row, never the page. The row holds its own ring room so the focused tab's
266
+ * ring survives the scroll clip, and focus reveals a scrolled-off tab, so every tab is reachable
267
+ * by keyboard without scrolling first.
268
+ * - The tabs on the row share one height, so every marker sits on one line whether or not a
269
+ * neighbour's label wrapped.
257
270
  * - Selection is marked by a persistent line under the active tab: `--tab-marker-thickness` in
258
271
  * `foreground`, constant thickness in both states, so switching shifts no widths and no weights.
259
272
  * Keyboard focus is the separate shared focus ring. Colour moves on the one motion token.
260
273
  *
261
274
  * @CallerMustEnsure — the component cannot see these and does not check them
262
275
  * - One List with Tab elements as direct DOM children (arrays and fragments are supported),
263
- * and sibling Panels under Root. Do not wrap tabs in host elements.
276
+ * and the Panels under the same Root - as siblings of the List, or with it inside one
277
+ * arrangement such as a `Stack` (see the separation guidance below). Do not wrap tabs in host
278
+ * elements.
279
+ * - A label is a meaningful visible text name, with non-interactive decoration at most: no link,
280
+ * button, input or other focusable descendant, since the tab is the one control and a nested
281
+ * control inside a `button` is invalid. Decorative parts - an icon that repeats the text - carry
282
+ * `aria-hidden` so they stay out of the name; the library does not guess which parts those are.
283
+ * A count or an emphasis is meaning and stays in the name.
264
284
  * - At least two tabs, each `value` unique and stable, each with exactly one matching `Panel`
265
285
  * under the same `Root`, and `active` naming a declared pair. Invalid input is a contract
266
286
  * violation, not a request for a fallback.
@@ -271,10 +291,17 @@ const TabsPanel: FunctionComponent<ITabsPanelProps> = ({
271
291
  *
272
292
  * @UXGuidelines
273
293
  * - Labels are short names for views, not actions; the consuming app words and translates them.
294
+ * A long translation wraps inside its tab and the row scrolls only when even the longest words
295
+ * do not fit, so a label is never the reason to shorten a name.
274
296
  * - This is not a router: no location, no history, no deep links. Wire `onSelect$` to whatever
275
297
  * owns the active key and pass that key back in.
298
+ * - Tabs sets no spacing between the row and the panel; the separation is a `Stack` between Root
299
+ * and its members - `<Tabs.Root><Stack gap="region"><Tabs.List/>…<Tabs.Panel/>…</Stack>
300
+ * </Tabs.Root>`. `gap="stack"` holds the row and its view together as one block; `gap="region"`
301
+ * sets the view apart as a region of its own. Inactive panels are hidden, so the gap is exactly
302
+ * one whichever panel is active, from the token the consumer's other groups already use.
276
303
  * - The panel is an opaque slot: compose the view's own rhythm inside it - a `Stack`, a `Prose` -
277
- * as the view owns it; Tabs sets no spacing between the row and the panel.
304
+ * as the view owns it.
278
305
  */
279
306
  export const Tabs = {
280
307
  Root: TabsRoot,
@@ -12,7 +12,7 @@ import type { Subject } from 'rxjs';
12
12
  // docs/adr/0004's (#90); so is its size (#92) - `body` is the role clearing the 16px below which
13
13
  // iOS Safari zooms a focused control.
14
14
  const textArea = cva(
15
- 'block min-h-24 w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-primary text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
15
+ 'block min-h-24 w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 font-control text-body text-foreground transition-colors duration-[var(--motion-duration-color)] outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)] [&:user-invalid]:border-error aria-[invalid=true]:border-error disabled:cursor-not-allowed disabled:border-disabled disabled:text-muted',
16
16
  );
17
17
 
18
18
  interface ITextAreaProps {
@@ -347,11 +347,12 @@ const DialogTitle: FunctionComponent<IDialogTitleProps> = ({
347
347
  data-testid={testId}
348
348
  >
349
349
  {/* The independent Dialog h1 at its own type role - not the page's display role, and not a
350
- generic heading primitive (docs/adr/0004 and 0005, Amendments). */}
350
+ generic heading primitive - in the heading face every heading reads (docs/adr/0004 and
351
+ 0005, Amendments; #120). */}
351
352
  <h1
352
353
  id={titleId}
353
354
  className={
354
- 'font-primary text-dialog-title leading-dialog-title text-foreground'
355
+ 'font-heading text-dialog-title leading-dialog-title text-foreground'
355
356
  }
356
357
  >
357
358
  {children}
@@ -374,7 +375,7 @@ const DialogDescription: FunctionComponent<IDialogDescriptionProps> = ({
374
375
  >
375
376
  <p
376
377
  id={descriptionId}
377
- className={'font-primary text-body leading-body text-foreground'}
378
+ className={'font-body text-body leading-body text-foreground'}
378
379
  >
379
380
  {children}
380
381
  </p>