@juwel-development/design-system 1.1.0 → 2.0.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 (61) hide show
  1. package/dist/design-system.js +541 -36
  2. package/dist/index.css +1 -1
  3. package/dist/types/Display/Brandmark/Brandmark.d.ts +50 -0
  4. package/dist/types/Display/Checklist/Checklist.d.ts +26 -0
  5. package/dist/types/Display/DefinitionList/DefinitionList.d.ts +41 -0
  6. package/dist/types/Display/Figure/Figure.d.ts +52 -0
  7. package/dist/types/Display/Rail/Rail.d.ts +54 -0
  8. package/dist/types/Display/Table/Table.d.ts +61 -0
  9. package/dist/types/Display/Typography/Eyebrow/Eyebrow.d.ts +26 -0
  10. package/dist/types/Display/Typography/H1/H1.d.ts +27 -0
  11. package/dist/types/Display/Typography/H2/H2.d.ts +24 -0
  12. package/dist/types/Display/Typography/H3/H3.d.ts +23 -0
  13. package/dist/types/Display/Typography/H4/H4.d.ts +22 -0
  14. package/dist/types/Display/Typography/H5/H5.d.ts +23 -0
  15. package/dist/types/Display/Typography/H6/H6.d.ts +23 -0
  16. package/dist/types/Display/Typography/P/P.d.ts +25 -0
  17. package/dist/types/Display/Typography/Prose/Prose.d.ts +42 -0
  18. package/dist/types/Interaction/Button/Button.d.ts +7 -3
  19. package/dist/types/Interaction/Input/Input.d.ts +29 -0
  20. package/dist/types/Interaction/Link/Link.d.ts +27 -0
  21. package/dist/types/Interaction/TextArea/TextArea.d.ts +25 -0
  22. package/dist/types/Layout/Footer/Footer.d.ts +34 -0
  23. package/dist/types/Layout/Form/Form.d.ts +31 -0
  24. package/dist/types/Layout/Header/Header.d.ts +41 -0
  25. package/dist/types/Layout/Hero/Hero.d.ts +46 -0
  26. package/dist/types/Layout/PageHead/PageHead.d.ts +36 -0
  27. package/dist/types/Layout/Section/Section.d.ts +38 -0
  28. package/dist/types/Theme/Palette.d.ts +25 -6
  29. package/dist/types/index.d.ts +25 -1
  30. package/package.json +1 -1
  31. package/src/Display/.gitkeep +0 -0
  32. package/src/Display/Brandmark/Brandmark.tsx +97 -0
  33. package/src/Display/Checklist/Checklist.tsx +75 -0
  34. package/src/Display/DefinitionList/DefinitionList.tsx +90 -0
  35. package/src/Display/Figure/Figure.tsx +116 -0
  36. package/src/Display/Rail/Rail.tsx +108 -0
  37. package/src/Display/Table/Table.tsx +191 -0
  38. package/src/Display/Typography/Eyebrow/Eyebrow.tsx +46 -0
  39. package/src/Display/Typography/H1/H1.tsx +46 -0
  40. package/src/Display/Typography/H2/H2.tsx +43 -0
  41. package/src/Display/Typography/H3/H3.tsx +41 -0
  42. package/src/Display/Typography/H4/H4.tsx +40 -0
  43. package/src/Display/Typography/H5/H5.tsx +41 -0
  44. package/src/Display/Typography/H6/H6.tsx +41 -0
  45. package/src/Display/Typography/P/P.tsx +38 -0
  46. package/src/Display/Typography/Prose/Prose.tsx +91 -0
  47. package/src/Interaction/Button/Button.tsx +15 -10
  48. package/src/Interaction/Input/Input.tsx +103 -0
  49. package/src/Interaction/Link/Link.tsx +70 -0
  50. package/src/Interaction/TextArea/TextArea.tsx +93 -0
  51. package/src/Layout/.gitkeep +0 -0
  52. package/src/Layout/Footer/Footer.tsx +60 -0
  53. package/src/Layout/Form/Form.tsx +102 -0
  54. package/src/Layout/Header/Header.tsx +84 -0
  55. package/src/Layout/Hero/Hero.tsx +73 -0
  56. package/src/Layout/PageHead/PageHead.tsx +79 -0
  57. package/src/Layout/Section/Section.tsx +79 -0
  58. package/src/Theme/Palette.ts +38 -12
  59. package/src/Theme/renderTokens.ts +179 -2
  60. package/src/index.ts +25 -1
  61. package/src/tokens.css +113 -9
@@ -0,0 +1,90 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ // Rules are the layout. Each Item owns its two-track grid and its hairlines. The term track is a fixed
5
+ // width, so per-item grids line up without a subgrid. At `lg` (64rem) a second `dt` is pinned to the
6
+ // term column so it cannot auto-place into the description column and break the row silently. Colours
7
+ // are semantic tokens re-pointed by `.dark`, so no class carries a `dark:` prefix.
8
+ const item = cva(
9
+ [
10
+ 'grid gap-[var(--space-stack)] py-6',
11
+ 'border-b border-solid border-border first:border-t',
12
+ 'lg:grid-cols-[16rem_minmax(0,1fr)] lg:items-baseline lg:gap-x-12',
13
+ 'lg:[&>dt]:col-start-1 lg:[&>dd]:col-start-2 lg:[&>dd]:row-start-1',
14
+ ].join(' '),
15
+ );
16
+
17
+ // The term at the subtitle role - a step below title (docs/adr/0005 fixes the role, no size prop), so
18
+ // terms never tie with the heading introducing the list. Foreground, and no measure cap.
19
+ const term = cva('font-primary text-subtitle leading-subtitle text-foreground');
20
+
21
+ // The description at the body role, muted, capped at the reading measure in both layout modes.
22
+ const description = cva(
23
+ 'font-primary text-body leading-body text-muted max-w-[var(--measure)]',
24
+ );
25
+
26
+ interface IDefinitionListRootProps {
27
+ children?: ReactNode;
28
+ testId?: string;
29
+ }
30
+
31
+ interface IDefinitionListItemProps {
32
+ children?: ReactNode;
33
+ testId?: string;
34
+ }
35
+
36
+ interface IDefinitionListTermProps {
37
+ children?: ReactNode;
38
+ }
39
+
40
+ interface IDefinitionListDescriptionProps {
41
+ children?: ReactNode;
42
+ }
43
+
44
+ const DefinitionListRoot: FunctionComponent<IDefinitionListRootProps> = ({
45
+ children,
46
+ testId,
47
+ }) => <dl data-testid={testId}>{children}</dl>;
48
+
49
+ const DefinitionListItem: FunctionComponent<IDefinitionListItemProps> = ({
50
+ children,
51
+ testId,
52
+ }) => (
53
+ <div className={item()} data-testid={testId}>
54
+ {children}
55
+ </div>
56
+ );
57
+
58
+ const DefinitionListTerm: FunctionComponent<IDefinitionListTermProps> = ({
59
+ children,
60
+ }) => <dt className={term()}>{children}</dt>;
61
+
62
+ const DefinitionListDescription: FunctionComponent<
63
+ IDefinitionListDescriptionProps
64
+ > = ({ children }) => <dd className={description()}>{children}</dd>;
65
+
66
+ /**
67
+ * A typeset list of terms and their descriptions, where the rules are the layout. The consumer
68
+ * composes the list from the four members; no member takes a data array. `Root` renders `<dl>`,
69
+ * `Item` the grouping `<div>` (valid inside `<dl>` for exactly this purpose), `Term` a `<dt>`,
70
+ * `Description` a `<dd>`.
71
+ *
72
+ * @Guarantees — enforced on every render
73
+ * - Renders semantic `dl`/`div`/`dt`/`dd`, and works with JavaScript off.
74
+ * - The term is fixed to the subtitle type role and exposes no size prop or variant (docs/adr/0005).
75
+ * - The description is body, muted, and capped at `--measure`; the term carries no measure cap.
76
+ * - A hairline sits above the first item and below every item, in `border`, and the block closes at
77
+ * the foot. No card, box, fill, icon or bullet - it reads from the rules alone.
78
+ * - Single column below 64rem with the term above its description; two columns at and above it with a
79
+ * fixed term track and baseline-aligned rows. The switch is a media query, not a prop.
80
+ *
81
+ * @UXGuidelines
82
+ * - Several `Term`s may share one `Description`; keep them inside one `Item` so the term column
83
+ * stays intact.
84
+ */
85
+ export const DefinitionList = {
86
+ Root: DefinitionListRoot,
87
+ Item: DefinitionListItem,
88
+ Term: DefinitionListTerm,
89
+ Description: DefinitionListDescription,
90
+ } as const;
@@ -0,0 +1,116 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent } from 'react';
4
+
5
+ // One recipe, on the `img` - the only element that varies. The wrapper `figure` and the `figcaption`
6
+ // carry literal class strings in JSX (the Input pattern, not the compound namespace: there is nothing
7
+ // here to compose). `ratio` has no default, so an omitted ratio emits no aspect-* class and the image
8
+ // keeps its intrinsic shape; `focus` defaults to center and is inert without a ratio, since object-*
9
+ // has nothing to crop in the base. Colours are semantic tokens re-pointed by `.dark`, so no `dark:`.
10
+ const figure = cva('block w-full object-cover bg-backing', {
11
+ variants: {
12
+ ratio: {
13
+ portrait: 'aspect-portrait',
14
+ square: 'aspect-square',
15
+ landscape: 'aspect-landscape',
16
+ wide: 'aspect-wide',
17
+ },
18
+ focus: {
19
+ center: 'object-center',
20
+ top: 'object-top',
21
+ bottom: 'object-bottom',
22
+ left: 'object-left',
23
+ right: 'object-right',
24
+ },
25
+ },
26
+ defaultVariants: { focus: 'center' },
27
+ });
28
+
29
+ export interface IFigureProps extends VariantProps<typeof figure> {
30
+ /** The image source. Produce it through the consuming build's image pipeline - see @UXGuidelines. */
31
+ src: string;
32
+ /** The image's text alternative. Empty string when the image is decorative, or when `caption`
33
+ * already names it - a non-empty alt beside a caption reads the same content to a screen reader
34
+ * twice. */
35
+ alt: string;
36
+ /** The source's intrinsic pixel width. */
37
+ width: number;
38
+ /** The source's intrinsic pixel height. */
39
+ height: number;
40
+ /** Responsive delivery. One decision with two parts, so they are one prop: a `srcSet` with `w`
41
+ * descriptors and no `sizes` makes the browser assume 100vw and download the largest candidate. */
42
+ responsive?: { srcSet: string; sizes: string };
43
+ /** Names the image. Its presence is what makes this a `figure`. */
44
+ caption?: string;
45
+ /** Whether this image is visible when the page first paints. Set it on a hero. */
46
+ priority?: boolean;
47
+ testId?: string;
48
+ }
49
+
50
+ /**
51
+ * An aspect-locked image frame with a focal point and an optional caption. The caption decides the
52
+ * element: with one, the image is wrapped in a `figure` with a `figcaption`; without one, the image
53
+ * renders alone, making no self-contained claim a thumbnail or logo should not make.
54
+ *
55
+ * @Guarantees — enforced on every render
56
+ * - Every image reserves its box before it loads: an intrinsic `width`/`height`, and a locked
57
+ * `aspect-ratio` once a `ratio` is given.
58
+ * - `decoding="async"` always; `loading` follows `priority` (`eager`+`fetchpriority="high"` when set,
59
+ * `lazy` otherwise), so an above-the-fold hero is never deferred.
60
+ * - A `figure` element appears only where there is a caption.
61
+ * - `srcset` never ships without `sizes`: `responsive` bundles the two so one cannot go without the other.
62
+ *
63
+ * @UXGuidelines
64
+ * - Pass a `src` your build's image pipeline produced. One raw source measured 2 MB against 31.6 KB
65
+ * for the same crop, taking `load` from 0.95 s to 10.3 s. The component cannot check this.
66
+ * - Check a photograph's hue range before putting a brand colour beside it. One frame sat entirely
67
+ * between hue 14° and 40°, so an amber call-to-action muddied against a terracotta wall - the accent
68
+ * lives where the photo isn't. `Figure` renders no overlay, so this is about what a page puts next
69
+ * to a figure, not on it.
70
+ */
71
+ export const Figure: FunctionComponent<IFigureProps> = ({
72
+ src,
73
+ alt,
74
+ width,
75
+ height,
76
+ ratio,
77
+ focus,
78
+ responsive,
79
+ caption,
80
+ priority,
81
+ testId,
82
+ }) => {
83
+ const image = (
84
+ <img
85
+ src={src}
86
+ alt={alt}
87
+ width={width}
88
+ height={height}
89
+ className={figure({ ratio, focus })}
90
+ srcSet={responsive?.srcSet}
91
+ sizes={responsive?.sizes}
92
+ loading={priority ? 'eager' : 'lazy'}
93
+ fetchPriority={priority ? 'high' : undefined}
94
+ decoding={'async'}
95
+ data-testid={caption === undefined ? testId : undefined}
96
+ />
97
+ );
98
+
99
+ if (caption === undefined) {
100
+ return image;
101
+ }
102
+
103
+ return (
104
+ <figure
105
+ className={'flex flex-col gap-[var(--space-stack)]'}
106
+ data-testid={testId}
107
+ >
108
+ {image}
109
+ <figcaption
110
+ className={'font-secondary text-label tracking-label text-muted'}
111
+ >
112
+ {caption}
113
+ </figcaption>
114
+ </figure>
115
+ );
116
+ };
@@ -0,0 +1,108 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ // Root owns both tracks so the sticky index has this grid as its containing block - the whole guarantee
5
+ // rests on Index being a direct child. One column below 64rem, index above content; at `lg` a fixed
6
+ // 10rem index track and `minmax(0,1fr)` beside it, with `items-start` giving the index cell the full row
7
+ // height to stick within. The three numbers are literals stated in prose (docs/adr/0003), not tokens.
8
+ const railRoot = cva(
9
+ [
10
+ 'grid gap-[var(--space-stack)]',
11
+ 'lg:grid-cols-[10rem_minmax(0,1fr)] lg:items-start lg:gap-x-12',
12
+ ].join(' '),
13
+ );
14
+
15
+ // The index track: sticky with a 1.5rem offset only at and above 64rem; below it the index lies down
16
+ // and stays static, so it needs no opaque fill, edge or stacking decision. It is aria-hidden - it
17
+ // repeats the section's heading, which lives in the content track. The label role sits on each part.
18
+ const railIndex = cva('lg:sticky lg:top-6');
19
+
20
+ // Both parts at the label role (docs/adr/0005 binds it to no heading), told apart by colour alone. The
21
+ // numeral is muted with a hairline under it in `border`, not `rule` - the page-structure weight Section
22
+ // joins carry, which a device this small should not draw. The name is foreground. Colours are semantic
23
+ // tokens re-pointed by `.dark`, so no class carries a `dark:` prefix.
24
+ const railNumeral = cva(
25
+ 'block border-b border-solid border-border pb-1 font-secondary text-label tracking-label text-muted',
26
+ );
27
+
28
+ const railName = cva(
29
+ 'block pt-1 font-secondary text-label tracking-label text-foreground',
30
+ );
31
+
32
+ interface IRailRootProps {
33
+ children?: ReactNode;
34
+ testId?: string;
35
+ }
36
+
37
+ interface IRailIndexProps {
38
+ /** The filing numeral as a string - `02`, not `2`. Zero-padding is a brand decision, so the caller
39
+ * writes the exact glyphs. Omitted, the index renders its name alone with no empty numeral element. */
40
+ number?: string;
41
+ children?: ReactNode;
42
+ testId?: string;
43
+ }
44
+
45
+ interface IRailContentProps {
46
+ children?: ReactNode;
47
+ testId?: string;
48
+ }
49
+
50
+ const RailRoot: FunctionComponent<IRailRootProps> = ({ children, testId }) => (
51
+ <div className={railRoot()} data-testid={testId}>
52
+ {children}
53
+ </div>
54
+ );
55
+
56
+ const RailIndex: FunctionComponent<IRailIndexProps> = ({
57
+ number,
58
+ children,
59
+ testId,
60
+ }) => (
61
+ <div className={railIndex()} data-testid={testId} aria-hidden="true">
62
+ {number !== undefined && <span className={railNumeral()}>{number}</span>}
63
+ <span className={railName()}>{children}</span>
64
+ </div>
65
+ );
66
+
67
+ const RailContent: FunctionComponent<IRailContentProps> = ({
68
+ children,
69
+ testId,
70
+ }) => <div data-testid={testId}>{children}</div>;
71
+
72
+ /**
73
+ * A section filed beside a sticky index: the section's matter in the wide track and, at and above 64rem,
74
+ * a persistent index in the narrow one. Composed from three members - `Root` renders the two-track grid,
75
+ * `Index` the sticky filing device, `Content` the opaque slot for the matter. `Root` is a `div` with no
76
+ * landmark; the section's heading lives in `Content`, and `Index` repeats it only as a visual marker.
77
+ *
78
+ * @Guarantees — enforced on every render
79
+ * - `Index` persists beside its section at and above 64rem and reads as a filed heading above the text
80
+ * below it. The switch is a media query, not a prop.
81
+ * - `Index` is decorative to assistive technology (`aria-hidden`): it repeats the heading the content
82
+ * track carries and does not replace it.
83
+ * - The index numeral is muted with a hairline under it in `border`; the name is foreground; both sit at
84
+ * the label role. Colours are semantic tokens re-pointed by `.dark`, so no class carries a `dark:` prefix.
85
+ * - `number` is optional: omitted, the index renders the name alone and no empty numeral element.
86
+ * - `Content` renders its children unmodified and sets no measure cap - `Prose` owns the reading measure.
87
+ * - No margin, max-width, fill, box or vertical rule; no `tabular-nums` or `font-variant-numeric`.
88
+ * - It needs no JavaScript: `position: sticky` is CSS.
89
+ *
90
+ * @CallerMustEnsure — the component cannot see these and does not check them
91
+ * - `Rail.Index` is a direct child of `Rail.Root`: `position: sticky` positions against the grid
92
+ * container, so the index sticks for the height of its row only when Root is its parent.
93
+ * - The section's heading lives in `Rail.Content`. `Index` is aria-hidden and carries no heading, so a
94
+ * consumer that files a section only in the index leaves it without an accessible name.
95
+ *
96
+ * @UXGuidelines
97
+ * - An index is an information layer, not a layout remedy. Measured on the page that prompted it, the
98
+ * filing numeral bought 0px of width across three fallback rungs, and the column it sits in closed only
99
+ * +224px of a 933px deficit - it widens a composition's span, not its content. If a page sags, the
100
+ * answer is a column; whether that column should also be an index is a separate decision.
101
+ * - Do not place a rail on a hero or a full-bleed section. A bleeding section drops the gutter, so there
102
+ * is no inset for an index track to stand in, and a poster is not a filed section.
103
+ */
104
+ export const Rail = {
105
+ Root: RailRoot,
106
+ Index: RailIndex,
107
+ Content: RailContent,
108
+ } as const;
@@ -0,0 +1,191 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // Rules are the layout. One recipe styles the whole table from its wrapper, so the block reads as a
6
+ // table from two rule weights and nothing else: no cell borders, no fill, no zebra, no hover. Colours
7
+ // are semantic tokens re-pointed by `.dark`, so no selector carries a `dark:` class. The responsive
8
+ // behaviour is keyed on the wrapper's `data-notes`, so a server renders the right markup with no
9
+ // hydration and the mode is one attribute a stylesheet and a test can both read.
10
+ const table = cva(
11
+ [
12
+ // With no note column the wrapper becomes a horizontally scrollable region (see Root); the scroll
13
+ // sits here so the table keeps its table formatting context and the figures stay column-aligned.
14
+ '[&:not([data-notes])]:overflow-x-auto',
15
+ // The table: full width, collapsed borders so adjacent row rules meet as one line, text flush left.
16
+ '[&>table]:w-full [&>table]:border-collapse [&>table]:text-left',
17
+ // The required caption, rendered first, as the table's label in the tracked grotesk device.
18
+ '[&_caption]:pb-3 [&_caption]:text-left [&_caption]:font-secondary [&_caption]:text-label [&_caption]:tracking-label [&_caption]:text-muted',
19
+ // Every row carries a hairline top (Row); the first row - the first row group after the caption -
20
+ // is promoted to the heavier `rule` colour, and that heavier line is what reads as a table not a list.
21
+ '[&>table>*:nth-child(2)>tr:first-child]:border-rule',
22
+ // notes="supplementary": the note column leaves the page for everyone, sighted or not, below 48rem.
23
+ '[&[data-notes=supplementary]_[data-variant=note]]:max-md:hidden',
24
+ // notes="content": the note is the content, so each row stacks into a single column below 48rem.
25
+ '[&[data-notes=content]>table]:max-md:block',
26
+ '[&[data-notes=content]_thead]:max-md:block [&[data-notes=content]_tbody]:max-md:block [&[data-notes=content]_tfoot]:max-md:block',
27
+ '[&[data-notes=content]_tr]:max-md:block [&[data-notes=content]_td]:max-md:block [&[data-notes=content]_th]:max-md:block',
28
+ ].join(' '),
29
+ );
30
+
31
+ // One hairline above every row, in `border`. Root promotes the first row's colour to `rule`; the last
32
+ // row takes no bottom rule, so the block stays open at the foot - the difference from a closed list.
33
+ const tableRow = cva('border-t border-solid border-border');
34
+
35
+ // A value is serif with real tabular figures; a note is the muted grotesk. Both sit at the small role,
36
+ // which carries the enforced 15px floor below which figures stop comparing column to column.
37
+ const tableCell = cva('px-4 py-2 text-small first:pl-0 last:pr-0', {
38
+ variants: {
39
+ variant: {
40
+ value: 'font-primary text-foreground tabular-nums',
41
+ note: 'font-secondary text-muted',
42
+ },
43
+ align: { left: 'text-left', right: 'text-right', center: 'text-center' },
44
+ },
45
+ defaultVariants: { variant: 'value', align: 'left' },
46
+ });
47
+
48
+ // The label: the tracked muted grotesk, at the label role. Carried by both scopes (column and row).
49
+ const tableHeaderCell = cva(
50
+ 'px-4 py-2 font-secondary font-medium text-label text-muted tracking-label first:pl-0 last:pr-0',
51
+ {
52
+ variants: {
53
+ align: { left: 'text-left', right: 'text-right', center: 'text-center' },
54
+ },
55
+ defaultVariants: { align: 'left' },
56
+ },
57
+ );
58
+
59
+ interface ITableRootProps {
60
+ /** The table's accessible name. Rendered as the first child; always present. */
61
+ caption: string;
62
+ /** What the note column is. Governs narrow-viewport behaviour; omit when there is none. */
63
+ notes?: 'supplementary' | 'content';
64
+ children?: ReactNode;
65
+ testId?: string;
66
+ }
67
+
68
+ interface ITableSectionProps {
69
+ children?: ReactNode;
70
+ }
71
+
72
+ interface ITableRowProps {
73
+ children?: ReactNode;
74
+ testId?: string;
75
+ }
76
+
77
+ interface ITableCellProps extends VariantProps<typeof tableCell> {
78
+ children?: ReactNode;
79
+ }
80
+
81
+ interface ITableHeaderCellProps extends VariantProps<typeof tableHeaderCell> {
82
+ /** Explicit, never inferred from Head/Body position - inference would need render-time context. */
83
+ scope: 'row' | 'col';
84
+ children?: ReactNode;
85
+ }
86
+
87
+ const TableRoot: FunctionComponent<ITableRootProps> = ({
88
+ caption,
89
+ notes,
90
+ children,
91
+ testId,
92
+ }) => {
93
+ const content = (
94
+ <table>
95
+ <caption>{caption}</caption>
96
+ {children}
97
+ </table>
98
+ );
99
+ // No note column: the wrapper is a labelled region (a named `section`) so the horizontally
100
+ // scrolled table is keyboard-operable - WCAG 2.1.1 needs the scroll container itself focusable,
101
+ // there being no focusable cell content to carry it. With a note column there is nothing to
102
+ // scroll to, so the wrapper stays a plain grouping element.
103
+ if (notes === undefined) {
104
+ return (
105
+ <section
106
+ className={table()}
107
+ data-testid={testId}
108
+ aria-label={caption}
109
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scroll container must be keyboard-operable (WCAG 2.1.1)
110
+ tabIndex={0}
111
+ >
112
+ {content}
113
+ </section>
114
+ );
115
+ }
116
+ return (
117
+ <div className={table()} data-notes={notes} data-testid={testId}>
118
+ {content}
119
+ </div>
120
+ );
121
+ };
122
+
123
+ const TableHead: FunctionComponent<ITableSectionProps> = ({ children }) => (
124
+ <thead>{children}</thead>
125
+ );
126
+
127
+ const TableBody: FunctionComponent<ITableSectionProps> = ({ children }) => (
128
+ <tbody>{children}</tbody>
129
+ );
130
+
131
+ const TableFooter: FunctionComponent<ITableSectionProps> = ({ children }) => (
132
+ <tfoot>{children}</tfoot>
133
+ );
134
+
135
+ const TableRow: FunctionComponent<ITableRowProps> = ({ children, testId }) => (
136
+ <tr className={tableRow()} data-testid={testId}>
137
+ {children}
138
+ </tr>
139
+ );
140
+
141
+ const TableHeaderCell: FunctionComponent<ITableHeaderCellProps> = ({
142
+ scope,
143
+ align,
144
+ children,
145
+ }) => (
146
+ <th scope={scope} className={tableHeaderCell({ align })}>
147
+ {children}
148
+ </th>
149
+ );
150
+
151
+ const TableCell: FunctionComponent<ITableCellProps> = ({
152
+ variant,
153
+ align,
154
+ children,
155
+ }) => (
156
+ <td
157
+ className={tableCell({ variant, align })}
158
+ data-variant={variant ?? 'value'}
159
+ >
160
+ {children}
161
+ </td>
162
+ );
163
+
164
+ /**
165
+ * A composable data table where the rules are the layout. The consumer composes the table from the
166
+ * seven members; no member takes a data array. A specification / details table is the shape it is
167
+ * built for - a row label, a value in tabular figures, and a note.
168
+ *
169
+ * @Guarantees — enforced on every render
170
+ * - Renders semantic `table`/`thead`/`tbody`/`tfoot`/`tr`/`th`/`td`, and works with JavaScript off.
171
+ * - `Root` renders its required `caption` as the table's first child, so every table is named.
172
+ * - The block reads as a table from two rule weights alone: the heavier `rule` above the first row,
173
+ * `border` hairlines between rows, and no bottom rule on the last. No cell borders, fill, zebra or hover.
174
+ * - `Cell variant="value"` sets tabular figures; `variant="note"` does not. Both at the 15px small role.
175
+ * - `HeaderCell` emits the `scope` it is given; none is inferred.
176
+ *
177
+ * @UXGuidelines
178
+ * - `align` is a cell property but reads as a column one: set the same `align` on a `HeaderCell` and
179
+ * every `Cell` beneath it, and keep them in sync - the component cannot align a column for you.
180
+ * - Choose `notes` by what the note column *is*: `"supplementary"` drops it below 48rem for everyone
181
+ * (out of the accessibility tree too); `"content"` stacks each row; omit it when there is no note.
182
+ */
183
+ export const Table = {
184
+ Root: TableRoot,
185
+ Head: TableHead,
186
+ Body: TableBody,
187
+ Footer: TableFooter,
188
+ Row: TableRow,
189
+ HeaderCell: TableHeaderCell,
190
+ Cell: TableCell,
191
+ } as const;
@@ -0,0 +1,46 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // The letter-spaced eyebrow device: the secondary family, the label size and the label tracking, at
6
+ // weight 500 (docs/adr/0004). First consumer of --font-secondary. The tracking is a fixed style, read
7
+ // from --tracking-label so a site-wide optical curve cannot collapse it. It defaults to `muted`
8
+ // because the device is defined as muted - the one primitive to invert that default. Never sets
9
+ // font-variant-caps or -numeric: both fail silently on a face without the feature. Colour is a
10
+ // semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
11
+ const eyebrow = cva('font-secondary text-label tracking-label font-medium', {
12
+ variants: {
13
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
14
+ },
15
+ defaultVariants: { color: 'muted' },
16
+ });
17
+
18
+ interface IEyebrowProps extends VariantProps<typeof eyebrow> {
19
+ children: ReactNode;
20
+ testId?: string;
21
+ }
22
+
23
+ /**
24
+ * The eyebrow / caption device, a `p` at the label type role: the secondary family, the label size
25
+ * and a fixed +0.14em tracking, muted. The single primitive for the one *unowned* use of the device
26
+ * — a short tracked line standing above a section heading.
27
+ *
28
+ * @Guarantees — enforced on every render
29
+ * - Renders a `p`, reading `--font-secondary`, sized by `--text-label` and tracked by
30
+ * `--tracking-label`, at weight 500.
31
+ * - `color` selects the `muted` (default) or `foreground` role; nothing else paints text.
32
+ * - Sets neither `font-variant-caps` nor `font-variant-numeric` under any prop.
33
+ *
34
+ * @CallerMustEnsure — the component cannot see these and does not check them
35
+ * - This is **not a form label**: it renders no `htmlFor` and labels no control. A labelled control
36
+ * uses `Input`/`TextArea`, which label themselves.
37
+ */
38
+ export const Eyebrow: FunctionComponent<IEyebrowProps> = ({
39
+ children,
40
+ color,
41
+ testId,
42
+ }) => (
43
+ <p className={eyebrow({ color })} data-testid={testId}>
44
+ {children}
45
+ </p>
46
+ );
@@ -0,0 +1,46 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // Level fixes role: an h1 is always the display role, with no size prop to invert the ladder
6
+ // (docs/adr/0005). Weight inherits - Tailwind's preflight resets h1-h6 to font-weight: inherit, so
7
+ // the sized levels carry no weight class. --tracking-optical is the large-type correction, carried by
8
+ // the title role and above (#57), so the biggest type on the page is the first to take it. Colour is a
9
+ // semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
10
+ const h1 = cva('font-primary text-display leading-display tracking-optical', {
11
+ variants: {
12
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
13
+ },
14
+ defaultVariants: { color: 'foreground' },
15
+ });
16
+
17
+ interface IH1Props extends VariantProps<typeof h1> {
18
+ children: ReactNode;
19
+ testId?: string;
20
+ }
21
+
22
+ /**
23
+ * The page-title heading, an `h1` at the display type role.
24
+ *
25
+ * @Guarantees — enforced on every render
26
+ * - Renders an `h1`; its outline level and the display role are one choice, not two (docs/adr/0005).
27
+ * - Reads `--font-primary`, sized by `--text-display`, led by `--leading-display` and optically
28
+ * corrected by `--tracking-optical`, the large-type correction every role from title up carries.
29
+ * - `color` selects the `foreground` or `muted` role; nothing else paints text.
30
+ *
31
+ * @CallerMustEnsure — the component cannot see these and does not check them
32
+ * - This is an ordinary page title, and it is also a hero's lead - `display` is the hero role, so a
33
+ * poster hero's `h1` is this one, placed inside a `Hero` (#17), which renders no heading of its own.
34
+ * - A subpage head is the exception: `PageHead` renders its own `h1` at the `title` role, the one
35
+ * sanctioned escape valve from level-fixes-role (docs/adr/0005). Reach for it where it fits.
36
+ * - Heading levels descend without skipping — an `h1` is followed by an `h2`, never an `h3`.
37
+ */
38
+ export const H1: FunctionComponent<IH1Props> = ({
39
+ children,
40
+ color,
41
+ testId,
42
+ }) => (
43
+ <h1 className={h1({ color })} data-testid={testId}>
44
+ {children}
45
+ </h1>
46
+ );
@@ -0,0 +1,43 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // Level fixes role: an h2 is always the title role, with no size prop (docs/adr/0005). Weight
6
+ // inherits - Tailwind's preflight resets h1-h6 to font-weight: inherit, so the sized levels carry no
7
+ // weight class. --tracking-optical is the large-type correction and the title role is where it starts
8
+ // (#57), so an h2 and the page head's h1 - the same role - are tracked alike, and h3 down is not.
9
+ // Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
10
+ const h2 = cva('font-primary text-title leading-title tracking-optical', {
11
+ variants: {
12
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
13
+ },
14
+ defaultVariants: { color: 'foreground' },
15
+ });
16
+
17
+ interface IH2Props extends VariantProps<typeof h2> {
18
+ children: ReactNode;
19
+ testId?: string;
20
+ }
21
+
22
+ /**
23
+ * The section heading, an `h2` at the title type role.
24
+ *
25
+ * @Guarantees — enforced on every render
26
+ * - Renders an `h2`; its outline level and the title role are one choice, not two (docs/adr/0005).
27
+ * - Reads `--font-primary`, sized by `--text-title`, led by `--leading-title` and optically corrected
28
+ * by `--tracking-optical` — the title role is the smallest role that carries it, so `H3` and below
29
+ * take none.
30
+ * - `color` selects the `foreground` or `muted` role; nothing else paints text.
31
+ *
32
+ * @CallerMustEnsure — the component cannot see these and does not check them
33
+ * - Heading levels descend without skipping — an `h2` sits under an `h1`, not under an `h3`.
34
+ */
35
+ export const H2: FunctionComponent<IH2Props> = ({
36
+ children,
37
+ color,
38
+ testId,
39
+ }) => (
40
+ <h2 className={h2({ color })} data-testid={testId}>
41
+ {children}
42
+ </h2>
43
+ );