@juwel-development/design-system 1.0.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 +16 -9
  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 +198 -2
  60. package/src/index.ts +25 -1
  61. package/src/tokens.css +126 -9
@@ -0,0 +1,41 @@
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 h3 is always the subtitle 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. Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:`
8
+ // class.
9
+ const h3 = cva('font-primary text-subtitle leading-subtitle', {
10
+ variants: {
11
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
12
+ },
13
+ defaultVariants: { color: 'foreground' },
14
+ });
15
+
16
+ interface IH3Props extends VariantProps<typeof h3> {
17
+ children: ReactNode;
18
+ testId?: string;
19
+ }
20
+
21
+ /**
22
+ * The subsection heading, an `h3` at the subtitle type role. The last of the three sized steps;
23
+ * levels below it share the body size and separate by weight.
24
+ *
25
+ * @Guarantees — enforced on every render
26
+ * - Renders an `h3`; its outline level and the subtitle role are one choice, not two (docs/adr/0005).
27
+ * - Reads `--font-primary`, sized by `--text-subtitle` and led by `--leading-subtitle`.
28
+ * - `color` selects the `foreground` or `muted` role; nothing else paints text.
29
+ *
30
+ * @CallerMustEnsure — the component cannot see these and does not check them
31
+ * - Heading levels descend without skipping — an `h3` sits under an `h2`, not under an `h1`.
32
+ */
33
+ export const H3: FunctionComponent<IH3Props> = ({
34
+ children,
35
+ color,
36
+ testId,
37
+ }) => (
38
+ <h3 className={h3({ color })} data-testid={testId}>
39
+ {children}
40
+ </h3>
41
+ );
@@ -0,0 +1,40 @@
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 h4 shares the body size with h5, h6 and p, told apart by weight alone
6
+ // (docs/adr/0005). The `font-bold` is load-bearing - Tailwind's preflight resets h1-h6 to
7
+ // font-weight: inherit, so without it an h4 renders identically to a paragraph. Colour is a semantic
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', {
10
+ variants: {
11
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
12
+ },
13
+ defaultVariants: { color: 'foreground' },
14
+ });
15
+
16
+ interface IH4Props extends VariantProps<typeof h4> {
17
+ children: ReactNode;
18
+ testId?: string;
19
+ }
20
+
21
+ /**
22
+ * The first of the weight-separated headings, an `h4` at the body size, rendered bold.
23
+ *
24
+ * @Guarantees — enforced on every render
25
+ * - Renders an `h4`; its outline level and the body role are one choice, not two (docs/adr/0005).
26
+ * - Reads `--font-primary`, sized by `--text-body`, and is bold so it stands apart from a paragraph.
27
+ * - `color` selects the `foreground` or `muted` role; nothing else paints text.
28
+ *
29
+ * @CallerMustEnsure — the component cannot see these and does not check them
30
+ * - Heading levels descend without skipping — an `h4` sits under an `h3`, not under an `h2`.
31
+ */
32
+ export const H4: FunctionComponent<IH4Props> = ({
33
+ children,
34
+ color,
35
+ testId,
36
+ }) => (
37
+ <h4 className={h4({ color })} data-testid={testId}>
38
+ {children}
39
+ </h4>
40
+ );
@@ -0,0 +1,41 @@
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 h5 shares the body size with h4, h6 and p, told apart by weight alone
6
+ // (docs/adr/0005). The `font-semibold` is load-bearing - Tailwind's preflight resets h1-h6 to
7
+ // font-weight: inherit, so without it an h5 renders identically to a paragraph. Colour is a semantic
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', {
10
+ variants: {
11
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
12
+ },
13
+ defaultVariants: { color: 'foreground' },
14
+ });
15
+
16
+ interface IH5Props extends VariantProps<typeof h5> {
17
+ children: ReactNode;
18
+ testId?: string;
19
+ }
20
+
21
+ /**
22
+ * A weight-separated heading, an `h5` at the body size, rendered semibold — one step lighter than
23
+ * `h4`.
24
+ *
25
+ * @Guarantees — enforced on every render
26
+ * - Renders an `h5`; its outline level and the body role are one choice, not two (docs/adr/0005).
27
+ * - Reads `--font-primary`, sized by `--text-body`, semibold so it stands apart from a paragraph.
28
+ * - `color` selects the `foreground` or `muted` role; nothing else paints text.
29
+ *
30
+ * @CallerMustEnsure — the component cannot see these and does not check them
31
+ * - Heading levels descend without skipping — an `h5` sits under an `h4`, not under an `h3`.
32
+ */
33
+ export const H5: FunctionComponent<IH5Props> = ({
34
+ children,
35
+ color,
36
+ testId,
37
+ }) => (
38
+ <h5 className={h5({ color })} data-testid={testId}>
39
+ {children}
40
+ </h5>
41
+ );
@@ -0,0 +1,41 @@
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 h6 shares the body size with h4, h5 and p, told apart by weight alone
6
+ // (docs/adr/0005). The `font-medium` is load-bearing - Tailwind's preflight resets h1-h6 to
7
+ // font-weight: inherit, so without it an h6 renders identically to a paragraph. Colour is a semantic
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', {
10
+ variants: {
11
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
12
+ },
13
+ defaultVariants: { color: 'foreground' },
14
+ });
15
+
16
+ interface IH6Props extends VariantProps<typeof h6> {
17
+ children: ReactNode;
18
+ testId?: string;
19
+ }
20
+
21
+ /**
22
+ * The lightest heading, an `h6` at the body size, rendered medium — the deepest level the ladder
23
+ * offers, because the weight lever runs out here.
24
+ *
25
+ * @Guarantees — enforced on every render
26
+ * - Renders an `h6`; its outline level and the body role are one choice, not two (docs/adr/0005).
27
+ * - Reads `--font-primary`, sized by `--text-body`, medium so it stands apart from a paragraph.
28
+ * - `color` selects the `foreground` or `muted` role; nothing else paints text.
29
+ *
30
+ * @CallerMustEnsure — the component cannot see these and does not check them
31
+ * - Heading levels descend without skipping — an `h6` sits under an `h5`, not under an `h4`.
32
+ */
33
+ export const H6: FunctionComponent<IH6Props> = ({
34
+ children,
35
+ color,
36
+ testId,
37
+ }) => (
38
+ <h6 className={h6({ color })} data-testid={testId}>
39
+ {children}
40
+ </h6>
41
+ );
@@ -0,0 +1,38 @@
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 body role, shared with h4-h6 which add a weight to stand apart from it (docs/adr/0005). Weight
6
+ // inherits. Colour is a semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
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', {
9
+ variants: {
10
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
11
+ },
12
+ defaultVariants: { color: 'foreground' },
13
+ });
14
+
15
+ interface IPProps extends VariantProps<typeof p> {
16
+ children: ReactNode;
17
+ testId?: string;
18
+ }
19
+
20
+ /**
21
+ * A paragraph of body copy, a `p` at the body type role. Unbounded — usable in a form, a card or a
22
+ * table cell — because the reading measure belongs to whatever owns the reading column (Prose #21).
23
+ *
24
+ * @Guarantees — enforced on every render
25
+ * - Renders a `p`, reading `--font-primary`, sized by `--text-body` and led by `--leading-body`.
26
+ * - `color` selects the `foreground` or `muted` role; nothing else paints text.
27
+ *
28
+ * @CallerMustEnsure — the component cannot see these and does not check them
29
+ * - Where line length matters, place the paragraph inside whatever bounds the reading measure; `P`
30
+ * does not constrain its own width.
31
+ * - For a paragraph inside a reading column, reach for `Prose.Body`, which is measure-bounded by its
32
+ * `Prose.Root`; `P` is for a paragraph with no reading column around it.
33
+ */
34
+ export const P: FunctionComponent<IPProps> = ({ children, color, testId }) => (
35
+ <p className={p({ color })} data-testid={testId}>
36
+ {children}
37
+ </p>
38
+ );
@@ -0,0 +1,91 @@
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 reading column. It caps every child at --measure and sets no font-size, so the ch measure
6
+ // keeps resolving against inherited body type - the same rule Form follows when it takes the measure.
7
+ // The sibling gap is the type-scale --space-stack, never a one-idea-per-screen gap: the air the block
8
+ // wants is at its edges (the 66ch measure) and inside the type, not between the blocks. It sets no
9
+ // margin, so the page - not the component - owns the vertical rhythm around the column.
10
+ const proseRoot = cva(
11
+ 'flex max-w-[var(--measure)] flex-col gap-[var(--space-stack)]',
12
+ );
13
+
14
+ // The opening paragraph, at the new lede role: larger than body and led tighter (docs/adr/0004), but
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');
17
+
18
+ // The body role inside the column - the same utilities P emits, restated because the no-cross-import
19
+ // rule forbids reusing P's recipe. A test pins the two equal so they cannot drift. Colour is a
20
+ // semantic token re-pointed by `.dark`, so no variant carries a `dark:` class.
21
+ const proseBody = cva('font-primary text-body leading-body', {
22
+ variants: {
23
+ color: { foreground: 'text-foreground', muted: 'text-muted' },
24
+ },
25
+ defaultVariants: { color: 'foreground' },
26
+ });
27
+
28
+ // The stepped-down closing note: the small role, always muted. small carries the enforced 15px floor.
29
+ const proseTail = cva('font-primary text-small text-muted');
30
+
31
+ interface IProseRootProps {
32
+ children?: ReactNode;
33
+ testId?: string;
34
+ }
35
+
36
+ interface IProseLedeProps {
37
+ children?: ReactNode;
38
+ }
39
+
40
+ interface IProseBodyProps extends VariantProps<typeof proseBody> {
41
+ children?: ReactNode;
42
+ }
43
+
44
+ interface IProseTailProps {
45
+ children?: ReactNode;
46
+ }
47
+
48
+ const ProseRoot: FunctionComponent<IProseRootProps> = ({
49
+ children,
50
+ testId,
51
+ }) => (
52
+ <div className={proseRoot()} data-testid={testId}>
53
+ {children}
54
+ </div>
55
+ );
56
+
57
+ const ProseLede: FunctionComponent<IProseLedeProps> = ({ children }) => (
58
+ <p className={proseLede()}>{children}</p>
59
+ );
60
+
61
+ const ProseBody: FunctionComponent<IProseBodyProps> = ({ color, children }) => (
62
+ <p className={proseBody({ color })}>{children}</p>
63
+ );
64
+
65
+ const ProseTail: FunctionComponent<IProseTailProps> = ({ children }) => (
66
+ <p className={proseTail()}>{children}</p>
67
+ );
68
+
69
+ /**
70
+ * A measure-bounded reading block: a lede, body paragraphs and an optional stepped-down muted tail,
71
+ * all capped at the reading measure - including inside a wider grid cell, which follows from `Root`
72
+ * bounding the column. Composed from the four members; the consumer supplies the copy.
73
+ *
74
+ * @Guarantees — enforced on every render
75
+ * - `Root` renders a `div` (never a `section`: it owns no landmark and no heading), capped at
76
+ * `--measure`, setting no font-size so the `ch` measure resolves against inherited body type.
77
+ * - `Root` stacks its children on `--space-stack` and takes no outer margin: the page owns the
78
+ * rhythm around the block, and the air the block wants is at its edges and inside the type.
79
+ * - `Lede` renders a `p` at the lede role; `Body` at the body role with a `foreground`/`muted`
80
+ * `color`; `Tail` at the small role, muted. No drop cap or other invented device.
81
+ *
82
+ * @CallerMustEnsure — the component cannot see these and does not check them
83
+ * - Use `Prose.Body` for a paragraph inside this reading column; for a paragraph with no reading
84
+ * column around it - in a form, a card, a table cell - use `P` instead.
85
+ */
86
+ export const Prose = {
87
+ Root: ProseRoot,
88
+ Lede: ProseLede,
89
+ Body: ProseBody,
90
+ Tail: ProseTail,
91
+ } as const;
@@ -1,21 +1,26 @@
1
1
  import type { VariantProps } from 'class-variance-authority';
2
2
  import { cva } from 'class-variance-authority';
3
- import type { FunctionComponent, PropsWithChildren } from 'react';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
4
  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.
8
13
  const button = cva(
9
- '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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface',
14
+ '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)]',
10
15
  {
11
16
  variants: {
12
17
  variant: {
13
18
  primary:
14
- 'px-4 sm:px-6 min-w-42 bg-primary text-primary-foreground rounded-lg transition-all duration-200 hover:bg-primary-hover focus-visible:ring-primary-ring',
19
+ 'px-4 sm:px-6 min-w-42 bg-primary text-primary-foreground hover:bg-primary-hover',
15
20
  secondary:
16
- 'px-4 sm:px-6 min-w-42 bg-secondary text-secondary-foreground rounded-lg transition-all duration-200 hover:bg-secondary-hover focus-visible:ring-secondary-ring',
21
+ 'px-4 sm:px-6 min-w-42 bg-secondary text-secondary-foreground hover:bg-secondary-hover',
17
22
  ghost:
18
- 'px-2 min-w-0 bg-transparent text-foreground hover:underline transition-all duration-200 focus-visible:ring-ring',
23
+ 'px-2 min-w-0 bg-transparent text-foreground hover:underline hover:decoration-[length:var(--underline-thickness)] hover:underline-offset-[var(--underline-offset)]',
19
24
  },
20
25
  },
21
26
  defaultVariants: {
@@ -24,9 +29,9 @@ const button = cva(
24
29
  },
25
30
  );
26
31
 
27
- export interface IButtonProps
28
- extends VariantProps<typeof button>,
29
- PropsWithChildren {
32
+ interface IButtonProps extends VariantProps<typeof button> {
33
+ /** Optional: an icon-only button renders none, and names itself with `ariaLabel` instead. */
34
+ children?: ReactNode;
30
35
  onClick$?: Subject<void>;
31
36
  disabled?: boolean;
32
37
  testId?: string;
@@ -51,13 +56,15 @@ export interface IButtonProps
51
56
  * - Provide visual feedback on hover/active states
52
57
  * - Ensure sufficient touch target size (minimum 44x44px) for mobile users
53
58
  * - Position primary actions on the right for multi-button layouts
59
+ * - A submit button's busy state is a label swap ("Send" to "Sending…"), never a spinner: it costs
60
+ * nothing to render server-side and keeps a Form's `sending` state driver-agnostic
54
61
  *
55
62
  * @Accessibility
56
63
  * - Ensure adequate color contrast (4.5:1 minimum ratio)
57
64
  * - Provide focus styles for keyboard navigation
58
65
  * - Use appropriate ARIA attributes when needed
59
66
  */
60
- export const Button: FunctionComponent<PropsWithChildren<IButtonProps>> = ({
67
+ export const Button: FunctionComponent<IButtonProps> = ({
61
68
  children,
62
69
  disabled,
63
70
  testId,
@@ -0,0 +1,103 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import { type FunctionComponent, useId } from 'react';
4
+ import type { Subject } from 'rxjs';
5
+
6
+ // One recipe, deliberately not shared with TextArea (issue #5): each control owns its whole recipe
7
+ // so one-recipe-per-component holds without a base module. Colours are semantic tokens re-pointed by
8
+ // `.dark`, so no variant carries a `dark:` class. The border is the only boundary of a transparent
9
+ // control, drawn in `controlBorder` (>=3:1 against surface) and turned `error` on both `:user-invalid`
10
+ // and `aria-invalid` so a server-rendered and a browser-validated invalid state paint identically.
11
+ // Focus adds only the shared ring - the border never changes on focus (docs/adr/0002).
12
+ const input = cva(
13
+ 'block w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 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',
14
+ {
15
+ variants: {
16
+ // text/email/url are visually identical; the axis only selects the control's `type`
17
+ // attribute (issue #5), so each option carries no class of its own.
18
+ variant: { text: '', email: '', url: '' },
19
+ },
20
+ defaultVariants: { variant: 'text' },
21
+ },
22
+ );
23
+
24
+ interface IInputProps extends VariantProps<typeof input> {
25
+ /** Always rendered and associated with the control; never replaced by the placeholder. */
26
+ label: string;
27
+ /** How the surrounding form reads the value on submit. */
28
+ name: string;
29
+ required?: boolean;
30
+ invalid?: boolean;
31
+ disabled?: boolean;
32
+ defaultValue?: string;
33
+ placeholder?: string;
34
+ autocomplete?: 'name' | 'email' | 'url' | 'organization' | 'tel' | 'off';
35
+ hint?: string;
36
+ errorMessage?: string;
37
+ onInput$?: Subject<string>;
38
+ testId?: string;
39
+ }
40
+
41
+ /**
42
+ * A labelled single-line text control. Its value is uncontrolled - the form reads it by `name` on
43
+ * submit - so it works with JavaScript disabled. Ids are minted internally, so the prop surface
44
+ * stays closed and the label/hint/error associations survive with no hydration.
45
+ */
46
+ export const Input: FunctionComponent<IInputProps> = ({
47
+ label,
48
+ name,
49
+ variant,
50
+ required,
51
+ invalid,
52
+ disabled,
53
+ defaultValue,
54
+ placeholder,
55
+ autocomplete,
56
+ hint,
57
+ errorMessage,
58
+ onInput$,
59
+ testId,
60
+ }) => {
61
+ const id = useId();
62
+ const controlId = `${id}-control`;
63
+ const hintId = `${id}-hint`;
64
+ const errorId = `${id}-error`;
65
+ const describedBy =
66
+ [hint ? hintId : undefined, invalid ? errorId : undefined]
67
+ .filter(Boolean)
68
+ .join(' ') || undefined;
69
+
70
+ return (
71
+ <div className={'flex flex-col gap-[var(--space-stack)]'}>
72
+ <label htmlFor={controlId} className={'font-medium text-foreground'}>
73
+ {label}
74
+ </label>
75
+ <input
76
+ id={controlId}
77
+ name={name}
78
+ type={variant ?? 'text'}
79
+ className={input({ variant })}
80
+ required={required}
81
+ disabled={disabled}
82
+ defaultValue={defaultValue}
83
+ placeholder={placeholder}
84
+ autoComplete={autocomplete}
85
+ aria-invalid={invalid || undefined}
86
+ aria-describedby={describedBy}
87
+ data-testid={testId}
88
+ onInput={(event) => onInput$?.next(event.currentTarget.value)}
89
+ />
90
+ {!required && <span className={'text-muted text-sm'}>optional</span>}
91
+ {hint && (
92
+ <p id={hintId} className={'text-muted text-sm'}>
93
+ {hint}
94
+ </p>
95
+ )}
96
+ {invalid && (
97
+ <p id={errorId} className={'text-error text-sm'}>
98
+ {errorMessage}
99
+ </p>
100
+ )}
101
+ </div>
102
+ );
103
+ };
@@ -0,0 +1,70 @@
1
+ import type { VariantProps } from 'class-variance-authority';
2
+ import { cva } from 'class-variance-authority';
3
+ import type { FunctionComponent, ReactNode } from 'react';
4
+
5
+ // One recipe, four treatments that behave differently, not one link with a colour prop (see
6
+ // docs/adr/0006). The focus ring is in the base - a link is focusable - drawn with outline, colour at
7
+ // rest so it never fades in (docs/adr/0002); no radius, a link has no box (docs/adr/0003). Colours are
8
+ // semantic tokens re-pointed by `.dark`, so no treatment carries a `dark:` class. Underlines read the
9
+ // library's --underline-* tokens and arrive instantly, off the transition allowlist (docs/adr/0001):
10
+ // prose thickens its on hover, quiet and label-link raise one at rest thickness, graphic has none.
11
+ const link = cva(
12
+ 'outline-focus-ring outline-offset-[var(--focus-ring-offset)] focus-visible:outline focus-visible:outline-[length:var(--focus-ring-width)]',
13
+ {
14
+ variants: {
15
+ treatment: {
16
+ prose:
17
+ 'text-link underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] hover:decoration-[length:var(--underline-thickness-hover)]',
18
+ quiet:
19
+ 'text-muted no-underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] transition-colors duration-[var(--motion-duration-color)] hover:text-foreground hover:underline',
20
+ 'label-link':
21
+ 'text-inherit no-underline decoration-[length:var(--underline-thickness)] underline-offset-[var(--underline-offset)] hover:underline',
22
+ graphic: 'text-inherit no-underline',
23
+ },
24
+ },
25
+ defaultVariants: { treatment: 'prose' },
26
+ },
27
+ );
28
+
29
+ interface ILinkProps extends VariantProps<typeof link> {
30
+ /** Where the link points. Carried on the anchor, so the link navigates with JavaScript disabled. */
31
+ href: string;
32
+ /** The link text. */
33
+ children: ReactNode;
34
+ /** Opens in a new tab and severs the opener together - `target="_blank"` implies the `rel`, so
35
+ * neither half is settable alone. */
36
+ external?: boolean;
37
+ /** Marks this link as the current page for assistive technology (`aria-current="page"`). Semantics
38
+ * only: any visual current-page treatment belongs to the Header, not here. */
39
+ current?: boolean;
40
+ testId?: string;
41
+ }
42
+
43
+ /**
44
+ * A link in one of four treatments. `prose` for running text (told apart by its underline, never by
45
+ * hue), `quiet` for standing navigation (muted, and foreground with an underline on hover),
46
+ * `label-link` for a link acting as a label (inherits its colour, underlines on hover, sets no type
47
+ * of its own), and `graphic` for an anchor whose child is not text (paints nothing, so a mark keeps
48
+ * its own colour). It renders a plain `<a>`, so it works with no hydration.
49
+ */
50
+ export const Link: FunctionComponent<ILinkProps> = ({
51
+ href,
52
+ children,
53
+ treatment,
54
+ external,
55
+ current,
56
+ testId,
57
+ }) => {
58
+ return (
59
+ <a
60
+ href={href}
61
+ className={link({ treatment })}
62
+ target={external ? '_blank' : undefined}
63
+ rel={external ? 'noopener noreferrer' : undefined}
64
+ aria-current={current ? 'page' : undefined}
65
+ data-testid={testId}
66
+ >
67
+ {children}
68
+ </a>
69
+ );
70
+ };
@@ -0,0 +1,93 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import { type FunctionComponent, useId } from 'react';
3
+ import type { Subject } from 'rxjs';
4
+
5
+ // One recipe, deliberately not shared with Input (issue #5): each control owns its whole recipe so
6
+ // one-recipe-per-component holds without a base module. Colours are semantic tokens re-pointed by
7
+ // `.dark`, so no `dark:` class is needed. The border is the only boundary of a transparent control,
8
+ // drawn in `controlBorder` (>=3:1 against surface) and turned `error` on both `:user-invalid` and
9
+ // `aria-invalid` so a server-rendered and a browser-validated invalid state paint identically. Focus
10
+ // adds only the shared ring - the border never changes on focus (docs/adr/0002). Height is the
11
+ // recipe's, not a `rows` prop.
12
+ const textArea = cva(
13
+ 'block min-h-24 w-full rounded-[var(--radius-control)] border border-solid border-control-border bg-transparent px-3 py-2 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',
14
+ );
15
+
16
+ interface ITextAreaProps {
17
+ /** Always rendered and associated with the control; never replaced by the placeholder. */
18
+ label: string;
19
+ /** How the surrounding form reads the value on submit. */
20
+ name: string;
21
+ required?: boolean;
22
+ invalid?: boolean;
23
+ disabled?: boolean;
24
+ defaultValue?: string;
25
+ placeholder?: string;
26
+ autocomplete?: 'name' | 'email' | 'url' | 'organization' | 'tel' | 'off';
27
+ hint?: string;
28
+ errorMessage?: string;
29
+ onInput$?: Subject<string>;
30
+ testId?: string;
31
+ }
32
+
33
+ /**
34
+ * A labelled multi-line text control. Its value is uncontrolled - the form reads it by `name` on
35
+ * submit - so it works with JavaScript disabled. Ids are minted internally, so the prop surface
36
+ * stays closed and the label/hint/error associations survive with no hydration.
37
+ */
38
+ export const TextArea: FunctionComponent<ITextAreaProps> = ({
39
+ label,
40
+ name,
41
+ required,
42
+ invalid,
43
+ disabled,
44
+ defaultValue,
45
+ placeholder,
46
+ autocomplete,
47
+ hint,
48
+ errorMessage,
49
+ onInput$,
50
+ testId,
51
+ }) => {
52
+ const id = useId();
53
+ const controlId = `${id}-control`;
54
+ const hintId = `${id}-hint`;
55
+ const errorId = `${id}-error`;
56
+ const describedBy =
57
+ [hint ? hintId : undefined, invalid ? errorId : undefined]
58
+ .filter(Boolean)
59
+ .join(' ') || undefined;
60
+
61
+ return (
62
+ <div className={'flex flex-col gap-[var(--space-stack)]'}>
63
+ <label htmlFor={controlId} className={'font-medium text-foreground'}>
64
+ {label}
65
+ </label>
66
+ <textarea
67
+ id={controlId}
68
+ name={name}
69
+ className={textArea()}
70
+ required={required}
71
+ disabled={disabled}
72
+ defaultValue={defaultValue}
73
+ placeholder={placeholder}
74
+ autoComplete={autocomplete}
75
+ aria-invalid={invalid || undefined}
76
+ aria-describedby={describedBy}
77
+ data-testid={testId}
78
+ onInput={(event) => onInput$?.next(event.currentTarget.value)}
79
+ />
80
+ {!required && <span className={'text-muted text-sm'}>optional</span>}
81
+ {hint && (
82
+ <p id={hintId} className={'text-muted text-sm'}>
83
+ {hint}
84
+ </p>
85
+ )}
86
+ {invalid && (
87
+ <p id={errorId} className={'text-error text-sm'}>
88
+ {errorMessage}
89
+ </p>
90
+ )}
91
+ </div>
92
+ );
93
+ };
File without changes