@lutra-ui-system/react 0.1.2 → 0.3.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.
@@ -1 +1 @@
1
- {"version":3,"file":"field-support.d.ts","sourceRoot":"","sources":["../../src/internal/field-support.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;;;;;;GAWG;AAEH,wDAAwD;AACxD,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;AAE9D,UAAU,aAAa;IACrB,4CAA4C;IAC5C,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,4DAA4D;IAC5D,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,yFAAyF;IACzF,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,aAAa,GAAG,QAAQ,CAiB3F;AAED,UAAU,eAAe;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,cAAc,CAAC;IAC1B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,KAAK,EAAE,SAAS,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,EACzB,SAAS,EACT,KAAK,EACL,SAAS,EACT,IAAI,EACJ,MAAM,EACN,KAAK,EACL,OAAO,EACP,QAAQ,EACR,SAAS,EACT,QAAQ,GACT,EAAE,eAAe,+BAmCjB"}
1
+ {"version":3,"file":"field-support.d.ts","sourceRoot":"","sources":["../../src/internal/field-support.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;;;;;;;GAWG;AAEH,wDAAwD;AACxD,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;AAE9D,UAAU,aAAa;IACrB,4CAA4C;IAC5C,EAAE,EAAE,MAAM,GAAG,SAAS,CAAC;IACvB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,4DAA4D;IAC5D,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,yFAAyF;IACzF,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,EAAE,aAAa,GAAG,QAAQ,CAmB3F;AAED,UAAU,eAAe;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,SAAS,CAAC;IACjB,SAAS,EAAE,cAAc,CAAC;IAC1B;;;;;;OAMG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,KAAK,EAAE,SAAS,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,UAAU,CAAC,EACzB,SAAS,EACT,KAAK,EACL,SAAS,EACT,QAAQ,EACR,IAAI,EACJ,MAAM,EACN,KAAK,EACL,OAAO,EACP,QAAQ,EACR,SAAS,EACT,QAAQ,GACT,EAAE,eAAe,+BAsDjB"}
@@ -17,7 +17,9 @@ export function useFieldIds({ id, hasHint, hasError, describedBy }) {
17
17
  const hintId = hasHint ? `${controlId}-hint` : undefined;
18
18
  const errorId = hasError ? `${controlId}-error` : undefined;
19
19
  // Order matters: the consumer's own description first, then the hint, then the error. A screen
20
- // reader reads them in this order, and the error is the thing to leave ringing in the ear.
20
+ // reader reads them in this order, the error is the thing to leave ringing in the ear, and since
21
+ // supporting text moved below the control this is also the DOM order — reading order and
22
+ // description order are now the same sequence rather than two orders to keep in step.
21
23
  const parts = [describedBy, hintId, errorId].filter((part) => Boolean(part));
22
24
  return {
23
25
  controlId,
@@ -28,15 +30,41 @@ export function useFieldIds({ id, hasHint, hasError, describedBy }) {
28
30
  }
29
31
  /**
30
32
  * Renders the visible parts around a control, in the documented order:
31
- * label → hintcontrol → error.
33
+ * label → controlhint → error.
32
34
  *
33
- * The hint sits above the control so it is read before the control is operated; the error sits
34
- * below it, which is where the Figma annotation places it and where Field Group puts its own.
35
+ * **Supporting text sits below the control**, which is what the Figma component draws and what its
36
+ * accessibility annotation describes. An earlier implementation placed the hint above the control on
37
+ * the argument that guidance should be read before typing; the design system decided otherwise, and
38
+ * the contract is the design system's to set. The `aria-describedby` order is unchanged and now
39
+ * matches the DOM order exactly — consumer description, then hint, then error.
35
40
  *
36
- * The indicator word is rendered *inside* the `<label>`, so it becomes part of the accessible name
37
- * and is announced with it rather than being a separate visual flourish the label does not carry.
41
+ * ## The indicator and the accessible name
42
+ *
43
+ * The indicator is rendered inside the `<label>`, so whatever of it is exposed becomes part of the
44
+ * accessible name. Figma draws `(required *)` and `(optional)` immediately after the label text, and
45
+ * the visible output is exactly that in every case. What differs is how much of it assistive
46
+ * technology sees, and the rule is: **expose the indicator only when nothing else carries the same
47
+ * information.**
48
+ *
49
+ * - **`required` indicator with the native `required` attribute** — hidden entirely. The attribute
50
+ * already conveys the required state, and repeating the word in the name says the same thing
51
+ * twice about one control. This is the ordinary case, because `indicator` defaults to `'required'`
52
+ * precisely when `required` is set.
53
+ * - **`required` indicator without the attribute** — the word stays in the name. A consumer may ask
54
+ * for the indicator on a field that is validated elsewhere, and in that case the name is the only
55
+ * thing carrying it. Hiding it there would leave the requirement visible-only, which is the defect
56
+ * this rule exists to avoid.
57
+ * - **`optional` indicator** — always stays in the name. There is no native attribute and no ARIA
58
+ * state for "optional", so nothing else conveys it and there is no duplication to remove.
59
+ *
60
+ * The asterisk is separately `aria-hidden` in the one case where the word is exposed: it is a
61
+ * decorative convention duplicating the word beside it, and it must never be the only signal.
62
+ *
63
+ * How any given screen reader words the required state is its own business and is not asserted here.
38
64
  */
39
- export function FieldShell({ controlId, label, indicator, hint, hintId, error, errorId, disabled, className, children, }) {
40
- return (_jsxs("div", { className: className === undefined ? 'lutra-field' : `lutra-field ${className}`, "data-invalid": error ? 'true' : undefined, "data-disabled": disabled ? 'true' : undefined, children: [_jsxs("label", { className: "lutra-field__label", htmlFor: controlId, children: [label, indicator !== 'none' && (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "lutra-field__indicator", "data-indicator": indicator, children: indicator === 'required' ? 'Required' : 'Optional' })] }))] }), hint !== undefined && hint !== null && hint !== false && (_jsx("p", { className: "lutra-field__hint", id: hintId, children: hint })), children, error !== undefined && error !== null && error !== false && (_jsx("p", { className: "lutra-field__error", id: errorId, children: error }))] }));
65
+ export function FieldShell({ controlId, label, indicator, required, hint, hintId, error, errorId, disabled, className, children, }) {
66
+ // The one case where the indicator says nothing the control has not already said.
67
+ const indicatorIsRedundant = indicator === 'required' && required;
68
+ return (_jsxs("div", { className: className === undefined ? 'lutra-field' : `lutra-field ${className}`, "data-invalid": error ? 'true' : undefined, "data-disabled": disabled ? 'true' : undefined, children: [_jsxs("label", { className: "lutra-field__label", htmlFor: controlId, children: [label, indicator !== 'none' && (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "lutra-field__indicator", "data-indicator": indicator, "aria-hidden": indicatorIsRedundant || undefined, children: indicator === 'required' ? (_jsxs(_Fragment, { children: ['(required', _jsx("span", { "aria-hidden": "true", children: ' *' }), ')'] })) : ('(optional)') })] }))] }), children, hint !== undefined && hint !== null && hint !== false && (_jsx("p", { className: "lutra-field__hint", id: hintId, children: hint })), error !== undefined && error !== null && error !== false && (_jsx("p", { className: "lutra-field__error", id: errorId, children: error }))] }));
41
69
  }
42
70
  //# sourceMappingURL=field-support.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"field-support.js","sourceRoot":"","sources":["../../src/internal/field-support.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAoC9B;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAiB;IAC/E,MAAM,SAAS,GAAG,KAAK,EAAE,CAAC;IAC1B,MAAM,SAAS,GAAG,EAAE,IAAI,eAAe,SAAS,EAAE,CAAC;IAEnD,MAAM,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,SAAS,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IACzD,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,SAAS,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;IAE5D,+FAA+F;IAC/F,2FAA2F;IAC3F,MAAM,KAAK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IAE7F,OAAO;QACL,SAAS;QACT,MAAM;QACN,OAAO;QACP,WAAW,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;KAC5D,CAAC;AACJ,CAAC;AAgBD;;;;;;;;;GASG;AACH,MAAM,UAAU,UAAU,CAAC,EACzB,SAAS,EACT,KAAK,EACL,SAAS,EACT,IAAI,EACJ,MAAM,EACN,KAAK,EACL,OAAO,EACP,QAAQ,EACR,SAAS,EACT,QAAQ,GACQ;IAChB,OAAO,CACL,eACE,SAAS,EAAE,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,eAAe,SAAS,EAAE,kBACjE,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,mBACzB,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,aAE5C,iBAAO,SAAS,EAAC,oBAAoB,EAAC,OAAO,EAAE,SAAS,aACrD,KAAK,EACL,SAAS,KAAK,MAAM,IAAI,CACvB,8BAEyF,GAAG,EAC1F,eAAM,SAAS,EAAC,wBAAwB,oBAAiB,SAAS,YAC/D,SAAS,KAAK,UAAU,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,UAAU,GAC9C,IACN,CACJ,IACK,EAEP,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,IAAI,CACxD,YAAG,SAAS,EAAC,mBAAmB,EAAC,EAAE,EAAE,MAAM,YACxC,IAAI,GACH,CACL,EAEA,QAAQ,EAER,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,IAAI,CAC3D,YAAG,SAAS,EAAC,oBAAoB,EAAC,EAAE,EAAE,OAAO,YAC1C,KAAK,GACJ,CACL,IACG,CACP,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"field-support.js","sourceRoot":"","sources":["../../src/internal/field-support.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAoC9B;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAiB;IAC/E,MAAM,SAAS,GAAG,KAAK,EAAE,CAAC;IAC1B,MAAM,SAAS,GAAG,EAAE,IAAI,eAAe,SAAS,EAAE,CAAC;IAEnD,MAAM,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,GAAG,SAAS,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;IACzD,MAAM,OAAO,GAAG,QAAQ,CAAC,CAAC,CAAC,GAAG,SAAS,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC;IAE5D,+FAA+F;IAC/F,iGAAiG;IACjG,yFAAyF;IACzF,sFAAsF;IACtF,MAAM,KAAK,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAkB,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IAE7F,OAAO;QACL,SAAS;QACT,MAAM;QACN,OAAO;QACP,WAAW,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;KAC5D,CAAC;AACJ,CAAC;AAwBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,MAAM,UAAU,UAAU,CAAC,EACzB,SAAS,EACT,KAAK,EACL,SAAS,EACT,QAAQ,EACR,IAAI,EACJ,MAAM,EACN,KAAK,EACL,OAAO,EACP,QAAQ,EACR,SAAS,EACT,QAAQ,GACQ;IAChB,kFAAkF;IAClF,MAAM,oBAAoB,GAAG,SAAS,KAAK,UAAU,IAAI,QAAQ,CAAC;IAElE,OAAO,CACL,eACE,SAAS,EAAE,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,eAAe,SAAS,EAAE,kBACjE,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,mBACzB,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,aAE5C,iBAAO,SAAS,EAAC,oBAAoB,EAAC,OAAO,EAAE,SAAS,aACrD,KAAK,EACL,SAAS,KAAK,MAAM,IAAI,CACvB,8BAGoF,GAAG,EACrF,eACE,SAAS,EAAC,wBAAwB,oBAClB,SAAS,iBACZ,oBAAoB,IAAI,SAAS,YAE7C,SAAS,KAAK,UAAU,CAAC,CAAC,CAAC,CAC1B,8BACG,WAAW,EAIZ,8BAAkB,MAAM,YAAE,IAAI,GAAQ,EACrC,GAAG,IACH,CACJ,CAAC,CAAC,CAAC,CACF,YAAY,CACb,GACI,IACN,CACJ,IACK,EAEP,QAAQ,EAER,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,KAAK,IAAI,CACxD,YAAG,SAAS,EAAC,mBAAmB,EAAC,EAAE,EAAE,MAAM,YACxC,IAAI,GACH,CACL,EAEA,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,IAAI,CAC3D,YAAG,SAAS,EAAC,oBAAoB,EAAC,EAAE,EAAE,OAAO,YAC1C,KAAK,GACJ,CACL,IACG,CACP,CAAC;AACJ,CAAC"}
package/dist/styles.css CHANGED
@@ -15,18 +15,24 @@
15
15
  * The file has two layers, and the order matters:
16
16
  *
17
17
  * 1. The generated token layer, which declares every Lutra design token as a CSS
18
- * custom property on `:root`, plus the dark values on `[data-theme='dark']`.
19
- * It is produced from `tokens/source.json` and is not hand-edited.
18
+ * custom property on `:root`, plus the dark values on `[data-theme='dark']`
19
+ * and the condensed values on `[data-density='condensed']`. Theme and density
20
+ * are independent axes and compose freely. It is produced from
21
+ * `tokens/source.json` and is not hand-edited.
20
22
  * 2. One file per component, which reads those tokens and never a raw value.
21
23
  *
22
24
  * Rules for anything added here:
23
25
  * - Every selector is prefixed with `lutra-`, so the library cannot collide with
24
26
  * a consuming application's styles.
25
- * - The only global selectors permitted are the token layer's `:root` and
26
- * `[data-theme]` blocks, and they declare custom properties and nothing else.
27
- * A declaration that paints — a colour, a font, a margin on `body` — must never
28
- * appear on a global selector: a library stylesheet does not get to restyle a
29
- * page it was merely imported into. See `docs/decisions/0002-design-tokens.md`.
27
+ * - The only global selectors permitted are the token layer's `:root`,
28
+ * `[data-theme]` and `[data-density]` blocks, and they declare custom properties
29
+ * and nothing else. A declaration that paints — a colour, a font, a margin on
30
+ * `body` — must never appear on a global selector: a library stylesheet does not
31
+ * get to restyle a page it was merely imported into. See
32
+ * `docs/decisions/0002-design-token-pipeline.md` and
33
+ * `docs/decisions/0004-global-density-modes.md`.
34
+ * - A component rule never matches on `data-theme` or `data-density`. Those are
35
+ * global modes resolved once in the token layer; a component reads the tokens.
30
36
  * - No component rule may reach for `:root`, an element selector, or `*`.
31
37
  * - Sizes use `rem` and unitless line heights, so the library respects the user's
32
38
  * browser font size and keeps working under zoom. Border and outline widths are
@@ -245,6 +251,10 @@
245
251
  --lutra-layout-gutter-tablet: 1.5rem;
246
252
  --lutra-layout-section-lg: 6rem;
247
253
  --lutra-layout-section-sm: 4rem;
254
+
255
+ /* Lutra Density */
256
+ --lutra-control-text-size: 1rem;
257
+ --lutra-field-spacing: 0.5rem;
248
258
  }
249
259
 
250
260
  /* Dark mode. Every token whose value differs in dark, plus every alias that reaches one — an
@@ -494,6 +504,27 @@
494
504
  --lutra-component-tooltip-text: var(--lutra-color-background-canvas);
495
505
  }
496
506
 
507
+ /* Condensed density. Every token whose value differs between the density modes, plus every
508
+ * alias that reaches one — an alias is substituted where it is declared, so it has to be
509
+ * re-declared here to resolve against condensed values rather than inheriting the spacious
510
+ * result from :root.
511
+ *
512
+ * Independent of theme by construction: this block declares no colour and the theme blocks
513
+ * declare no density value, so `data-theme` and `data-density` compose freely on the same
514
+ * element or on different ancestors. Density changes composition spacing and control text
515
+ * size only — never a control height or a target size. */
516
+ [data-density='condensed'] {
517
+ --lutra-control-text-size: 0.875rem;
518
+ --lutra-field-spacing: 0.25rem;
519
+ }
520
+
521
+ /* An explicit spacious region, so a spacious island inside a condensed one is possible. Same
522
+ * set as above, back at their spacious values. */
523
+ [data-density='spacious'] {
524
+ --lutra-control-text-size: 1rem;
525
+ --lutra-field-spacing: 0.5rem;
526
+ }
527
+
497
528
  /*
498
529
  * Button.
499
530
  *
@@ -2185,10 +2216,16 @@
2185
2216
  * focus and disabled are selected from the platform — `:hover`, `:focus-visible`,
2186
2217
  * `:disabled` — never from a prop.
2187
2218
  *
2219
+ * `data-overflow` is set by the component, not the consumer: it means part of the
2220
+ * value is scrolled out of sight. See the overflow earmark near the bottom of
2221
+ * this file, and `overflow.ts` for how the state is derived.
2222
+ *
2188
2223
  * Colour comes from Figma's `component/input/*` alias layer, so the design system
2189
2224
  * decides what an input surface, its text, its placeholder and each of its border
2190
- * states are. Sizes, spacing and type come from the core scales. The values with
2191
- * no Figma variable behind them are marked.
2225
+ * states are. Geometry and spacing come from the core scales, and the text size
2226
+ * comes from the global density axis — `control/text-size`, inherited through
2227
+ * `data-density`, not a prop and not a function of `size`. The values with no
2228
+ * Figma variable behind them are marked.
2192
2229
  *
2193
2230
  * The error state is styled from `aria-invalid` rather than a `data-*` attribute
2194
2231
  * of its own. There is then no way for the field to look wrong while reporting
@@ -2231,18 +2268,23 @@
2231
2268
  --lutra-input-padding-inline-md: var(--lutra-space-md);
2232
2269
  --lutra-input-padding-inline-lg: var(--lutra-space-lg);
2233
2270
 
2234
- /* 14px, 16px, 18px. */
2235
- --lutra-input-font-size-sm: var(--lutra-text-body-small);
2236
- --lutra-input-font-size-md: var(--lutra-text-body);
2237
- --lutra-input-font-size-lg: var(--lutra-text-h3);
2238
-
2239
- /* The leading paired with each size in the type ramp. Figma draws a flat 120%
2240
- * on this component with no variable behind it; inside a fixed-height control
2241
- * the two are visually identical, and using the ramp keeps the field consistent
2242
- * with every other piece of text at the same size. */
2243
- --lutra-input-line-height-sm: var(--lutra-type-line-height-body-small);
2244
- --lutra-input-line-height-md: var(--lutra-type-line-height-body);
2245
- --lutra-input-line-height-lg: var(--lutra-type-line-height-h3);
2271
+ /* Control text size, from the global density axis rather than from `size`.
2272
+ *
2273
+ * Figma binds `control/text-size` to the value text of all eighteen Input
2274
+ * variants, so `size` owns height and inline padding and density owns the
2275
+ * type: 16px in Spacious, 14px in Condensed, the same in sm, md and lg. It is
2276
+ * inherited from whatever `data-density` is in scope and is declared here, on
2277
+ * the control, so a nested `[data-density]` region resolves it correctly.
2278
+ * There is deliberately no density prop see `docs/design-tokens.md`. */
2279
+ --lutra-input-font-size: var(--lutra-control-text-size);
2280
+
2281
+ /* One leading for one size. Figma draws a flat 120% here with no variable
2282
+ * behind it; the body ramp is used instead because `control/text-size`
2283
+ * resolves to `text/body` in the default mode, and inside a min-height control
2284
+ * the two are visually identical. Condensed keeps this ratio on 14px text,
2285
+ * which is looser than the body-small ramp and is what lets the line still
2286
+ * grow rather than crowd when the reader raises their font size. */
2287
+ --lutra-input-line-height: var(--lutra-type-line-height-body);
2246
2288
 
2247
2289
  /* NO FIGMA VARIABLE. Figma draws inputs at a fixed 36/40/44px; these become
2248
2290
  * `min-block-size` instead, because a fixed height on a text-bearing control
@@ -2255,6 +2297,41 @@
2255
2297
  * vertical breathing room the text needs once the field is free to grow. */
2256
2298
  --lutra-input-padding-block: var(--lutra-space-2xs);
2257
2299
 
2300
+ /* Trailing affordance reserve — an internal contract, not public API.
2301
+ *
2302
+ * Two additive slots, both `0px` unless something claims them, added to the
2303
+ * trailing inline padding by each size rule. Additive because they answer to
2304
+ * different owners and can apply at once, and CSS cannot let one custom
2305
+ * property add to itself:
2306
+ *
2307
+ * - `--lutra-input-trailing-reserve` is for a *composition* that puts a
2308
+ * control at the trailing edge — the Info trigger `Input with Tooltip`
2309
+ * needs, a password reveal, a clear button. Nothing claims it today. A
2310
+ * composition sets it on the input to the width it is occupying, and the
2311
+ * value keeps clear of whatever it placed there.
2312
+ * - `--lutra-input-overflow-reserve` belongs to this file and is claimed by
2313
+ * the `data-overflow` rule below. It is not the composition's to set.
2314
+ *
2315
+ * Neither is documented for consumers and neither is a prop. If a public
2316
+ * trailing-slot API is ever wanted, it is a decision record, not a rename. */
2317
+ --lutra-input-trailing-reserve: 0px;
2318
+ --lutra-input-overflow-reserve: 0px;
2319
+
2320
+ /* The overflow earmark.
2321
+ *
2322
+ * Size: Figma draws the earmark vector at a flat 8x8 in all eighteen variants
2323
+ * and binds no variable to it, so `space/sm` is the token that equals what is
2324
+ * drawn — the same treatment as the 36/40/44px control heights below.
2325
+ *
2326
+ * Colour: Figma binds the earmark's fill to the *border* token of the current
2327
+ * status — `component/input/border/default`, `/invalid`, and
2328
+ * `color/border/disabled`. Code currently draws it in `color/icon/default` in
2329
+ * every state instead. That is a real disagreement, not a tokenisation
2330
+ * difference, and it is open rather than settled: the earmark's treatment is
2331
+ * being revisited as its own design decision. See `contract.md` §14. */
2332
+ --lutra-input-overflow-mark-size: var(--lutra-space-sm);
2333
+ --lutra-input-overflow-mark: var(--lutra-color-icon-default);
2334
+
2258
2335
  box-sizing: border-box;
2259
2336
  inline-size: 100%;
2260
2337
  max-inline-size: 100%;
@@ -2267,7 +2344,9 @@
2267
2344
  background-color: var(--lutra-input-surface);
2268
2345
  color: var(--lutra-input-text);
2269
2346
  font-family: var(--lutra-input-font-family);
2347
+ font-size: var(--lutra-input-font-size);
2270
2348
  font-weight: var(--lutra-input-font-weight);
2349
+ line-height: var(--lutra-input-line-height);
2271
2350
 
2272
2351
  /* Strips the platform's own field chrome — notably iOS Safari's inner shadow
2273
2352
  * and rounded corners — so the token values are what shows. */
@@ -2275,29 +2354,40 @@
2275
2354
  }
2276
2355
 
2277
2356
  /* Sizes.
2357
+ *
2358
+ * Geometry only — height and inline padding. Type comes from the density axis
2359
+ * above and is identical across the three, which is what Figma encodes.
2278
2360
  *
2279
2361
  * `min-block-size` rather than `height`: it reproduces the Figma height at the
2280
- * default font size and lets the field grow when the reader raises theirs. */
2362
+ * default font size and lets the field grow when the reader raises theirs. That
2363
+ * is also why Condensed cannot shrink a control: it lowers the text size inside
2364
+ * a minimum that density never touches. */
2281
2365
 
2282
2366
  .lutra-input[data-size='sm'] {
2283
2367
  min-block-size: var(--lutra-input-min-height-sm);
2284
- padding-inline: var(--lutra-input-padding-inline-sm);
2285
- font-size: var(--lutra-input-font-size-sm);
2286
- line-height: var(--lutra-input-line-height-sm);
2368
+ padding-inline-start: var(--lutra-input-padding-inline-sm);
2369
+ padding-inline-end: calc(
2370
+ var(--lutra-input-padding-inline-sm) + var(--lutra-input-trailing-reserve) +
2371
+ var(--lutra-input-overflow-reserve)
2372
+ );
2287
2373
  }
2288
2374
 
2289
2375
  .lutra-input[data-size='md'] {
2290
2376
  min-block-size: var(--lutra-input-min-height-md);
2291
- padding-inline: var(--lutra-input-padding-inline-md);
2292
- font-size: var(--lutra-input-font-size-md);
2293
- line-height: var(--lutra-input-line-height-md);
2377
+ padding-inline-start: var(--lutra-input-padding-inline-md);
2378
+ padding-inline-end: calc(
2379
+ var(--lutra-input-padding-inline-md) + var(--lutra-input-trailing-reserve) +
2380
+ var(--lutra-input-overflow-reserve)
2381
+ );
2294
2382
  }
2295
2383
 
2296
2384
  .lutra-input[data-size='lg'] {
2297
2385
  min-block-size: var(--lutra-input-min-height-lg);
2298
- padding-inline: var(--lutra-input-padding-inline-lg);
2299
- font-size: var(--lutra-input-font-size-lg);
2300
- line-height: var(--lutra-input-line-height-lg);
2386
+ padding-inline-start: var(--lutra-input-padding-inline-lg);
2387
+ padding-inline-end: calc(
2388
+ var(--lutra-input-padding-inline-lg) + var(--lutra-input-trailing-reserve) +
2389
+ var(--lutra-input-overflow-reserve)
2390
+ );
2301
2391
  }
2302
2392
 
2303
2393
  /* Placeholder.
@@ -2384,6 +2474,94 @@
2384
2474
  -webkit-text-fill-color: var(--lutra-input-disabled-text);
2385
2475
  }
2386
2476
 
2477
+ /* The overflow earmark.
2478
+ *
2479
+ * A single-line input scrolls its value rather than wrapping, and gives no sign
2480
+ * that it is doing so — `…@an-unusually-long-` looks exactly like a value that
2481
+ * ends there. `data-overflow` is set by the component when part of the value is
2482
+ * outside the visible area, and this draws a small folded corner to say so.
2483
+ *
2484
+ * ## Why a background rather than an element
2485
+ *
2486
+ * An `<input>` is a replaced element and cannot render `::before` or `::after`,
2487
+ * so a mark inside the control is either a background or a sibling in a wrapper.
2488
+ * A background needs no wrapper, no extra node and no change to what Input
2489
+ * renders — and it is decorative by construction: there is nothing to give a
2490
+ * role, nothing to focus, nothing in the accessibility tree, and no way for it
2491
+ * to touch the accessible name or value. Select has a wrapper because its
2492
+ * chevron must be a token-coloured mask; this needs no mask.
2493
+ *
2494
+ * ## The shape
2495
+ *
2496
+ * `linear-gradient(to bottom left, … 50%, transparent 50%)` in a square placed
2497
+ * at the top right. The gradient axis runs toward the bottom-left corner, so the
2498
+ * hard stop at 50% is a line from the square's top-left to its bottom-right and
2499
+ * the filled half is the one nearest the top-right corner. That yields a
2500
+ * triangle whose two flat edges lie along the top and right edges of the field
2501
+ * with the diagonal facing inward, toward the content — a folded corner, not an
2502
+ * arrow pointing out of it.
2503
+ *
2504
+ * The size is `space/sm` (8px), which the field's own `radius/md` corner then
2505
+ * rounds off at the tip. That is deliberate: a mark flush with a rounded corner
2506
+ * has to follow it, and the result reads as a fold rather than a pasted-on
2507
+ * triangle.
2508
+ *
2509
+ * Figma draws the same triangle: an 8x8 `Overflow earmark` vector in all
2510
+ * eighteen variants, pinned to the top-right corner by MAX/MIN constraints,
2511
+ * whose path `M 0 0 L 8 0 L 8 8 L 0 0 Z` has its vertices at the box's
2512
+ * top-left, top-right and bottom-right. Flat top, flat right, diagonal facing
2513
+ * inward — the shape this gradient produces.
2514
+ *
2515
+ * ## Colour — currently disagreeing with Figma
2516
+ *
2517
+ * Figma binds the earmark's fill to the border token of the current status, so
2518
+ * it turns red on an invalid field. This draws it in `color/icon/default` in
2519
+ * every state instead, on the argument that it reports a fact about the viewport
2520
+ * rather than a problem with the value and must not read as a validation
2521
+ * indicator.
2522
+ *
2523
+ * That argument may lose. The earmark's treatment has been identified as too
2524
+ * subtle and is being revisited as its own design decision, and the state
2525
+ * colouring belongs to that decision rather than to this file. **Do not
2526
+ * reconcile it either way here** — `contract.md` §14 records it.
2527
+ *
2528
+ * ## Clearance
2529
+ *
2530
+ * The mark claims `--lutra-input-overflow-reserve`, which the size rules add to
2531
+ * the trailing padding, so the value's scroll area stops before the corner
2532
+ * instead of running under it. The reserve exists only while the mark does, so a
2533
+ * field that fits wastes nothing. Measuring against the reduced width is what
2534
+ * keeps that stable: once the mark is showing, the value has to shrink past the
2535
+ * reserve to lose it, so a value sitting on the boundary cannot flicker.
2536
+ *
2537
+ * Figma's own Value layers are set to `textTruncation: ENDING`, which reserves
2538
+ * no trailing space — its static text is already cut off with an ellipsis before
2539
+ * the corner. The runtime reserves the space because its text *scrolls*, so
2540
+ * without it the value would pass under the mark as the caret moved. See
2541
+ * `contract.md` §6.
2542
+ *
2543
+ * Forced-colours modes drop background images, so the mark is absent there. It
2544
+ * is a discoverability cue rather than information, the value is still reachable
2545
+ * by caret and by assistive technology, and painting a substitute would mean
2546
+ * inventing a border treatment the design system has not got. */
2547
+
2548
+ .lutra-input[data-overflow='true'] {
2549
+ --lutra-input-overflow-reserve: var(--lutra-input-overflow-mark-size);
2550
+
2551
+ background-image: linear-gradient(
2552
+ to bottom left,
2553
+ var(--lutra-input-overflow-mark) 50%,
2554
+ transparent 50%
2555
+ );
2556
+ background-repeat: no-repeat;
2557
+ background-position: top right;
2558
+ background-size: var(--lutra-input-overflow-mark-size) var(--lutra-input-overflow-mark-size);
2559
+ }
2560
+
2561
+ .lutra-input[data-overflow='true']:disabled {
2562
+ --lutra-input-overflow-mark: var(--lutra-input-disabled-text);
2563
+ }
2564
+
2387
2565
  /* Read-only keeps the normal visual treatment. It stays focusable and its value
2388
2566
  * stays selectable and copyable, which is the behaviour that distinguishes it
2389
2567
  * from disabled, so it must not be dressed up to look unavailable. */
@@ -2498,13 +2676,14 @@
2498
2676
  --lutra-select-padding-inline-md: var(--lutra-space-md);
2499
2677
  --lutra-select-padding-inline-lg: var(--lutra-space-lg);
2500
2678
 
2501
- --lutra-select-font-size-sm: var(--lutra-text-body-small);
2502
- --lutra-select-font-size-md: var(--lutra-text-body);
2503
- --lutra-select-font-size-lg: var(--lutra-text-h3);
2504
-
2505
- --lutra-select-line-height-sm: var(--lutra-type-line-height-body-small);
2506
- --lutra-select-line-height-md: var(--lutra-type-line-height-body);
2507
- --lutra-select-line-height-lg: var(--lutra-type-line-height-h3);
2679
+ /* Control text size, from the global density axis rather than from `size` —
2680
+ * exactly as Input does it, and for the same reason: Figma binds
2681
+ * `control/text-size` to the value text of every Select variant, so the
2682
+ * placeholder and the selected value are 16px in Spacious and 14px in
2683
+ * Condensed at all three sizes. Declared here on the control so a nested
2684
+ * `[data-density]` region resolves it. There is no density prop. */
2685
+ --lutra-select-font-size: var(--lutra-control-text-size);
2686
+ --lutra-select-line-height: var(--lutra-type-line-height-body);
2508
2687
 
2509
2688
  /* NO FIGMA VARIABLE. Figma draws 36/40/44px fixed heights; these become `min-block-size` so the
2510
2689
  * control grows with the reader's font size instead of clipping at 200% zoom. */
@@ -2525,15 +2704,20 @@
2525
2704
  background-color: var(--lutra-select-surface);
2526
2705
  color: var(--lutra-select-text);
2527
2706
  font-family: var(--lutra-select-font-family);
2707
+ font-size: var(--lutra-select-font-size);
2528
2708
  font-weight: var(--lutra-select-font-weight);
2709
+ line-height: var(--lutra-select-line-height);
2529
2710
  cursor: pointer;
2530
2711
 
2531
2712
  /* Removes the platform's own dropdown arrow, so the Figma chevron is the only one drawn. */
2532
2713
  appearance: none;
2533
2714
  }
2534
2715
 
2535
- /* Sizes. `min-block-size` rather than `height`, so the control grows with the reader's font size.
2536
- * Trailing padding reserves room for the chevron. */
2716
+ /* Sizes. Geometry only height, inline padding and the chevron reserve. Type comes from the density
2717
+ * axis and is the same across the three.
2718
+ *
2719
+ * `min-block-size` rather than `height`, so the control grows with the reader's font size. Trailing
2720
+ * padding reserves room for the chevron. */
2537
2721
 
2538
2722
  .lutra-select[data-size='sm'] {
2539
2723
  min-block-size: var(--lutra-select-min-height-sm);
@@ -2542,8 +2726,6 @@
2542
2726
  var(--lutra-select-padding-inline-sm) + var(--lutra-select-chevron-size) +
2543
2727
  var(--lutra-select-chevron-gap)
2544
2728
  );
2545
- font-size: var(--lutra-select-font-size-sm);
2546
- line-height: var(--lutra-select-line-height-sm);
2547
2729
  }
2548
2730
 
2549
2731
  .lutra-select[data-size='md'] {
@@ -2553,8 +2735,6 @@
2553
2735
  var(--lutra-select-padding-inline-md) + var(--lutra-select-chevron-size) +
2554
2736
  var(--lutra-select-chevron-gap)
2555
2737
  );
2556
- font-size: var(--lutra-select-font-size-md);
2557
- line-height: var(--lutra-select-line-height-md);
2558
2738
  }
2559
2739
 
2560
2740
  .lutra-select[data-size='lg'] {
@@ -2564,8 +2744,6 @@
2564
2744
  var(--lutra-select-padding-inline-lg) + var(--lutra-select-chevron-size) +
2565
2745
  var(--lutra-select-chevron-gap)
2566
2746
  );
2567
- font-size: var(--lutra-select-font-size-lg);
2568
- line-height: var(--lutra-select-line-height-lg);
2569
2747
  }
2570
2748
 
2571
2749
  /* The chevron sits at the same inset as the control's own padding, per size. */
@@ -3177,18 +3355,28 @@
3177
3355
  * These classes are shared: `Field` and `SelectField` render the same shell around different
3178
3356
  * controls, so both use `.lutra-field*`. The control inside brings its own styling.
3179
3357
  *
3180
- * Order is label → hintcontrol → error, expressed as source order in a vertical stack rather
3358
+ * Order is label → controlhint → error, expressed as source order in a vertical stack rather
3181
3359
  * than by any reordering property, so the visual order and the DOM order — which is the order a
3182
- * screen reader and the Tab key follow — are the same thing.
3183
- *
3184
- * Figma composes the field with 4px auto-layout spacing and draws its supporting text below the
3185
- * control, because the component models one supporting slot that becomes the error when invalid.
3186
- * The accessibility annotation is the behavioural contract and puts the error below the control
3187
- * while a hint is guidance read before typing, so the implementation splits them. See `field.md`.
3360
+ * screen reader and the Tab key follow — are the same thing. Nothing here uses `order`,
3361
+ * `flex-direction: column-reverse` or `grid-row`, and nothing should: a field whose visual order
3362
+ * differed from its DOM order would read in one sequence and look like another.
3363
+ *
3364
+ * Figma binds the field's auto-layout `itemSpacing` and its supporting text's indent to
3365
+ * `field/spacing`, the global density variable 8px in Spacious, 4px in Condensed. Both come from
3366
+ * the one token here, so the vertical rhythm and the indent cannot drift apart, and neither is a
3367
+ * component property: density is inherited from `data-density`. See `docs/design-tokens.md`.
3368
+ *
3369
+ * Figma draws one supporting slot, below the control, that becomes the error when invalid. Code
3370
+ * keeps hint and error as separate slots because both can be present at once, and renders them in
3371
+ * that order underneath the control. See `field.md`.
3188
3372
  */
3189
3373
 
3190
3374
  .lutra-field {
3191
- --lutra-field-gap: var(--lutra-space-xs);
3375
+ /* Density-owned. Declared on the field rather than inherited pre-resolved from `:root`, so a
3376
+ * `[data-density]` region anywhere above it re-substitutes and takes effect. */
3377
+ --lutra-field-gap: var(--lutra-field-spacing);
3378
+ --lutra-field-support-indent: var(--lutra-field-spacing);
3379
+
3192
3380
  --lutra-field-label-color: var(--lutra-color-text-primary);
3193
3381
  --lutra-field-label-size: var(--lutra-text-body-small);
3194
3382
  --lutra-field-label-line-height: var(--lutra-type-line-height-body-small);
@@ -3214,7 +3402,8 @@
3214
3402
 
3215
3403
  /* Normal inline flow rather than flex, so the indicator wraps inline with the label text the way
3216
3404
  * the annotation describes, and so the space between them is a real text space that the accessible
3217
- * name includes. A flex `gap` would look right and read as "Email addressRequired". */
3405
+ * name includes. A flex `gap` would look right and read as "Email address(required)". Figma builds
3406
+ * the same result with a `Label Row` frame set to WRAP; the DOM gets it from text flow. */
3218
3407
 
3219
3408
  .lutra-field__label {
3220
3409
  display: block;
@@ -3241,9 +3430,20 @@
3241
3430
  color: var(--lutra-field-indicator-required-color);
3242
3431
  }
3243
3432
 
3433
+ /* Supporting text.
3434
+ *
3435
+ * The indent sets the supporting and error text in from the label and the control edge, which is
3436
+ * what Figma draws. Figma expresses it as `paragraphIndent` because a Figma TEXT node has no
3437
+ * padding; `padding-inline-start` is the runtime equivalent and is deliberately not `text-indent`,
3438
+ * which would indent only the first line and leave a wrapped error message ragged against its own
3439
+ * left edge. Logical property, so it flips with the writing mode.
3440
+ *
3441
+ * Nothing here sets a height or prevents wrapping — a long message grows the field. */
3442
+
3244
3443
  .lutra-field__hint,
3245
3444
  .lutra-field__error {
3246
3445
  margin: 0;
3446
+ padding-inline-start: var(--lutra-field-support-indent);
3247
3447
  font-size: var(--lutra-field-label-size);
3248
3448
  line-height: var(--lutra-field-label-line-height);
3249
3449
  font-weight: var(--lutra-field-hint-weight);
package/dist/tokens.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "figma": {
4
4
  "fileKey": "3I29aswJJqsIwh8jO1uBje",
5
5
  "fileName": "Lutra UI",
6
- "readAt": "2026-08-09 (dark danger ramp resynced after the contrast study)"
6
+ "readAt": "2026-09-02 (Lutra Density collection added; Spacious is the default mode)"
7
7
  },
8
8
  "tokens": {
9
9
  "--lutra-color-background-canvas": {
@@ -996,6 +996,20 @@
996
996
  "figmaName": "layout/section/sm",
997
997
  "value": "4rem",
998
998
  "themed": false
999
+ },
1000
+ "--lutra-control-text-size": {
1001
+ "figmaName": "control/text-size",
1002
+ "spacious": "1rem",
1003
+ "condensed": "0.875rem",
1004
+ "themed": false,
1005
+ "density": true
1006
+ },
1007
+ "--lutra-field-spacing": {
1008
+ "figmaName": "field/spacing",
1009
+ "spacious": "0.5rem",
1010
+ "condensed": "0.25rem",
1011
+ "themed": false,
1012
+ "density": true
999
1013
  }
1000
1014
  }
1001
1015
  }
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@lutra-ui-system/react",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Accessible React components and design tokens for Lutra UI",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/my-h-duong/lutra-ui.git",
8
+ "url": "git+https://github.com/my-h-duong-labs/lutra-ui.git",
9
9
  "directory": "packages/react"
10
10
  },
11
- "homepage": "https://github.com/my-h-duong/lutra-ui/tree/main/packages/react#readme",
11
+ "homepage": "https://github.com/my-h-duong-labs/lutra-ui/tree/main/packages/react#readme",
12
12
  "bugs": {
13
- "url": "https://github.com/my-h-duong/lutra-ui/issues"
13
+ "url": "https://github.com/my-h-duong-labs/lutra-ui/issues"
14
14
  },
15
15
  "keywords": [
16
16
  "lutra-ui",
@@ -47,13 +47,15 @@
47
47
  "scripts": {
48
48
  "build": "node ./scripts/clean-dist.mjs && node ./scripts/generate-tokens.mjs && tsc --project tsconfig.build.json && node ./scripts/build-assets.mjs",
49
49
  "generate:tokens": "node ./scripts/generate-tokens.mjs",
50
+ "generate:health": "node ./scripts/generate-component-health.mjs",
50
51
  "pack:verify": "node ./scripts/verify-pack.mjs",
51
52
  "typecheck": "tsc --project tsconfig.json --noEmit",
52
53
  "test": "vitest run",
53
54
  "test:watch": "vitest",
54
55
  "lint": "eslint .",
55
56
  "storybook": "storybook dev --port 6006 --no-open",
56
- "build-storybook": "storybook build"
57
+ "build-storybook": "storybook build",
58
+ "storybook:verify": "node ./scripts/verify-manager-bundle.mjs"
57
59
  },
58
60
  "peerDependencies": {
59
61
  "react": "^18.3.0 || ^19.0.0",