@juwel-development/design-system 3.0.0 → 3.2.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 (40) hide show
  1. package/README.md +31 -0
  2. package/dist/design-system.js +219 -128
  3. package/dist/index.css +1 -1
  4. package/dist/types/Arrangement/Cluster/Cluster.d.ts +50 -0
  5. package/dist/types/Arrangement/Stack/Stack.d.ts +51 -0
  6. package/dist/types/Display/Figure/Figure.d.ts +21 -4
  7. package/dist/types/Display/Rail/Rail.d.ts +6 -0
  8. package/dist/types/Display/Typography/H1/H1.d.ts +7 -1
  9. package/dist/types/Display/Typography/Note/Note.d.ts +35 -0
  10. package/dist/types/Interaction/Link/Link.d.ts +4 -4
  11. package/dist/types/Layout/Cover/Cover.d.ts +48 -0
  12. package/dist/types/Layout/Form/Form.d.ts +8 -4
  13. package/dist/types/Layout/Header/Header.d.ts +20 -1
  14. package/dist/types/Layout/Hero/Hero.d.ts +9 -7
  15. package/dist/types/Layout/Section/Section.d.ts +7 -0
  16. package/dist/types/Theme/Palette.d.ts +55 -6
  17. package/dist/types/index.d.ts +4 -0
  18. package/package.json +1 -1
  19. package/src/Arrangement/Cluster/Cluster.tsx +80 -0
  20. package/src/Arrangement/Stack/Stack.tsx +81 -0
  21. package/src/Display/Figure/Figure.tsx +48 -7
  22. package/src/Display/Rail/Rail.tsx +6 -0
  23. package/src/Display/Typography/H1/H1.tsx +17 -7
  24. package/src/Display/Typography/Note/Note.tsx +56 -0
  25. package/src/Interaction/Button/Button.tsx +4 -1
  26. package/src/Interaction/Input/Input.tsx +17 -6
  27. package/src/Interaction/Link/Link.tsx +9 -5
  28. package/src/Interaction/TextArea/TextArea.tsx +17 -6
  29. package/src/Layout/Cover/Cover.tsx +85 -0
  30. package/src/Layout/Form/Form.tsx +24 -8
  31. package/src/Layout/Header/Header.tsx +37 -4
  32. package/src/Layout/Hero/Hero.tsx +10 -6
  33. package/src/Layout/PageHead/PageHead.tsx +2 -0
  34. package/src/Layout/Section/Section.tsx +11 -0
  35. package/src/Theme/Palette.ts +63 -14
  36. package/src/Theme/renderTokens.ts +37 -7
  37. package/src/index.ts +4 -0
  38. package/src/tokens.css +29 -10
  39. package/src/tokens.dark.css +25 -6
  40. package/src/tokens.light.css +25 -6
@@ -8,9 +8,11 @@ import type { Subject } from 'rxjs';
8
8
  // drawn in `controlBorder` (>=3:1 against surface) and turned `error` on both `:user-invalid` and
9
9
  // `aria-invalid` so a server-rendered and a browser-validated invalid state paint identically. Focus
10
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.
11
+ // recipe's, not a `rows` prop. The control's face, and the placeholder that follows it, are
12
+ // docs/adr/0004's (#90); so is its size (#92) - `body` is the role clearing the 16px below which
13
+ // iOS Safari zooms a focused control.
12
14
  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',
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',
14
16
  );
15
17
 
16
18
  interface ITextAreaProps {
@@ -82,7 +84,14 @@ export const TextArea: FunctionComponent<ITextAreaProps> = ({
82
84
 
83
85
  return (
84
86
  <div className={'flex flex-col gap-[var(--space-stack)]'}>
85
- <label htmlFor={controlId} className={'font-medium text-foreground'}>
87
+ {/* Each of these declares `font-secondary` on itself, never on the wrapper above - the
88
+ wrapper would hand the labelling face to the control too (docs/adr/0004, #90). The
89
+ label's size is declared here for the same reason, and it is `body`, the control's own
90
+ role, rather than `label` (docs/adr/0004, #92). */}
91
+ <label
92
+ htmlFor={controlId}
93
+ className={'font-secondary font-medium text-body text-foreground'}
94
+ >
86
95
  {label}
87
96
  </label>
88
97
  <textarea
@@ -101,15 +110,17 @@ export const TextArea: FunctionComponent<ITextAreaProps> = ({
101
110
  onInput={(event) => onInput$?.next(event.currentTarget.value)}
102
111
  />
103
112
  {!required && optionalLabel && (
104
- <span className={'text-muted text-sm'}>{optionalLabel}</span>
113
+ <span className={'font-secondary text-muted text-small'}>
114
+ {optionalLabel}
115
+ </span>
105
116
  )}
106
117
  {hint && (
107
- <p id={hintId} className={'text-muted text-sm'}>
118
+ <p id={hintId} className={'font-secondary text-muted text-small'}>
108
119
  {hint}
109
120
  </p>
110
121
  )}
111
122
  {invalid && (
112
- <p id={errorId} className={'text-error text-sm'}>
123
+ <p id={errorId} className={'font-secondary text-error text-small'}>
113
124
  {errorMessage}
114
125
  </p>
115
126
  )}
@@ -0,0 +1,85 @@
1
+ import { cva } from 'class-variance-authority';
2
+ import type { FunctionComponent, ReactNode } from 'react';
3
+
4
+ // One recipe, no variants: both-axes centring is the component's whole job (#96), so there is nothing
5
+ // to choose - a distribution axis waits for evidence under ADR 0008's test. items-center centres the
6
+ // inline axis, the slot's auto margins (below) the block axis; alone among the composables it carries
7
+ // its own inset - the deliberate exception to "Section owns the gutter" (CONTEXT.md: Cover).
8
+ const cover = cva(
9
+ [
10
+ 'flex flex-col items-center',
11
+ 'min-h-[var(--cover-height)]',
12
+ 'px-[var(--gutter)] py-[var(--space-region)]',
13
+ ].join(' '),
14
+ );
15
+
16
+ // Not a second recipe - the slot has nothing to vary, and the standard allows one cva()
17
+ // (design-system-components.md §4), the frame's own above. Block-axis auto margins split the leftover
18
+ // space equally, so a foot after the slot still lands on the bottom edge - justify-center on the
19
+ // frame would centre slot and foot as one group and lift the foot off that edge.
20
+ const slot = 'my-auto';
21
+
22
+ export interface ICoverProps {
23
+ /** The screen's one opaque slot, centred on both axes. Rendered unmodified: a menu composes a title,
24
+ * a tagline and a stack of actions here; a sign-in composes a form. `Cover` imposes no anatomy. */
25
+ children: ReactNode;
26
+ /** The line on the screen's bottom edge - a version line, a legal line - centred on the inline axis.
27
+ * Omitted - or given nothing: `null`, a flag's `false` - nothing renders: no empty container
28
+ * holds its place. */
29
+ foot?: ReactNode;
30
+ testId?: string;
31
+ }
32
+
33
+ /**
34
+ * A whole screen's frame: a plain container at least the cover height tall that centres one column in
35
+ * the leftover space, on both axes, with an optional foot pinned to the bottom edge. It is the fold's
36
+ * counterpart (CONTEXT.md): a menu, a sign-in, a splash is the entire app for a moment, and nothing
37
+ * follows it - so where `Hero` deliberately stops short of the viewport to say the page continues, a
38
+ * cover reaches it, because stopping short would signal a continuation that does not exist. It renders
39
+ * no heading and no landmark: whatever the screen says is composed in the slot.
40
+ *
41
+ * @Guarantees — enforced on every render
42
+ * - It is at least `--cover-height` tall: a `min-height` floor, not a fixed height, so content longer
43
+ * than the viewport grows the frame rather than overflowing it.
44
+ * - `children` sit in the middle of the leftover space, centred on both axes; the centring is fixed,
45
+ * with no distribution to choose.
46
+ * - `foot` renders on the frame's bottom edge, centred on the inline axis, and renders nothing - not
47
+ * even an empty container - when not given or given nothing to render (`null`, a flag's `false`).
48
+ * - `children` and `foot` render unmodified; the component adds nothing to and strips nothing from
49
+ * them, and sets no colour, no heading and no landmark of its own.
50
+ * - It owns its own inset - `--gutter` on the inline axis, `--space-region` on the block axis - the
51
+ * deliberate exception to "`Section` owns the gutter": the frame equals the viewport, so it cannot
52
+ * sit inside a `Section` band without overflowing it, and there is no band around it to carry one.
53
+ * - It is never sticky or fixed and needs no JavaScript, so it renders identically server-side.
54
+ *
55
+ * @CallerMustEnsure — the component cannot see these and does not check them
56
+ * - The cover stands on its own, never inside a `Section`: the band's vertical air would push the
57
+ * frame past the viewport it exists to equal. A page that continues past its first screen wants
58
+ * `Section` and `Hero` instead - the fold, not the screen.
59
+ * - Anything the page must announce - a landmark, a heading - is composed in the slot; the frame
60
+ * declares nothing over it.
61
+ *
62
+ * @UXGuidelines
63
+ * - A cover is for a page that is the whole app for a moment - a menu, a sign-in, a splash. The
64
+ * moment content follows on the same page, the screen has become a fold and stopping short of the
65
+ * viewport is the honest signal: reach for `Hero` inside a `Section` instead.
66
+ * - The foot is a quiet line, not a footer: a version, a legal notice. Content a viewer must reach
67
+ * belongs in the slot, where it sits in the column the screen is actually about.
68
+ */
69
+ export const Cover: FunctionComponent<ICoverProps> = ({
70
+ children,
71
+ foot,
72
+ testId,
73
+ }) => {
74
+ // Absence, not falsiness, after Form's note guard: `foot={showLegal && <p/>}` hands over `false`,
75
+ // and an empty <div> would still be a flex item sitting on the bottom edge.
76
+ const hasFoot =
77
+ foot !== undefined && foot !== null && typeof foot !== 'boolean';
78
+
79
+ return (
80
+ <div className={cover()} data-testid={testId}>
81
+ <div className={slot}>{children}</div>
82
+ {hasFoot && <div>{foot}</div>}
83
+ </div>
84
+ );
85
+ };
@@ -8,8 +8,9 @@ export type FormState = 'idle' | 'sending' | 'sent' | 'failed';
8
8
  // The one recipe, and it paints the note only (issue #6): `state` selects the note's tone - muted
9
9
  // while the form stands, success on the outcome, error on the failure. Region selection is the
10
10
  // NOTE_ROLE map below, not classes, so this axis never grows a layout job. Colours are semantic
11
- // tokens re-pointed by `.dark`, so no variant carries a `dark:` class.
12
- const form = cva('text-sm', {
11
+ // tokens re-pointed by `.dark`, so no variant carries a `dark:` class. The face joins the size in
12
+ // the base, so no state can disagree with it (docs/adr/0004, #90).
13
+ const form = cva('font-secondary text-small', {
13
14
  variants: {
14
15
  state: {
15
16
  idle: 'text-muted',
@@ -37,8 +38,12 @@ interface IFormProps {
37
38
  children?: ReactNode;
38
39
  /** The actions row - the consumer supplies its own submit Button. */
39
40
  actions?: ReactNode;
40
- /** Standing note in `idle`/`sending`; the outcome message in `sent`/`failed`. */
41
- note?: string;
41
+ /** Standing note in `idle`/`sending`; the outcome message in `sent`/`failed`. Inline content: a
42
+ * sentence, with a Link or emphasis inside it. Form renders the paragraph the note sits in and
43
+ * owns its ARIA role, so a node that brings its own block element - a second `<p>` - is unnested
44
+ * by the parser and the text leaves the region carrying that role. A node that renders nothing
45
+ * (`null`, `false`, an omitted prop) is no note: the paragraph is not rendered at all. */
46
+ note?: ReactNode;
42
47
  testId?: string;
43
48
  }
44
49
 
@@ -51,8 +56,8 @@ interface IFormProps {
51
56
  * `sent` drops the fields and actions so a completed submission cannot be resubmitted, while the
52
57
  * `<form>` itself is retained in every state so the DOM shape is stable across a runtime change.
53
58
  *
54
- * The note's text is always the consumer's; Form chooses only its element and ARIA role. `sent` is
55
- * the outcome message, so a `sent` with no `note` is a programmer error and throws.
59
+ * The note's content is always the consumer's; Form chooses only its element and ARIA role. `sent`
60
+ * is the outcome message, so a `sent` with no `note` is a programmer error and throws.
56
61
  */
57
62
  export const Form: FunctionComponent<IFormProps> = ({
58
63
  action,
@@ -63,7 +68,16 @@ export const Form: FunctionComponent<IFormProps> = ({
63
68
  note,
64
69
  testId,
65
70
  }) => {
66
- if (state === 'sent' && !note) {
71
+ // Absence, not falsiness: a ReactNode may be falsy and still render (`0` renders as `"0"`), so a
72
+ // truthiness test would both refuse a legitimate note in `sent` and drop a paragraph React had
73
+ // written a value into. Absence is React's own set of nothing-to-render nodes rather than
74
+ // `undefined` alone, because `note={showPrivacy && <>...</>}` hands over `false` when the line is
75
+ // off - and an empty `<p>` is still a flex item, so it would cost a region gap and, in `sent`, a
76
+ // status region announcing nothing.
77
+ const hasNote =
78
+ note !== undefined && note !== null && typeof note !== 'boolean';
79
+
80
+ if (state === 'sent' && !hasNote) {
67
81
  throw new Error(
68
82
  'Form in the `sent` state must be given a `note` - it is the outcome message.',
69
83
  );
@@ -87,12 +101,14 @@ export const Form: FunctionComponent<IFormProps> = ({
87
101
  {children}
88
102
  </div>
89
103
  )}
104
+ {/* Not pinned against Stack (#75) as the two columns above are: the actions row runs along the
105
+ other axis, which is Cluster's arrangement (#76) and not a stack with a different gap. */}
90
106
  {showFields && actions && (
91
107
  <div className={'flex flex-row gap-[var(--space-stack)]'}>
92
108
  {actions}
93
109
  </div>
94
110
  )}
95
- {note && (
111
+ {hasNote && (
96
112
  <p role={noteRole} className={form({ state })}>
97
113
  {note}
98
114
  </p>
@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority';
2
2
  import { cva } from 'class-variance-authority';
3
3
  import type { FunctionComponent, ReactNode } from 'react';
4
4
 
5
- // One recipe on the <header>. It sets the label type role - the "small grotesk, letter-spaced, muted"
5
+ // The recipe on the <header>. It sets the label type role - the "small grotesk, letter-spaced, muted"
6
6
  // the issue described, whose "never grows past 1rem" was a role wearing a number, so no size literal
7
7
  // appears here (#14). The shell's air is one value in every direction: --space-region above, below and
8
8
  // (on the nav) between, --gutter across, so the bar aligns with every inset Section. The current-page
@@ -15,7 +15,7 @@ import type { FunctionComponent, ReactNode } from 'react';
15
15
  const header = cva(
16
16
  [
17
17
  'flex items-baseline justify-between',
18
- 'font-secondary text-label tracking-label text-muted',
18
+ 'font-secondary text-label leading-label tracking-label text-muted',
19
19
  'py-[var(--space-region)] px-[var(--gutter)]',
20
20
  '[&_[aria-current=page]]:text-foreground',
21
21
  ].join(' '),
@@ -30,6 +30,20 @@ const header = cva(
30
30
  },
31
31
  );
32
32
 
33
+ // Not a second recipe - the standing slot has nothing to vary, and the standard allows a component
34
+ // one cva() (design-system-components.md §4), which is the bar's own above. A named constant beside
35
+ // the nav's inline class string, so the comment has something to sit on.
36
+ //
37
+ // The height floor is the nav's own line box, written from the two tokens the recipe above sets the
38
+ // header from, so the declared floor and the rendered line cannot drift (#81). `shrink-0` because an
39
+ // explicit min-width replaces a flex item's automatic minimum - without it the bar squeezes the slot
40
+ // below its content and wraps the place name the floor exists to keep on one line.
41
+ const standingSlot = [
42
+ 'inline-flex shrink-0 items-center',
43
+ 'min-h-[calc(var(--text-label)*var(--leading-label))]',
44
+ 'min-w-[var(--standing-min-width)]',
45
+ ].join(' ');
46
+
33
47
  export interface IHeaderProps extends VariantProps<typeof header> {
34
48
  /** The standing link: a place name, a mark, or a home link. The consumer supplies the whole anchor -
35
49
  * Header renders no link of its own. A place name uses `<Link treatment="quiet" href="/">…</Link>`; a
@@ -49,7 +63,14 @@ export interface IHeaderProps extends VariantProps<typeof header> {
49
63
  *
50
64
  * @Guarantees — enforced on every render
51
65
  * - The header is set at the label role and carries no size of its own: `font-secondary text-label
52
- * tracking-label text-muted`, so it never grows past that role whatever the page font size.
66
+ * leading-label tracking-label text-muted`, so it never grows past that role whatever the page font size.
67
+ * - The nav's line box is the library's own rather than the consuming document's: the header leads
68
+ * itself at the label role, so the height it declares for its slot is the height it renders.
69
+ * - The standing slot is floored, never fixed: its minimum height is that same line box - the label
70
+ * role's size times its leading - its minimum width is `--standing-min-width`, and its contents are
71
+ * centred. Standing content inside both floors leaves the bar the same height from route to route,
72
+ * so a place name on one page and a mark on another do not move the shell; content past either floor
73
+ * grows the slot, on one line, and is never clamped or wrapped.
53
74
  * - A nav item marked `aria-current="page"` renders at `foreground` whatever supplied it - the treatment
54
75
  * keys on the attribute, not on `Link`, so it holds against a bare anchor or any component.
55
76
  * - The shell's air is one value above, below and between: `--space-region` vertically and between nav
@@ -57,6 +78,18 @@ export interface IHeaderProps extends VariantProps<typeof header> {
57
78
  * - It is never sticky and needs no JavaScript: there is no `sticky` variant and nothing to hydrate.
58
79
  * - Omitting `navName` emits no `aria-label` at all, not an empty one.
59
80
  *
81
+ * @CallerMustEnsure — the component cannot see these and does not check them
82
+ * - A mark that should *fill* the standing slot is given a **definite width** by whoever placed it -
83
+ * `width: var(--standing-min-width)` on the element wrapping it, so the mark and the floor move
84
+ * together when a theme re-points the token. Measured against a `viewBox`-only SVG, the common
85
+ * shape: it has no intrinsic width to fill from, and `width: 100%` cannot resolve against the slot's
86
+ * indefinite basis - so a mark told to fill that way renders at its intrinsic width instead (300px in
87
+ * a slot floored at 120px, against 120px on the place-name route) and the floor stops governing,
88
+ * which is the jump between routes the floor exists to remove. A definite width holds at any bar
89
+ * width; `flex: 1 1 0; min-width: 0` only collapses the mark when the bar is already out of room, so
90
+ * it is not the rule to reach for. The library cannot apply either rule for you: the same width on a
91
+ * place name would clamp the name and wrap it.
92
+ *
60
93
  * @UXGuidelines
61
94
  * - Name the nav with `navName` once the page has more than one navigation landmark - a footer nav will
62
95
  * be the second, and two unnamed navs are indistinguishable to a screen-reader user.
@@ -73,7 +106,7 @@ export const Header: FunctionComponent<IHeaderProps> = ({
73
106
  testId,
74
107
  }) => (
75
108
  <header className={header({ edge })} data-testid={testId}>
76
- <div>{standing}</div>
109
+ <div className={standingSlot}>{standing}</div>
77
110
  <nav
78
111
  aria-label={navName}
79
112
  className={'flex flex-wrap items-baseline gap-[var(--space-region)]'}
@@ -2,6 +2,8 @@ import type { VariantProps } from 'class-variance-authority';
2
2
  import { cva } from 'class-variance-authority';
3
3
  import type { FunctionComponent, ReactNode } from 'react';
4
4
 
5
+ // Not pinned against Stack (#75): the fold height is this component's own, so the base legitimately
6
+ // differs and an equality spec would only forbid a difference that is the point of the component.
5
7
  // One recipe on a plain <div>. It holds the fold - a minimum height taller than its content, so leftover
6
8
  // space is guaranteed and how the slot sits in it is a decision, not a default (#17). With no className
7
9
  // escape hatch, whatever place picks is final, so it is a variant: center is the conventional hero and the
@@ -52,12 +54,14 @@ export interface IHeroProps extends VariantProps<typeof hero> {
52
54
  * bleed, the band, the gutter and the region. `Hero` imports no component and adds none of these.
53
55
  *
54
56
  * @UXGuidelines
55
- * - Cap the lead at `--measure-display`, not at a caption's width, and apply it to your own lead - not
56
- * here, since capping the slot would cap the mark too. Measured on the lockup that prompted this: at
57
- * 24ch a one-sentence lead set five lines and became a second block competing with the mark; at 36ch it
58
- * sets three and reads as the mark's caption. This is the one place where copy length is a layout
59
- * parameter - a hero whose lead sentence can vary in length needs its measure chosen against the real
60
- * sentence, not a rule of thumb.
57
+ * - The lead is capped at `--measure-display`, and `H1` is what carries the cap - place one in the slot
58
+ * and the bound comes with it (#87). The slot itself stays uncapped, because capping it would cap the
59
+ * mark too. Measured on the lockup that prompted this: at 24ch a one-sentence lead set five lines and
60
+ * became a second block competing with the mark; at 36ch, where the token sits, it sets three and reads
61
+ * as the mark's caption. This is the one place where copy length is a layout parameter, and the token
62
+ * is one value for the whole product - so a hero whose lead runs longer than that measurement assumed
63
+ * is a reason to re-measure `--measure-display` against the real sentence, which moves every `H1` with
64
+ * it, and never a reason to cap that one hero by hand.
61
65
  * - A hero built on a wide lockup is a desktop bet by construction. A 15-character lockup can only be as
62
66
  * wide as the phone, so at 390px the scale contrast between mark and lead is capped by the viewport
63
67
  * rather than chosen - roughly 2:1, against roughly 6:1 at 1440.
@@ -10,6 +10,8 @@ import type { FunctionComponent, ReactNode } from 'react';
10
10
  // literal appears. That tracking is the title role's, not this component's (#57): H2 binds to the same
11
11
  // role and carries the same correction, so the role reads one way wherever it is rendered. The stack gap is --space-stack, the sibling gap Prose uses. Colours are semantic
12
12
  // tokens re-pointed by `.dark`, so no `dark:` class.
13
+ // Not pinned against Stack (#75): the band and the gutter are the head's own page job, so the base
14
+ // legitimately differs and there is no equality to hold - a Stack carries neither.
13
15
  const pageHead = cva(
14
16
  'flex flex-col gap-[var(--space-stack)] py-[var(--space-band)] px-[var(--gutter)]',
15
17
  );
@@ -16,6 +16,10 @@ import type { FunctionComponent, ReactNode } from 'react';
16
16
  // `.dark`, so no `dark:` class.
17
17
  const section = cva(
18
18
  [
19
+ // Anchor, not placement: a consumer hangs its own decoration off the join, and cannot get an anchor
20
+ // by wrapping the section without costing two joins. No offset and no z-index, so the layout is
21
+ // unchanged and no stacking context forms - decoration at z-index:-1 must still escape to an outer one.
22
+ 'relative',
19
23
  'py-[var(--space-band)]',
20
24
  '[[data-section]+&]:border-t [[data-section]+&]:border-solid [[data-section]+&]:border-rule',
21
25
  ].join(' '),
@@ -51,10 +55,17 @@ export interface ISectionProps extends VariantProps<typeof section> {
51
55
  * - A `bleed="full"` section drops the gutter and runs edge to edge; the vertical band is kept on both
52
56
  * bleed variants, and the join spans the full width of either.
53
57
  * - An unnamed section is not a landmark: it emits no `aria-label` and is inert to assistive technology.
58
+ * - The section is a positioning context, under either bleed, so a consumer may place absolutely-positioned
59
+ * decoration against its edges - including the join above it, the one edge only a section knows. It sets
60
+ * no offset and no `z-index`, so it is not a stacking context and decoration at a negative `z-index`
61
+ * still resolves against an outer one.
54
62
  *
55
63
  * @CallerMustEnsure
56
64
  * - Where `name` is given it matches the section's visible heading. The component labels the region with
57
65
  * that string because it cannot reach the heading's id to reference it with `aria-labelledby` instead.
66
+ * - A descendant meant to position against an ancestor *outside* the section is given its own positioned
67
+ * wrapper, since the section is now the nearer positioned ancestor and takes the anchor. The
68
+ * re-anchoring is silent - no error and no warning, only a descendant that lands somewhere else.
58
69
  *
59
70
  * @UXGuidelines
60
71
  * - On a page with no borrowed proof - no logos, no testimonials, no credits - a gap between blocks reads
@@ -39,15 +39,36 @@ export type PaletteTokens = {
39
39
  * keeps it near `surface` rather than a mid grey. */
40
40
  backing: string;
41
41
 
42
- /** The main call-to-action fill. */
42
+ /** The main call-to-action fill. A filled control draws no border, so the fill is the only thing
43
+ * separating the control from the surface - what `controlBorder` is for an unfilled one.
44
+ * Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against `surface` in the same theme,
45
+ * `primaryHover` included, since a hovered control has to stay identifiable too. A second
46
+ * constraint governs this value and `primaryHover` from the other side, stated on
47
+ * `primaryForeground` - the ink they carry - rather than restated here. */
43
48
  primary: string;
44
49
  primaryHover: string;
45
- /** Text and icons drawn on top of `primary`. */
50
+ /** Text and icons drawn on top of `primary`. Constraint (WCAG 2.2 SC 1.4.3): at least 4.5:1
51
+ * against both `primary` and `primaryHover` in the same theme, hover included, since a hovered
52
+ * control has to stay readable too. Button text is normal-weight at the `body` role, so it takes
53
+ * the text threshold rather than the 3:1 large-text allowance. This is what makes the ink follow
54
+ * the theme where the fills' own roles do not: a fill light enough to clear 3:1 against a dark
55
+ * surface is too light to carry near-white text, so the ink sits at the *surface's* end of the
56
+ * neutral range in each theme. Not required against `disabled` or `disabledHover` - a disabled
57
+ * control is an inactive user interface component, which SC 1.4.3 exempts. */
46
58
  primaryForeground: string;
47
59
 
48
- /** The alternative action fill, for choices that sit beside a primary one. */
60
+ /** The alternative action fill, for choices that sit beside a primary one. Filled like `primary`
61
+ * and so identified the same way. Constraint (WCAG 2.2 SC 1.4.11): at least 3:1 against
62
+ * `surface` in the same theme, `secondaryHover` included. A second constraint governs this value
63
+ * and `secondaryHover` from the other side, stated on `secondaryForeground`. */
49
64
  secondary: string;
50
65
  secondaryHover: string;
66
+ /** Text and icons drawn on top of `secondary`. Constraint (WCAG 2.2 SC 1.4.3): at least 4.5:1
67
+ * against both `secondary` and `secondaryHover` in the same theme, hover included, since a
68
+ * hovered control has to stay readable too. Not required against `disabled` or `disabledHover` -
69
+ * a disabled control is an inactive user interface component, which SC 1.4.3 exempts. Why the
70
+ * threshold is 4.5 and not 3, and why this makes the ink follow the theme: see
71
+ * `primaryForeground`, which carries the same rule for the other ramp. */
51
72
  secondaryForeground: string;
52
73
 
53
74
  /** The tone a control takes when it cannot be interacted with - its fill, or its border when
@@ -75,6 +96,25 @@ export type PaletteTokens = {
75
96
  info: string;
76
97
  };
77
98
 
99
+ /**
100
+ * The ink follows the theme, and that is what set the eight fill and ink values across both sets
101
+ * (issue #93). Solve the window a fill has to sit in - at least 3:1 against its surface (#78) and
102
+ * at least 4.5:1 against the ink drawn on it - and it comes out lopsided: a near-white ink is
103
+ * unbounded above in light and leaves a window just 1.26x wide in dark, and a near-black ink is the
104
+ * exact inverse. The reason is structural - in a dark theme a fill has to be light enough to
105
+ * separate from a near-black surface, and a light fill wants dark text - so pinning the ink
106
+ * near-white in both themes asks a dark fill to be both at once. That 1.26x has to hold two values,
107
+ * rest and hover, where what shipped before it stepped 1.35x and 1.23x here and 1.35x and 1.48x in
108
+ * dark. Letting the ink invert instead puts every value on a stock ramp step, with hover steps of
109
+ * 1.25x and 1.27x here and 1.56x and 1.48x in dark.
110
+ *
111
+ * Two routes were rejected, and they are what a reader arriving here is most likely to re-propose:
112
+ * - Keep one near-white ink in both themes and move the fills. It clears 4.5:1, but caps the dark
113
+ * hover at that same 1.26x - gutting the state #78 constrained hover in order to keep.
114
+ * - Give each ramp its own ink, the same in both themes. It clears 4.5:1 only against pure black:
115
+ * against `#0f172a`, the dark set's own `surface`, sky-600 lands at 4.36 and fails. It also buys
116
+ * a light theme with white text on one button and black on the one beside it.
117
+ */
78
118
  export const light: PaletteTokens = {
79
119
  surface: '#ffffff',
80
120
  foreground: '#0f172a',
@@ -84,12 +124,12 @@ export const light: PaletteTokens = {
84
124
  rule: '#808fa3',
85
125
  backing: '#f1f5f9',
86
126
 
87
- primary: '#8b5cf6',
88
- primaryHover: '#7c3aed',
127
+ primary: '#7c3aed',
128
+ primaryHover: '#6d28d9',
89
129
  primaryForeground: '#f8fafc',
90
130
 
91
- secondary: '#0ea5e9',
92
- secondaryHover: '#0284c7',
131
+ secondary: '#0369a1',
132
+ secondaryHover: '#075985',
93
133
  secondaryForeground: '#f8fafc',
94
134
 
95
135
  disabled: '#94a3b8',
@@ -106,10 +146,19 @@ export const light: PaletteTokens = {
106
146
  };
107
147
 
108
148
  /**
109
- * Dark reverses the ramp the fills are drawn from: `primary` takes the step that light uses for
110
- * its hover, and hovering steps *up* into light. That is what the components' old
149
+ * Dark reverses the direction the fills step: it sits higher up the ramp than light does and hovers
150
+ * *up* into lighter still, where light sits lower and hovers down. That is what the components' old
111
151
  * `dark:bg-primary-600 dark:hover:bg-primary-500` pair encoded, moved here so it is stated once
112
- * for the whole system instead of repeated per component.
152
+ * for the whole system instead of repeated per component. The two sets no longer share a step, since
153
+ * each theme's pair has to clear its own ink as well as its own surface (issue #93).
154
+ *
155
+ * The ink inverts with the ramp: slate-950 under these lighter fills, where light takes slate-50
156
+ * under its darker ones. The pair is the two ends of the one neutral ramp the rest of the palette is
157
+ * already built from - `#f8fafc` is slate-50, `surface` slate-900, `muted` slate-500 - rather than a
158
+ * new colour arriving for a single job. `#020617` is also the lightest slate step that still admits
159
+ * violet-500 and sky-600, which is what leaves this set's `secondary` pair unmoved: slate-900 draws
160
+ * 4.22 and 4.36 against them and fails. Why the ink follows the theme at all, and the two routes
161
+ * rejected in getting here: see the light set.
113
162
  */
114
163
  export const dark: PaletteTokens = {
115
164
  surface: '#0f172a',
@@ -120,13 +169,13 @@ export const dark: PaletteTokens = {
120
169
  rule: '#5b6a80',
121
170
  backing: '#1e293b',
122
171
 
123
- primary: '#7c3aed',
124
- primaryHover: '#8b5cf6',
125
- primaryForeground: '#f8fafc',
172
+ primary: '#8b5cf6',
173
+ primaryHover: '#a78bfa',
174
+ primaryForeground: '#020617',
126
175
 
127
176
  secondary: '#0284c7',
128
177
  secondaryHover: '#0ea5e9',
129
- secondaryForeground: '#f8fafc',
178
+ secondaryForeground: '#020617',
130
179
 
131
180
  disabled: '#475569',
132
181
  disabledHover: '#334155',
@@ -61,6 +61,14 @@ const CONTROL_MIN_WIDTH = `:root {
61
61
  --control-min-width: 10.5rem;
62
62
  }`;
63
63
 
64
+ /* The shell's standing slot claims a width, and that is what this names: how much of the bar the
65
+ slot takes, never how wide any drawing is - a slot role, so Brandmark keeps setting no dimension of
66
+ its own (.out-of-scope/brandmark-size-vocabulary.md). Not a colour and no Tailwind namespace, so it
67
+ sits in :root beside the control minimum width. 7.5rem is the one attested value, measured in #81. */
68
+ const STANDING_MIN_WIDTH = `:root {
69
+ --standing-min-width: 7.5rem;
70
+ }`;
71
+
64
72
  /* The library's underline is not a colour: like the focus-ring dimensions it lives in :root only,
65
73
  never @theme inline. Not prose's alone - every Link treatment that draws a line draws it from these.
66
74
  Constraint: --underline-thickness-hover > --underline-thickness (the thicken on hover is the only
@@ -102,11 +110,17 @@ const TYPOGRAPHY = `@theme {
102
110
  subtitle was rejected in ADR 0004 - it welds the lede's size to H3's and its leading to a head's. */
103
111
  --text-lede: 1.25rem; /* 20px */
104
112
  --leading-lede: 1.4;
105
- --text-body: 1.0625rem; /* 17px */
113
+ --text-body: 1.0625rem; /* 17px, floored at 16px: below it iOS Safari zooms the viewport when a control set at this role takes focus (#92) */
106
114
  --leading-body: 1.6;
107
115
  --text-small: 0.9375rem; /* 15px floor: below it tabular figures stop comparing column to column */
108
116
  --text-label: 0.8125rem; /* 13px floor: below it the tracking reads as damage, not a device */
109
117
 
118
+ /* The label role's leading, declared standalone and deliberately not paired into text-label's
119
+ utility: pairing would move every existing label site's line box at once, silently. A component
120
+ whose own height is measured against its labels reads leading-label explicitly (Header #81). 1.5
121
+ is the line height SC 1.4.12 expects text to survive, so a user stylesheet applying it moves nothing. */
122
+ --leading-label: 1.5;
123
+
110
124
  /* Two quantities on one property that must not collapse: --tracking-label is a fixed letter-spaced
111
125
  style, --tracking-optical a correction that varies with size. The names say which is which, so the
112
126
  distinction survives without the ADR in hand. Scope: the title role and above - H1, H2 and the page
@@ -115,15 +129,15 @@ const TYPOGRAPHY = `@theme {
115
129
  --tracking-optical: -0.02em;
116
130
  }`;
117
131
 
118
- /* The reading measure is in ch, not rem, so the character count stays held when the body size moves
119
- under it; --measure-display is narrower because bigger type wants fewer characters per line, and
120
- --measure-wide slightly wider - the standfirst of a page head (PageHead #18) is an opening statement,
121
- not a reading column, so it runs a little past the reading measure by design. All three in ch and,
122
- having no Tailwind namespace, in :root beside radius, read as max-w-[var(--measure)]. */
132
+ /* The reading measures are in ch so the character count holds when the body size moves under them:
133
+ --measure-display narrower because bigger type wants fewer characters per line, --measure-wide a
134
+ little past the reading column for the page-head standfirst (PageHead #18). --measure-action is
135
+ the action column, in rem (docs/adr/0008, Amendments, #97); 20rem clears --control-min-width. */
123
136
  const MEASURE = `:root {
124
137
  --measure: 66ch;
125
138
  --measure-display: 36ch;
126
139
  --measure-wide: 72ch;
140
+ --measure-action: 20rem;
127
141
  }`;
128
142
 
129
143
  /* Three spacing roles, not a ladder: --space-stack is the sibling gap in a stack, --space-region the air
@@ -159,6 +173,14 @@ const FOLD = `:root {
159
173
  --fold-height: min(70vh, 40rem);
160
174
  }`;
161
175
 
176
+ /* The least height a whole screen takes (CONTEXT.md: Cover) - measured against the screen like the
177
+ fold, in :root beside it with no Tailwind namespace, read as min-h-[var(--cover-height)]. In svh so
178
+ a mobile browser's collapsing chrome never hides a foot pinned to the bottom edge. A floor and
179
+ never a ceiling, so overflowing content grows the frame; enforced per ADR 0004. */
180
+ const COVER = `:root {
181
+ --cover-height: 100svh;
182
+ }`;
183
+
162
184
  /* Four aspect roles in a @theme block like typography: --aspect-* is a Tailwind 4 namespace, so it
163
185
  generates the aspect-portrait utilities Figure reaches rather than a :root value. Named shapes, not
164
186
  a ladder - ADR 0003/0004 rejected a radius and spacing scale (see SPACING) because a scale lets a
@@ -225,6 +247,9 @@ ${RADIUS}
225
247
  /* The control minimum width is not a colour either, and sits in :root beside radius. */
226
248
  ${CONTROL_MIN_WIDTH}
227
249
 
250
+ /* The standing slot's minimum width is not a colour either, and sits in :root beside the control one. */
251
+ ${STANDING_MIN_WIDTH}
252
+
228
253
  /* The library's underline dimensions are not colours either, and sit in :root beside radius. They are
229
254
  not prose's alone: every Link treatment that draws a line draws it from these (docs/adr/0006). */
230
255
  ${UNDERLINE}
@@ -237,7 +262,9 @@ ${TYPOGRAPHY}
237
262
  after it and are not carried in @theme inline with the palette. */
238
263
  ${ASPECT}
239
264
 
240
- /* The reading measure has no Tailwind namespace, so it sits in :root beside radius. */
265
+ /* The measures have no Tailwind namespace, so they sit in :root beside radius. The reading measures
266
+ are in ch against the type; the action column is in rem against the root, since it bounds a stack
267
+ of controls rather than a reading line (docs/adr/0008, Amendments). */
241
268
  ${MEASURE}
242
269
 
243
270
  /* Spacing has no Tailwind namespace either - --spacing is a single base multiplier ADR 0004 forbids
@@ -250,6 +277,9 @@ ${GUTTER}
250
277
  /* The fold height has no Tailwind namespace either, so it sits in :root beside the gutter. */
251
278
  ${FOLD}
252
279
 
280
+ /* The cover height has no Tailwind namespace either, so it sits in :root beside the fold. */
281
+ ${COVER}
282
+
253
283
  /* The checklist tick's dimensions are not colours either, and sit in :root beside the underline block. */
254
284
  ${TICK}
255
285
  `;
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import './styles.css';
2
2
 
3
+ export { Cluster } from 'Arrangement/Cluster/Cluster';
4
+ export { Stack } from 'Arrangement/Stack/Stack';
3
5
  export { Brandmark } from 'Display/Brandmark/Brandmark';
4
6
  export { Checklist } from 'Display/Checklist/Checklist';
5
7
  export { DefinitionList } from 'Display/DefinitionList/DefinitionList';
@@ -13,12 +15,14 @@ export { H3 } from 'Display/Typography/H3/H3';
13
15
  export { H4 } from 'Display/Typography/H4/H4';
14
16
  export { H5 } from 'Display/Typography/H5/H5';
15
17
  export { H6 } from 'Display/Typography/H6/H6';
18
+ export { Note } from 'Display/Typography/Note/Note';
16
19
  export { P } from 'Display/Typography/P/P';
17
20
  export { Prose } from 'Display/Typography/Prose/Prose';
18
21
  export { Button } from 'Interaction/Button/Button';
19
22
  export { Input } from 'Interaction/Input/Input';
20
23
  export { Link } from 'Interaction/Link/Link';
21
24
  export { TextArea } from 'Interaction/TextArea/TextArea';
25
+ export { Cover } from 'Layout/Cover/Cover';
22
26
  export { Footer } from 'Layout/Footer/Footer';
23
27
  export type { FormState } from 'Layout/Form/Form';
24
28
  export { Form } from 'Layout/Form/Form';