@lutra-ui-system/react 0.1.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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -0
  3. package/dist/components/button/button.d.ts +105 -0
  4. package/dist/components/button/button.d.ts.map +1 -0
  5. package/dist/components/button/button.js +91 -0
  6. package/dist/components/button/button.js.map +1 -0
  7. package/dist/components/button/index.d.ts +3 -0
  8. package/dist/components/button/index.d.ts.map +1 -0
  9. package/dist/components/button/index.js +2 -0
  10. package/dist/components/button/index.js.map +1 -0
  11. package/dist/components/card/card.d.ts +100 -0
  12. package/dist/components/card/card.d.ts.map +1 -0
  13. package/dist/components/card/card.js +51 -0
  14. package/dist/components/card/card.js.map +1 -0
  15. package/dist/components/card/index.d.ts +3 -0
  16. package/dist/components/card/index.d.ts.map +1 -0
  17. package/dist/components/card/index.js +2 -0
  18. package/dist/components/card/index.js.map +1 -0
  19. package/dist/components/checkbox/checkbox.d.ts +67 -0
  20. package/dist/components/checkbox/checkbox.d.ts.map +1 -0
  21. package/dist/components/checkbox/checkbox.js +63 -0
  22. package/dist/components/checkbox/checkbox.js.map +1 -0
  23. package/dist/components/checkbox/index.d.ts +3 -0
  24. package/dist/components/checkbox/index.d.ts.map +1 -0
  25. package/dist/components/checkbox/index.js +2 -0
  26. package/dist/components/checkbox/index.js.map +1 -0
  27. package/dist/components/dialog/dialog.d.ts +88 -0
  28. package/dist/components/dialog/dialog.d.ts.map +1 -0
  29. package/dist/components/dialog/dialog.js +156 -0
  30. package/dist/components/dialog/dialog.js.map +1 -0
  31. package/dist/components/dialog/index.d.ts +3 -0
  32. package/dist/components/dialog/index.d.ts.map +1 -0
  33. package/dist/components/dialog/index.js +2 -0
  34. package/dist/components/dialog/index.js.map +1 -0
  35. package/dist/components/dropdown/dropdown.d.ts +89 -0
  36. package/dist/components/dropdown/dropdown.d.ts.map +1 -0
  37. package/dist/components/dropdown/dropdown.js +173 -0
  38. package/dist/components/dropdown/dropdown.js.map +1 -0
  39. package/dist/components/dropdown/index.d.ts +3 -0
  40. package/dist/components/dropdown/index.d.ts.map +1 -0
  41. package/dist/components/dropdown/index.js +2 -0
  42. package/dist/components/dropdown/index.js.map +1 -0
  43. package/dist/components/error-summary/error-summary.d.ts +81 -0
  44. package/dist/components/error-summary/error-summary.d.ts.map +1 -0
  45. package/dist/components/error-summary/error-summary.js +85 -0
  46. package/dist/components/error-summary/error-summary.js.map +1 -0
  47. package/dist/components/error-summary/index.d.ts +3 -0
  48. package/dist/components/error-summary/index.d.ts.map +1 -0
  49. package/dist/components/error-summary/index.js +2 -0
  50. package/dist/components/error-summary/index.js.map +1 -0
  51. package/dist/components/field/field.d.ts +55 -0
  52. package/dist/components/field/field.d.ts.map +1 -0
  53. package/dist/components/field/field.js +26 -0
  54. package/dist/components/field/field.js.map +1 -0
  55. package/dist/components/field/index.d.ts +3 -0
  56. package/dist/components/field/index.d.ts.map +1 -0
  57. package/dist/components/field/index.js +2 -0
  58. package/dist/components/field/index.js.map +1 -0
  59. package/dist/components/field-group/field-group.d.ts +67 -0
  60. package/dist/components/field-group/field-group.d.ts.map +1 -0
  61. package/dist/components/field-group/field-group.js +38 -0
  62. package/dist/components/field-group/field-group.js.map +1 -0
  63. package/dist/components/field-group/index.d.ts +3 -0
  64. package/dist/components/field-group/index.d.ts.map +1 -0
  65. package/dist/components/field-group/index.js +2 -0
  66. package/dist/components/field-group/index.js.map +1 -0
  67. package/dist/components/input/index.d.ts +3 -0
  68. package/dist/components/input/index.d.ts.map +1 -0
  69. package/dist/components/input/index.js +2 -0
  70. package/dist/components/input/index.js.map +1 -0
  71. package/dist/components/input/input.d.ts +96 -0
  72. package/dist/components/input/input.d.ts.map +1 -0
  73. package/dist/components/input/input.js +26 -0
  74. package/dist/components/input/input.js.map +1 -0
  75. package/dist/components/link/index.d.ts +3 -0
  76. package/dist/components/link/index.d.ts.map +1 -0
  77. package/dist/components/link/index.js +2 -0
  78. package/dist/components/link/index.js.map +1 -0
  79. package/dist/components/link/link.d.ts +44 -0
  80. package/dist/components/link/link.d.ts.map +1 -0
  81. package/dist/components/link/link.js +26 -0
  82. package/dist/components/link/link.js.map +1 -0
  83. package/dist/components/link-card/index.d.ts +3 -0
  84. package/dist/components/link-card/index.d.ts.map +1 -0
  85. package/dist/components/link-card/index.js +2 -0
  86. package/dist/components/link-card/index.js.map +1 -0
  87. package/dist/components/link-card/link-card.d.ts +62 -0
  88. package/dist/components/link-card/link-card.d.ts.map +1 -0
  89. package/dist/components/link-card/link-card.js +43 -0
  90. package/dist/components/link-card/link-card.js.map +1 -0
  91. package/dist/components/mobile-navigation/index.d.ts +3 -0
  92. package/dist/components/mobile-navigation/index.d.ts.map +1 -0
  93. package/dist/components/mobile-navigation/index.js +2 -0
  94. package/dist/components/mobile-navigation/index.js.map +1 -0
  95. package/dist/components/mobile-navigation/mobile-navigation.d.ts +88 -0
  96. package/dist/components/mobile-navigation/mobile-navigation.d.ts.map +1 -0
  97. package/dist/components/mobile-navigation/mobile-navigation.js +153 -0
  98. package/dist/components/mobile-navigation/mobile-navigation.js.map +1 -0
  99. package/dist/components/navigation-item/index.d.ts +3 -0
  100. package/dist/components/navigation-item/index.d.ts.map +1 -0
  101. package/dist/components/navigation-item/index.js +2 -0
  102. package/dist/components/navigation-item/index.js.map +1 -0
  103. package/dist/components/navigation-item/navigation-item.d.ts +38 -0
  104. package/dist/components/navigation-item/navigation-item.d.ts.map +1 -0
  105. package/dist/components/navigation-item/navigation-item.js +22 -0
  106. package/dist/components/navigation-item/navigation-item.js.map +1 -0
  107. package/dist/components/radio/index.d.ts +3 -0
  108. package/dist/components/radio/index.d.ts.map +1 -0
  109. package/dist/components/radio/index.js +2 -0
  110. package/dist/components/radio/index.js.map +1 -0
  111. package/dist/components/radio/radio.d.ts +58 -0
  112. package/dist/components/radio/radio.d.ts.map +1 -0
  113. package/dist/components/radio/radio.js +37 -0
  114. package/dist/components/radio/radio.js.map +1 -0
  115. package/dist/components/select/index.d.ts +3 -0
  116. package/dist/components/select/index.d.ts.map +1 -0
  117. package/dist/components/select/index.js +2 -0
  118. package/dist/components/select/index.js.map +1 -0
  119. package/dist/components/select/select.d.ts +67 -0
  120. package/dist/components/select/select.d.ts.map +1 -0
  121. package/dist/components/select/select.js +25 -0
  122. package/dist/components/select/select.js.map +1 -0
  123. package/dist/components/select-field/index.d.ts +3 -0
  124. package/dist/components/select-field/index.d.ts.map +1 -0
  125. package/dist/components/select-field/index.js +2 -0
  126. package/dist/components/select-field/index.js.map +1 -0
  127. package/dist/components/select-field/select-field.d.ts +43 -0
  128. package/dist/components/select-field/select-field.d.ts.map +1 -0
  129. package/dist/components/select-field/select-field.js +24 -0
  130. package/dist/components/select-field/select-field.js.map +1 -0
  131. package/dist/components/table/index.d.ts +3 -0
  132. package/dist/components/table/index.d.ts.map +1 -0
  133. package/dist/components/table/index.js +2 -0
  134. package/dist/components/table/index.js.map +1 -0
  135. package/dist/components/table/table.d.ts +251 -0
  136. package/dist/components/table/table.d.ts.map +1 -0
  137. package/dist/components/table/table.js +209 -0
  138. package/dist/components/table/table.js.map +1 -0
  139. package/dist/components/toggle/index.d.ts +3 -0
  140. package/dist/components/toggle/index.d.ts.map +1 -0
  141. package/dist/components/toggle/index.js +2 -0
  142. package/dist/components/toggle/index.js.map +1 -0
  143. package/dist/components/toggle/toggle.d.ts +66 -0
  144. package/dist/components/toggle/toggle.d.ts.map +1 -0
  145. package/dist/components/toggle/toggle.js +49 -0
  146. package/dist/components/toggle/toggle.js.map +1 -0
  147. package/dist/components/tooltip/index.d.ts +3 -0
  148. package/dist/components/tooltip/index.d.ts.map +1 -0
  149. package/dist/components/tooltip/index.js +2 -0
  150. package/dist/components/tooltip/index.js.map +1 -0
  151. package/dist/components/tooltip/tooltip.d.ts +74 -0
  152. package/dist/components/tooltip/tooltip.d.ts.map +1 -0
  153. package/dist/components/tooltip/tooltip.js +180 -0
  154. package/dist/components/tooltip/tooltip.js.map +1 -0
  155. package/dist/index.d.ts +57 -0
  156. package/dist/index.d.ts.map +1 -0
  157. package/dist/index.js +38 -0
  158. package/dist/index.js.map +1 -0
  159. package/dist/internal/development.d.ts +2 -0
  160. package/dist/internal/development.d.ts.map +1 -0
  161. package/dist/internal/development.js +2 -0
  162. package/dist/internal/development.js.map +1 -0
  163. package/dist/internal/field-support.d.ts +68 -0
  164. package/dist/internal/field-support.d.ts.map +1 -0
  165. package/dist/internal/field-support.js +42 -0
  166. package/dist/internal/field-support.js.map +1 -0
  167. package/dist/internal/isomorphic-layout-effect.d.ts +26 -0
  168. package/dist/internal/isomorphic-layout-effect.d.ts.map +1 -0
  169. package/dist/internal/isomorphic-layout-effect.js +26 -0
  170. package/dist/internal/isomorphic-layout-effect.js.map +1 -0
  171. package/dist/styles.css +3474 -0
  172. package/dist/tokens.json +995 -0
  173. package/package.json +76 -0
@@ -0,0 +1,3474 @@
1
+ /*
2
+ * @lutra-ui-system/react stylesheet.
3
+ *
4
+ * Plain, standards-based CSS shipped as a single file. Consumers import it once:
5
+ *
6
+ * import '@lutra-ui-system/react/styles.css';
7
+ *
8
+ * This file is the source-side entry point and is composed with plain `@import`.
9
+ * The build inlines those imports, so what ships in `dist/styles.css` is one flat
10
+ * stylesheet with no `@import` left in it. That matters: a distributed stylesheet
11
+ * that still contained `@import` would either need the consumer's bundler to
12
+ * resolve it or would cost a second network request, and neither is something a
13
+ * library should require. See `scripts/build-styles.mjs`.
14
+ *
15
+ * The file has two layers, and the order matters:
16
+ *
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.
20
+ * 2. One file per component, which reads those tokens and never a raw value.
21
+ *
22
+ * Rules for anything added here:
23
+ * - Every selector is prefixed with `lutra-`, so the library cannot collide with
24
+ * 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`.
30
+ * - No component rule may reach for `:root`, an element selector, or `*`.
31
+ * - Sizes use `rem` and unitless line heights, so the library respects the user's
32
+ * browser font size and keeps working under zoom. Border and outline widths are
33
+ * the exception and stay in `px`; see the unit policy in `docs/design-tokens.md`.
34
+ * - No rule may convey state through colour alone.
35
+ * - Text must be allowed to wrap rather than overflow or clip.
36
+ */
37
+
38
+ /*
39
+ * GENERATED FILE — DO NOT EDIT.
40
+ *
41
+ * Produced by `scripts/generate-tokens.mjs` from `src/tokens/source.json`.
42
+ * Change a value in `source.json` and regenerate; an edit here is overwritten by the next build
43
+ * and is caught by the drift check in `src/tokens/tokens.test.ts`.
44
+ *
45
+ * See `docs/design-tokens.md` for the naming rule, the unit policy, and the theme contract.
46
+ */
47
+ :root {
48
+ /* Lutra Theme */
49
+ --lutra-color-background-canvas: #f5f7fa;
50
+ --lutra-color-background-disabled: #e5e7eb;
51
+ --lutra-color-background-elevated: #ffffff;
52
+ --lutra-color-background-subtle: #f5f7fa;
53
+ --lutra-color-background-subtle-active: #dfe5ed;
54
+ --lutra-color-background-subtle-hover: #eaeef4;
55
+ --lutra-color-background-surface: #ffffff;
56
+ --lutra-color-background-transparent: #ffffff00;
57
+ --lutra-color-border-default: #d0d7de;
58
+ --lutra-color-border-disabled: #d1d5db;
59
+ --lutra-color-border-strong: #7f8896;
60
+ --lutra-color-border-subtle: #e8ecf2;
61
+ --lutra-color-border-transparent: #ffffff00;
62
+ --lutra-color-brand-primary: #3265d3;
63
+ --lutra-color-brand-primary-active: #1f4087;
64
+ --lutra-color-brand-primary-hover: #2852ac;
65
+ --lutra-color-feedback-danger: #d14343;
66
+ --lutra-color-feedback-danger-active: #942c2c;
67
+ --lutra-color-feedback-danger-hover: #b33636;
68
+ --lutra-color-feedback-danger-text: #c73f3f;
69
+ --lutra-color-focus-ring: #3f7cff;
70
+ --lutra-color-icon-default: var(--lutra-color-text-primary);
71
+ --lutra-color-link-text: var(--lutra-color-brand-primary);
72
+ --lutra-color-text-disabled: #9ca3af;
73
+ --lutra-color-text-inverse: #ffffff;
74
+ --lutra-color-text-muted: #6b7280;
75
+ --lutra-color-text-on-brand: #ffffff;
76
+ --lutra-color-text-on-danger: #ffffff;
77
+ --lutra-color-text-primary: #111827;
78
+ --lutra-color-text-secondary: #6b7280;
79
+ --lutra-component-button-danger-border: var(--lutra-color-feedback-danger);
80
+ --lutra-component-button-danger-surface: var(--lutra-color-feedback-danger);
81
+ --lutra-component-button-danger-surface-active: var(--lutra-color-feedback-danger-active);
82
+ --lutra-component-button-danger-surface-hover: var(--lutra-color-feedback-danger-hover);
83
+ --lutra-component-button-danger-text: var(--lutra-color-text-on-danger);
84
+ --lutra-component-button-disabled-border: var(--lutra-color-border-disabled);
85
+ --lutra-component-button-disabled-surface: var(--lutra-color-background-disabled);
86
+ --lutra-component-button-disabled-text: var(--lutra-color-text-disabled);
87
+ --lutra-component-button-focus: var(--lutra-color-focus-ring);
88
+ --lutra-component-button-primary-border: var(--lutra-color-brand-primary);
89
+ --lutra-component-button-primary-surface: var(--lutra-color-brand-primary);
90
+ --lutra-component-button-primary-surface-active: var(--lutra-color-brand-primary-active);
91
+ --lutra-component-button-primary-surface-hover: var(--lutra-color-brand-primary-hover);
92
+ --lutra-component-button-primary-text: var(--lutra-color-text-on-brand);
93
+ --lutra-component-button-secondary-border: var(--lutra-color-border-strong);
94
+ --lutra-component-button-secondary-surface: var(--lutra-color-background-subtle);
95
+ --lutra-component-button-secondary-surface-active: var(--lutra-color-background-subtle-active);
96
+ --lutra-component-button-secondary-surface-hover: var(--lutra-color-background-subtle-hover);
97
+ --lutra-component-button-secondary-text: var(--lutra-color-text-primary);
98
+ --lutra-component-checkbox-border-default: var(--lutra-component-input-border-default);
99
+ --lutra-component-checkbox-border-disabled: var(--lutra-color-border-disabled);
100
+ --lutra-component-checkbox-border-hover: var(--lutra-component-input-border-hover);
101
+ --lutra-component-checkbox-border-invalid: var(--lutra-component-input-border-invalid);
102
+ --lutra-component-checkbox-description: var(--lutra-color-text-secondary);
103
+ --lutra-component-checkbox-error-text: var(--lutra-component-field-error-text);
104
+ --lutra-component-checkbox-fill-disabled: var(--lutra-color-background-disabled);
105
+ --lutra-component-checkbox-fill-invalid: var(--lutra-color-feedback-danger);
106
+ --lutra-component-checkbox-fill-selected: var(--lutra-color-brand-primary);
107
+ --lutra-component-checkbox-fill-selected-hover: var(--lutra-color-brand-primary-hover);
108
+ --lutra-component-checkbox-label: var(--lutra-color-text-primary);
109
+ --lutra-component-checkbox-mark: var(--lutra-color-text-on-brand);
110
+ --lutra-component-checkbox-mark-on-danger: var(--lutra-color-text-on-danger);
111
+ --lutra-component-checkbox-surface: var(--lutra-color-background-surface);
112
+ --lutra-component-checkbox-text-disabled: var(--lutra-color-text-disabled);
113
+ --lutra-component-content-accent: var(--lutra-color-brand-primary);
114
+ --lutra-component-content-body: var(--lutra-color-text-secondary);
115
+ --lutra-component-content-border: var(--lutra-color-border-default);
116
+ --lutra-component-content-icon: var(--lutra-color-icon-default);
117
+ --lutra-component-content-surface: var(--lutra-color-background-surface);
118
+ --lutra-component-content-surface-subtle: var(--lutra-color-background-subtle);
119
+ --lutra-component-content-title: var(--lutra-color-text-primary);
120
+ --lutra-component-field-error-text: var(--lutra-color-feedback-danger-text);
121
+ --lutra-component-input-border-default: var(--lutra-color-border-strong);
122
+ --lutra-component-input-border-focus: var(--lutra-color-focus-ring);
123
+ --lutra-component-input-border-hover: var(--lutra-color-brand-primary-hover);
124
+ --lutra-component-input-border-invalid: var(--lutra-color-feedback-danger);
125
+ --lutra-component-input-placeholder: var(--lutra-color-text-secondary);
126
+ --lutra-component-input-surface: var(--lutra-color-background-surface);
127
+ --lutra-component-input-text: var(--lutra-color-text-primary);
128
+ --lutra-component-link-text-default: var(--lutra-color-link-text);
129
+ --lutra-component-link-text-focus: var(--lutra-color-link-text);
130
+ --lutra-component-link-text-hover: var(--lutra-color-link-text);
131
+ --lutra-component-link-text-visited: var(--lutra-color-text-secondary);
132
+ --lutra-component-menu-border: var(--lutra-component-input-border-default);
133
+ --lutra-component-menu-divider: var(--lutra-component-input-border-default);
134
+ --lutra-component-menu-item-danger: var(--lutra-component-field-error-text);
135
+ --lutra-component-menu-item-hover: var(--lutra-color-background-subtle);
136
+ --lutra-component-menu-item-text: var(--lutra-color-text-primary);
137
+ --lutra-component-menu-surface: var(--lutra-color-background-elevated);
138
+ --lutra-component-modal-body: var(--lutra-color-text-secondary);
139
+ --lutra-component-modal-border: var(--lutra-component-input-border-default);
140
+ --lutra-component-modal-divider: var(--lutra-component-input-border-default);
141
+ --lutra-component-modal-scrim: #0f172aa3;
142
+ --lutra-component-modal-surface: var(--lutra-color-background-elevated);
143
+ --lutra-component-modal-title: var(--lutra-color-text-primary);
144
+ --lutra-component-navigation-border: var(--lutra-color-border-default);
145
+ --lutra-component-navigation-focus: var(--lutra-color-focus-ring);
146
+ --lutra-component-navigation-item-indicator: var(--lutra-color-brand-primary);
147
+ --lutra-component-navigation-item-text: var(--lutra-color-text-primary);
148
+ --lutra-component-navigation-surface: var(--lutra-color-background-surface);
149
+ --lutra-component-publishing-accent: var(--lutra-color-brand-primary);
150
+ --lutra-component-publishing-body: var(--lutra-color-text-secondary);
151
+ --lutra-component-publishing-border: var(--lutra-color-border-default);
152
+ --lutra-component-publishing-code-surface: var(--lutra-color-background-elevated);
153
+ --lutra-component-publishing-metadata: var(--lutra-color-text-muted);
154
+ --lutra-component-publishing-surface: var(--lutra-color-background-surface);
155
+ --lutra-component-publishing-surface-subtle: var(--lutra-color-background-subtle);
156
+ --lutra-component-publishing-title: var(--lutra-color-text-primary);
157
+ --lutra-component-radio-border-default: var(--lutra-component-checkbox-border-default);
158
+ --lutra-component-radio-border-disabled: var(--lutra-component-checkbox-border-disabled);
159
+ --lutra-component-radio-border-hover: var(--lutra-component-checkbox-border-hover);
160
+ --lutra-component-radio-border-invalid: var(--lutra-component-checkbox-border-invalid);
161
+ --lutra-component-radio-description: var(--lutra-component-checkbox-description);
162
+ --lutra-component-radio-error-text: var(--lutra-component-checkbox-error-text);
163
+ --lutra-component-radio-fill-disabled: var(--lutra-component-checkbox-fill-disabled);
164
+ --lutra-component-radio-fill-invalid: var(--lutra-component-checkbox-fill-invalid);
165
+ --lutra-component-radio-fill-selected: var(--lutra-component-checkbox-fill-selected);
166
+ --lutra-component-radio-fill-selected-hover: var(--lutra-component-checkbox-fill-selected-hover);
167
+ --lutra-component-radio-label: var(--lutra-component-checkbox-label);
168
+ --lutra-component-radio-mark: var(--lutra-component-checkbox-mark);
169
+ --lutra-component-radio-mark-on-danger: var(--lutra-component-checkbox-mark-on-danger);
170
+ --lutra-component-radio-surface: var(--lutra-component-checkbox-surface);
171
+ --lutra-component-radio-text-disabled: var(--lutra-component-checkbox-text-disabled);
172
+ --lutra-component-toggle-border-default: var(--lutra-component-input-border-default);
173
+ --lutra-component-toggle-border-hover: var(--lutra-component-input-border-hover);
174
+ --lutra-component-toggle-border-invalid: var(--lutra-component-input-border-invalid);
175
+ --lutra-component-toggle-description: var(--lutra-color-text-secondary);
176
+ --lutra-component-toggle-error-text: var(--lutra-component-field-error-text);
177
+ --lutra-component-toggle-focus: var(--lutra-color-focus-ring);
178
+ --lutra-component-toggle-label: var(--lutra-color-text-primary);
179
+ --lutra-component-toggle-thumb-disabled: var(--lutra-color-text-disabled);
180
+ --lutra-component-toggle-thumb-off: var(--lutra-color-text-secondary);
181
+ --lutra-component-toggle-thumb-on: var(--lutra-color-text-on-brand);
182
+ --lutra-component-toggle-track-disabled: var(--lutra-color-background-disabled);
183
+ --lutra-component-toggle-track-off: var(--lutra-component-checkbox-surface);
184
+ --lutra-component-toggle-track-on: var(--lutra-component-checkbox-fill-selected);
185
+ --lutra-component-toggle-track-on-hover: var(--lutra-component-checkbox-fill-selected-hover);
186
+ --lutra-component-tooltip-border: var(--lutra-color-text-primary);
187
+ --lutra-component-tooltip-surface: var(--lutra-color-text-primary);
188
+ --lutra-component-tooltip-text: var(--lutra-color-background-canvas);
189
+
190
+ /* Lutra Typography */
191
+ --lutra-text-body: 1rem;
192
+ --lutra-text-body-small: 0.875rem;
193
+ --lutra-text-button-lg: 1rem;
194
+ --lutra-text-button-md: 0.875rem;
195
+ --lutra-text-button-sm: 0.8125rem;
196
+ --lutra-text-caption: 0.75rem;
197
+ --lutra-text-code: 0.875rem;
198
+ --lutra-text-display: 2rem;
199
+ --lutra-text-h1: 1.5rem;
200
+ --lutra-text-h2: 1.25rem;
201
+ --lutra-text-h3: 1.125rem;
202
+ --lutra-text-label: 0.8125rem;
203
+ --lutra-type-family-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
204
+ --lutra-type-family-sans: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
205
+ --lutra-type-line-height-body: 1.5;
206
+ --lutra-type-line-height-body-small: 1.4286;
207
+ --lutra-type-line-height-button-lg: 1.5;
208
+ --lutra-type-line-height-button-md: 1.4286;
209
+ --lutra-type-line-height-button-sm: 1.2308;
210
+ --lutra-type-line-height-caption: 1.3333;
211
+ --lutra-type-line-height-code: 1.5714;
212
+ --lutra-type-line-height-display: 1.25;
213
+ --lutra-type-line-height-h1: 1.3333;
214
+ --lutra-type-line-height-h2: 1.4;
215
+ --lutra-type-line-height-h3: 1.3333;
216
+ --lutra-type-line-height-label: 1.2308;
217
+ --lutra-type-weight-bold: 700;
218
+ --lutra-type-weight-medium: 500;
219
+ --lutra-type-weight-regular: 400;
220
+ --lutra-type-weight-semibold: 600;
221
+
222
+ /* Lutra Spacing */
223
+ --lutra-border-width-thick: 2px;
224
+ --lutra-border-width-thin: 1px;
225
+ --lutra-radius-full: 999px;
226
+ --lutra-radius-lg: 0.75rem;
227
+ --lutra-radius-md: 0.5rem;
228
+ --lutra-radius-none: 0;
229
+ --lutra-radius-sm: 0.25rem;
230
+ --lutra-space-2xl: 1.5rem;
231
+ --lutra-space-2xs: 0.125rem;
232
+ --lutra-space-3xl: 2rem;
233
+ --lutra-space-lg: 1rem;
234
+ --lutra-space-md: 0.75rem;
235
+ --lutra-space-sm: 0.5rem;
236
+ --lutra-space-xl: 1.25rem;
237
+ --lutra-space-xs: 0.25rem;
238
+
239
+ /* Lutra Layout */
240
+ --lutra-layout-article-max: 45rem;
241
+ --lutra-layout-container-max: 75rem;
242
+ --lutra-layout-gutter-desktop: 2rem;
243
+ --lutra-layout-gutter-mobile: 1rem;
244
+ --lutra-layout-gutter-tablet: 1.5rem;
245
+ --lutra-layout-section-lg: 6rem;
246
+ --lutra-layout-section-sm: 4rem;
247
+ }
248
+
249
+ /* Dark mode. Every token whose value differs in dark, plus every alias that reaches one — an
250
+ * alias is substituted where it is declared, so it has to be re-declared here to resolve
251
+ * against dark values rather than inheriting the light result from :root. */
252
+ [data-theme='dark'] {
253
+ --lutra-color-background-canvas: #0f172a;
254
+ --lutra-color-background-disabled: #1f2937;
255
+ --lutra-color-background-elevated: #1f2937;
256
+ --lutra-color-background-subtle: #1f2937;
257
+ --lutra-color-background-subtle-active: #354256;
258
+ --lutra-color-background-subtle-hover: #2a3646;
259
+ --lutra-color-background-surface: #111827;
260
+ --lutra-color-border-default: #374151;
261
+ --lutra-color-border-disabled: #374151;
262
+ --lutra-color-border-strong: #6b7688;
263
+ --lutra-color-border-subtle: #2d3748;
264
+ --lutra-color-brand-primary: #3a6ee0;
265
+ --lutra-color-feedback-danger: #ff5c8a;
266
+ --lutra-color-feedback-danger-active: #ff9ab8;
267
+ --lutra-color-feedback-danger-hover: #ff7ba1;
268
+ --lutra-color-feedback-danger-text: #ff8fab;
269
+ --lutra-color-icon-default: var(--lutra-color-text-primary);
270
+ --lutra-color-link-text: #7aa2ff;
271
+ --lutra-color-text-disabled: #6b7280;
272
+ --lutra-color-text-inverse: #111827;
273
+ --lutra-color-text-muted: #9ca3af;
274
+ --lutra-color-text-on-danger: #2b0713;
275
+ --lutra-color-text-primary: #f9fafb;
276
+ --lutra-color-text-secondary: #d1d5db;
277
+ --lutra-component-button-danger-border: var(--lutra-color-feedback-danger);
278
+ --lutra-component-button-danger-surface: var(--lutra-color-feedback-danger);
279
+ --lutra-component-button-danger-surface-active: var(--lutra-color-feedback-danger-active);
280
+ --lutra-component-button-danger-surface-hover: var(--lutra-color-feedback-danger-hover);
281
+ --lutra-component-button-danger-text: var(--lutra-color-text-on-danger);
282
+ --lutra-component-button-disabled-border: var(--lutra-color-border-disabled);
283
+ --lutra-component-button-disabled-surface: var(--lutra-color-background-disabled);
284
+ --lutra-component-button-disabled-text: var(--lutra-color-text-disabled);
285
+ --lutra-component-button-primary-border: var(--lutra-color-brand-primary);
286
+ --lutra-component-button-primary-surface: var(--lutra-color-brand-primary);
287
+ --lutra-component-button-secondary-border: var(--lutra-color-border-strong);
288
+ --lutra-component-button-secondary-surface: var(--lutra-color-background-subtle);
289
+ --lutra-component-button-secondary-surface-active: var(--lutra-color-background-subtle-active);
290
+ --lutra-component-button-secondary-surface-hover: var(--lutra-color-background-subtle-hover);
291
+ --lutra-component-button-secondary-text: var(--lutra-color-text-primary);
292
+ --lutra-component-checkbox-border-default: var(--lutra-component-input-border-default);
293
+ --lutra-component-checkbox-border-disabled: var(--lutra-color-border-disabled);
294
+ --lutra-component-checkbox-border-invalid: var(--lutra-component-input-border-invalid);
295
+ --lutra-component-checkbox-description: var(--lutra-color-text-secondary);
296
+ --lutra-component-checkbox-error-text: var(--lutra-component-field-error-text);
297
+ --lutra-component-checkbox-fill-disabled: var(--lutra-color-background-disabled);
298
+ --lutra-component-checkbox-fill-invalid: var(--lutra-color-feedback-danger);
299
+ --lutra-component-checkbox-fill-selected: var(--lutra-color-brand-primary);
300
+ --lutra-component-checkbox-label: var(--lutra-color-text-primary);
301
+ --lutra-component-checkbox-mark-on-danger: var(--lutra-color-text-on-danger);
302
+ --lutra-component-checkbox-surface: var(--lutra-color-background-surface);
303
+ --lutra-component-checkbox-text-disabled: var(--lutra-color-text-disabled);
304
+ --lutra-component-content-accent: var(--lutra-color-brand-primary);
305
+ --lutra-component-content-body: var(--lutra-color-text-secondary);
306
+ --lutra-component-content-border: var(--lutra-color-border-default);
307
+ --lutra-component-content-icon: var(--lutra-color-icon-default);
308
+ --lutra-component-content-surface: var(--lutra-color-background-surface);
309
+ --lutra-component-content-surface-subtle: var(--lutra-color-background-subtle);
310
+ --lutra-component-content-title: var(--lutra-color-text-primary);
311
+ --lutra-component-field-error-text: var(--lutra-color-feedback-danger-text);
312
+ --lutra-component-input-border-default: var(--lutra-color-border-strong);
313
+ --lutra-component-input-border-invalid: var(--lutra-color-feedback-danger);
314
+ --lutra-component-input-placeholder: var(--lutra-color-text-secondary);
315
+ --lutra-component-input-surface: var(--lutra-color-background-surface);
316
+ --lutra-component-input-text: var(--lutra-color-text-primary);
317
+ --lutra-component-link-text-default: var(--lutra-color-link-text);
318
+ --lutra-component-link-text-focus: var(--lutra-color-link-text);
319
+ --lutra-component-link-text-hover: var(--lutra-color-link-text);
320
+ --lutra-component-link-text-visited: var(--lutra-color-text-secondary);
321
+ --lutra-component-menu-border: var(--lutra-component-input-border-default);
322
+ --lutra-component-menu-divider: var(--lutra-component-input-border-default);
323
+ --lutra-component-menu-item-danger: var(--lutra-component-field-error-text);
324
+ --lutra-component-menu-item-hover: var(--lutra-color-background-subtle);
325
+ --lutra-component-menu-item-text: var(--lutra-color-text-primary);
326
+ --lutra-component-menu-surface: var(--lutra-color-background-elevated);
327
+ --lutra-component-modal-body: var(--lutra-color-text-secondary);
328
+ --lutra-component-modal-border: var(--lutra-component-input-border-default);
329
+ --lutra-component-modal-divider: var(--lutra-component-input-border-default);
330
+ --lutra-component-modal-scrim: #0f172ac2;
331
+ --lutra-component-modal-surface: var(--lutra-color-background-elevated);
332
+ --lutra-component-modal-title: var(--lutra-color-text-primary);
333
+ --lutra-component-navigation-border: var(--lutra-color-border-default);
334
+ --lutra-component-navigation-item-indicator: var(--lutra-color-focus-ring);
335
+ --lutra-component-navigation-item-text: var(--lutra-color-text-primary);
336
+ --lutra-component-navigation-surface: var(--lutra-color-background-surface);
337
+ --lutra-component-publishing-accent: var(--lutra-color-brand-primary);
338
+ --lutra-component-publishing-body: var(--lutra-color-text-secondary);
339
+ --lutra-component-publishing-border: var(--lutra-color-border-default);
340
+ --lutra-component-publishing-code-surface: var(--lutra-color-background-elevated);
341
+ --lutra-component-publishing-metadata: var(--lutra-color-text-muted);
342
+ --lutra-component-publishing-surface: var(--lutra-color-background-surface);
343
+ --lutra-component-publishing-surface-subtle: var(--lutra-color-background-subtle);
344
+ --lutra-component-publishing-title: var(--lutra-color-text-primary);
345
+ --lutra-component-radio-border-default: var(--lutra-component-checkbox-border-default);
346
+ --lutra-component-radio-border-disabled: var(--lutra-component-checkbox-border-disabled);
347
+ --lutra-component-radio-border-invalid: var(--lutra-component-checkbox-border-invalid);
348
+ --lutra-component-radio-description: var(--lutra-component-checkbox-description);
349
+ --lutra-component-radio-error-text: var(--lutra-component-checkbox-error-text);
350
+ --lutra-component-radio-fill-disabled: var(--lutra-component-checkbox-fill-disabled);
351
+ --lutra-component-radio-fill-invalid: var(--lutra-component-checkbox-fill-invalid);
352
+ --lutra-component-radio-fill-selected: var(--lutra-component-checkbox-fill-selected);
353
+ --lutra-component-radio-label: var(--lutra-component-checkbox-label);
354
+ --lutra-component-radio-mark-on-danger: var(--lutra-component-checkbox-mark-on-danger);
355
+ --lutra-component-radio-surface: var(--lutra-component-checkbox-surface);
356
+ --lutra-component-radio-text-disabled: var(--lutra-component-checkbox-text-disabled);
357
+ --lutra-component-toggle-border-default: var(--lutra-component-input-border-default);
358
+ --lutra-component-toggle-border-invalid: var(--lutra-component-input-border-invalid);
359
+ --lutra-component-toggle-description: var(--lutra-color-text-secondary);
360
+ --lutra-component-toggle-error-text: var(--lutra-component-field-error-text);
361
+ --lutra-component-toggle-label: var(--lutra-color-text-primary);
362
+ --lutra-component-toggle-thumb-disabled: var(--lutra-color-text-disabled);
363
+ --lutra-component-toggle-thumb-off: var(--lutra-color-text-secondary);
364
+ --lutra-component-toggle-track-disabled: var(--lutra-color-background-disabled);
365
+ --lutra-component-toggle-track-off: var(--lutra-component-checkbox-surface);
366
+ --lutra-component-toggle-track-on: var(--lutra-component-checkbox-fill-selected);
367
+ --lutra-component-tooltip-border: var(--lutra-color-text-primary);
368
+ --lutra-component-tooltip-surface: var(--lutra-color-text-primary);
369
+ --lutra-component-tooltip-text: var(--lutra-color-background-canvas);
370
+ }
371
+
372
+ /* An explicit light region, so a light island inside a dark one is possible. Same set as above,
373
+ * back at their light values. */
374
+ [data-theme='light'] {
375
+ --lutra-color-background-canvas: #f5f7fa;
376
+ --lutra-color-background-disabled: #e5e7eb;
377
+ --lutra-color-background-elevated: #ffffff;
378
+ --lutra-color-background-subtle: #f5f7fa;
379
+ --lutra-color-background-subtle-active: #dfe5ed;
380
+ --lutra-color-background-subtle-hover: #eaeef4;
381
+ --lutra-color-background-surface: #ffffff;
382
+ --lutra-color-border-default: #d0d7de;
383
+ --lutra-color-border-disabled: #d1d5db;
384
+ --lutra-color-border-strong: #7f8896;
385
+ --lutra-color-border-subtle: #e8ecf2;
386
+ --lutra-color-brand-primary: #3265d3;
387
+ --lutra-color-feedback-danger: #d14343;
388
+ --lutra-color-feedback-danger-active: #942c2c;
389
+ --lutra-color-feedback-danger-hover: #b33636;
390
+ --lutra-color-feedback-danger-text: #c73f3f;
391
+ --lutra-color-icon-default: var(--lutra-color-text-primary);
392
+ --lutra-color-link-text: var(--lutra-color-brand-primary);
393
+ --lutra-color-text-disabled: #9ca3af;
394
+ --lutra-color-text-inverse: #ffffff;
395
+ --lutra-color-text-muted: #6b7280;
396
+ --lutra-color-text-on-danger: #ffffff;
397
+ --lutra-color-text-primary: #111827;
398
+ --lutra-color-text-secondary: #6b7280;
399
+ --lutra-component-button-danger-border: var(--lutra-color-feedback-danger);
400
+ --lutra-component-button-danger-surface: var(--lutra-color-feedback-danger);
401
+ --lutra-component-button-danger-surface-active: var(--lutra-color-feedback-danger-active);
402
+ --lutra-component-button-danger-surface-hover: var(--lutra-color-feedback-danger-hover);
403
+ --lutra-component-button-danger-text: var(--lutra-color-text-on-danger);
404
+ --lutra-component-button-disabled-border: var(--lutra-color-border-disabled);
405
+ --lutra-component-button-disabled-surface: var(--lutra-color-background-disabled);
406
+ --lutra-component-button-disabled-text: var(--lutra-color-text-disabled);
407
+ --lutra-component-button-primary-border: var(--lutra-color-brand-primary);
408
+ --lutra-component-button-primary-surface: var(--lutra-color-brand-primary);
409
+ --lutra-component-button-secondary-border: var(--lutra-color-border-strong);
410
+ --lutra-component-button-secondary-surface: var(--lutra-color-background-subtle);
411
+ --lutra-component-button-secondary-surface-active: var(--lutra-color-background-subtle-active);
412
+ --lutra-component-button-secondary-surface-hover: var(--lutra-color-background-subtle-hover);
413
+ --lutra-component-button-secondary-text: var(--lutra-color-text-primary);
414
+ --lutra-component-checkbox-border-default: var(--lutra-component-input-border-default);
415
+ --lutra-component-checkbox-border-disabled: var(--lutra-color-border-disabled);
416
+ --lutra-component-checkbox-border-invalid: var(--lutra-component-input-border-invalid);
417
+ --lutra-component-checkbox-description: var(--lutra-color-text-secondary);
418
+ --lutra-component-checkbox-error-text: var(--lutra-component-field-error-text);
419
+ --lutra-component-checkbox-fill-disabled: var(--lutra-color-background-disabled);
420
+ --lutra-component-checkbox-fill-invalid: var(--lutra-color-feedback-danger);
421
+ --lutra-component-checkbox-fill-selected: var(--lutra-color-brand-primary);
422
+ --lutra-component-checkbox-label: var(--lutra-color-text-primary);
423
+ --lutra-component-checkbox-mark-on-danger: var(--lutra-color-text-on-danger);
424
+ --lutra-component-checkbox-surface: var(--lutra-color-background-surface);
425
+ --lutra-component-checkbox-text-disabled: var(--lutra-color-text-disabled);
426
+ --lutra-component-content-accent: var(--lutra-color-brand-primary);
427
+ --lutra-component-content-body: var(--lutra-color-text-secondary);
428
+ --lutra-component-content-border: var(--lutra-color-border-default);
429
+ --lutra-component-content-icon: var(--lutra-color-icon-default);
430
+ --lutra-component-content-surface: var(--lutra-color-background-surface);
431
+ --lutra-component-content-surface-subtle: var(--lutra-color-background-subtle);
432
+ --lutra-component-content-title: var(--lutra-color-text-primary);
433
+ --lutra-component-field-error-text: var(--lutra-color-feedback-danger-text);
434
+ --lutra-component-input-border-default: var(--lutra-color-border-strong);
435
+ --lutra-component-input-border-invalid: var(--lutra-color-feedback-danger);
436
+ --lutra-component-input-placeholder: var(--lutra-color-text-secondary);
437
+ --lutra-component-input-surface: var(--lutra-color-background-surface);
438
+ --lutra-component-input-text: var(--lutra-color-text-primary);
439
+ --lutra-component-link-text-default: var(--lutra-color-link-text);
440
+ --lutra-component-link-text-focus: var(--lutra-color-link-text);
441
+ --lutra-component-link-text-hover: var(--lutra-color-link-text);
442
+ --lutra-component-link-text-visited: var(--lutra-color-text-secondary);
443
+ --lutra-component-menu-border: var(--lutra-component-input-border-default);
444
+ --lutra-component-menu-divider: var(--lutra-component-input-border-default);
445
+ --lutra-component-menu-item-danger: var(--lutra-component-field-error-text);
446
+ --lutra-component-menu-item-hover: var(--lutra-color-background-subtle);
447
+ --lutra-component-menu-item-text: var(--lutra-color-text-primary);
448
+ --lutra-component-menu-surface: var(--lutra-color-background-elevated);
449
+ --lutra-component-modal-body: var(--lutra-color-text-secondary);
450
+ --lutra-component-modal-border: var(--lutra-component-input-border-default);
451
+ --lutra-component-modal-divider: var(--lutra-component-input-border-default);
452
+ --lutra-component-modal-scrim: #0f172aa3;
453
+ --lutra-component-modal-surface: var(--lutra-color-background-elevated);
454
+ --lutra-component-modal-title: var(--lutra-color-text-primary);
455
+ --lutra-component-navigation-border: var(--lutra-color-border-default);
456
+ --lutra-component-navigation-item-indicator: var(--lutra-color-brand-primary);
457
+ --lutra-component-navigation-item-text: var(--lutra-color-text-primary);
458
+ --lutra-component-navigation-surface: var(--lutra-color-background-surface);
459
+ --lutra-component-publishing-accent: var(--lutra-color-brand-primary);
460
+ --lutra-component-publishing-body: var(--lutra-color-text-secondary);
461
+ --lutra-component-publishing-border: var(--lutra-color-border-default);
462
+ --lutra-component-publishing-code-surface: var(--lutra-color-background-elevated);
463
+ --lutra-component-publishing-metadata: var(--lutra-color-text-muted);
464
+ --lutra-component-publishing-surface: var(--lutra-color-background-surface);
465
+ --lutra-component-publishing-surface-subtle: var(--lutra-color-background-subtle);
466
+ --lutra-component-publishing-title: var(--lutra-color-text-primary);
467
+ --lutra-component-radio-border-default: var(--lutra-component-checkbox-border-default);
468
+ --lutra-component-radio-border-disabled: var(--lutra-component-checkbox-border-disabled);
469
+ --lutra-component-radio-border-invalid: var(--lutra-component-checkbox-border-invalid);
470
+ --lutra-component-radio-description: var(--lutra-component-checkbox-description);
471
+ --lutra-component-radio-error-text: var(--lutra-component-checkbox-error-text);
472
+ --lutra-component-radio-fill-disabled: var(--lutra-component-checkbox-fill-disabled);
473
+ --lutra-component-radio-fill-invalid: var(--lutra-component-checkbox-fill-invalid);
474
+ --lutra-component-radio-fill-selected: var(--lutra-component-checkbox-fill-selected);
475
+ --lutra-component-radio-label: var(--lutra-component-checkbox-label);
476
+ --lutra-component-radio-mark-on-danger: var(--lutra-component-checkbox-mark-on-danger);
477
+ --lutra-component-radio-surface: var(--lutra-component-checkbox-surface);
478
+ --lutra-component-radio-text-disabled: var(--lutra-component-checkbox-text-disabled);
479
+ --lutra-component-toggle-border-default: var(--lutra-component-input-border-default);
480
+ --lutra-component-toggle-border-invalid: var(--lutra-component-input-border-invalid);
481
+ --lutra-component-toggle-description: var(--lutra-color-text-secondary);
482
+ --lutra-component-toggle-error-text: var(--lutra-component-field-error-text);
483
+ --lutra-component-toggle-label: var(--lutra-color-text-primary);
484
+ --lutra-component-toggle-thumb-disabled: var(--lutra-color-text-disabled);
485
+ --lutra-component-toggle-thumb-off: var(--lutra-color-text-secondary);
486
+ --lutra-component-toggle-track-disabled: var(--lutra-color-background-disabled);
487
+ --lutra-component-toggle-track-off: var(--lutra-component-checkbox-surface);
488
+ --lutra-component-toggle-track-on: var(--lutra-component-checkbox-fill-selected);
489
+ --lutra-component-tooltip-border: var(--lutra-color-text-primary);
490
+ --lutra-component-tooltip-surface: var(--lutra-color-text-primary);
491
+ --lutra-component-tooltip-text: var(--lutra-color-background-canvas);
492
+ }
493
+
494
+ /*
495
+ * Button.
496
+ *
497
+ * The public styling contract is the `lutra-button` class plus the `data-variant`,
498
+ * `data-size`, `data-content-type` and `data-full-width` attributes. Interaction
499
+ * states are selected from the platform — `:hover`, `:active`, `:focus-visible`,
500
+ * `:disabled` — never from a prop.
501
+ *
502
+ * Two token tiers are in play:
503
+ *
504
+ * 1. **Design tokens** — generated from the Figma library into
505
+ * `src/tokens/generated.css` and declared on `:root`. Button never reads a raw
506
+ * value, and never hard-codes a Figma colour as a `var()` fallback: the token
507
+ * layer is always present, because it ships in the same stylesheet.
508
+ *
509
+ * Every colour comes from Figma's own `component/button/*` alias layer, so the
510
+ * design system decides what "the primary surface" is and this file only
511
+ * consumes it. Sizes, spacing and type come from the core scales
512
+ * (`--lutra-space-*`, `--lutra-text-*`, `--lutra-radius-*`,
513
+ * `--lutra-border-width-*`, `--lutra-type-*`), which Figma does not mirror per
514
+ * component.
515
+ * 2. **Component tokens** — `--lutra-button-*`, declared below on the component
516
+ * root. Each one names which design token it takes its value from, so the
517
+ * mapping from the design system to this component is one readable list.
518
+ *
519
+ * Overriding a `--lutra-button-*` property on a Button, or a `--lutra-*` design
520
+ * token at any level above it, retunes the component without touching this file.
521
+ *
522
+ * The handful of values with no Figma variable behind them are marked. They are
523
+ * accessibility floors and drawing details Figma has no way to express, not colours
524
+ * or type someone forgot to tokenise.
525
+ */
526
+
527
+ .lutra-button {
528
+ /* Brand, used by `variant="primary"`. */
529
+ --lutra-button-brand: var(--lutra-component-button-primary-surface);
530
+ --lutra-button-brand-hover: var(--lutra-component-button-primary-surface-hover);
531
+ --lutra-button-brand-active: var(--lutra-component-button-primary-surface-active);
532
+ --lutra-button-brand-border: var(--lutra-component-button-primary-border);
533
+ --lutra-button-on-brand: var(--lutra-component-button-primary-text);
534
+
535
+ /* Danger, used by `variant="danger"`. */
536
+ --lutra-button-danger: var(--lutra-component-button-danger-surface);
537
+ --lutra-button-danger-hover: var(--lutra-component-button-danger-surface-hover);
538
+ --lutra-button-danger-active: var(--lutra-component-button-danger-surface-active);
539
+ --lutra-button-danger-border: var(--lutra-component-button-danger-border);
540
+ --lutra-button-on-danger: var(--lutra-component-button-danger-text);
541
+
542
+ /* Neutral, used by `variant="secondary"`. */
543
+ --lutra-button-neutral: var(--lutra-component-button-secondary-surface);
544
+ --lutra-button-neutral-hover: var(--lutra-component-button-secondary-surface-hover);
545
+ --lutra-button-neutral-active: var(--lutra-component-button-secondary-surface-active);
546
+ --lutra-button-border: var(--lutra-component-button-secondary-border);
547
+ --lutra-button-text: var(--lutra-component-button-secondary-text);
548
+
549
+ /* Disabled. Deliberate colours rather than an opacity wash, so the state is a
550
+ * real visual change and not just a faded version of the enabled button. */
551
+ --lutra-button-disabled-surface: var(--lutra-component-button-disabled-surface);
552
+ --lutra-button-disabled-border: var(--lutra-component-button-disabled-border);
553
+ --lutra-button-disabled-text: var(--lutra-component-button-disabled-text);
554
+
555
+ /* Type. Line heights are unitless ratios, so they follow the font size. */
556
+ --lutra-button-font-family: var(--lutra-type-family-sans);
557
+ --lutra-button-font-weight: var(--lutra-type-weight-medium);
558
+ --lutra-button-font-size-sm: var(--lutra-text-button-sm);
559
+ --lutra-button-font-size-md: var(--lutra-text-button-md);
560
+ --lutra-button-font-size-lg: var(--lutra-text-button-lg);
561
+ --lutra-button-line-height-sm: var(--lutra-type-line-height-button-sm);
562
+ --lutra-button-line-height-md: var(--lutra-type-line-height-button-md);
563
+ --lutra-button-line-height-lg: var(--lutra-type-line-height-button-lg);
564
+
565
+ /* Spacing. */
566
+ --lutra-button-padding-inline-sm: var(--lutra-space-sm);
567
+ --lutra-button-padding-inline-md: var(--lutra-space-lg);
568
+ --lutra-button-padding-inline-lg: var(--lutra-space-xl);
569
+ --lutra-button-gap-sm: var(--lutra-space-xs);
570
+ --lutra-button-gap-md: var(--lutra-space-sm);
571
+ --lutra-button-gap-lg: var(--lutra-space-md);
572
+
573
+ /* Shape. */
574
+ --lutra-button-radius: var(--lutra-radius-md);
575
+ --lutra-button-border-width: var(--lutra-border-width-thin);
576
+
577
+ /* Focus. The ring colour is the one canonical `color/focus/ring`; the width is
578
+ * Figma's thick border. The offset has no Figma variable — Figma documents the
579
+ * ring as a drawn rectangle rather than a value — and it is not cosmetic: it is
580
+ * what puts the ring on the surface *behind* a filled button, where it clears
581
+ * WCAG 1.4.11's 3:1, instead of on the button's own fill where it would be about
582
+ * 1.4:1 and invisible. See `button.md`. */
583
+ --lutra-button-focus-ring-color: var(--lutra-component-button-focus);
584
+ --lutra-button-focus-ring-width: var(--lutra-border-width-thick);
585
+ --lutra-button-focus-ring-offset: 2px;
586
+
587
+ /* NO FIGMA VARIABLE. Figma draws buttons at a fixed height; these become
588
+ * `min-block-size` instead, because a fixed height on a text-bearing container
589
+ * clips wrapped labels and breaks at 200% zoom. `rem` so they grow with the
590
+ * reader's font size. 36px, 40px, 44px. */
591
+ --lutra-button-min-height-sm: 2.25rem;
592
+ --lutra-button-min-height-md: 2.5rem;
593
+ --lutra-button-min-height-lg: 2.75rem;
594
+
595
+ /* NO FIGMA VARIABLE. Vertical breathing room a wrapped label needs once the
596
+ * button is free to grow; small enough that a single-line button is still
597
+ * exactly its minimum height. */
598
+ --lutra-button-padding-block: var(--lutra-space-xs);
599
+
600
+ /* NO FIGMA VARIABLE. The stable 16px icon slot from the Figma documentation, so
601
+ * a button's height does not depend on the icon a consumer passes. */
602
+ --lutra-button-icon-size: 1rem;
603
+
604
+ /* NO FIGMA VARIABLE. The 44px minimum pointer target. An accessibility floor
605
+ * rather than a design value, and it must not be tuned down. */
606
+ --lutra-button-min-target-size: 2.75rem;
607
+
608
+ box-sizing: border-box;
609
+ display: inline-flex;
610
+ align-items: center;
611
+ justify-content: center;
612
+ margin: 0;
613
+ padding-block: var(--lutra-button-padding-block);
614
+ border-style: solid;
615
+ border-width: var(--lutra-button-border-width);
616
+ border-radius: var(--lutra-button-radius);
617
+ font-family: var(--lutra-button-font-family);
618
+ font-weight: var(--lutra-button-font-weight);
619
+ text-align: center;
620
+ cursor: pointer;
621
+
622
+ /* Strips the platform's own button chrome so the token colours are what shows,
623
+ * notably on iOS Safari. */
624
+ appearance: none;
625
+
626
+ /* Long labels and long words wrap rather than overflow the button. Nothing here
627
+ * sets a fixed height, so the button grows to hold them. */
628
+ overflow-wrap: break-word;
629
+ text-wrap: pretty;
630
+ }
631
+
632
+ /* Variants.
633
+ *
634
+ * Each carries a border in its own fill colour, so swapping variants never
635
+ * changes a button's size. */
636
+
637
+ .lutra-button[data-variant='primary'] {
638
+ border-color: var(--lutra-button-brand-border);
639
+ background-color: var(--lutra-button-brand);
640
+ color: var(--lutra-button-on-brand);
641
+ }
642
+
643
+ /* Only the fill moves on hover and active. The border stays at the base brand
644
+ * colour, which is what Figma draws and why `component/button/primary/border` is a
645
+ * separate token from the surface. */
646
+
647
+ .lutra-button[data-variant='primary']:not(:disabled):hover {
648
+ background-color: var(--lutra-button-brand-hover);
649
+ }
650
+
651
+ .lutra-button[data-variant='primary']:not(:disabled):active {
652
+ background-color: var(--lutra-button-brand-active);
653
+ }
654
+
655
+ .lutra-button[data-variant='secondary'] {
656
+ border-color: var(--lutra-button-border);
657
+ background-color: var(--lutra-button-neutral);
658
+ color: var(--lutra-button-text);
659
+ }
660
+
661
+ .lutra-button[data-variant='secondary']:not(:disabled):hover {
662
+ background-color: var(--lutra-button-neutral-hover);
663
+ }
664
+
665
+ .lutra-button[data-variant='secondary']:not(:disabled):active {
666
+ background-color: var(--lutra-button-neutral-active);
667
+ }
668
+
669
+ .lutra-button[data-variant='danger'] {
670
+ border-color: var(--lutra-button-danger-border);
671
+ background-color: var(--lutra-button-danger);
672
+ color: var(--lutra-button-on-danger);
673
+ }
674
+
675
+ .lutra-button[data-variant='danger']:not(:disabled):hover {
676
+ background-color: var(--lutra-button-danger-hover);
677
+ }
678
+
679
+ .lutra-button[data-variant='danger']:not(:disabled):active {
680
+ background-color: var(--lutra-button-danger-active);
681
+ }
682
+
683
+ /* Sizes.
684
+ *
685
+ * `min-block-size` rather than `height`: it reproduces the Figma height for the
686
+ * single-line case the design draws, and lets the button grow when the label
687
+ * wraps or the user raises their font size. */
688
+
689
+ .lutra-button[data-size='sm'] {
690
+ min-block-size: var(--lutra-button-min-height-sm);
691
+ padding-inline: var(--lutra-button-padding-inline-sm);
692
+ gap: var(--lutra-button-gap-sm);
693
+ font-size: var(--lutra-button-font-size-sm);
694
+ line-height: var(--lutra-button-line-height-sm);
695
+ }
696
+
697
+ .lutra-button[data-size='md'] {
698
+ min-block-size: var(--lutra-button-min-height-md);
699
+ padding-inline: var(--lutra-button-padding-inline-md);
700
+ gap: var(--lutra-button-gap-md);
701
+ font-size: var(--lutra-button-font-size-md);
702
+ line-height: var(--lutra-button-line-height-md);
703
+ }
704
+
705
+ .lutra-button[data-size='lg'] {
706
+ min-block-size: var(--lutra-button-min-height-lg);
707
+ padding-inline: var(--lutra-button-padding-inline-lg);
708
+ gap: var(--lutra-button-gap-lg);
709
+ font-size: var(--lutra-button-font-size-lg);
710
+ line-height: var(--lutra-button-line-height-lg);
711
+ }
712
+
713
+ /* Disabled, which is also how loading renders.
714
+ *
715
+ * The colour change is a real one — a different surface, border and text, not the
716
+ * enabled button at a lower opacity — and it is never the only signal: the button
717
+ * is natively disabled, so it is skipped by Tab, announced as unavailable, and
718
+ * shows the platform's own cursor. Hover and active are excluded above. */
719
+
720
+ .lutra-button:disabled {
721
+ border-color: var(--lutra-button-disabled-border);
722
+ background-color: var(--lutra-button-disabled-surface);
723
+ color: var(--lutra-button-disabled-text);
724
+ cursor: default;
725
+ }
726
+
727
+ /* Focus.
728
+ *
729
+ * The browser outline is replaced, not removed: a 2px ring at a 2px offset, in a
730
+ * colour with enough contrast against the surface behind the button. The offset
731
+ * is what makes the ring visible against a filled button — see `tokens/focus.css`. */
732
+
733
+ .lutra-button:focus-visible {
734
+ outline: var(--lutra-button-focus-ring-width) solid var(--lutra-button-focus-ring-color);
735
+ outline-offset: var(--lutra-button-focus-ring-offset);
736
+ }
737
+
738
+ /* Full width. Layout only. */
739
+
740
+ .lutra-button[data-full-width] {
741
+ inline-size: 100%;
742
+ }
743
+
744
+ /* Icon slot.
745
+ *
746
+ * A fixed 16px box, so a button's height does not depend on the icon a consumer
747
+ * happens to pass. The SVG rule sizes an arbitrary consumer icon to the slot; it
748
+ * is scoped under the icon class, so it cannot restyle anything else on the page. */
749
+
750
+ .lutra-button__icon {
751
+ display: inline-flex;
752
+ flex: 0 0 auto;
753
+ align-items: center;
754
+ justify-content: center;
755
+ inline-size: var(--lutra-button-icon-size);
756
+ block-size: var(--lutra-button-icon-size);
757
+ }
758
+
759
+ .lutra-button__icon > svg {
760
+ inline-size: 100%;
761
+ block-size: 100%;
762
+ }
763
+
764
+ /* Label.
765
+ *
766
+ * When a loading label replaces the ordinary one, both are stacked in the same
767
+ * grid cell so the button keeps the wider of the two widths and the swap causes no
768
+ * layout shift. The measuring copy is hidden from sight and from assistive
769
+ * technology, so it contributes width and nothing else. */
770
+
771
+ .lutra-button__label {
772
+ min-inline-size: 0;
773
+ }
774
+
775
+ .lutra-button__label--swapped {
776
+ display: grid;
777
+ grid-template-areas: 'label';
778
+ align-items: center;
779
+ justify-items: center;
780
+ }
781
+
782
+ .lutra-button__label--swapped > * {
783
+ grid-area: label;
784
+ }
785
+
786
+ .lutra-button__label-measure {
787
+ visibility: hidden;
788
+ }
789
+
790
+ /* Icon-only.
791
+ *
792
+ * The drawn button keeps its size at every scale — an `sm` icon-only button is
793
+ * still 36px — and reaches the 44px minimum pointer target through a transparent
794
+ * hit area centred on it. Growing the button instead would make `sm`, `md` and
795
+ * `lg` icon-only buttons indistinguishable. */
796
+
797
+ .lutra-button[data-content-type='icon-only'] {
798
+ position: relative;
799
+ padding-inline: 0;
800
+ }
801
+
802
+ .lutra-button[data-content-type='icon-only'][data-size='sm'] {
803
+ inline-size: var(--lutra-button-min-height-sm);
804
+ }
805
+
806
+ .lutra-button[data-content-type='icon-only'][data-size='md'] {
807
+ inline-size: var(--lutra-button-min-height-md);
808
+ }
809
+
810
+ .lutra-button[data-content-type='icon-only'][data-size='lg'] {
811
+ inline-size: var(--lutra-button-min-height-lg);
812
+ }
813
+
814
+ .lutra-button[data-content-type='icon-only']::after {
815
+ content: '';
816
+ position: absolute;
817
+ inset-block-start: 50%;
818
+ inset-inline-start: 50%;
819
+ transform: translate(-50%, -50%);
820
+ inline-size: 100%;
821
+ block-size: 100%;
822
+ min-inline-size: var(--lutra-button-min-target-size);
823
+ min-block-size: var(--lutra-button-min-target-size);
824
+ }
825
+
826
+ /* Spinner.
827
+ *
828
+ * Built from borders rather than an imported asset: three sides in the current
829
+ * text colour, one transparent, rotating. `currentcolor` means it is legible on
830
+ * every variant and in the disabled palette without a token of its own. */
831
+
832
+ .lutra-button__spinner {
833
+ flex: 0 0 auto;
834
+ box-sizing: border-box;
835
+ inline-size: var(--lutra-button-icon-size);
836
+ block-size: var(--lutra-button-icon-size);
837
+ border: var(--lutra-button-focus-ring-width) solid currentcolor;
838
+ border-block-start-color: transparent;
839
+ border-radius: 50%;
840
+ animation: lutra-button-spin 0.7s linear infinite;
841
+ }
842
+
843
+ @keyframes lutra-button-spin {
844
+ to {
845
+ transform: rotate(1turn);
846
+ }
847
+ }
848
+
849
+ /* Reduced motion: the rotation is slowed to a crawl rather than removed. A static
850
+ * ring does not read as "working"; a slow one does, without the fast spin that
851
+ * the preference exists to avoid. */
852
+
853
+ @media (prefers-reduced-motion: reduce) {
854
+ .lutra-button__spinner {
855
+ animation-duration: 2.4s;
856
+ }
857
+ }
858
+
859
+ /* Windows High Contrast Mode and other forced-colours modes.
860
+ *
861
+ * The system replaces the token colours, which is the point of the mode. What it
862
+ * does not do reliably is keep a custom focus ring visible, so the ring is pinned
863
+ * to the system highlight colour. Disabled buttons already pick up `GrayText`
864
+ * from the native `disabled` attribute. */
865
+
866
+ @media (forced-colors: active) {
867
+ .lutra-button:focus-visible {
868
+ outline-color: Highlight;
869
+ }
870
+ }
871
+
872
+ /*
873
+ * Link.
874
+ *
875
+ * The public styling contract is the `lutra-link` class plus `data-variant`. Hover, focus, active
876
+ * and visited are selected from the platform — `:hover`, `:focus-visible`, `:active`, `:visited` —
877
+ * never from a prop.
878
+ *
879
+ * Colour is never the only thing separating an inline link from the prose around it: inline links
880
+ * keep their underline at rest, which is what the annotation asks for.
881
+ */
882
+
883
+ .lutra-link {
884
+ --lutra-link-text: var(--lutra-component-link-text-default);
885
+ --lutra-link-text-hover: var(--lutra-component-link-text-hover);
886
+ --lutra-link-text-focus: var(--lutra-component-link-text-focus);
887
+ --lutra-link-text-visited: var(--lutra-component-link-text-visited);
888
+
889
+ --lutra-link-focus-ring-color: var(--lutra-component-navigation-focus);
890
+ --lutra-link-focus-ring-width: var(--lutra-border-width-thick);
891
+
892
+ /* NO FIGMA VARIABLE. The same 2px offset every other Lutra control uses, so a focused link in a
893
+ * row of controls shows one treatment. */
894
+ --lutra-link-focus-ring-offset: 2px;
895
+
896
+ /* NO FIGMA VARIABLE. Figma keeps a 44px target on standalone links. */
897
+ --lutra-link-standalone-min-target: 2.75rem;
898
+
899
+ color: var(--lutra-link-text);
900
+ border-radius: var(--lutra-radius-sm);
901
+
902
+ /* Underline thickness follows the font, and the offset keeps the line clear of descenders so the
903
+ * text stays readable rather than struck through. */
904
+ text-decoration-thickness: from-font;
905
+ text-underline-offset: 0.2em;
906
+
907
+ /* A long URL or an unbroken word wraps rather than pushing its container wider. */
908
+ overflow-wrap: break-word;
909
+ }
910
+
911
+ /* Inline links live in prose and keep their underline at rest — colour alone is not enough to find
912
+ * a link inside a paragraph. */
913
+
914
+ .lutra-link[data-variant='inline'] {
915
+ text-decoration-line: underline;
916
+ }
917
+
918
+ /* Standalone links sit on their own, where position and spacing already separate them, so the
919
+ * underline is reserved for hover. The minimum target is met without changing the drawn text: an
920
+ * inline-flex box with a minimum block size grows the hit area, not the type. */
921
+
922
+ .lutra-link[data-variant='standalone'] {
923
+ display: inline-flex;
924
+ align-items: center;
925
+ min-block-size: var(--lutra-link-standalone-min-target);
926
+ text-decoration-line: none;
927
+ }
928
+
929
+ .lutra-link:hover {
930
+ color: var(--lutra-link-text-hover);
931
+ text-decoration-line: underline;
932
+ }
933
+
934
+ /* `:visited` is deliberately after `:hover` for the colour, and the browser restricts what may be
935
+ * styled here anyway — colour is one of the few properties allowed, which is exactly what Figma
936
+ * varies. */
937
+
938
+ .lutra-link:visited {
939
+ color: var(--lutra-link-text-visited);
940
+ }
941
+
942
+ .lutra-link:visited:hover {
943
+ color: var(--lutra-link-text-hover);
944
+ }
945
+
946
+ .lutra-link:focus-visible {
947
+ color: var(--lutra-link-text-focus);
948
+ outline: var(--lutra-link-focus-ring-width) solid var(--lutra-link-focus-ring-color);
949
+ outline-offset: var(--lutra-link-focus-ring-offset);
950
+ }
951
+
952
+ /* Forced-colours modes: the system supplies its own link colour, and what it does not reliably keep
953
+ * is a custom focus ring, so that is pinned to the system highlight. */
954
+
955
+ @media (forced-colors: active) {
956
+ .lutra-link:focus-visible {
957
+ outline-color: Highlight;
958
+ }
959
+ }
960
+
961
+ /*
962
+ * Navigation Item.
963
+ *
964
+ * A destination inside a navigation landmark. The styling contract is the `lutra-navigation-item`
965
+ * class; the current state is selected from `[aria-current='page']` rather than from a class, so the
966
+ * look and what assistive technology is told cannot drift apart.
967
+ *
968
+ * Figma draws four states — default, hover, focus, current. Three of them are platform states and are
969
+ * selected as such; only `current` is a property, because only `current` is a fact about the page
970
+ * rather than about the pointer.
971
+ */
972
+
973
+ .lutra-navigation-item {
974
+ --lutra-navigation-item-text: var(--lutra-component-navigation-item-text);
975
+ --lutra-navigation-item-indicator: var(--lutra-component-navigation-item-indicator);
976
+ --lutra-navigation-item-focus-ring-color: var(--lutra-component-navigation-focus);
977
+ --lutra-navigation-item-focus-ring-width: var(--lutra-border-width-thick);
978
+
979
+ /* NO FIGMA VARIABLE. Figma draws a 44px tall item with 8/8/6/8 padding, a 4px gap above a 32×2
980
+ * indicator, and a 4px corner on the focus ring. The 44px target is the annotation's minimum. */
981
+ --lutra-navigation-item-min-target: 2.75rem;
982
+ --lutra-navigation-item-indicator-inline-size: 2rem;
983
+ --lutra-navigation-item-indicator-block-size: 2px;
984
+
985
+ display: flex;
986
+ flex-direction: column;
987
+ align-items: center;
988
+ justify-content: center;
989
+ gap: var(--lutra-space-xs);
990
+ box-sizing: border-box;
991
+ min-block-size: var(--lutra-navigation-item-min-target);
992
+ padding: var(--lutra-space-sm) var(--lutra-space-sm) calc(var(--lutra-space-sm) - 2px);
993
+ border-radius: var(--lutra-radius-sm);
994
+
995
+ color: var(--lutra-navigation-item-text);
996
+ font-family: var(--lutra-type-family-sans);
997
+ font-size: var(--lutra-text-body);
998
+ line-height: var(--lutra-type-line-height-body);
999
+ font-weight: var(--lutra-type-weight-medium);
1000
+ text-decoration-line: none;
1001
+
1002
+ /* A long destination name wraps rather than pushing the navigation wider. */
1003
+ overflow-wrap: break-word;
1004
+ text-wrap: pretty;
1005
+ }
1006
+
1007
+ /* Hover is the only place the underline appears at rest-adjacent state, matching Figma. */
1008
+
1009
+ .lutra-navigation-item:hover {
1010
+ text-decoration-line: underline;
1011
+ text-decoration-thickness: from-font;
1012
+ text-underline-offset: 0.2em;
1013
+ }
1014
+
1015
+ .lutra-navigation-item:focus-visible {
1016
+ outline: var(--lutra-navigation-item-focus-ring-width) solid
1017
+ var(--lutra-navigation-item-focus-ring-color);
1018
+
1019
+ /* No offset, because Figma draws the ring on the item's own 4px corner rather than around it, and
1020
+ * items sit 4px apart — an offset ring would touch its neighbour. */
1021
+ outline-offset: 0;
1022
+ }
1023
+
1024
+ /* The current destination: a weight change, an indicator bar, and colour — three signals, so no one
1025
+ * of them has to carry it alone. Selected from the ARIA attribute, so it cannot be styled as current
1026
+ * without also being announced as current. */
1027
+
1028
+ .lutra-navigation-item[aria-current='page'] {
1029
+ font-weight: var(--lutra-type-weight-semibold);
1030
+ }
1031
+
1032
+ .lutra-navigation-item__indicator {
1033
+ inline-size: var(--lutra-navigation-item-indicator-inline-size);
1034
+ block-size: var(--lutra-navigation-item-indicator-block-size);
1035
+ border-radius: var(--lutra-radius-full);
1036
+ background-color: var(--lutra-navigation-item-indicator);
1037
+
1038
+ /* Holds its size when the item is in a flex column that would otherwise squash it. */
1039
+ flex: 0 0 auto;
1040
+ }
1041
+
1042
+ /* Forced-colours modes: the indicator's background is not painted, so it is pinned to the system
1043
+ * highlight, and the focus ring to the same — otherwise the current item and the focused item both
1044
+ * lose their only remaining distinction. */
1045
+
1046
+ @media (forced-colors: active) {
1047
+ .lutra-navigation-item__indicator {
1048
+ background-color: Highlight;
1049
+ forced-color-adjust: none;
1050
+ }
1051
+
1052
+ .lutra-navigation-item:focus-visible {
1053
+ outline-color: Highlight;
1054
+ }
1055
+ }
1056
+
1057
+ /*
1058
+ * Mobile Navigation.
1059
+ *
1060
+ * A disclosure: a trigger button and a panel of destinations. Nothing here is modal, so nothing here
1061
+ * needs a scrim or the top layer — the panel is an ordinary positioned element.
1062
+ *
1063
+ * Figma draws a 390px panel below the trigger with a 16px inline gutter and a 24px bottom pad. The
1064
+ * width is not fixed here: the panel stretches to its container and is capped, so it works at 400%
1065
+ * zoom, where a fixed 390px panel would simply overflow.
1066
+ */
1067
+
1068
+ .lutra-mobile-navigation {
1069
+ --lutra-mobile-navigation-surface: var(--lutra-component-navigation-surface);
1070
+ --lutra-mobile-navigation-border: var(--lutra-component-navigation-border);
1071
+ --lutra-mobile-navigation-border-width: var(--lutra-border-width-thin);
1072
+
1073
+ /* NO FIGMA VARIABLE. Figma draws a 390px panel. */
1074
+ --lutra-mobile-navigation-max-inline-size: 24.375rem;
1075
+
1076
+ /* **No shadow, deliberately.** The Figma component binds no effect style and carries no effect of
1077
+ * its own — unlike Tooltip, Dropdown and Dialog, which each bind one. The panel is separated by its
1078
+ * border and surface alone, which is consistent with the Elevation page's instruction to use
1079
+ * elevation "sparingly to clarify layer relationships—not as decoration". The shadow that used to
1080
+ * be here was invented, and is gone.
1081
+ *
1082
+ * NO FIGMA VARIABLE: the layer token does not exist, so the z-index keeps a local fallback. There
1083
+ * is no layer scale in Figma — elevation is expressed as effect styles, not as a z-index ramp. */
1084
+ --lutra-mobile-navigation-layer: var(--lutra-layer-popover, 900);
1085
+
1086
+ position: relative;
1087
+ }
1088
+
1089
+ .lutra-mobile-navigation__panel {
1090
+ position: absolute;
1091
+ inset-block-start: calc(100% + var(--lutra-space-xs));
1092
+
1093
+ box-sizing: border-box;
1094
+ inline-size: max-content;
1095
+ min-inline-size: 100%;
1096
+ max-inline-size: min(var(--lutra-mobile-navigation-max-inline-size), 100vw);
1097
+
1098
+ display: flex;
1099
+ flex-direction: column;
1100
+ gap: var(--lutra-space-sm);
1101
+ padding: var(--lutra-space-md) var(--lutra-space-lg) var(--lutra-space-2xl);
1102
+
1103
+ border: var(--lutra-mobile-navigation-border-width) solid var(--lutra-mobile-navigation-border);
1104
+ border-radius: var(--lutra-radius-md);
1105
+ background-color: var(--lutra-mobile-navigation-surface);
1106
+ z-index: var(--lutra-mobile-navigation-layer);
1107
+ }
1108
+
1109
+ /*
1110
+ * Which edge the panel is anchored to, following the same `data-align` convention as Tooltip.
1111
+ *
1112
+ * The panel used to be anchored to the inline start unconditionally. That is correct only when the
1113
+ * trigger sits at the start of its row — and the Figma `Site Header` puts it at the **end**, with the
1114
+ * site name at the start. A start-anchored panel hanging off a trigger that is itself a gutter's
1115
+ * width from the edge extends past the viewport, which forces the horizontal scrolling that WCAG
1116
+ * 1.4.10 exists to prevent. No story exercised an end-aligned trigger, so nothing caught it until a
1117
+ * real site header did.
1118
+ *
1119
+ * `start` remains the default, so this changes no existing composition.
1120
+ */
1121
+
1122
+ .lutra-mobile-navigation__panel[data-align='start'] {
1123
+ inset-inline-start: 0;
1124
+ }
1125
+
1126
+ .lutra-mobile-navigation__panel[data-align='end'] {
1127
+ inset-inline-end: 0;
1128
+ }
1129
+
1130
+ /* The panel's own controls row. The close button sits at the end, where a thumb reaches it. */
1131
+
1132
+ .lutra-mobile-navigation__controls {
1133
+ display: flex;
1134
+ justify-content: flex-end;
1135
+ flex: 0 0 auto;
1136
+ }
1137
+
1138
+ /* Destinations stack full width, so the whole row is the target rather than just the text. */
1139
+
1140
+ .lutra-mobile-navigation__nav {
1141
+ display: flex;
1142
+ flex-direction: column;
1143
+ gap: var(--lutra-space-xs);
1144
+ }
1145
+
1146
+ .lutra-mobile-navigation__nav .lutra-navigation-item {
1147
+ align-items: flex-start;
1148
+ }
1149
+
1150
+ /* Forced-colours modes: the border is what separates the panel from the page behind it — which is
1151
+ * true in every mode here, since this panel has no shadow. */
1152
+
1153
+ @media (forced-colors: active) {
1154
+ .lutra-mobile-navigation__panel {
1155
+ border-color: CanvasText;
1156
+ }
1157
+ }
1158
+
1159
+ /*
1160
+ * Tooltip.
1161
+ *
1162
+ * Positioned with ordinary absolute positioning against an inline-block wrapper. `placement` and
1163
+ * `align` are data attributes; the component flips to the opposite side when the preferred one does
1164
+ * not fit, and that flip is the whole of the positioning logic. No floating-element engine, and no
1165
+ * dependency.
1166
+ *
1167
+ * The tooltip is only in the DOM while open, so there is nothing to hide and no stale
1168
+ * `aria-describedby` target left behind.
1169
+ *
1170
+ * There is no transition. Motion tokens are still deferred in the design system, and the 120ms open
1171
+ * delay in the component comes from the Figma API contract rather than from an invented scale.
1172
+ */
1173
+
1174
+ .lutra-tooltip-wrapper {
1175
+ position: relative;
1176
+ display: inline-block;
1177
+ }
1178
+
1179
+ .lutra-tooltip {
1180
+ --lutra-tooltip-surface: var(--lutra-component-tooltip-surface);
1181
+ --lutra-tooltip-text: var(--lutra-component-tooltip-text);
1182
+ --lutra-tooltip-border: var(--lutra-component-tooltip-border);
1183
+ --lutra-tooltip-radius: var(--lutra-radius-md);
1184
+ --lutra-tooltip-border-width: var(--lutra-border-width-thin);
1185
+ --lutra-tooltip-padding-block: var(--lutra-space-xs);
1186
+ --lutra-tooltip-padding-inline: var(--lutra-space-sm);
1187
+
1188
+ /* NO FIGMA VARIABLE. The 8px offset from the Figma API contract, and a maximum width so a long
1189
+ * tooltip wraps into a readable column instead of stretching across the viewport. */
1190
+ --lutra-tooltip-offset: var(--lutra-space-sm);
1191
+ --lutra-tooltip-max-inline-size: 20rem;
1192
+
1193
+ /* The canonical `Elevation/Medium` effect style, reproduced verbatim: 0 6px 16px black at 20%.
1194
+ *
1195
+ * NO FIGMA VARIABLE, and not an invented value either — the Elevation foundations page states that
1196
+ * elevation "is expressed as reusable effect styles, not numeric tokens", so there is nothing for
1197
+ * the token pipeline to read. Every one of the twelve Tooltip variants binds this style; the
1198
+ * stylesheet had no shadow at all, which is the drift this corrects. */
1199
+ --lutra-tooltip-shadow: 0 6px 16px rgb(0 0 0 / 20%);
1200
+
1201
+ position: absolute;
1202
+ z-index: var(--lutra-layer-tooltip, 950);
1203
+ box-sizing: border-box;
1204
+ inline-size: max-content;
1205
+ max-inline-size: var(--lutra-tooltip-max-inline-size);
1206
+ padding: var(--lutra-tooltip-padding-block) var(--lutra-tooltip-padding-inline);
1207
+ border: var(--lutra-tooltip-border-width) solid var(--lutra-tooltip-border);
1208
+ border-radius: var(--lutra-tooltip-radius);
1209
+ background-color: var(--lutra-tooltip-surface);
1210
+ box-shadow: var(--lutra-tooltip-shadow);
1211
+ color: var(--lutra-tooltip-text);
1212
+ font-family: var(--lutra-type-family-sans);
1213
+ font-size: var(--lutra-text-body-small);
1214
+ line-height: var(--lutra-type-line-height-body-small);
1215
+
1216
+ /* The pointer *is* allowed here, and that is deliberate. WCAG 2.2 SC 1.4.13 requires content shown
1217
+ * on hover to stay visible while the pointer is on it, which a surface with `pointer-events: none`
1218
+ * can never satisfy — the pointer would pass straight through and the tooltip would close as soon
1219
+ * as it left the trigger.
1220
+ *
1221
+ * Nothing is swallowed by this, because the tooltip holds nothing to interact with: it sits
1222
+ * outside the trigger rather than over it, and interactive content inside a tooltip warns in
1223
+ * development. */
1224
+
1225
+ overflow-wrap: break-word;
1226
+ text-wrap: pretty;
1227
+ }
1228
+
1229
+ /* Placement. */
1230
+
1231
+ .lutra-tooltip[data-placement='top'] {
1232
+ inset-block-end: calc(100% + var(--lutra-tooltip-offset));
1233
+ }
1234
+
1235
+ .lutra-tooltip[data-placement='bottom'] {
1236
+ inset-block-start: calc(100% + var(--lutra-tooltip-offset));
1237
+ }
1238
+
1239
+ .lutra-tooltip[data-placement='left'] {
1240
+ inset-inline-end: calc(100% + var(--lutra-tooltip-offset));
1241
+ }
1242
+
1243
+ .lutra-tooltip[data-placement='right'] {
1244
+ inset-inline-start: calc(100% + var(--lutra-tooltip-offset));
1245
+ }
1246
+
1247
+ /* Alignment along the chosen side. Vertical placements align horizontally and the reverse, so the
1248
+ * two axes are handled separately. */
1249
+
1250
+ .lutra-tooltip[data-placement='top'][data-align='start'],
1251
+ .lutra-tooltip[data-placement='bottom'][data-align='start'] {
1252
+ inset-inline-start: 0;
1253
+ }
1254
+
1255
+ .lutra-tooltip[data-placement='top'][data-align='center'],
1256
+ .lutra-tooltip[data-placement='bottom'][data-align='center'] {
1257
+ inset-inline-start: 50%;
1258
+ transform: translateX(-50%);
1259
+ }
1260
+
1261
+ .lutra-tooltip[data-placement='top'][data-align='end'],
1262
+ .lutra-tooltip[data-placement='bottom'][data-align='end'] {
1263
+ inset-inline-end: 0;
1264
+ }
1265
+
1266
+ .lutra-tooltip[data-placement='left'][data-align='start'],
1267
+ .lutra-tooltip[data-placement='right'][data-align='start'] {
1268
+ inset-block-start: 0;
1269
+ }
1270
+
1271
+ .lutra-tooltip[data-placement='left'][data-align='center'],
1272
+ .lutra-tooltip[data-placement='right'][data-align='center'] {
1273
+ inset-block-start: 50%;
1274
+ transform: translateY(-50%);
1275
+ }
1276
+
1277
+ .lutra-tooltip[data-placement='left'][data-align='end'],
1278
+ .lutra-tooltip[data-placement='right'][data-align='end'] {
1279
+ inset-block-end: 0;
1280
+ }
1281
+
1282
+ /* Forced-colours modes: the system replaces the surface and text, and the border is what keeps the
1283
+ * tooltip distinguishable from the page behind it. */
1284
+
1285
+ @media (forced-colors: active) {
1286
+ .lutra-tooltip {
1287
+ border-color: CanvasText;
1288
+ }
1289
+ }
1290
+
1291
+ /*
1292
+ * Dropdown.
1293
+ *
1294
+ * A disclosure: a button and a panel of ordinary buttons and links. Nothing here implies a menu
1295
+ * role, and the items are styled by class rather than by element, so a `<button>` item and an `<a>`
1296
+ * item are indistinguishable to look at while staying distinct to assistive technology.
1297
+ *
1298
+ * The panel is positioned absolutely against the wrapper. It needs to sit above the page, and while
1299
+ * Figma does define elevation, it defines it as effect styles rather than as a z-index scale — so
1300
+ * the shadow is canonical and the z-index falls back to a local value —
1301
+ * see `dropdown.md`.
1302
+ */
1303
+
1304
+ .lutra-dropdown {
1305
+ position: relative;
1306
+ display: inline-block;
1307
+ }
1308
+
1309
+ .lutra-dropdown__panel {
1310
+ --lutra-dropdown-surface: var(--lutra-component-menu-surface);
1311
+ --lutra-dropdown-border: var(--lutra-component-menu-border);
1312
+ --lutra-dropdown-radius: var(--lutra-radius-md);
1313
+ --lutra-dropdown-border-width: var(--lutra-border-width-thin);
1314
+ --lutra-dropdown-padding-block: var(--lutra-space-xs);
1315
+
1316
+ /* NO FIGMA VARIABLE. Figma draws a 240px panel. */
1317
+ --lutra-dropdown-min-inline-size: 15rem;
1318
+
1319
+ /* The canonical `Elevation/Medium` effect style, reproduced verbatim: 0 6px 16px black at 20%.
1320
+ *
1321
+ * NO FIGMA VARIABLE, and not an invented value either — the Elevation foundations page states that
1322
+ * elevation "is expressed as reusable effect styles, not numeric tokens", so there is nothing for
1323
+ * the token pipeline to read. The style is named here so the value stays traceable to it. */
1324
+ --lutra-dropdown-shadow: 0 6px 16px rgb(0 0 0 / 20%);
1325
+
1326
+ position: absolute;
1327
+ z-index: var(--lutra-layer-popover, 900);
1328
+ inset-block-start: calc(100% + var(--lutra-space-xs));
1329
+ inset-inline-start: 0;
1330
+ box-sizing: border-box;
1331
+ min-inline-size: var(--lutra-dropdown-min-inline-size);
1332
+ max-inline-size: min(20rem, calc(100vw - var(--lutra-space-2xl)));
1333
+ padding-block: var(--lutra-dropdown-padding-block);
1334
+ border: var(--lutra-dropdown-border-width) solid var(--lutra-dropdown-border);
1335
+ border-radius: var(--lutra-dropdown-radius);
1336
+ background-color: var(--lutra-dropdown-surface);
1337
+ box-shadow: var(--lutra-dropdown-shadow);
1338
+ }
1339
+
1340
+ /* Items.
1341
+ *
1342
+ * Styled by class, not element, so a button item and a link item look identical while keeping the
1343
+ * semantics that tell them apart. */
1344
+
1345
+ .lutra-dropdown__item {
1346
+ --lutra-dropdown-item-text: var(--lutra-component-menu-item-text);
1347
+ --lutra-dropdown-item-hover: var(--lutra-component-menu-item-hover);
1348
+ --lutra-dropdown-item-danger: var(--lutra-component-menu-item-danger);
1349
+ --lutra-dropdown-item-disabled: var(--lutra-color-text-disabled);
1350
+
1351
+ /* NO FIGMA VARIABLE. Figma draws a 44px row, which is also the minimum pointer target. */
1352
+ --lutra-dropdown-item-min-height: 2.75rem;
1353
+
1354
+ box-sizing: border-box;
1355
+ display: flex;
1356
+ align-items: center;
1357
+ gap: var(--lutra-space-sm);
1358
+ inline-size: 100%;
1359
+ min-block-size: var(--lutra-dropdown-item-min-height);
1360
+ margin: 0;
1361
+ padding: var(--lutra-space-xs) var(--lutra-space-md);
1362
+ border: 0;
1363
+ border-radius: var(--lutra-radius-sm);
1364
+ background-color: transparent;
1365
+ color: var(--lutra-dropdown-item-text);
1366
+ font-family: var(--lutra-type-family-sans);
1367
+ font-size: var(--lutra-text-body-small);
1368
+ line-height: var(--lutra-type-line-height-body-small);
1369
+ text-align: start;
1370
+ text-decoration: none;
1371
+ cursor: pointer;
1372
+ appearance: none;
1373
+ }
1374
+
1375
+ .lutra-dropdown__item[data-tone='danger'] {
1376
+ color: var(--lutra-dropdown-item-danger);
1377
+ }
1378
+
1379
+ .lutra-dropdown__item:hover:not(:disabled):not([aria-disabled='true']) {
1380
+ background-color: var(--lutra-dropdown-item-hover);
1381
+ }
1382
+
1383
+ /* Focus is a ring at the same width and offset every other Lutra control uses, drawn inside the
1384
+ * panel so it is not clipped by the panel's own rounded corner. */
1385
+
1386
+ .lutra-dropdown__item:focus-visible {
1387
+ background-color: var(--lutra-dropdown-item-hover);
1388
+ outline: var(--lutra-border-width-thick) solid var(--lutra-color-focus-ring);
1389
+ outline-offset: -2px;
1390
+ }
1391
+
1392
+ .lutra-dropdown__item:disabled,
1393
+ .lutra-dropdown__item[aria-disabled='true'] {
1394
+ color: var(--lutra-dropdown-item-disabled);
1395
+ cursor: not-allowed;
1396
+ }
1397
+
1398
+ /* Long labels wrap inside the panel rather than escaping it. */
1399
+
1400
+ .lutra-dropdown__item-label {
1401
+ min-inline-size: 0;
1402
+ overflow-wrap: break-word;
1403
+ text-wrap: pretty;
1404
+ }
1405
+
1406
+ .lutra-dropdown__item-icon {
1407
+ flex: 0 0 auto;
1408
+ display: inline-flex;
1409
+ inline-size: 1rem;
1410
+ block-size: 1rem;
1411
+ }
1412
+
1413
+ .lutra-dropdown__item-icon > svg {
1414
+ inline-size: 100%;
1415
+ block-size: 100%;
1416
+ }
1417
+
1418
+ .lutra-dropdown__separator {
1419
+ margin: var(--lutra-space-xs) 0;
1420
+ border: 0;
1421
+ border-block-start: var(--lutra-border-width-thin) solid var(--lutra-component-menu-divider);
1422
+ }
1423
+
1424
+ /* Forced-colours modes: the system replaces the surface and the shadow is not painted, so the
1425
+ * border is what keeps the panel distinguishable from the page behind it. */
1426
+
1427
+ @media (forced-colors: active) {
1428
+ .lutra-dropdown__panel {
1429
+ border-color: CanvasText;
1430
+ }
1431
+ }
1432
+
1433
+ /*
1434
+ * Dialog.
1435
+ *
1436
+ * A native <dialog> opened with showModal(), so the top layer, the inert background, focus
1437
+ * containment and Escape all come from the browser. The stylesheet only has to draw it.
1438
+ *
1439
+ * The scrim is ::backdrop — the element's own pseudo-element — which is why no separate overlay node
1440
+ * exists and why nothing has to manage z-index against the rest of the page. The top layer sits
1441
+ * above everything regardless of stacking context, which is also why Dialog needs no entry in the
1442
+ * layer scale the design system is still missing.
1443
+ */
1444
+
1445
+ .lutra-dialog {
1446
+ --lutra-dialog-surface: var(--lutra-component-modal-surface);
1447
+ --lutra-dialog-border: var(--lutra-component-modal-border);
1448
+ --lutra-dialog-title: var(--lutra-component-modal-title);
1449
+ --lutra-dialog-body: var(--lutra-component-modal-body);
1450
+ --lutra-dialog-divider: var(--lutra-component-modal-divider);
1451
+ --lutra-dialog-scrim: var(--lutra-component-modal-scrim);
1452
+ --lutra-dialog-radius: var(--lutra-radius-lg);
1453
+ --lutra-dialog-border-width: var(--lutra-border-width-thin);
1454
+
1455
+ /* NO FIGMA VARIABLE. Figma draws 400/560/720px panels and a 16px viewport gutter. */
1456
+ --lutra-dialog-max-inline-size-sm: 25rem;
1457
+ --lutra-dialog-max-inline-size-md: 35rem;
1458
+ --lutra-dialog-max-inline-size-lg: 45rem;
1459
+ --lutra-dialog-gutter: var(--lutra-space-lg);
1460
+
1461
+ /* The canonical `Elevation/Large` effect style, reproduced verbatim: 0 8px 24px black at 18%.
1462
+ *
1463
+ * NO FIGMA VARIABLE, and not an invented value either — the Elevation foundations page states that
1464
+ * elevation "is expressed as reusable effect styles, not numeric tokens", so there is nothing for
1465
+ * the token pipeline to read. The style is named here so the value stays traceable to it. */
1466
+ --lutra-dialog-shadow: 0 8px 24px rgb(0 0 0 / 18%);
1467
+
1468
+ box-sizing: border-box;
1469
+ padding: 0;
1470
+ border: var(--lutra-dialog-border-width) solid var(--lutra-dialog-border);
1471
+ border-radius: var(--lutra-dialog-radius);
1472
+ background-color: var(--lutra-dialog-surface);
1473
+ color: var(--lutra-dialog-body);
1474
+ font-family: var(--lutra-type-family-sans);
1475
+ box-shadow: var(--lutra-dialog-shadow);
1476
+
1477
+ /* Stretches to the available width on a narrow screen and never touches the viewport edge.
1478
+ * `dvh` so a mobile browser's collapsing toolbar does not hide the footer. */
1479
+ inline-size: calc(100vw - var(--lutra-dialog-gutter) * 2);
1480
+ max-block-size: calc(100dvh - var(--lutra-dialog-gutter) * 2);
1481
+
1482
+ /* The dialog element itself never scrolls; the body inside it does. Scrolling here as well would
1483
+ * let the header and footer drift out of reach. */
1484
+ overflow: hidden;
1485
+ }
1486
+
1487
+ .lutra-dialog[data-size='sm'] {
1488
+ max-inline-size: var(--lutra-dialog-max-inline-size-sm);
1489
+ }
1490
+
1491
+ .lutra-dialog[data-size='md'] {
1492
+ max-inline-size: var(--lutra-dialog-max-inline-size-md);
1493
+ }
1494
+
1495
+ .lutra-dialog[data-size='lg'] {
1496
+ max-inline-size: var(--lutra-dialog-max-inline-size-lg);
1497
+ }
1498
+
1499
+ .lutra-dialog::backdrop {
1500
+ background-color: var(--lutra-dialog-scrim);
1501
+ }
1502
+
1503
+ /* The dialog is focusable only as a target for `initialFocus="dialog"`. It is not a tab stop, and
1504
+ * the ring would be noise around the whole panel, so it is suppressed here specifically — every
1505
+ * control inside keeps its own. */
1506
+ .lutra-dialog:focus {
1507
+ outline: none;
1508
+ }
1509
+
1510
+ /* A column: header and footer hold their size, the body takes what is left and scrolls. */
1511
+
1512
+ .lutra-dialog__panel {
1513
+ display: flex;
1514
+ flex-direction: column;
1515
+ max-block-size: inherit;
1516
+ block-size: 100%;
1517
+ }
1518
+
1519
+ .lutra-dialog__header {
1520
+ display: flex;
1521
+ align-items: flex-start;
1522
+ gap: var(--lutra-space-lg);
1523
+ flex: 0 0 auto;
1524
+ padding: var(--lutra-space-2xl) var(--lutra-space-2xl) var(--lutra-space-lg);
1525
+ }
1526
+
1527
+ .lutra-dialog__heading-group {
1528
+ display: flex;
1529
+ flex-direction: column;
1530
+ gap: var(--lutra-space-sm);
1531
+ min-inline-size: 0;
1532
+ flex: 1 1 auto;
1533
+ }
1534
+
1535
+ .lutra-dialog__title {
1536
+ margin: 0;
1537
+ font-size: var(--lutra-text-h2);
1538
+ line-height: var(--lutra-type-line-height-h2);
1539
+ font-weight: var(--lutra-type-weight-semibold);
1540
+ color: var(--lutra-dialog-title);
1541
+
1542
+ /* A long title wraps rather than pushing the close button off the panel. */
1543
+ overflow-wrap: break-word;
1544
+ text-wrap: pretty;
1545
+ }
1546
+
1547
+ .lutra-dialog__description {
1548
+ margin: 0;
1549
+ font-size: var(--lutra-text-body);
1550
+ line-height: var(--lutra-type-line-height-body);
1551
+ color: var(--lutra-dialog-body);
1552
+ overflow-wrap: break-word;
1553
+ text-wrap: pretty;
1554
+ }
1555
+
1556
+ /* The only scrolling region, so the header and the actions can never drift out of reach. The
1557
+ * footer's border doubles as the line the content scrolls beneath. */
1558
+
1559
+ .lutra-dialog__body {
1560
+ flex: 1 1 auto;
1561
+ min-block-size: 0;
1562
+ overflow-y: auto;
1563
+ padding: 0 var(--lutra-space-2xl) var(--lutra-space-2xl);
1564
+ color: var(--lutra-dialog-body);
1565
+ font-size: var(--lutra-text-body);
1566
+ line-height: var(--lutra-type-line-height-body);
1567
+ overscroll-behavior: contain;
1568
+ }
1569
+
1570
+ .lutra-dialog__footer {
1571
+ display: flex;
1572
+ flex-wrap: wrap;
1573
+ justify-content: flex-end;
1574
+ gap: var(--lutra-space-md);
1575
+ flex: 0 0 auto;
1576
+ padding: var(--lutra-space-lg) var(--lutra-space-2xl);
1577
+ border-block-start: var(--lutra-border-width-thin) solid var(--lutra-dialog-divider);
1578
+ }
1579
+
1580
+ /* Forced-colours modes: the scrim is not painted and the shadow disappears, so the border is what
1581
+ * separates the dialog from the page behind it. */
1582
+
1583
+ @media (forced-colors: active) {
1584
+ .lutra-dialog {
1585
+ border-color: CanvasText;
1586
+ }
1587
+ }
1588
+
1589
+ /*
1590
+ * Card and Card Footer.
1591
+ *
1592
+ * A bounded surface and its action row. Everything drawn here comes from foundations the rest of the
1593
+ * library already uses — a card is not a second place where type, spacing or colour get decided.
1594
+ *
1595
+ * The border is on both elevations deliberately. In dark mode `color/background/surface` and
1596
+ * `color/border/default` carry the whole separation from the page, and the shadow contributes almost
1597
+ * nothing, which is why a flat card still reads as bounded there.
1598
+ */
1599
+
1600
+ .lutra-card {
1601
+ --lutra-card-surface: var(--lutra-color-background-surface);
1602
+ --lutra-card-border: var(--lutra-color-border-default);
1603
+ --lutra-card-border-width: var(--lutra-border-width-thin);
1604
+ --lutra-card-radius: var(--lutra-radius-lg);
1605
+ --lutra-card-padding: var(--lutra-space-2xl);
1606
+ --lutra-card-gap: var(--lutra-space-lg);
1607
+ --lutra-card-header-gap: var(--lutra-space-xs);
1608
+ --lutra-card-title: var(--lutra-color-text-primary);
1609
+ --lutra-card-text: var(--lutra-color-text-secondary);
1610
+
1611
+ /* The canonical `Elevation/Small` effect style, reproduced verbatim: 0 1px 2px black at 10%.
1612
+ *
1613
+ * NO FIGMA VARIABLE, and not an invented value either — the Elevation foundations page states that
1614
+ * elevation "is expressed as reusable effect styles, not numeric tokens", so there is nothing for
1615
+ * the token pipeline to read. The style is named here so the value stays traceable to it. */
1616
+ --lutra-card-shadow-raised: 0 1px 2px rgb(0 0 0 / 10%);
1617
+
1618
+ box-sizing: border-box;
1619
+ display: flex;
1620
+ flex-direction: column;
1621
+ gap: var(--lutra-card-gap);
1622
+ padding: var(--lutra-card-padding);
1623
+
1624
+ border: var(--lutra-card-border-width) solid var(--lutra-card-border);
1625
+ border-radius: var(--lutra-card-radius);
1626
+ background-color: var(--lutra-card-surface);
1627
+ color: var(--lutra-card-text);
1628
+ font-family: var(--lutra-type-family-sans);
1629
+ }
1630
+
1631
+ .lutra-card[data-elevation='raised'] {
1632
+ box-shadow: var(--lutra-card-shadow-raised);
1633
+ }
1634
+
1635
+ /* The heading and its metadata read as one unit, so they share a block with a tighter gap than the
1636
+ * one between blocks. */
1637
+
1638
+ .lutra-card__header {
1639
+ display: flex;
1640
+ flex-direction: column;
1641
+ gap: var(--lutra-card-header-gap);
1642
+ min-inline-size: 0;
1643
+ }
1644
+
1645
+ .lutra-card__title {
1646
+ margin: 0;
1647
+ font-size: var(--lutra-text-h3);
1648
+ line-height: var(--lutra-type-line-height-h3);
1649
+ font-weight: var(--lutra-type-weight-semibold);
1650
+ color: var(--lutra-card-title);
1651
+
1652
+ /* Headings wrap rather than truncate. Truncation hides information from everyone equally,
1653
+ * including from assistive technology, which reads the full string while the reader cannot. */
1654
+ overflow-wrap: break-word;
1655
+ text-wrap: pretty;
1656
+ }
1657
+
1658
+ .lutra-card__supporting-text {
1659
+ margin: 0;
1660
+ font-size: var(--lutra-text-body-small);
1661
+ line-height: var(--lutra-type-line-height-body-small);
1662
+ color: var(--lutra-card-text);
1663
+ overflow-wrap: break-word;
1664
+ text-wrap: pretty;
1665
+ }
1666
+
1667
+ .lutra-card__body {
1668
+ font-size: var(--lutra-text-body);
1669
+ line-height: var(--lutra-type-line-height-body);
1670
+ color: var(--lutra-card-text);
1671
+ overflow-wrap: break-word;
1672
+ text-wrap: pretty;
1673
+
1674
+ /* Grows with its content. Nothing here depends on a particular string length, and cards in a row
1675
+ * are equal width with independent height. */
1676
+ min-inline-size: 0;
1677
+ }
1678
+
1679
+ /* The action row.
1680
+ *
1681
+ * `align` sets `justify-content` and nothing else. There is no `row-reverse`: it would put focus
1682
+ * order out of step with reading order, which is a defect that looks perfectly correct on screen. */
1683
+
1684
+ .lutra-card-footer {
1685
+ --lutra-card-footer-gap: var(--lutra-space-md);
1686
+
1687
+ display: flex;
1688
+ flex-wrap: wrap;
1689
+ align-items: center;
1690
+ gap: var(--lutra-card-footer-gap);
1691
+ }
1692
+
1693
+ .lutra-card-footer[data-align='start'] {
1694
+ justify-content: flex-start;
1695
+ }
1696
+
1697
+ .lutra-card-footer[data-align='end'] {
1698
+ justify-content: flex-end;
1699
+ }
1700
+
1701
+ /* Forced-colours modes: the shadow is not painted, so the border is the whole of the boundary. */
1702
+
1703
+ @media (forced-colors: active) {
1704
+ .lutra-card {
1705
+ border-color: CanvasText;
1706
+ }
1707
+ }
1708
+
1709
+ /*
1710
+ * Link Card.
1711
+ *
1712
+ * The same surface as Card, with one anchor whose pseudo-element covers it. The card is the
1713
+ * positioning context; the anchor is the only interactive thing in it.
1714
+ *
1715
+ * Hover and focus are selected from the platform — `:hover` and `:has(:focus-visible)` — never from a
1716
+ * prop, and both change more than colour so the state survives greyscale.
1717
+ */
1718
+
1719
+ .lutra-link-card {
1720
+ --lutra-link-card-surface: var(--lutra-color-background-surface);
1721
+ --lutra-link-card-border: var(--lutra-color-border-default);
1722
+ --lutra-link-card-border-hover: var(--lutra-color-border-strong);
1723
+ --lutra-link-card-border-width: var(--lutra-border-width-thin);
1724
+ --lutra-link-card-radius: var(--lutra-radius-lg);
1725
+ --lutra-link-card-padding: var(--lutra-space-2xl);
1726
+ --lutra-link-card-gap: var(--lutra-space-lg);
1727
+ --lutra-link-card-row-gap: var(--lutra-space-sm);
1728
+ --lutra-link-card-text: var(--lutra-component-link-text-default);
1729
+ --lutra-link-card-text-hover: var(--lutra-component-link-text-hover);
1730
+ --lutra-link-card-supporting-text: var(--lutra-color-text-secondary);
1731
+ --lutra-link-card-focus-ring-color: var(--lutra-color-focus-ring);
1732
+ --lutra-link-card-focus-ring-width: var(--lutra-border-width-thick);
1733
+
1734
+ /* The canonical `Elevation/Small` effect style, reproduced verbatim: 0 1px 2px black at 10%. Same
1735
+ * value and same reasoning as Card's raised elevation.
1736
+ *
1737
+ * NO FIGMA VARIABLE, and not an invented value either — the Elevation foundations page states that
1738
+ * elevation "is expressed as reusable effect styles, not numeric tokens", so there is nothing for
1739
+ * the token pipeline to read. The style is named here so the value stays traceable to it. */
1740
+ --lutra-link-card-shadow-hover: 0 1px 2px rgb(0 0 0 / 10%);
1741
+
1742
+ /* The positioning context the stretched pseudo-element resolves against. */
1743
+ position: relative;
1744
+
1745
+ box-sizing: border-box;
1746
+ display: flex;
1747
+ flex-direction: column;
1748
+ gap: var(--lutra-link-card-gap);
1749
+ padding: var(--lutra-link-card-padding);
1750
+
1751
+ border: var(--lutra-link-card-border-width) solid var(--lutra-link-card-border);
1752
+ border-radius: var(--lutra-link-card-radius);
1753
+ background-color: var(--lutra-link-card-surface);
1754
+ font-family: var(--lutra-type-family-sans);
1755
+ }
1756
+
1757
+ /* The heading holds the link and the chevron on one row. It is a heading, so it keeps its margin
1758
+ * reset and its own type — the level it renders at is a prop, and never implied by this size. */
1759
+
1760
+ .lutra-link-card__heading {
1761
+ display: flex;
1762
+ align-items: flex-start;
1763
+ gap: var(--lutra-link-card-row-gap);
1764
+ margin: 0;
1765
+ font-size: var(--lutra-text-h3);
1766
+ line-height: var(--lutra-type-line-height-h3);
1767
+ font-weight: var(--lutra-type-weight-semibold);
1768
+ min-inline-size: 0;
1769
+ }
1770
+
1771
+ .lutra-link-card__link {
1772
+ color: var(--lutra-link-card-text);
1773
+ text-decoration-line: none;
1774
+ min-inline-size: 0;
1775
+
1776
+ /* Headings wrap rather than truncate, here as everywhere. */
1777
+ overflow-wrap: break-word;
1778
+ text-wrap: pretty;
1779
+ }
1780
+
1781
+ /* What makes the whole surface clickable without putting the whole surface inside the anchor. The
1782
+ * anchor keeps its own small box — and therefore its own accessible name — while this covers the
1783
+ * card. */
1784
+ .lutra-link-card__link::after {
1785
+ content: '';
1786
+ position: absolute;
1787
+ inset: 0;
1788
+ border-radius: inherit;
1789
+ }
1790
+
1791
+ .lutra-link-card__chevron {
1792
+ /* Holds its size beside a wrapping heading, and aligns with the first line rather than centring
1793
+ * against a heading that may be three lines tall. */
1794
+ flex: 0 0 auto;
1795
+ inline-size: 1.5rem;
1796
+ block-size: 1.5rem;
1797
+ margin-block-start: 0.125rem;
1798
+ }
1799
+
1800
+ .lutra-link-card__supporting-text {
1801
+ margin: 0;
1802
+ font-size: var(--lutra-text-body-small);
1803
+ line-height: var(--lutra-type-line-height-body-small);
1804
+ font-weight: var(--lutra-type-weight-regular);
1805
+ color: var(--lutra-link-card-supporting-text);
1806
+ overflow-wrap: break-word;
1807
+ text-wrap: pretty;
1808
+ }
1809
+
1810
+ /* Hover raises the card, strengthens the border and underlines the link text. Selected on the card
1811
+ * rather than the anchor, because the pseudo-element means the pointer is over the card whenever it
1812
+ * is over the link. */
1813
+
1814
+ .lutra-link-card:hover {
1815
+ border-color: var(--lutra-link-card-border-hover);
1816
+ box-shadow: var(--lutra-link-card-shadow-hover);
1817
+ }
1818
+
1819
+ .lutra-link-card:hover .lutra-link-card__link {
1820
+ color: var(--lutra-link-card-text-hover);
1821
+ text-decoration-line: underline;
1822
+ text-decoration-thickness: from-font;
1823
+ text-underline-offset: 0.2em;
1824
+ }
1825
+
1826
+ /* Focus. Drawn on the card, because a ring around the anchor's own small box would not describe what
1827
+ * activating it does.
1828
+ *
1829
+ * Figma thickens the 1px border to a 2px stroke. An outline is used instead so the ring can appear
1830
+ * without a 1px layout shift that would nudge every card in a grid; it reads identically and keeps
1831
+ * the weight change the annotation asks for. `:focus-visible` so it answers the keyboard rather than
1832
+ * the pointer. */
1833
+
1834
+ .lutra-link-card:has(.lutra-link-card__link:focus-visible) {
1835
+ outline: var(--lutra-link-card-focus-ring-width) solid var(--lutra-link-card-focus-ring-color);
1836
+ outline-offset: 0;
1837
+ border-color: var(--lutra-link-card-focus-ring-color);
1838
+ }
1839
+
1840
+ .lutra-link-card:has(.lutra-link-card__link:focus-visible) .lutra-link-card__link {
1841
+ color: var(--lutra-link-card-text-hover);
1842
+ }
1843
+
1844
+ /* Forced-colours modes: the shadow is not painted and system colours replace the rest, so the border
1845
+ * carries the boundary and the ring is pinned to the system highlight. */
1846
+
1847
+ @media (forced-colors: active) {
1848
+ .lutra-link-card {
1849
+ border-color: CanvasText;
1850
+ }
1851
+
1852
+ .lutra-link-card:has(.lutra-link-card__link:focus-visible) {
1853
+ outline-color: Highlight;
1854
+ }
1855
+ }
1856
+
1857
+ /*
1858
+ * Table.
1859
+ *
1860
+ * Native table semantics throughout; this stylesheet only draws them. Every part carries its own
1861
+ * class rather than being reached through an element selector, which is why `TableRow` exists as a
1862
+ * component at all — the divider, the hover tint and the selected tint have to hang off something
1863
+ * prefixed with `lutra-`.
1864
+ *
1865
+ * Row dividers use `color/border/default` rather than `color/border/subtle`. Default carries more of
1866
+ * the separation in dark mode, where the surface tints sit close together, and the Table pattern says
1867
+ * so explicitly.
1868
+ */
1869
+
1870
+ .lutra-table-wrapper {
1871
+ --lutra-table-surface: var(--lutra-color-background-surface);
1872
+ --lutra-table-header-surface: var(--lutra-color-background-subtle);
1873
+ --lutra-table-row-hover: var(--lutra-color-background-subtle);
1874
+ --lutra-table-row-selected: var(--lutra-color-background-subtle);
1875
+ --lutra-table-border: var(--lutra-color-border-default);
1876
+ --lutra-table-border-width: var(--lutra-border-width-thin);
1877
+ --lutra-table-radius: var(--lutra-radius-md);
1878
+ --lutra-table-text: var(--lutra-color-text-primary);
1879
+ --lutra-table-text-muted: var(--lutra-color-text-secondary);
1880
+ --lutra-table-icon-muted: var(--lutra-color-text-muted);
1881
+ --lutra-table-padding-block: var(--lutra-space-md);
1882
+ --lutra-table-padding-inline: var(--lutra-space-lg);
1883
+ --lutra-table-focus-ring-color: var(--lutra-color-focus-ring);
1884
+ --lutra-table-focus-ring-width: var(--lutra-border-width-thick);
1885
+
1886
+ /* NO FIGMA VARIABLE. The 44px pointer target the whole library holds to, expressed here as a hit
1887
+ * area that grows into the cell padding rather than as a taller row — Figma draws the dashed 44px
1888
+ * outlines overlapping a 52px row for exactly this reason. */
1889
+ --lutra-table-hit-target: 2.75rem;
1890
+
1891
+ display: flex;
1892
+ flex-direction: column;
1893
+ font-family: var(--lutra-type-family-sans);
1894
+ }
1895
+
1896
+ /* The result count after a sort, filter or page change. Visible as well as announced — a count is
1897
+ * useful to everyone, and a live region nobody can see is easy to forget to maintain.
1898
+ *
1899
+ * The node is always in the DOM, because a live region only announces changes to a region that was
1900
+ * already there. Empty, it has no text and therefore no line box, so it draws nothing; the spacing
1901
+ * below it is applied only when it has content, which is why the wrapper carries no gap. It is never
1902
+ * hidden with `display: none` — that would remove it from the accessibility tree and silence it. */
1903
+
1904
+ .lutra-table__status {
1905
+ font-size: var(--lutra-text-body-small);
1906
+ line-height: var(--lutra-type-line-height-body-small);
1907
+ color: var(--lutra-table-text-muted);
1908
+ }
1909
+
1910
+ .lutra-table__status:not(:empty) {
1911
+ margin-block-end: var(--lutra-space-sm);
1912
+ }
1913
+
1914
+ /* The scroll region. Focusable only while it overflows, which is when there is something to reach. */
1915
+
1916
+ .lutra-table__scroll {
1917
+ overflow-x: auto;
1918
+ border: var(--lutra-table-border-width) solid var(--lutra-table-border);
1919
+ border-radius: var(--lutra-table-radius);
1920
+ background-color: var(--lutra-table-surface);
1921
+ }
1922
+
1923
+ .lutra-table__scroll:focus-visible {
1924
+ outline: var(--lutra-table-focus-ring-width) solid var(--lutra-table-focus-ring-color);
1925
+ outline-offset: 2px;
1926
+ }
1927
+
1928
+ .lutra-table {
1929
+ inline-size: 100%;
1930
+
1931
+ /* Collapsed, so a row's divider is one line rather than two touching borders. */
1932
+ border-collapse: collapse;
1933
+ color: var(--lutra-table-text);
1934
+ font-size: var(--lutra-text-body-small);
1935
+ line-height: var(--lutra-type-line-height-body-small);
1936
+ }
1937
+
1938
+ .lutra-table__caption {
1939
+ padding: var(--lutra-table-padding-block) var(--lutra-table-padding-inline);
1940
+ color: var(--lutra-table-text-muted);
1941
+ text-align: start;
1942
+ }
1943
+
1944
+ /* Visually hidden, but present for assistive technology. Scoped to the table rather than shipped as
1945
+ * a global utility — the stylesheet does not get to add global classes to a consuming page. */
1946
+
1947
+ .lutra-table__visually-hidden {
1948
+ position: absolute;
1949
+ inline-size: 1px;
1950
+ block-size: 1px;
1951
+ margin: -1px;
1952
+ padding: 0;
1953
+ overflow: hidden;
1954
+ white-space: nowrap;
1955
+ clip-path: inset(50%);
1956
+ border: 0;
1957
+ }
1958
+
1959
+ /* Rows. The divider sits on the row, so it spans the full width regardless of how many cells there
1960
+ * are. */
1961
+
1962
+ .lutra-table-row {
1963
+ border-block-start: var(--lutra-table-border-width) solid var(--lutra-table-border);
1964
+ }
1965
+
1966
+ .lutra-table-row:hover {
1967
+ background-color: var(--lutra-table-row-hover);
1968
+ }
1969
+
1970
+ /* The tint is supplemental. The checked box in the selection cell is the primary cue, which is what
1971
+ * keeps selection legible in greyscale, in high contrast and on paper. */
1972
+
1973
+ .lutra-table-row[data-selected='true'] {
1974
+ background-color: var(--lutra-table-row-selected);
1975
+ }
1976
+
1977
+ /* Header cells. */
1978
+
1979
+ .lutra-table-header-cell {
1980
+ padding: var(--lutra-table-padding-block) var(--lutra-table-padding-inline);
1981
+ background-color: var(--lutra-table-header-surface);
1982
+ color: var(--lutra-table-text);
1983
+ font-weight: var(--lutra-type-weight-semibold);
1984
+ text-align: start;
1985
+ vertical-align: middle;
1986
+ }
1987
+
1988
+ .lutra-table-header-cell[data-align='right'] {
1989
+ text-align: end;
1990
+ }
1991
+
1992
+ /* A sortable header holds a button. The button fills the cell so the whole header is the target,
1993
+ * while remaining a real button rather than a clickable `th`. */
1994
+
1995
+ .lutra-table-header-cell__button {
1996
+ display: inline-flex;
1997
+ align-items: center;
1998
+ gap: var(--lutra-space-sm);
1999
+ inline-size: 100%;
2000
+ margin: 0;
2001
+ padding: 0;
2002
+ border: 0;
2003
+ border-radius: var(--lutra-radius-sm);
2004
+ background: none;
2005
+ color: inherit;
2006
+ font: inherit;
2007
+ text-align: inherit;
2008
+ cursor: pointer;
2009
+ }
2010
+
2011
+ .lutra-table-header-cell[data-align='right'] .lutra-table-header-cell__button {
2012
+ justify-content: flex-end;
2013
+ }
2014
+
2015
+ .lutra-table-header-cell__button:focus-visible {
2016
+ outline: var(--lutra-table-focus-ring-width) solid var(--lutra-table-focus-ring-color);
2017
+ outline-offset: 2px;
2018
+ }
2019
+
2020
+ .lutra-table-header-cell__sort-icon {
2021
+ flex: 0 0 auto;
2022
+ inline-size: 1rem;
2023
+ block-size: 1rem;
2024
+ }
2025
+
2026
+ /* Unsorted is quieter than a live sort direction, but the glyph differs in all three states, so the
2027
+ * colour is never what carries the direction. */
2028
+
2029
+ .lutra-table-header-cell__sort-icon[data-state='unsorted'] {
2030
+ color: var(--lutra-table-icon-muted);
2031
+ }
2032
+
2033
+ /* Value cells. */
2034
+
2035
+ .lutra-table-cell {
2036
+ padding: var(--lutra-table-padding-block) var(--lutra-table-padding-inline);
2037
+ text-align: start;
2038
+ vertical-align: top;
2039
+ overflow-wrap: break-word;
2040
+ }
2041
+
2042
+ .lutra-table-cell[data-emphasis='strong'] {
2043
+ font-weight: var(--lutra-type-weight-semibold);
2044
+ color: var(--lutra-table-text);
2045
+ }
2046
+
2047
+ .lutra-table-cell[data-emphasis='muted'] {
2048
+ color: var(--lutra-table-text-muted);
2049
+ }
2050
+
2051
+ /* Right-aligned cells are numeric by convention, and numbers only line up column-wise in tabular
2052
+ * figures. Figma cannot express the OpenType feature, so the design depends on this rule existing. */
2053
+
2054
+ .lutra-table-cell[data-align='right'] {
2055
+ text-align: end;
2056
+ font-variant-numeric: tabular-nums;
2057
+ }
2058
+
2059
+ /* Selection cells. */
2060
+
2061
+ .lutra-table-selection-cell {
2062
+ inline-size: 1px;
2063
+ padding: var(--lutra-table-padding-block) var(--lutra-table-padding-inline);
2064
+ vertical-align: top;
2065
+ }
2066
+
2067
+ .lutra-table-selection-cell__label {
2068
+ display: inline-flex;
2069
+ align-items: center;
2070
+
2071
+ /* The label is the 44px target for the checkbox inside it, without changing the drawn control. */
2072
+ min-inline-size: var(--lutra-table-hit-target);
2073
+ min-block-size: var(--lutra-table-hit-target);
2074
+ margin-block: calc((var(--lutra-table-hit-target) - 1.5rem) / -2);
2075
+ }
2076
+
2077
+ /* Row actions.
2078
+ *
2079
+ * The buttons stay compact so rows stay dense; the pointer target grows into the cell padding
2080
+ * instead of adding to the row height. */
2081
+
2082
+ .lutra-table-row-actions {
2083
+ padding: var(--lutra-space-sm) var(--lutra-table-padding-inline);
2084
+ text-align: end;
2085
+ vertical-align: middle;
2086
+ white-space: nowrap;
2087
+ }
2088
+
2089
+ .lutra-table-row-actions__row {
2090
+ display: flex;
2091
+ align-items: center;
2092
+ justify-content: flex-end;
2093
+ gap: var(--lutra-space-sm);
2094
+ }
2095
+
2096
+ .lutra-table-row-actions__row > * {
2097
+ position: relative;
2098
+ }
2099
+
2100
+ /* The invisible 44 × 44px target, centred on the control and overlapping the row padding. The row
2101
+ * below stays its own height; only the hit area grows. */
2102
+
2103
+ .lutra-table-row-actions__row > *::before {
2104
+ content: '';
2105
+ position: absolute;
2106
+ inset-block-start: 50%;
2107
+ inset-inline-start: 50%;
2108
+ translate: -50% -50%;
2109
+ min-inline-size: var(--lutra-table-hit-target);
2110
+ min-block-size: var(--lutra-table-hit-target);
2111
+ inline-size: 100%;
2112
+ block-size: 100%;
2113
+ }
2114
+
2115
+ /* Empty state. The structure around it stays; only the rows are gone. */
2116
+
2117
+ .lutra-table-empty-state__cell {
2118
+ padding: var(--lutra-space-3xl) var(--lutra-table-padding-inline);
2119
+ }
2120
+
2121
+ .lutra-table-empty-state__content {
2122
+ display: flex;
2123
+ flex-direction: column;
2124
+ align-items: center;
2125
+ gap: var(--lutra-space-md);
2126
+ text-align: center;
2127
+ }
2128
+
2129
+ .lutra-table-empty-state__icon {
2130
+ inline-size: 1.5rem;
2131
+ block-size: 1.5rem;
2132
+ color: var(--lutra-table-icon-muted);
2133
+ }
2134
+
2135
+ .lutra-table-empty-state__title {
2136
+ margin: 0;
2137
+ font-size: var(--lutra-text-body);
2138
+ line-height: var(--lutra-type-line-height-body);
2139
+ color: var(--lutra-table-text);
2140
+ }
2141
+
2142
+ .lutra-table-empty-state__body {
2143
+ margin: 0;
2144
+ color: var(--lutra-table-text-muted);
2145
+ text-wrap: pretty;
2146
+ }
2147
+
2148
+ /* Forced-colours modes: tints are not painted, so the dividers and the border carry the structure,
2149
+ * and the focus ring is pinned to the system highlight. */
2150
+
2151
+ @media (forced-colors: active) {
2152
+ .lutra-table-row,
2153
+ .lutra-table__scroll {
2154
+ border-color: CanvasText;
2155
+ }
2156
+
2157
+ .lutra-table__scroll:focus-visible,
2158
+ .lutra-table-header-cell__button:focus-visible {
2159
+ outline-color: Highlight;
2160
+ }
2161
+ }
2162
+
2163
+ /*
2164
+ * Input.
2165
+ *
2166
+ * The public styling contract is the `lutra-input` class plus the `data-size` and
2167
+ * `data-full-width` attributes, and `aria-invalid` for the error state. Hover,
2168
+ * focus and disabled are selected from the platform — `:hover`, `:focus-visible`,
2169
+ * `:disabled` — never from a prop.
2170
+ *
2171
+ * Colour comes from Figma's `component/input/*` alias layer, so the design system
2172
+ * decides what an input surface, its text, its placeholder and each of its border
2173
+ * states are. Sizes, spacing and type come from the core scales. The values with
2174
+ * no Figma variable behind them are marked.
2175
+ *
2176
+ * The error state is styled from `aria-invalid` rather than a `data-*` attribute
2177
+ * of its own. There is then no way for the field to look wrong while reporting
2178
+ * itself valid, because the attribute that assistive technology reads is the same
2179
+ * one the stylesheet matches on.
2180
+ */
2181
+
2182
+ .lutra-input {
2183
+ /* Surface and text. */
2184
+ --lutra-input-surface: var(--lutra-component-input-surface);
2185
+ --lutra-input-text: var(--lutra-component-input-text);
2186
+ --lutra-input-placeholder: var(--lutra-component-input-placeholder);
2187
+
2188
+ /* Border, one token per state. */
2189
+ --lutra-input-border: var(--lutra-component-input-border-default);
2190
+ --lutra-input-border-hover: var(--lutra-component-input-border-hover);
2191
+ --lutra-input-border-focus: var(--lutra-component-input-border-focus);
2192
+ --lutra-input-border-invalid: var(--lutra-component-input-border-invalid);
2193
+
2194
+ /* Disabled.
2195
+ *
2196
+ * NO `component/input/*` ALIAS. Figma binds the core disabled tokens directly
2197
+ * here — the Input alias layer has surface, text, placeholder and four border
2198
+ * states, but no disabled trio. These are the tokens the Figma component
2199
+ * actually uses, so they are what this reproduces; the gap is recorded in
2200
+ * `input.md` as something for the design system to close. */
2201
+ --lutra-input-disabled-surface: var(--lutra-color-background-disabled);
2202
+ --lutra-input-disabled-border: var(--lutra-color-border-disabled);
2203
+ --lutra-input-disabled-text: var(--lutra-color-text-disabled);
2204
+
2205
+ /* Shape and type. */
2206
+ --lutra-input-radius: var(--lutra-radius-md);
2207
+ --lutra-input-border-width: var(--lutra-border-width-thin);
2208
+ --lutra-input-border-width-focus: var(--lutra-border-width-thick);
2209
+ --lutra-input-font-family: var(--lutra-type-family-sans);
2210
+ --lutra-input-font-weight: var(--lutra-type-weight-medium);
2211
+
2212
+ /* Per size: 8px, 12px, 16px of inline padding. */
2213
+ --lutra-input-padding-inline-sm: var(--lutra-space-sm);
2214
+ --lutra-input-padding-inline-md: var(--lutra-space-md);
2215
+ --lutra-input-padding-inline-lg: var(--lutra-space-lg);
2216
+
2217
+ /* 14px, 16px, 18px. */
2218
+ --lutra-input-font-size-sm: var(--lutra-text-body-small);
2219
+ --lutra-input-font-size-md: var(--lutra-text-body);
2220
+ --lutra-input-font-size-lg: var(--lutra-text-h3);
2221
+
2222
+ /* The leading paired with each size in the type ramp. Figma draws a flat 120%
2223
+ * on this component with no variable behind it; inside a fixed-height control
2224
+ * the two are visually identical, and using the ramp keeps the field consistent
2225
+ * with every other piece of text at the same size. */
2226
+ --lutra-input-line-height-sm: var(--lutra-type-line-height-body-small);
2227
+ --lutra-input-line-height-md: var(--lutra-type-line-height-body);
2228
+ --lutra-input-line-height-lg: var(--lutra-type-line-height-h3);
2229
+
2230
+ /* NO FIGMA VARIABLE. Figma draws inputs at a fixed 36/40/44px; these become
2231
+ * `min-block-size` instead, because a fixed height on a text-bearing control
2232
+ * breaks at 200% zoom. `rem` so they grow with the reader's font size. */
2233
+ --lutra-input-min-height-sm: 2.25rem;
2234
+ --lutra-input-min-height-md: 2.5rem;
2235
+ --lutra-input-min-height-lg: 2.75rem;
2236
+
2237
+ /* NO FIGMA VARIABLE. Figma centres the value inside a fixed height; this is the
2238
+ * vertical breathing room the text needs once the field is free to grow. */
2239
+ --lutra-input-padding-block: var(--lutra-space-2xs);
2240
+
2241
+ box-sizing: border-box;
2242
+ inline-size: 100%;
2243
+ max-inline-size: 100%;
2244
+ margin: 0;
2245
+ padding-block: var(--lutra-input-padding-block);
2246
+ border-style: solid;
2247
+ border-width: var(--lutra-input-border-width);
2248
+ border-color: var(--lutra-input-border);
2249
+ border-radius: var(--lutra-input-radius);
2250
+ background-color: var(--lutra-input-surface);
2251
+ color: var(--lutra-input-text);
2252
+ font-family: var(--lutra-input-font-family);
2253
+ font-weight: var(--lutra-input-font-weight);
2254
+
2255
+ /* Strips the platform's own field chrome — notably iOS Safari's inner shadow
2256
+ * and rounded corners — so the token values are what shows. */
2257
+ appearance: none;
2258
+ }
2259
+
2260
+ /* Sizes.
2261
+ *
2262
+ * `min-block-size` rather than `height`: it reproduces the Figma height at the
2263
+ * default font size and lets the field grow when the reader raises theirs. */
2264
+
2265
+ .lutra-input[data-size='sm'] {
2266
+ min-block-size: var(--lutra-input-min-height-sm);
2267
+ padding-inline: var(--lutra-input-padding-inline-sm);
2268
+ font-size: var(--lutra-input-font-size-sm);
2269
+ line-height: var(--lutra-input-line-height-sm);
2270
+ }
2271
+
2272
+ .lutra-input[data-size='md'] {
2273
+ min-block-size: var(--lutra-input-min-height-md);
2274
+ padding-inline: var(--lutra-input-padding-inline-md);
2275
+ font-size: var(--lutra-input-font-size-md);
2276
+ line-height: var(--lutra-input-line-height-md);
2277
+ }
2278
+
2279
+ .lutra-input[data-size='lg'] {
2280
+ min-block-size: var(--lutra-input-min-height-lg);
2281
+ padding-inline: var(--lutra-input-padding-inline-lg);
2282
+ font-size: var(--lutra-input-font-size-lg);
2283
+ line-height: var(--lutra-input-line-height-lg);
2284
+ }
2285
+
2286
+ /* Placeholder.
2287
+ *
2288
+ * A different token from the value, so an empty field and a filled one are
2289
+ * distinguishable at a glance. Before this distinction existed both resolved to
2290
+ * the same colour. It is an emphasis difference only — the placeholder still
2291
+ * measures above 4.5:1 on both surfaces, because a placeholder is text and
2292
+ * dimming it below AA to sharpen the contrast between the two is not a trade this
2293
+ * library makes. `opacity: 1` overrides the faded default Firefox applies. */
2294
+
2295
+ .lutra-input::placeholder {
2296
+ color: var(--lutra-input-placeholder);
2297
+ opacity: 1;
2298
+ }
2299
+
2300
+ /* Interaction states. */
2301
+
2302
+ .lutra-input:hover:not(:disabled) {
2303
+ border-color: var(--lutra-input-border-hover);
2304
+ }
2305
+
2306
+ /* Focus.
2307
+ *
2308
+ * Input draws focus by thickening its own border rather than adding a separate
2309
+ * ring — a deliberate difference from Button, using the same `color/focus/ring`
2310
+ * source. The thickening is not decoration: the focus colour against the resting
2311
+ * border is only about 1.1:1, so a colour change alone would not be a perceivable
2312
+ * indicator. The added pixel replaces surface, where the focus colour reaches
2313
+ * roughly 3.8:1 on light and clears the 3:1 that WCAG 2.4.11 asks of the changed
2314
+ * area. Removing the thickening would break the indicator.
2315
+ *
2316
+ * That pixel is added as an inset shadow rather than by raising `border-width`,
2317
+ * so the value does not shift sideways when the field takes focus.
2318
+ *
2319
+ * The native outline is replaced, not removed — and `forced-colors` below covers
2320
+ * the mode where a shadow is not painted at all. */
2321
+
2322
+ .lutra-input:focus-visible {
2323
+ border-color: var(--lutra-input-border-focus);
2324
+ box-shadow: inset 0 0 0
2325
+ calc(var(--lutra-input-border-width-focus) - var(--lutra-input-border-width))
2326
+ var(--lutra-input-border-focus);
2327
+ outline: none;
2328
+ }
2329
+
2330
+ /* Invalid.
2331
+ *
2332
+ * Matched on `aria-invalid` so the look and the announced state are the same
2333
+ * fact. Focus is ordered after this on purpose: a focused invalid field shows the
2334
+ * focus indicator, because losing track of focus is the more serious failure and
2335
+ * the error is still announced by `aria-invalid` and carried by the message the
2336
+ * field wrapper renders. Figma does not draw the focused-and-invalid combination;
2337
+ * this is recorded in `input.md`. */
2338
+
2339
+ .lutra-input[aria-invalid='true'] {
2340
+ border-color: var(--lutra-input-border-invalid);
2341
+ }
2342
+
2343
+ .lutra-input[aria-invalid='true']:focus-visible {
2344
+ border-color: var(--lutra-input-border-focus);
2345
+ }
2346
+
2347
+ /* Disabled.
2348
+ *
2349
+ * Deliberate colours rather than an opacity wash, so the state is a real visual
2350
+ * change. It is never the only signal: the field is natively disabled, so it is
2351
+ * skipped by Tab, announced as unavailable, and rejects input.
2352
+ *
2353
+ * `-webkit-text-fill-color` is not redundant. iOS Safari renders disabled field
2354
+ * text at reduced opacity and ignores `color`, which would wash out the chosen
2355
+ * disabled token and undo the point of picking one. */
2356
+
2357
+ .lutra-input:disabled {
2358
+ border-color: var(--lutra-input-disabled-border);
2359
+ background-color: var(--lutra-input-disabled-surface);
2360
+ color: var(--lutra-input-disabled-text);
2361
+ -webkit-text-fill-color: var(--lutra-input-disabled-text);
2362
+ cursor: not-allowed;
2363
+ }
2364
+
2365
+ .lutra-input:disabled::placeholder {
2366
+ color: var(--lutra-input-disabled-text);
2367
+ -webkit-text-fill-color: var(--lutra-input-disabled-text);
2368
+ }
2369
+
2370
+ /* Read-only keeps the normal visual treatment. It stays focusable and its value
2371
+ * stays selectable and copyable, which is the behaviour that distinguishes it
2372
+ * from disabled, so it must not be dressed up to look unavailable. */
2373
+
2374
+ /* Full width. Layout only.
2375
+ *
2376
+ * The base rule already fills the inline axis, which is what a field in a form
2377
+ * almost always wants. `data-full-width` additionally opts out of any width a
2378
+ * layout has constrained it to. */
2379
+
2380
+ .lutra-input[data-full-width] {
2381
+ inline-size: 100%;
2382
+ }
2383
+
2384
+ /* Forced-colours modes.
2385
+ *
2386
+ * The system replaces the token colours, which is the point of the mode. What it
2387
+ * does not do is paint the inset shadow the focus indicator is built from, so
2388
+ * focus is restored here as a real outline in the system highlight colour.
2389
+ * Disabled fields already pick up `GrayText` from the native attribute. */
2390
+
2391
+ @media (forced-colors: active) {
2392
+ .lutra-input:focus-visible {
2393
+ outline: var(--lutra-input-border-width-focus) solid Highlight;
2394
+ outline-offset: 1px;
2395
+ }
2396
+ }
2397
+
2398
+ /*
2399
+ * Select.
2400
+ *
2401
+ * The public styling contract is the `lutra-select` class plus `data-size` and `data-full-width`,
2402
+ * and `aria-invalid` for the error state — the same contract Input uses, and for the same reason:
2403
+ * matching on the ARIA attribute means the look and the announced state cannot drift apart.
2404
+ *
2405
+ * Figma binds Select to the `component/input/*` alias layer rather than one of its own, so this
2406
+ * consumes exactly the same tokens Input does. That is the design system's decision, not a
2407
+ * shortcut: a select and a text field are the same surface with the same border states.
2408
+ *
2409
+ * ## Why there is a wrapper
2410
+ *
2411
+ * A `<select>` cannot render a pseudo-element, and a `background-image` chevron would have to be a
2412
+ * fixed-colour asset — wrong in the other theme and wrong when disabled. The wrapper exists solely
2413
+ * so the chevron can be a mask filled with a token colour. It is the smallest thing that keeps the
2414
+ * chevron on the token system, and it is why this component has one when Input does not.
2415
+ */
2416
+
2417
+ .lutra-select-wrapper {
2418
+ --lutra-select-chevron-size: 1rem;
2419
+ --lutra-select-chevron-color: var(--lutra-component-input-text);
2420
+
2421
+ /* NO FIGMA VARIABLE. Figma lays the chevron out with auto-layout; in CSS the trailing space has
2422
+ * to be reserved explicitly, so it is the leading padding plus the icon plus a gap. */
2423
+ --lutra-select-chevron-gap: var(--lutra-space-sm);
2424
+
2425
+ position: relative;
2426
+ display: block;
2427
+ inline-size: 100%;
2428
+ }
2429
+
2430
+ /* The chevron. A mask filled with `background-color`, so the glyph takes a token colour and follows
2431
+ * the theme and the disabled state, rather than being a fixed-colour image. `pointer-events: none`
2432
+ * keeps clicks going to the control underneath. */
2433
+
2434
+ .lutra-select-wrapper::after {
2435
+ content: '';
2436
+ position: absolute;
2437
+ inset-inline-end: var(--lutra-select-chevron-inset, var(--lutra-space-md));
2438
+ inset-block-start: 50%;
2439
+ transform: translateY(-50%);
2440
+ inline-size: var(--lutra-select-chevron-size);
2441
+ block-size: var(--lutra-select-chevron-size);
2442
+ background-color: var(--lutra-select-chevron-color);
2443
+ pointer-events: none;
2444
+
2445
+ --lutra-select-chevron-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
2446
+ -webkit-mask-image: var(--lutra-select-chevron-glyph);
2447
+ mask-image: var(--lutra-select-chevron-glyph);
2448
+ -webkit-mask-repeat: no-repeat;
2449
+ mask-repeat: no-repeat;
2450
+ -webkit-mask-size: 100% 100%;
2451
+ mask-size: 100% 100%;
2452
+ }
2453
+
2454
+ /* The chevron greys out with the control, because it is part of the control. */
2455
+ .lutra-select-wrapper:has(> .lutra-select:disabled)::after {
2456
+ --lutra-select-chevron-color: var(--lutra-color-text-disabled);
2457
+ }
2458
+
2459
+ .lutra-select {
2460
+ /* Shared with Input, by way of Figma's own bindings. */
2461
+ --lutra-select-surface: var(--lutra-component-input-surface);
2462
+ --lutra-select-text: var(--lutra-component-input-text);
2463
+ --lutra-select-border: var(--lutra-component-input-border-default);
2464
+ --lutra-select-border-hover: var(--lutra-component-input-border-hover);
2465
+ --lutra-select-border-focus: var(--lutra-component-input-border-focus);
2466
+ --lutra-select-border-invalid: var(--lutra-component-input-border-invalid);
2467
+
2468
+ /* NO `component/input/*` ALIAS. Figma binds the core disabled tokens directly, exactly as it does
2469
+ * on Input; the alias layer has no disabled trio. See `select.md`. */
2470
+ --lutra-select-disabled-surface: var(--lutra-color-background-disabled);
2471
+ --lutra-select-disabled-border: var(--lutra-color-border-disabled);
2472
+ --lutra-select-disabled-text: var(--lutra-color-text-disabled);
2473
+
2474
+ --lutra-select-radius: var(--lutra-radius-md);
2475
+ --lutra-select-border-width: var(--lutra-border-width-thin);
2476
+ --lutra-select-border-width-focus: var(--lutra-border-width-thick);
2477
+ --lutra-select-font-family: var(--lutra-type-family-sans);
2478
+ --lutra-select-font-weight: var(--lutra-type-weight-medium);
2479
+
2480
+ --lutra-select-padding-inline-sm: var(--lutra-space-sm);
2481
+ --lutra-select-padding-inline-md: var(--lutra-space-md);
2482
+ --lutra-select-padding-inline-lg: var(--lutra-space-lg);
2483
+
2484
+ --lutra-select-font-size-sm: var(--lutra-text-body-small);
2485
+ --lutra-select-font-size-md: var(--lutra-text-body);
2486
+ --lutra-select-font-size-lg: var(--lutra-text-h3);
2487
+
2488
+ --lutra-select-line-height-sm: var(--lutra-type-line-height-body-small);
2489
+ --lutra-select-line-height-md: var(--lutra-type-line-height-body);
2490
+ --lutra-select-line-height-lg: var(--lutra-type-line-height-h3);
2491
+
2492
+ /* NO FIGMA VARIABLE. Figma draws 36/40/44px fixed heights; these become `min-block-size` so the
2493
+ * control grows with the reader's font size instead of clipping at 200% zoom. */
2494
+ --lutra-select-min-height-sm: 2.25rem;
2495
+ --lutra-select-min-height-md: 2.5rem;
2496
+ --lutra-select-min-height-lg: 2.75rem;
2497
+ --lutra-select-padding-block: var(--lutra-space-2xs);
2498
+
2499
+ box-sizing: border-box;
2500
+ inline-size: 100%;
2501
+ max-inline-size: 100%;
2502
+ margin: 0;
2503
+ padding-block: var(--lutra-select-padding-block);
2504
+ border-style: solid;
2505
+ border-width: var(--lutra-select-border-width);
2506
+ border-color: var(--lutra-select-border);
2507
+ border-radius: var(--lutra-select-radius);
2508
+ background-color: var(--lutra-select-surface);
2509
+ color: var(--lutra-select-text);
2510
+ font-family: var(--lutra-select-font-family);
2511
+ font-weight: var(--lutra-select-font-weight);
2512
+ cursor: pointer;
2513
+
2514
+ /* Removes the platform's own dropdown arrow, so the Figma chevron is the only one drawn. */
2515
+ appearance: none;
2516
+ }
2517
+
2518
+ /* Sizes. `min-block-size` rather than `height`, so the control grows with the reader's font size.
2519
+ * Trailing padding reserves room for the chevron. */
2520
+
2521
+ .lutra-select[data-size='sm'] {
2522
+ min-block-size: var(--lutra-select-min-height-sm);
2523
+ padding-inline: var(--lutra-select-padding-inline-sm)
2524
+ calc(
2525
+ var(--lutra-select-padding-inline-sm) + var(--lutra-select-chevron-size) +
2526
+ var(--lutra-select-chevron-gap)
2527
+ );
2528
+ font-size: var(--lutra-select-font-size-sm);
2529
+ line-height: var(--lutra-select-line-height-sm);
2530
+ }
2531
+
2532
+ .lutra-select[data-size='md'] {
2533
+ min-block-size: var(--lutra-select-min-height-md);
2534
+ padding-inline: var(--lutra-select-padding-inline-md)
2535
+ calc(
2536
+ var(--lutra-select-padding-inline-md) + var(--lutra-select-chevron-size) +
2537
+ var(--lutra-select-chevron-gap)
2538
+ );
2539
+ font-size: var(--lutra-select-font-size-md);
2540
+ line-height: var(--lutra-select-line-height-md);
2541
+ }
2542
+
2543
+ .lutra-select[data-size='lg'] {
2544
+ min-block-size: var(--lutra-select-min-height-lg);
2545
+ padding-inline: var(--lutra-select-padding-inline-lg)
2546
+ calc(
2547
+ var(--lutra-select-padding-inline-lg) + var(--lutra-select-chevron-size) +
2548
+ var(--lutra-select-chevron-gap)
2549
+ );
2550
+ font-size: var(--lutra-select-font-size-lg);
2551
+ line-height: var(--lutra-select-line-height-lg);
2552
+ }
2553
+
2554
+ /* The chevron sits at the same inset as the control's own padding, per size. */
2555
+
2556
+ .lutra-select-wrapper:has(> .lutra-select[data-size='sm']) {
2557
+ --lutra-select-chevron-inset: var(--lutra-space-sm);
2558
+ }
2559
+
2560
+ .lutra-select-wrapper:has(> .lutra-select[data-size='md']) {
2561
+ --lutra-select-chevron-inset: var(--lutra-space-md);
2562
+ }
2563
+
2564
+ .lutra-select-wrapper:has(> .lutra-select[data-size='lg']) {
2565
+ --lutra-select-chevron-inset: var(--lutra-space-lg);
2566
+ }
2567
+
2568
+ /* Interaction states, matching Input. */
2569
+
2570
+ .lutra-select:hover:not(:disabled) {
2571
+ border-color: var(--lutra-select-border-hover);
2572
+ }
2573
+
2574
+ /* Focus thickens the control's own border, as Input does. The extra pixel is an inset shadow rather
2575
+ * than a wider border, so the value does not shift sideways on focus — and it is what makes the
2576
+ * indicator perceivable: the focus colour against the resting border is only about 1.1:1, while the
2577
+ * added pixel replaces surface, where it clears 3:1. */
2578
+
2579
+ .lutra-select:focus-visible {
2580
+ border-color: var(--lutra-select-border-focus);
2581
+ box-shadow: inset 0 0 0
2582
+ calc(var(--lutra-select-border-width-focus) - var(--lutra-select-border-width))
2583
+ var(--lutra-select-border-focus);
2584
+ outline: none;
2585
+ }
2586
+
2587
+ .lutra-select[aria-invalid='true'] {
2588
+ border-color: var(--lutra-select-border-invalid);
2589
+ }
2590
+
2591
+ .lutra-select[aria-invalid='true']:focus-visible {
2592
+ border-color: var(--lutra-select-border-focus);
2593
+ }
2594
+
2595
+ .lutra-select:disabled {
2596
+ border-color: var(--lutra-select-disabled-border);
2597
+ background-color: var(--lutra-select-disabled-surface);
2598
+ color: var(--lutra-select-disabled-text);
2599
+ -webkit-text-fill-color: var(--lutra-select-disabled-text);
2600
+ cursor: not-allowed;
2601
+ }
2602
+
2603
+ /* The options popup is drawn by the operating system, not by this stylesheet. Setting a colour on
2604
+ * the options is the one thing that reliably keeps it readable in dark mode on the browsers that
2605
+ * respect it, without pretending the popup is ours to style. */
2606
+
2607
+ .lutra-select option {
2608
+ background-color: var(--lutra-select-surface);
2609
+ color: var(--lutra-select-text);
2610
+ }
2611
+
2612
+ /* Forced-colours modes. The mask is painted with `background-color`, which the system replaces, so
2613
+ * the chevron survives. The inset focus shadow is not painted, so focus is restored as an outline. */
2614
+
2615
+ @media (forced-colors: active) {
2616
+ .lutra-select:focus-visible {
2617
+ outline: var(--lutra-select-border-width-focus) solid Highlight;
2618
+ outline-offset: 1px;
2619
+ }
2620
+ }
2621
+
2622
+ /*
2623
+ * Checkbox, and the choice-item layout shared with Radio and Toggle.
2624
+ *
2625
+ * `.lutra-choice*` lives here because Checkbox is the first component to need it, the same way the
2626
+ * `.lutra-field*` shell lives in `field.css` and serves Select Field too. Radio Item and Toggle Item
2627
+ * reuse these classes rather than each defining their own — one label treatment, one description
2628
+ * treatment, one gap.
2629
+ *
2630
+ * The control is a native `<input type="checkbox">` with `appearance: none`, drawn with its own
2631
+ * pseudo-element. Once appearance is none the input is an ordinary box that accepts `::after`,
2632
+ * which is what lets the mark be a mask filled with a token colour rather than a fixed-colour
2633
+ * image that would be wrong in dark mode.
2634
+ *
2635
+ * The mixed state is selected with the native `:indeterminate` pseudo-class, which matches the DOM
2636
+ * property assistive technology reads. There is no mirrored data attribute to fall out of step.
2637
+ */
2638
+
2639
+ .lutra-checkbox {
2640
+ --lutra-checkbox-size: 1.25rem;
2641
+ --lutra-checkbox-surface: var(--lutra-component-checkbox-surface);
2642
+ --lutra-checkbox-border: var(--lutra-component-checkbox-border-default);
2643
+ --lutra-checkbox-border-hover: var(--lutra-component-checkbox-border-hover);
2644
+ --lutra-checkbox-border-invalid: var(--lutra-component-checkbox-border-invalid);
2645
+ --lutra-checkbox-border-disabled: var(--lutra-component-checkbox-border-disabled);
2646
+ --lutra-checkbox-fill-selected: var(--lutra-component-checkbox-fill-selected);
2647
+ --lutra-checkbox-fill-selected-hover: var(--lutra-component-checkbox-fill-selected-hover);
2648
+ --lutra-checkbox-fill-invalid: var(--lutra-component-checkbox-fill-invalid);
2649
+ --lutra-checkbox-fill-disabled: var(--lutra-component-checkbox-fill-disabled);
2650
+ --lutra-checkbox-mark: var(--lutra-component-checkbox-mark);
2651
+ --lutra-checkbox-mark-on-danger: var(--lutra-component-checkbox-mark-on-danger);
2652
+ --lutra-checkbox-mark-disabled: var(--lutra-component-checkbox-text-disabled);
2653
+
2654
+ --lutra-checkbox-radius: var(--lutra-radius-sm);
2655
+ --lutra-checkbox-border-width: var(--lutra-border-width-thick);
2656
+ --lutra-checkbox-focus-ring-color: var(--lutra-color-focus-ring);
2657
+ --lutra-checkbox-focus-ring-width: var(--lutra-border-width-thick);
2658
+
2659
+ /* NO FIGMA VARIABLE. Figma draws the focus state as a 28px ring around the 20px box, which is a
2660
+ * 2px ring at a 2px offset — the same geometry Button uses. */
2661
+ --lutra-checkbox-focus-ring-offset: 2px;
2662
+
2663
+ box-sizing: border-box;
2664
+ flex: 0 0 auto;
2665
+ display: grid;
2666
+ place-content: center;
2667
+ inline-size: var(--lutra-checkbox-size);
2668
+ block-size: var(--lutra-checkbox-size);
2669
+ margin: 0;
2670
+ border: var(--lutra-checkbox-border-width) solid var(--lutra-checkbox-border);
2671
+ border-radius: var(--lutra-checkbox-radius);
2672
+ background-color: var(--lutra-checkbox-surface);
2673
+ cursor: pointer;
2674
+ appearance: none;
2675
+ }
2676
+
2677
+ /* The mark. A mask filled with a token colour, so it follows the theme and the disabled state. */
2678
+
2679
+ .lutra-checkbox::after {
2680
+ content: '';
2681
+ inline-size: 0.75rem;
2682
+ block-size: 0.75rem;
2683
+ background-color: var(--lutra-checkbox-mark);
2684
+
2685
+ /* Nothing to show until the box is checked or mixed. */
2686
+ -webkit-mask-image: none;
2687
+ mask-image: none;
2688
+ -webkit-mask-repeat: no-repeat;
2689
+ mask-repeat: no-repeat;
2690
+ -webkit-mask-position: center;
2691
+ mask-position: center;
2692
+ -webkit-mask-size: 100% 100%;
2693
+ mask-size: 100% 100%;
2694
+ }
2695
+
2696
+ .lutra-checkbox:checked:not(:indeterminate) {
2697
+ --lutra-checkbox-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 6.5l2.5 2.5L10 3.5'/%3E%3C/svg%3E");
2698
+ }
2699
+
2700
+ /* The mixed state is a dash, and it wins over the tick: a box can be checked in the DOM and mixed
2701
+ * at the same time, and mixed is what is being communicated. */
2702
+ .lutra-checkbox:indeterminate {
2703
+ --lutra-checkbox-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M3 6h6'/%3E%3C/svg%3E");
2704
+ }
2705
+
2706
+ .lutra-checkbox:checked::after,
2707
+ .lutra-checkbox:indeterminate::after {
2708
+ -webkit-mask-image: var(--lutra-checkbox-check);
2709
+ mask-image: var(--lutra-checkbox-check);
2710
+ }
2711
+
2712
+ .lutra-checkbox:checked,
2713
+ .lutra-checkbox:indeterminate {
2714
+ border-color: var(--lutra-checkbox-fill-selected);
2715
+ background-color: var(--lutra-checkbox-fill-selected);
2716
+ }
2717
+
2718
+ /* Interaction states. */
2719
+
2720
+ .lutra-checkbox:hover:not(:disabled) {
2721
+ border-color: var(--lutra-checkbox-border-hover);
2722
+ }
2723
+
2724
+ .lutra-checkbox:checked:hover:not(:disabled),
2725
+ .lutra-checkbox:indeterminate:hover:not(:disabled) {
2726
+ border-color: var(--lutra-checkbox-fill-selected-hover);
2727
+ background-color: var(--lutra-checkbox-fill-selected-hover);
2728
+ }
2729
+
2730
+ /* Focus is a ring outside the box, not a border change: the box's own border already carries the
2731
+ * checked state, so changing it would make two states fight for the same pixel. */
2732
+
2733
+ .lutra-checkbox:focus-visible {
2734
+ outline: var(--lutra-checkbox-focus-ring-width) solid var(--lutra-checkbox-focus-ring-color);
2735
+ outline-offset: var(--lutra-checkbox-focus-ring-offset);
2736
+ }
2737
+
2738
+ /* Invalid, matched on the ARIA attribute so the look and the announced state are one fact. */
2739
+
2740
+ .lutra-checkbox[aria-invalid='true'] {
2741
+ border-color: var(--lutra-checkbox-border-invalid);
2742
+ }
2743
+
2744
+ .lutra-checkbox[aria-invalid='true']:checked,
2745
+ .lutra-checkbox[aria-invalid='true']:indeterminate {
2746
+ border-color: var(--lutra-checkbox-border-invalid);
2747
+ background-color: var(--lutra-checkbox-fill-invalid);
2748
+ }
2749
+
2750
+ .lutra-checkbox[aria-invalid='true']:checked::after,
2751
+ .lutra-checkbox[aria-invalid='true']:indeterminate::after {
2752
+ background-color: var(--lutra-checkbox-mark-on-danger);
2753
+ }
2754
+
2755
+ /* Disabled. Deliberate colours rather than an opacity wash; the native attribute is what carries
2756
+ * the state to assistive technology. */
2757
+
2758
+ .lutra-checkbox:disabled {
2759
+ border-color: var(--lutra-checkbox-border-disabled);
2760
+ background-color: var(--lutra-checkbox-fill-disabled);
2761
+ cursor: not-allowed;
2762
+ }
2763
+
2764
+ .lutra-checkbox:disabled::after {
2765
+ background-color: var(--lutra-checkbox-mark-disabled);
2766
+ }
2767
+
2768
+ /* Forced-colours modes: the mask survives because it is painted with `background-color`, but the
2769
+ * checked fill and the border are replaced by the system, so the mark needs to follow the system
2770
+ * text colour to stay visible. */
2771
+
2772
+ @media (forced-colors: active) {
2773
+ .lutra-checkbox::after {
2774
+ background-color: CanvasText;
2775
+ }
2776
+
2777
+ .lutra-checkbox:disabled::after {
2778
+ background-color: GrayText;
2779
+ }
2780
+ }
2781
+
2782
+ /*
2783
+ * The choice item: a control beside a label and an optional description.
2784
+ *
2785
+ * Shared by Checkbox Item, Radio Item and Toggle Item. Toggle Item puts its control after the
2786
+ * content, which is the only structural difference and is handled by a modifier.
2787
+ */
2788
+
2789
+ .lutra-choice {
2790
+ --lutra-choice-gap: var(--lutra-space-md);
2791
+ --lutra-choice-content-gap: var(--lutra-space-2xs);
2792
+ --lutra-choice-label-color: var(--lutra-component-checkbox-label);
2793
+ --lutra-choice-description-color: var(--lutra-component-checkbox-description);
2794
+ --lutra-choice-label-size: var(--lutra-text-body);
2795
+ --lutra-choice-label-line-height: var(--lutra-type-line-height-body);
2796
+ --lutra-choice-description-size: var(--lutra-text-body-small);
2797
+ --lutra-choice-description-line-height: var(--lutra-type-line-height-body-small);
2798
+
2799
+ display: flex;
2800
+ align-items: flex-start;
2801
+ gap: var(--lutra-choice-gap);
2802
+ font-family: var(--lutra-type-family-sans);
2803
+ }
2804
+
2805
+ .lutra-choice__content {
2806
+ display: flex;
2807
+ flex-direction: column;
2808
+ gap: var(--lutra-choice-content-gap);
2809
+ min-inline-size: 0;
2810
+ }
2811
+
2812
+ /* The label is the click target as well as the name, so it gets the pointer cursor the control has.
2813
+ * `display: block` and no height: a long label wraps and the row grows with it. */
2814
+
2815
+ .lutra-choice__label {
2816
+ display: block;
2817
+ font-size: var(--lutra-choice-label-size);
2818
+ line-height: var(--lutra-choice-label-line-height);
2819
+ color: var(--lutra-choice-label-color);
2820
+ cursor: pointer;
2821
+ overflow-wrap: break-word;
2822
+ text-wrap: pretty;
2823
+ }
2824
+
2825
+ .lutra-choice__description {
2826
+ margin: 0;
2827
+ font-size: var(--lutra-choice-description-size);
2828
+ line-height: var(--lutra-choice-description-line-height);
2829
+ color: var(--lutra-choice-description-color);
2830
+ overflow-wrap: break-word;
2831
+ text-wrap: pretty;
2832
+ }
2833
+
2834
+ .lutra-choice[data-disabled='true'] .lutra-choice__label {
2835
+ color: var(--lutra-component-checkbox-text-disabled);
2836
+ cursor: not-allowed;
2837
+ }
2838
+
2839
+ .lutra-choice[data-disabled='true'] .lutra-choice__description {
2840
+ color: var(--lutra-component-checkbox-text-disabled);
2841
+ }
2842
+
2843
+ /*
2844
+ * The control keeps its 20px drawn size, and the row reaches a comfortable pointer target through
2845
+ * the label rather than by growing the box: the label is associated with `for`/`id`, so the whole
2846
+ * of it is clickable. That is why nothing here sets a 44px height — a fixed height would clip a
2847
+ * wrapped label, and the target is already larger than the control.
2848
+ *
2849
+ * The control is nudged down so it sits on the label's first line rather than its box top.
2850
+ */
2851
+
2852
+ .lutra-choice > .lutra-checkbox,
2853
+ .lutra-choice > .lutra-radio {
2854
+ margin-block-start: calc(
2855
+ (var(--lutra-choice-label-line-height) * 1em - var(--lutra-checkbox-size, 1.25rem)) / 2
2856
+ );
2857
+ }
2858
+
2859
+ /*
2860
+ * Radio.
2861
+ *
2862
+ * The same 20px control as Checkbox, drawn round instead of square, with a dot instead of a tick.
2863
+ * It reuses the `.lutra-choice*` layout defined in `checkbox.css`, so a radio row and a checkbox row
2864
+ * are the same shape — same gap, same label treatment, same description treatment.
2865
+ *
2866
+ * The control is a native `<input type="radio">` with `appearance: none`, drawn with its own
2867
+ * pseudo-element. Grouping, arrow-key navigation and single-selection all come from the element.
2868
+ */
2869
+
2870
+ .lutra-radio {
2871
+ --lutra-radio-size: 1.25rem;
2872
+ --lutra-radio-surface: var(--lutra-component-radio-surface);
2873
+ --lutra-radio-border: var(--lutra-component-radio-border-default);
2874
+ --lutra-radio-border-hover: var(--lutra-component-radio-border-hover);
2875
+ --lutra-radio-border-invalid: var(--lutra-component-radio-border-invalid);
2876
+ --lutra-radio-border-disabled: var(--lutra-component-radio-border-disabled);
2877
+ --lutra-radio-fill-selected: var(--lutra-component-radio-fill-selected);
2878
+ --lutra-radio-fill-selected-hover: var(--lutra-component-radio-fill-selected-hover);
2879
+ --lutra-radio-fill-invalid: var(--lutra-component-radio-fill-invalid);
2880
+ --lutra-radio-fill-disabled: var(--lutra-component-radio-fill-disabled);
2881
+ --lutra-radio-mark: var(--lutra-component-radio-mark);
2882
+ --lutra-radio-mark-on-danger: var(--lutra-component-radio-mark-on-danger);
2883
+ --lutra-radio-mark-disabled: var(--lutra-component-radio-text-disabled);
2884
+
2885
+ --lutra-radio-border-width: var(--lutra-border-width-thick);
2886
+ --lutra-radio-focus-ring-color: var(--lutra-color-focus-ring);
2887
+ --lutra-radio-focus-ring-width: var(--lutra-border-width-thick);
2888
+
2889
+ /* NO FIGMA VARIABLE. Figma draws focus as a 28px ring around the 20px control — a 2px ring at a
2890
+ * 2px offset, the same geometry Checkbox and Button use. */
2891
+ --lutra-radio-focus-ring-offset: 2px;
2892
+
2893
+ /* NO FIGMA VARIABLE. Figma draws the selected dot at 8px inside the 20px control. */
2894
+ --lutra-radio-mark-size: 0.5rem;
2895
+
2896
+ box-sizing: border-box;
2897
+ flex: 0 0 auto;
2898
+ display: grid;
2899
+ place-content: center;
2900
+ inline-size: var(--lutra-radio-size);
2901
+ block-size: var(--lutra-radio-size);
2902
+ margin: 0;
2903
+ border: var(--lutra-radio-border-width) solid var(--lutra-radio-border);
2904
+ border-radius: var(--lutra-radius-full);
2905
+ background-color: var(--lutra-radio-surface);
2906
+ cursor: pointer;
2907
+ appearance: none;
2908
+ }
2909
+
2910
+ /* The dot. A solid circle rather than a mask — a filled shape needs no glyph. */
2911
+
2912
+ .lutra-radio::after {
2913
+ content: '';
2914
+ inline-size: var(--lutra-radio-mark-size);
2915
+ block-size: var(--lutra-radio-mark-size);
2916
+ border-radius: var(--lutra-radius-full);
2917
+ background-color: transparent;
2918
+ }
2919
+
2920
+ .lutra-radio:checked {
2921
+ border-color: var(--lutra-radio-fill-selected);
2922
+ background-color: var(--lutra-radio-fill-selected);
2923
+ }
2924
+
2925
+ .lutra-radio:checked::after {
2926
+ background-color: var(--lutra-radio-mark);
2927
+ }
2928
+
2929
+ /* Interaction states. */
2930
+
2931
+ .lutra-radio:hover:not(:disabled) {
2932
+ border-color: var(--lutra-radio-border-hover);
2933
+ }
2934
+
2935
+ .lutra-radio:checked:hover:not(:disabled) {
2936
+ border-color: var(--lutra-radio-fill-selected-hover);
2937
+ background-color: var(--lutra-radio-fill-selected-hover);
2938
+ }
2939
+
2940
+ /* Focus is a ring outside the control, not a border change: the border already carries the selected
2941
+ * state. */
2942
+
2943
+ .lutra-radio:focus-visible {
2944
+ outline: var(--lutra-radio-focus-ring-width) solid var(--lutra-radio-focus-ring-color);
2945
+ outline-offset: var(--lutra-radio-focus-ring-offset);
2946
+ }
2947
+
2948
+ /* Invalid.
2949
+ *
2950
+ * Matched on a data attribute rather than `aria-invalid`, which ARIA 1.2 deprecates on the radio
2951
+ * role — a single option cannot usefully be invalid on its own. The semantic state lives at group
2952
+ * level, which Field Group owns; this only draws it. */
2953
+
2954
+ .lutra-radio[data-invalid] {
2955
+ border-color: var(--lutra-radio-border-invalid);
2956
+ }
2957
+
2958
+ .lutra-radio[data-invalid]:checked {
2959
+ border-color: var(--lutra-radio-border-invalid);
2960
+ background-color: var(--lutra-radio-fill-invalid);
2961
+ }
2962
+
2963
+ .lutra-radio[data-invalid]:checked::after {
2964
+ background-color: var(--lutra-radio-mark-on-danger);
2965
+ }
2966
+
2967
+ /* Disabled. */
2968
+
2969
+ .lutra-radio:disabled {
2970
+ border-color: var(--lutra-radio-border-disabled);
2971
+ background-color: var(--lutra-radio-fill-disabled);
2972
+ cursor: not-allowed;
2973
+ }
2974
+
2975
+ .lutra-radio:disabled:checked::after {
2976
+ background-color: var(--lutra-radio-mark-disabled);
2977
+ }
2978
+
2979
+ /* Forced-colours modes: the system replaces the fill, so the dot follows the system text colour to
2980
+ * stay visible against it. */
2981
+
2982
+ @media (forced-colors: active) {
2983
+ .lutra-radio:checked::after {
2984
+ background-color: CanvasText;
2985
+ }
2986
+
2987
+ .lutra-radio:disabled:checked::after {
2988
+ background-color: GrayText;
2989
+ }
2990
+ }
2991
+
2992
+ /*
2993
+ * Toggle.
2994
+ *
2995
+ * A native `<input type="checkbox" role="switch">` with `appearance: none`, drawn as a track with a
2996
+ * thumb on its own pseudo-element.
2997
+ *
2998
+ * The state is carried by the thumb's position as well as its colour, and `role="switch"` puts
2999
+ * on/off in the accessibility tree — so nothing about this control depends on colour alone.
3000
+ *
3001
+ * There is deliberately **no transition** on the thumb. Motion tokens are still deferred in the
3002
+ * design system, so a duration here would be an invented value rather than a system one. When the
3003
+ * motion scale lands, the movement belongs on `--lutra-motion-*` and gets a
3004
+ * `prefers-reduced-motion` guard with it.
3005
+ */
3006
+
3007
+ .lutra-toggle {
3008
+ /* NO FIGMA VARIABLE. Figma draws the track at 40×24 with a 16px thumb, and no size scale exists
3009
+ * for this component. */
3010
+ --lutra-toggle-track-inline-size: 2.5rem;
3011
+ --lutra-toggle-track-block-size: 1.5rem;
3012
+ --lutra-toggle-thumb-size: 1rem;
3013
+
3014
+ --lutra-toggle-track-off: var(--lutra-component-toggle-track-off);
3015
+ --lutra-toggle-track-on: var(--lutra-component-toggle-track-on);
3016
+ --lutra-toggle-track-on-hover: var(--lutra-component-toggle-track-on-hover);
3017
+ --lutra-toggle-track-disabled: var(--lutra-component-toggle-track-disabled);
3018
+ --lutra-toggle-border: var(--lutra-component-toggle-border-default);
3019
+ --lutra-toggle-border-hover: var(--lutra-component-toggle-border-hover);
3020
+ --lutra-toggle-border-invalid: var(--lutra-component-toggle-border-invalid);
3021
+ --lutra-toggle-thumb-off: var(--lutra-component-toggle-thumb-off);
3022
+ --lutra-toggle-thumb-on: var(--lutra-component-toggle-thumb-on);
3023
+ --lutra-toggle-thumb-disabled: var(--lutra-component-toggle-thumb-disabled);
3024
+ --lutra-toggle-focus: var(--lutra-component-toggle-focus);
3025
+
3026
+ --lutra-toggle-border-width: var(--lutra-border-width-thin);
3027
+ --lutra-toggle-focus-ring-width: var(--lutra-border-width-thick);
3028
+
3029
+ /* NO FIGMA VARIABLE. Figma draws the focus state as a thicker track border in the focus colour;
3030
+ * this offsets a ring instead, matching every other control in the library, so a row of mixed
3031
+ * controls shows one focus treatment. */
3032
+ --lutra-toggle-focus-ring-offset: 2px;
3033
+
3034
+ box-sizing: border-box;
3035
+ position: relative;
3036
+ flex: 0 0 auto;
3037
+ display: block;
3038
+ inline-size: var(--lutra-toggle-track-inline-size);
3039
+ block-size: var(--lutra-toggle-track-block-size);
3040
+ margin: 0;
3041
+ border: var(--lutra-toggle-border-width) solid var(--lutra-toggle-border);
3042
+ border-radius: var(--lutra-radius-full);
3043
+ background-color: var(--lutra-toggle-track-off);
3044
+ cursor: pointer;
3045
+ appearance: none;
3046
+ }
3047
+
3048
+ /* The thumb. Its position is the primary signal — colour reinforces it. */
3049
+
3050
+ .lutra-toggle::after {
3051
+ content: '';
3052
+ position: absolute;
3053
+ inset-block-start: 50%;
3054
+ inset-inline-start: calc(
3055
+ (var(--lutra-toggle-track-block-size) - var(--lutra-toggle-thumb-size)) / 2 -
3056
+ var(--lutra-toggle-border-width)
3057
+ );
3058
+ transform: translateY(-50%);
3059
+ inline-size: var(--lutra-toggle-thumb-size);
3060
+ block-size: var(--lutra-toggle-thumb-size);
3061
+ border-radius: var(--lutra-radius-full);
3062
+ background-color: var(--lutra-toggle-thumb-off);
3063
+ }
3064
+
3065
+ .lutra-toggle:checked {
3066
+ border-color: var(--lutra-toggle-track-on);
3067
+ background-color: var(--lutra-toggle-track-on);
3068
+ }
3069
+
3070
+ .lutra-toggle:checked::after {
3071
+ inset-inline-start: auto;
3072
+ inset-inline-end: calc(
3073
+ (var(--lutra-toggle-track-block-size) - var(--lutra-toggle-thumb-size)) / 2 -
3074
+ var(--lutra-toggle-border-width)
3075
+ );
3076
+ background-color: var(--lutra-toggle-thumb-on);
3077
+ }
3078
+
3079
+ /* Interaction states. */
3080
+
3081
+ .lutra-toggle:hover:not(:disabled) {
3082
+ border-color: var(--lutra-toggle-border-hover);
3083
+ }
3084
+
3085
+ .lutra-toggle:checked:hover:not(:disabled) {
3086
+ border-color: var(--lutra-toggle-track-on-hover);
3087
+ background-color: var(--lutra-toggle-track-on-hover);
3088
+ }
3089
+
3090
+ .lutra-toggle:focus-visible {
3091
+ outline: var(--lutra-toggle-focus-ring-width) solid var(--lutra-toggle-focus);
3092
+ outline-offset: var(--lutra-toggle-focus-ring-offset);
3093
+ }
3094
+
3095
+ /* Invalid — visual only; see `toggle.md`. */
3096
+
3097
+ .lutra-toggle[data-invalid] {
3098
+ border-color: var(--lutra-toggle-border-invalid);
3099
+ }
3100
+
3101
+ /* Disabled. */
3102
+
3103
+ .lutra-toggle:disabled {
3104
+ background-color: var(--lutra-toggle-track-disabled);
3105
+ cursor: not-allowed;
3106
+ }
3107
+
3108
+ .lutra-toggle:disabled::after {
3109
+ background-color: var(--lutra-toggle-thumb-disabled);
3110
+ }
3111
+
3112
+ /* Forced-colours modes: the system replaces the track fill, so the thumb takes the system text
3113
+ * colour to stay visible, and the border carries the on/off difference. */
3114
+
3115
+ @media (forced-colors: active) {
3116
+ .lutra-toggle::after {
3117
+ background-color: CanvasText;
3118
+ }
3119
+
3120
+ .lutra-toggle:disabled::after {
3121
+ background-color: GrayText;
3122
+ }
3123
+ }
3124
+
3125
+ /*
3126
+ * The trailing-control variant of the choice row.
3127
+ *
3128
+ * Toggle Item puts the label first and the switch at the end — a settings-list convention, where the
3129
+ * name of the setting reads first and the switches line up down the right edge. Checkbox Item and
3130
+ * Radio Item put the control first. Both use `.lutra-choice`; only the order and the gap differ.
3131
+ *
3132
+ * The DOM order matches the visual order, so reading order and Tab order agree with what is drawn.
3133
+ */
3134
+
3135
+ .lutra-choice--trailing {
3136
+ --lutra-choice-gap: var(--lutra-space-lg);
3137
+
3138
+ justify-content: space-between;
3139
+ }
3140
+
3141
+ .lutra-choice--trailing .lutra-choice__content {
3142
+ flex: 1 1 auto;
3143
+ }
3144
+
3145
+ /* Aligned to the first line of the label rather than the top of the row, so a two-line label does
3146
+ * not leave the switch floating. */
3147
+ .lutra-choice--trailing > .lutra-toggle {
3148
+ margin-block-start: calc(
3149
+ (var(--lutra-choice-label-line-height) * 1em - var(--lutra-toggle-track-block-size)) / 2
3150
+ );
3151
+ }
3152
+
3153
+ .lutra-choice[data-disabled='true'] > .lutra-toggle {
3154
+ cursor: not-allowed;
3155
+ }
3156
+
3157
+ /*
3158
+ * Field.
3159
+ *
3160
+ * These classes are shared: `Field` and `SelectField` render the same shell around different
3161
+ * controls, so both use `.lutra-field*`. The control inside brings its own styling.
3162
+ *
3163
+ * Order is label → hint → control → error, expressed as source order in a vertical stack rather
3164
+ * than by any reordering property, so the visual order and the DOM order — which is the order a
3165
+ * screen reader and the Tab key follow — are the same thing.
3166
+ *
3167
+ * Figma composes the field with 4px auto-layout spacing and draws its supporting text below the
3168
+ * control, because the component models one supporting slot that becomes the error when invalid.
3169
+ * The accessibility annotation is the behavioural contract and puts the error below the control
3170
+ * while a hint is guidance read before typing, so the implementation splits them. See `field.md`.
3171
+ */
3172
+
3173
+ .lutra-field {
3174
+ --lutra-field-gap: var(--lutra-space-xs);
3175
+ --lutra-field-label-color: var(--lutra-color-text-primary);
3176
+ --lutra-field-label-size: var(--lutra-text-body-small);
3177
+ --lutra-field-label-line-height: var(--lutra-type-line-height-body-small);
3178
+
3179
+ /* NO FIGMA VARIABLE. The label and hint are the same size, so weight is what separates them.
3180
+ * Figma binds no weight variable on these text nodes. */
3181
+ --lutra-field-label-weight: var(--lutra-type-weight-medium);
3182
+ --lutra-field-hint-weight: var(--lutra-type-weight-regular);
3183
+
3184
+ /* Figma draws the Field hint in text/primary. Field Group draws its hint in text/secondary.
3185
+ * Both are reproduced as drawn; the divergence is recorded in `field.md` for the design system
3186
+ * to reconcile rather than being resolved unilaterally here. */
3187
+ --lutra-field-hint-color: var(--lutra-color-text-primary);
3188
+ --lutra-field-error-color: var(--lutra-component-field-error-text);
3189
+ --lutra-field-indicator-color: var(--lutra-color-text-primary);
3190
+ --lutra-field-indicator-required-color: var(--lutra-component-field-error-text);
3191
+
3192
+ display: flex;
3193
+ flex-direction: column;
3194
+ gap: var(--lutra-field-gap);
3195
+ font-family: var(--lutra-type-family-sans);
3196
+ }
3197
+
3198
+ /* Normal inline flow rather than flex, so the indicator wraps inline with the label text the way
3199
+ * the annotation describes, and so the space between them is a real text space that the accessible
3200
+ * name includes. A flex `gap` would look right and read as "Email addressRequired". */
3201
+
3202
+ .lutra-field__label {
3203
+ display: block;
3204
+ font-size: var(--lutra-field-label-size);
3205
+ line-height: var(--lutra-field-label-line-height);
3206
+ font-weight: var(--lutra-field-label-weight);
3207
+ color: var(--lutra-field-label-color);
3208
+
3209
+ /* A long label wraps and grows the field. Nothing here sets a height. */
3210
+ overflow-wrap: break-word;
3211
+ text-wrap: pretty;
3212
+ }
3213
+
3214
+ /* The indicator wraps inline with the label text rather than sitting in its own column, so a long
3215
+ * label pushes it onto the next line instead of squeezing the label. It is inside the <label>, so
3216
+ * it is part of the accessible name. */
3217
+
3218
+ .lutra-field__indicator {
3219
+ font-weight: var(--lutra-field-hint-weight);
3220
+ color: var(--lutra-field-indicator-color);
3221
+ }
3222
+
3223
+ .lutra-field__indicator[data-indicator='required'] {
3224
+ color: var(--lutra-field-indicator-required-color);
3225
+ }
3226
+
3227
+ .lutra-field__hint,
3228
+ .lutra-field__error {
3229
+ margin: 0;
3230
+ font-size: var(--lutra-field-label-size);
3231
+ line-height: var(--lutra-field-label-line-height);
3232
+ font-weight: var(--lutra-field-hint-weight);
3233
+ overflow-wrap: break-word;
3234
+ text-wrap: pretty;
3235
+ }
3236
+
3237
+ .lutra-field__hint {
3238
+ color: var(--lutra-field-hint-color);
3239
+ }
3240
+
3241
+ .lutra-field__error {
3242
+ color: var(--lutra-field-error-color);
3243
+ }
3244
+
3245
+ /* Invalid.
3246
+ *
3247
+ * The label takes the error colour, which is what Figma draws. Colour is never the only signal: the
3248
+ * control carries `aria-invalid`, and the error message is text rather than a coloured decoration.
3249
+ */
3250
+
3251
+ .lutra-field[data-invalid='true'] .lutra-field__label {
3252
+ color: var(--lutra-field-error-color);
3253
+ }
3254
+
3255
+ /*
3256
+ * Field Group.
3257
+ *
3258
+ * A native `<fieldset>` and `<legend>`. Both come with default browser styling that has to be
3259
+ * cleared before they can be laid out — a fieldset has a border and padding, and a legend sits
3260
+ * inside that border with its own inline behaviour. Everything below the reset is the design.
3261
+ *
3262
+ * Order is legend → hint → controls → error, matching Field, expressed as source order so the drawn
3263
+ * order and the reading order are the same.
3264
+ */
3265
+
3266
+ .lutra-field-group {
3267
+ --lutra-field-group-gap: var(--lutra-space-sm);
3268
+ --lutra-field-group-item-gap: var(--lutra-space-md);
3269
+ --lutra-field-group-legend-color: var(--lutra-color-text-primary);
3270
+ --lutra-field-group-legend-size: var(--lutra-text-body);
3271
+ --lutra-field-group-legend-line-height: var(--lutra-type-line-height-body);
3272
+ --lutra-field-group-hint-color: var(--lutra-color-text-secondary);
3273
+ --lutra-field-group-hint-size: var(--lutra-text-body-small);
3274
+ --lutra-field-group-hint-line-height: var(--lutra-type-line-height-body-small);
3275
+ --lutra-field-group-error-color: var(--lutra-component-field-error-text);
3276
+ --lutra-field-group-error-gap: var(--lutra-space-xs);
3277
+
3278
+ /* NO FIGMA VARIABLE. Figma binds no weight on the legend; it is heavier than the options so the
3279
+ * question reads before the answers. */
3280
+ --lutra-field-group-legend-weight: var(--lutra-type-weight-medium);
3281
+
3282
+ /* Clearing the browser's own fieldset chrome. */
3283
+ min-inline-size: 0;
3284
+ margin: 0;
3285
+ padding: 0;
3286
+ border: 0;
3287
+
3288
+ display: flex;
3289
+ flex-direction: column;
3290
+ gap: var(--lutra-field-group-gap);
3291
+ font-family: var(--lutra-type-family-sans);
3292
+ }
3293
+
3294
+ /* A legend is not an ordinary flex item — browsers give it special placement inside the fieldset
3295
+ * border. `float: left` with a full-width clear is the reliable way to make it behave as a normal
3296
+ * block at the top of the stack without losing its semantics. */
3297
+
3298
+ .lutra-field-group__legend {
3299
+ float: left;
3300
+ inline-size: 100%;
3301
+ padding: 0;
3302
+ font-size: var(--lutra-field-group-legend-size);
3303
+ line-height: var(--lutra-field-group-legend-line-height);
3304
+ font-weight: var(--lutra-field-group-legend-weight);
3305
+ color: var(--lutra-field-group-legend-color);
3306
+ overflow-wrap: break-word;
3307
+ text-wrap: pretty;
3308
+ }
3309
+
3310
+ .lutra-field-group__legend + * {
3311
+ clear: both;
3312
+ }
3313
+
3314
+ .lutra-field-group__hint {
3315
+ margin: 0;
3316
+ font-size: var(--lutra-field-group-hint-size);
3317
+ line-height: var(--lutra-field-group-hint-line-height);
3318
+ color: var(--lutra-field-group-hint-color);
3319
+ overflow-wrap: break-word;
3320
+ text-wrap: pretty;
3321
+ }
3322
+
3323
+ .lutra-field-group__items {
3324
+ display: flex;
3325
+ flex-direction: column;
3326
+ gap: var(--lutra-field-group-item-gap);
3327
+ }
3328
+
3329
+ /* The error.
3330
+ *
3331
+ * An icon beside the message, which is what Figma draws here — and what Field's error does not have.
3332
+ * The difference is reproduced rather than reconciled; see `field-group.md`. The icon is decorative,
3333
+ * so the signal is the message text and the shape, never the colour alone.
3334
+ */
3335
+
3336
+ .lutra-field-group__error {
3337
+ display: flex;
3338
+ align-items: flex-start;
3339
+ gap: var(--lutra-field-group-error-gap);
3340
+ margin: 0;
3341
+ font-size: var(--lutra-field-group-hint-size);
3342
+ line-height: var(--lutra-field-group-hint-line-height);
3343
+ color: var(--lutra-field-group-error-color);
3344
+ overflow-wrap: break-word;
3345
+ text-wrap: pretty;
3346
+ }
3347
+
3348
+ .lutra-field-group__error-icon {
3349
+ flex: 0 0 auto;
3350
+ inline-size: 1rem;
3351
+ block-size: 1rem;
3352
+
3353
+ /* Nudged onto the first line of the message rather than the top of its box. */
3354
+ margin-block-start: calc((var(--lutra-field-group-hint-line-height) * 1em - 1rem) / 2);
3355
+ }
3356
+
3357
+ /*
3358
+ * Error Summary.
3359
+ *
3360
+ * Shown above the form after a failed submit, and only then. The red border and the alert icon both
3361
+ * carry the meaning, and the heading says it in words, so nothing here depends on colour alone.
3362
+ */
3363
+
3364
+ .lutra-error-summary {
3365
+ --lutra-error-summary-surface: var(--lutra-color-background-surface);
3366
+ --lutra-error-summary-border: var(--lutra-color-feedback-danger);
3367
+ --lutra-error-summary-border-width: var(--lutra-border-width-thick);
3368
+ --lutra-error-summary-radius: var(--lutra-radius-md);
3369
+ --lutra-error-summary-padding: var(--lutra-space-xl);
3370
+ --lutra-error-summary-gap: var(--lutra-space-md);
3371
+ --lutra-error-summary-row-gap: var(--lutra-space-sm);
3372
+ --lutra-error-summary-title: var(--lutra-color-text-primary);
3373
+ --lutra-error-summary-link: var(--lutra-component-field-error-text);
3374
+ --lutra-error-summary-focus-ring-color: var(--lutra-color-focus-ring);
3375
+ --lutra-error-summary-focus-ring-width: var(--lutra-border-width-thick);
3376
+
3377
+ /* NO FIGMA VARIABLE. The same 2px offset every other Lutra control uses for its focus ring. */
3378
+ --lutra-error-summary-focus-ring-offset: 2px;
3379
+
3380
+ box-sizing: border-box;
3381
+ display: flex;
3382
+ flex-direction: column;
3383
+ gap: var(--lutra-error-summary-gap);
3384
+ padding: var(--lutra-error-summary-padding);
3385
+
3386
+ border: var(--lutra-error-summary-border-width) solid var(--lutra-error-summary-border);
3387
+ border-radius: var(--lutra-error-summary-radius);
3388
+ background-color: var(--lutra-error-summary-surface);
3389
+ font-family: var(--lutra-type-family-sans);
3390
+ }
3391
+
3392
+ /* The container takes focus programmatically, so its ring has to be visible when it does — this is
3393
+ * one of the few places `:focus` rather than `:focus-visible` is right, because the focus was moved
3394
+ * by the application and there is no keystroke for the browser to judge. */
3395
+
3396
+ .lutra-error-summary:focus {
3397
+ outline: var(--lutra-error-summary-focus-ring-width) solid
3398
+ var(--lutra-error-summary-focus-ring-color);
3399
+ outline-offset: var(--lutra-error-summary-focus-ring-offset);
3400
+ }
3401
+
3402
+ .lutra-error-summary__heading-row {
3403
+ display: flex;
3404
+ align-items: flex-start;
3405
+ gap: var(--lutra-error-summary-row-gap);
3406
+ }
3407
+
3408
+ .lutra-error-summary__icon {
3409
+ flex: 0 0 auto;
3410
+ inline-size: 1.5rem;
3411
+ block-size: 1.5rem;
3412
+ color: var(--lutra-error-summary-border);
3413
+ }
3414
+
3415
+ .lutra-error-summary__title {
3416
+ margin: 0;
3417
+ font-size: var(--lutra-text-h3);
3418
+ line-height: var(--lutra-type-line-height-h3);
3419
+ font-weight: var(--lutra-type-weight-semibold);
3420
+ color: var(--lutra-error-summary-title);
3421
+ overflow-wrap: break-word;
3422
+ text-wrap: pretty;
3423
+ }
3424
+
3425
+ .lutra-error-summary__list {
3426
+ display: flex;
3427
+ flex-direction: column;
3428
+ gap: var(--lutra-error-summary-row-gap);
3429
+ margin: 0;
3430
+ padding: 0;
3431
+
3432
+ /* A real list for the count, without the marker — the number of problems is the point, and the
3433
+ * bullets would compete with the link underlines. */
3434
+ list-style: none;
3435
+ }
3436
+
3437
+ .lutra-error-summary__link {
3438
+ color: var(--lutra-error-summary-link);
3439
+ font-size: var(--lutra-text-body);
3440
+ line-height: var(--lutra-type-line-height-body);
3441
+
3442
+ /* Underlined at rest: these are links inside a block of red text, and colour alone would not
3443
+ * separate them from the message they sit in. */
3444
+ text-decoration-line: underline;
3445
+ text-decoration-thickness: from-font;
3446
+ text-underline-offset: 0.2em;
3447
+ overflow-wrap: break-word;
3448
+ }
3449
+
3450
+ .lutra-error-summary__link:hover {
3451
+ text-decoration-thickness: 0.125em;
3452
+ }
3453
+
3454
+ .lutra-error-summary__link:focus-visible {
3455
+ outline: var(--lutra-error-summary-focus-ring-width) solid
3456
+ var(--lutra-error-summary-focus-ring-color);
3457
+ outline-offset: var(--lutra-error-summary-focus-ring-offset);
3458
+ border-radius: var(--lutra-radius-sm);
3459
+ }
3460
+
3461
+ /* Forced-colours modes: the red border is not painted, so the border is pinned to the system's own
3462
+ * boundary colour and the focus ring to its highlight. */
3463
+
3464
+ @media (forced-colors: active) {
3465
+ .lutra-error-summary {
3466
+ border-color: CanvasText;
3467
+ }
3468
+
3469
+ .lutra-error-summary:focus,
3470
+ .lutra-error-summary__link:focus-visible {
3471
+ outline-color: Highlight;
3472
+ }
3473
+ }
3474
+