@putdotio/design 5.2.0 → 5.2.1

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.
package/DESIGN.md CHANGED
@@ -78,7 +78,7 @@ Tier 1, 3, and 4 cards are Components (put.io owns the recipe); tier 2 cards are
78
78
 
79
79
  ## Colors
80
80
 
81
- - Yellow `#FDCE45` (`--yellow-solid`) is the brand value in both modes: brand mark, primary actions, folder and file emphasis, progress, focus. Hover is `--yellow-solid-hover`.
81
+ - Yellow `#FDCE45` (`--yellow-solid`) is the brand value in both modes: brand mark, primary actions, folder and file emphasis, progress. Hover is `--yellow-solid-hover`.
82
82
  - Every yellow is one of these tokens, flat. Yellow text on light surfaces is `--yellow-text-secondary`.
83
83
  - Dark is the default product mode; light serves landing pages, docs, and states that read better light.
84
84
  - Red, green, and neutral come from their token scales.
@@ -90,7 +90,7 @@ Tier 1, 3, and 4 cards are Components (put.io owns the recipe); tier 2 cards are
90
90
  - TV product UI has no mono face (a debug overlay may use the platform's system monospace): 10-foot numerics use GT America `tnum`. Platforms without OpenType feature selection (Roku: `Font` takes only `uri` and `size`) get proportional figures, so budget timecodes for reflow (`1` is 399/1000 em, `0` is 636).
91
91
  - GT America is Latin only (523 codepoints, Western and Central European plus Turkish, no dingbats such as `✓` `▶` `★`). Symbols are icons. Text that can hold a filename keeps a fallback family; web falls back per glyph, while one-font-per-label platforms must ship a fallback or render `.notdef` boxes.
92
92
  - The root is the browser default, `100%` (16px), so text follows the reader's setting. Components choose a text role, not a size step: `--fs-ui` (14px) for UI chrome (labels, buttons, nav, links, errors), `--fs-body` (16px) for body and long text, `--fs-caption` (12px) for captions, and `--fs-field` (16px) for typed text, which keeps iOS Safari from zooming on focus.
93
- - The steps are rem on Tailwind's 16px scale: `--fs-xs` 12px, `--fs-sm` 14, `--fs-base` 16, `--fs-md` 18, `--fs-lg` 24, `--fs-xl` 32, `--fs-2xl` 48, `--fs-3xl` 64, `--fs-display` 96. Screen titles and one-time-code digits take `--fs-title` (22px) on a 30px line with `--tracking-snug`; the code separator is `--fs-md`.
93
+ - The steps are rem on Tailwind's 16px scale: `--fs-xs` 12px, `--fs-sm` 14, `--fs-base` 16, `--fs-md` 18, `--fs-lg` 24, `--fs-xl` 32, `--fs-2xl` 48, `--fs-3xl` 64, `--fs-display` 96. Screen titles take `--fs-title` (22px) on a 30px line with `--tracking-snug`. One-time-code digits take `--fs-title` in mono; the code separator is `--fs-md`.
94
94
 
95
95
  ## Layout & Spacing
96
96
 
@@ -101,7 +101,7 @@ Tier 1, 3, and 4 cards are Components (put.io owns the recipe); tier 2 cards are
101
101
 
102
102
  ## Elevation & Depth
103
103
 
104
- Borders and soft shadows mark interaction: popovers, modals, menus, TV focus, drag and drop targets. Glow belongs only to focused TV states.
104
+ Borders and soft shadows mark interaction: popovers, modals, menus, TV focus, drag and drop targets. Nothing glows; TV focus is a fill.
105
105
 
106
106
  ## Shapes
107
107
 
@@ -116,7 +116,8 @@ Default radius `6px`; `4px` for tight controls, `8px` or `10px` for panels, pill
116
116
  - **Primary:** `--button-primary-bg`, hover `--button-primary-bg-hover`, label `--button-primary-fg`. At most one per action group.
117
117
  - **Secondary:** `--button-default-bg` plus a `--border` border; hover steps both (`--button-default-bg-hover`, `--border-hover`).
118
118
  - **Ghost:** text only, `--button-ghost-fg`; hover adds `--button-ghost-bg-hover` and lifts the label to `--button-ghost-fg-hover`. Use it for anything quieter than secondary: header `Sign in`, toolbar actions, `Cancel` beside a confirm, icon-only buttons.
119
- - **Disabled:** submit buttons are never disabled at rest; the submit validates. A filled button is disabled only while its action runs (with a spinner) or once it is done, so it keeps its own fill and label at full opacity; a finished 2FA “Verified” is a `.btn-success` with `data-state="success"`. Secondary and quiet buttons drop their text to `--solid` at full opacity.
119
+ - **Disabled:** `:disabled` and `aria-disabled="true"` read the same. Submit buttons are never disabled at rest; the submit validates. A filled button is disabled only while its action runs (with a spinner) or once it is done, so it keeps its own fill and label at full opacity; a finished 2FA “Verified” is a `.btn-success` with `data-state="success"`. Secondary and quiet buttons drop their text to `--solid` at full opacity.
120
+ - **Hover:** only under `(hover: hover) and (pointer: fine)`, so a tap never leaves a button lit.
120
121
  - **Focus:** `--shadow-focus`, a 2px `--bg` gap then a 2px `--solid-hover` ring, at least 3:1 against the page and panels in both modes.
121
122
  - A bordered button always fills (secondary). Border-only buttons read smaller beside solid fills and vanish on dark.
122
123
  - Four sizes share one box per size across tiers: default 36px, `md` 32px, `sm` 28px, `xs` 24px. Read `--button-height*`, `--button-padding-x*`, `--button-label-size*`, `--button-tracking*`, `--button-icon-size`, `--button-gap`.
@@ -135,14 +136,14 @@ Default radius `6px`; `4px` for tight controls, `8px` or `10px` for panels, pill
135
136
 
136
137
  ### Auth
137
138
 
138
- - Sign in, sign up, password reset, and two-factor pages have no card or panel: the form sits on `--bg`. Recipe: `.auth-page`, `.auth-header`, `.auth-main`, `.auth-column`, `.auth-title`, `.auth-note`, `.auth-actions`, `.auth-links`, `.auth-footer`.
139
+ - Sign in, sign up, password reset, and two-factor pages have no card or panel: the form sits on `--bg`. Recipe: `.auth-page`, `.auth-header`, `.auth-logo`, `.auth-main`, `.auth-column`, `.auth-title`, `.auth-note`, `.auth-actions`, `.auth-links`, `.auth-footer`.
139
140
  - The logo sits top-left in the header, in a `30.33px` box (26px ink × 7/6). The top right stays empty by default.
140
141
  - One left-aligned column, up to `340px`, is centred horizontally and optically centred vertically: the form's middle sits near 45% of the viewport height, never below the true middle.
141
142
  - A quiet centred footer links Terms of Service, Privacy Policy, and Help in muted `--fs-caption`, separated by middots, 16px above the page bottom.
142
143
  - Text is one size: labels, hints, `.auth-note` lines, errors, and the link row take `--fs-ui`. The footer and a label's own action take `--fs-caption`; fields take `--fs-field`.
143
144
  - One title per screen: `--fs-title` (`22px`), medium, `--tracking-snug` (`-0.01em`). No subtitles, taglines, or marketing lines; copy stays short.
144
145
  - Fields and buttons keep the standard 36px box: the filled field and a full-width default-size primary.
145
- - Labels are small, muted, and above the field.
146
+ - Labels are small, regular, muted, and above the field.
146
147
  - The primary button is full-width yellow. “Forgot password?” is the Password field's `.label-action`. Other secondary actions sit under the form in `.auth-links`: a bare link stays muted (“Use a recovery code”, “← Back to sign in”); a prompt's link (“New to put.io? Sign up”, “Wrong account? Sign out”) is `--text`, medium, and underlines only on hover. Two items split to the edges; a lone link or prompt centres.
147
148
  - Buttons outside a form sit in `.auth-actions`, 16px below what precedes them; a description `.auth-note` right before them leaves 28px. A back link leads with Phosphor's arrow-left as inline SVG. A legal line under the submit button centres on it.
148
149
  - Tab order runs fields (each followed by its label action), submit, then secondary links; DOM order follows visual order, with no positive `tabindex`. Two-factor runs the code, Verify, then its links. The reveal-password toggle takes `tabindex="-1"` and keeps its accessible name: keyboard-only users can't toggle visibility, screen-reader users still can in browse mode. No link leaves the tab order.
@@ -150,7 +151,7 @@ Default radius `6px`; `4px` for tight controls, `8px` or `10px` for panels, pill
150
151
  - Copy: “Sign in”, “Sign up”, and “That username or password doesn't look right.”
151
152
  - auth.put.io still ships the older card layout: drift.
152
153
  - References: `web-s06-auth`, `web-p00c-auth-signin`, `web-p00d-auth-signup`, `web-p00e-auth-2fa`. Outside references: the TIDAL, Stripe Link, Cursor, Linear, and Vercel sign-in pages.
153
- - One-time codes are one `.otp-input` (`inputmode="numeric"`, `autocomplete="one-time-code"`, `maxlength="6"`, `aria-invalid`) shown through two `.otp-group`s, one `.otp-separator`, and six `.otp-slot`s; script syncs slot text and `filled`/`active`, and maps a clicked slot to the input selection. The slots stretch the full column with mono digits. The label reads “Code from your authenticator app”, with no hint line.
154
+ - One-time codes are one `.otp-input` (`inputmode="numeric"`, `autocomplete="one-time-code"`, `maxlength="6"`, `aria-invalid`) inside an `.otp` root, shown through two `.otp-group`s, one `.otp-separator`, and six `.otp-slot`s; script syncs slot text and `filled`/`active`, and maps a clicked slot to the input selection. The slots stretch the full column with mono digits. The label reads “Code from your authenticator app”, with no hint line.
154
155
  - Editing an invalid code closes the error; retry waits for six digits. `data-state="verifying"` disables the input, keeps the digits, and announces through a polite live region. `data-state="success"` keeps the digits read-only, announces continuation in a `role="status"` region, and shows no further action; the consumer owns navigation. The submit turns a disabled `.btn-success` with `data-state="success"`, relabelled “Verified”, announced through a visually hidden status region.
155
156
  - “Use a recovery code” appears only once a consumer owns the route. Groups own the responsive width, so the control fits narrow columns without scrolling.
156
157
 
@@ -521,6 +521,9 @@
521
521
  }
522
522
 
523
523
  /* -- Document base ------------------------------------------------- */
524
+ /* Native scrollbars, controls and autofill follow the mode. */
525
+ :root { color-scheme: light; }
526
+ .dark { color-scheme: dark; }
524
527
  html {
525
528
  background: var(--html-bg);
526
529
  color: var(--text);
@@ -555,7 +558,6 @@ body {
555
558
  .num, .tabular {
556
559
  font-family: var(--font-mono);
557
560
  font-variant-numeric: tabular-nums;
558
- font-feature-settings: "tnum";
559
561
  }
560
562
 
561
563
  h1, .h1 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@putdotio/design",
3
- "version": "5.2.0",
3
+ "version": "5.2.1",
4
4
  "description": "Public put.io design tokens, brand assets, and design-system guidance.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -79,6 +79,7 @@ button i { font-size: var(--button-icon-size); line-height: 1; display: inline-f
79
79
  .btn-icon { width: calc(var(--button-height) * var(--tw-fs, 1)) !important; padding: 0 !important; text-transform: none; letter-spacing: normal; }
80
80
  .btn-icon.btn-md { width: calc(var(--button-height-md) * var(--tw-fs, 1)) !important; }
81
81
  .btn-icon.btn-sm { width: calc(var(--button-height-sm) * var(--tw-fs, 1)) !important; }
82
+ .btn-icon.btn-xs { width: calc(var(--button-height-xs) * var(--tw-fs, 1)) !important; }
82
83
 
83
84
  /* Layout-only modifier — full-width CTA */
84
85
  .btn-block { width: 100%; }
@@ -98,18 +99,17 @@ button i { font-size: var(--button-icon-size); line-height: 1; display: inline-f
98
99
  .btn-quiet.btn-danger, .btn-quiet.btn-info, .btn-quiet.btn-default {
99
100
  background: transparent; border-color: transparent; color: var(--button-ghost-fg);
100
101
  }
101
- .btn-quiet:hover:not(:disabled), .btn-quiet.btn-default:hover:not(:disabled) { background: var(--button-ghost-bg-hover); border-color: transparent; color: var(--button-ghost-fg-hover); }
102
+ /* The hover fill and focus ring round off; an icon-only quiet button takes the square box. */
103
+ .btn-quiet { border-radius: var(--button-radius); }
104
+ .btn-quiet.btn-icon { height: calc(var(--button-height) * var(--tw-fs, 1)); }
102
105
  .btn-quiet.btn-info { color: var(--text); }
103
106
  /* --yellow-text-secondary, not --yellow-solid: solid is a fill step, and in
104
107
  light mode the brand yellow as a glyph colour on a light surface is
105
108
  unreadable. The text step carries a legible value in both modes. */
106
109
  .btn-quiet.btn-primary, .btn-quiet.btn-warning { color: var(--yellow-text-secondary); }
107
- .btn-quiet.btn-primary:hover:not(:disabled), .btn-quiet.btn-warning:hover:not(:disabled) { background: var(--yellow-component-bg-hover); border-color: transparent; color: var(--yellow-text-secondary); }
108
110
  .btn-quiet.btn-success { color: var(--green-solid); }
109
- .btn-quiet.btn-success:hover:not(:disabled) { background: var(--green-component-bg-hover); border-color: transparent; color: var(--green-solid); }
110
111
  .btn-quiet.btn-danger { color: var(--red-solid); }
111
- .btn-quiet.btn-danger:hover:not(:disabled) { background: var(--red-component-bg-hover); border-color: transparent; color: var(--red-solid); }
112
- .btn-quiet:disabled { opacity: 1; color: var(--solid); background: transparent; }
112
+ .btn-quiet:is(:disabled, [aria-disabled="true"]) { opacity: 1; color: var(--solid); background: transparent; }
113
113
 
114
114
  /* Variants — token-driven. Foregrounds read from --*-foreground
115
115
  tokens in tokens.css so "what color on top" lives in one place.
@@ -121,17 +121,11 @@ button i { font-size: var(--button-icon-size); line-height: 1; display: inline-f
121
121
  accidental low-contrast bug. Never use these variants at xs/sm — the
122
122
  label drops below the large-text threshold. */
123
123
  .btn-primary { background: var(--button-primary-bg); border-color: var(--button-primary-bg); color: var(--button-primary-fg); }
124
- .btn-primary:hover:not(:disabled) { background: var(--button-primary-bg-hover); border-color: var(--button-primary-bg-hover); }
125
124
  .btn-success { background: var(--button-success-bg); border-color: var(--button-success-bg); color: var(--button-success-fg); }
126
- .btn-success:hover:not(:disabled) { background: var(--button-success-bg-hover); border-color: var(--button-success-bg-hover); }
127
125
  .btn-danger { background: var(--button-danger-bg); border-color: var(--button-danger-bg); color: var(--button-danger-fg); }
128
- .btn-danger:hover:not(:disabled) { background: var(--button-danger-bg-hover); border-color: var(--button-danger-bg-hover); }
129
126
  .btn-info { background: var(--button-info-bg); border-color: var(--button-info-bg); color: var(--button-info-fg); }
130
- .btn-info:hover:not(:disabled) { background: var(--button-info-bg-hover); border-color: var(--button-info-bg-hover); }
131
127
  .btn-invert { background: var(--text); border-color: var(--border); color: var(--invert-foreground); }
132
- .btn-invert:hover:not(:disabled) { border-color: var(--border-hover); }
133
128
  .btn-default { background: var(--button-default-bg); border-color: var(--border); color: var(--button-default-fg); }
134
- .btn-default:hover:not(:disabled) { background: var(--button-default-bg-hover); border-color: var(--border-hover); }
135
129
 
136
130
  /* Ghost — the third tier: text-only. No fill, no border at rest;
137
131
  hover paints the shared quiet fill and lifts the label a step
@@ -143,36 +137,55 @@ button i { font-size: var(--button-icon-size); line-height: 1; display: inline-f
143
137
  Disabled matches the quiet treatment: the colour drops to --solid,
144
138
  the box does not fade. */
145
139
  .btn-ghost { background: var(--button-ghost-bg); border-color: transparent; color: var(--button-ghost-fg); }
146
- .btn-ghost:hover:not(:disabled) { background: var(--button-ghost-bg-hover); border-color: transparent; color: var(--button-ghost-fg-hover); }
147
- .btn-ghost:disabled { opacity: 1; color: var(--solid); background: transparent; }
140
+ .btn-ghost:is(:disabled, [aria-disabled="true"]) { opacity: 1; color: var(--solid); background: transparent; }
141
+
142
+ /* Hover — pointer devices only, so a tapped button doesn't stay lit on touch.
143
+ Quiet comes after the filled variants, so a quiet info button keeps the
144
+ ghost hover. */
145
+ @media (hover: hover) and (pointer: fine) {
146
+ .btn-primary:hover:not(:disabled, [aria-disabled="true"]) { background: var(--button-primary-bg-hover); border-color: var(--button-primary-bg-hover); }
147
+ .btn-success:hover:not(:disabled, [aria-disabled="true"]) { background: var(--button-success-bg-hover); border-color: var(--button-success-bg-hover); }
148
+ .btn-danger:hover:not(:disabled, [aria-disabled="true"]) { background: var(--button-danger-bg-hover); border-color: var(--button-danger-bg-hover); }
149
+ .btn-info:hover:not(:disabled, [aria-disabled="true"]) { background: var(--button-info-bg-hover); border-color: var(--button-info-bg-hover); }
150
+ .btn-invert:hover:not(:disabled, [aria-disabled="true"]) { border-color: var(--border-hover); }
151
+ .btn-default:hover:not(:disabled, [aria-disabled="true"]) { background: var(--button-default-bg-hover); border-color: var(--border-hover); }
152
+ .btn-ghost:hover:not(:disabled, [aria-disabled="true"]) { background: var(--button-ghost-bg-hover); border-color: transparent; color: var(--button-ghost-fg-hover); }
153
+ .btn-quiet:hover:not(:disabled, [aria-disabled="true"]) { background: var(--button-ghost-bg-hover); border-color: transparent; color: var(--button-ghost-fg-hover); }
154
+ .btn-quiet.btn-primary:hover:not(:disabled, [aria-disabled="true"]), .btn-quiet.btn-warning:hover:not(:disabled, [aria-disabled="true"]) { background: var(--yellow-component-bg-hover); border-color: transparent; color: var(--yellow-text-secondary); }
155
+ .btn-quiet.btn-success:hover:not(:disabled, [aria-disabled="true"]) { background: var(--green-component-bg-hover); border-color: transparent; color: var(--green-solid); }
156
+ .btn-quiet.btn-danger:hover:not(:disabled, [aria-disabled="true"]) { background: var(--red-component-bg-hover); border-color: transparent; color: var(--red-solid); }
157
+ }
148
158
 
149
159
  /* States */
150
- /* Bare disabled buttons fade; hover styles above are gated on :not(:disabled).
160
+ /* Bare disabled buttons fade. `aria-disabled="true"` counts as disabled, as
161
+ Base UI sets it on focusable disabled buttons; hover skips both.
151
162
  Submit buttons are never disabled at rest: the submit validates. A filled
152
163
  button is disabled only while its action runs (with a spinner) or once it
153
164
  is done, so it keeps its own fill and label at full opacity: "Verifying…"
154
165
  stays yellow, a finished 2FA "Verified" stays green with
155
166
  data-state="success". */
156
- button:disabled { opacity: 0.3; cursor: not-allowed; }
157
- :is(.btn-primary, .btn-success, .btn-danger, .btn-info, .btn-invert):not(.btn-quiet):disabled { opacity: 1; }
158
- .btn-default:not(.btn-quiet):disabled { opacity: 1; color: var(--solid); }
159
- /* An opaque double ring: a --bg gap, then --solid-hover, ≥3:1 on page and panels. */
160
- button:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
167
+ button:is(:disabled, [aria-disabled="true"]) { opacity: 0.3; cursor: not-allowed; }
168
+ :is(.btn-primary, .btn-success, .btn-danger, .btn-info, .btn-invert):not(.btn-quiet):is(:disabled, [aria-disabled="true"]) { opacity: 1; }
169
+ .btn-default:not(.btn-quiet):is(:disabled, [aria-disabled="true"]) { opacity: 1; color: var(--solid); }
170
+ /* An opaque double ring: a --bg gap, then --solid-hover, ≥3:1 on page and panels.
171
+ Forced-colors mode drops box-shadow and paints the transparent outline. */
172
+ button:focus-visible { outline: 2px solid transparent; outline-offset: 2px; box-shadow: var(--shadow-focus); }
161
173
 
162
174
  /* Loading state — hides label, paints a CSS spinner over the button */
163
- button.is-loading { color: transparent !important; position: relative; pointer-events: none; opacity: 0.75; }
175
+ button.is-loading { color: transparent !important; position: relative; pointer-events: none; }
164
176
  button.is-loading::after {
165
177
  content: ''; position: absolute; inset: 0; margin: auto;
166
178
  width: 14px; height: 14px; border-radius: 50%;
167
179
  border: 1.5px solid currentColor; border-top-color: transparent;
180
+ color: var(--text);
168
181
  opacity: 0.75;
169
182
  animation: spin 0.7s linear infinite;
170
183
  }
171
184
  .btn-primary.is-loading::after { color: var(--primary-foreground); }
172
- .btn-success.is-loading::after,
173
- .btn-danger.is-loading::after,
174
- .btn-info.is-loading::after,
175
- .btn-invert.is-loading::after { color: var(--destructive-foreground); }
185
+ .btn-success.is-loading::after { color: var(--button-success-fg); }
186
+ .btn-danger.is-loading::after { color: var(--button-danger-fg); }
187
+ .btn-info.is-loading::after { color: var(--button-info-fg); }
188
+ .btn-invert.is-loading::after { color: var(--invert-foreground); }
176
189
  .btn-default.is-loading::after, .btn-ghost.is-loading::after { color: var(--text); }
177
190
  /* The spinner borrows currentColor, and .is-loading makes the label colour
178
191
  transparent — so a quiet button needs its own spinner colour or the control
@@ -186,14 +199,14 @@ button.is-loading::after {
186
199
  cards. Defined once here; do not redeclare it per card. */
187
200
  @keyframes spin { to { transform: rotate(360deg); } }
188
201
 
189
- /* Inputs/textareas/selects inherit nothing from the button reset.
190
- These are dropped for safety in case they ever do. */
202
+ /* Typed text never takes a label's tracking or case. Tailwind v4 preflight
203
+ sets letter-spacing: inherit on form controls, so reset both here. */
191
204
  input, textarea, select { text-transform: none; letter-spacing: normal; }
192
205
 
193
206
 
194
207
  /* ===========================================================
195
208
  Specimen header — the yellow-mono index marker that anchors
196
- each preview card. 11 files used to duplicate this verbatim.
209
+ each preview card.
197
210
  =========================================================== */
198
211
  .head {
199
212
  display: flex; align-items: baseline; gap: 16px;
@@ -221,13 +234,11 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
221
234
  pages take neither: see .auth-page.
222
235
 
223
236
  .panel = quiet inline reference container. Sits ON --bg,
224
- uses --bg-secondary so it stays recessive but
225
- still bordered. Code blocks, info asides, sidebar
226
- panels, callouts. Lower visual weight than a card.
237
+ reads --panel-* so consumers can retheme it apart
238
+ from cards; today the values match. Code blocks,
239
+ info asides, sidebar panels, callouts.
227
240
 
228
- Both use the same hairline border + 8–10 px radius — the
229
- ONLY thing that differs is the surface fill. Pick the role,
230
- not the chrome.
241
+ Pick the role, not the chrome.
231
242
  =========================================================== */
232
243
  .card {
233
244
  background: var(--component-bg);
@@ -252,10 +263,8 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
252
263
  /* ===========================================================
253
264
  Field primitives — production parity with app.put.io forms.
254
265
 
255
- ONE source of truth for input chrome. Local preview files used
256
- to redefine `.field` three times with different focus rings;
257
- now they all inherit from here. To override per-screen, change
258
- geometry (height/padding) — not the surface tokens.
266
+ ONE source of truth for input chrome. To override per-screen,
267
+ change geometry (height/padding) — not the surface tokens.
259
268
 
260
269
  Surface contract (filled, so a field reads on any surface):
261
270
  bg → --field-bg (= --bg-secondary)
@@ -290,10 +299,12 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
290
299
  background var(--dur-fast) var(--ease-out);
291
300
  }
292
301
  .field::placeholder { color: var(--field-placeholder); }
293
- .field:hover { border-color: var(--field-border-hover); }
302
+ @media (hover: hover) and (pointer: fine) {
303
+ .field:hover:where(:not(:disabled, [aria-disabled="true"])) { border-color: var(--field-border-hover); }
304
+ }
294
305
  .field:focus,
295
306
  .field:focus-visible {
296
- outline: none;
307
+ outline: 2px solid transparent;
297
308
  border-color: var(--field-border-focus);
298
309
  box-shadow: var(--field-ring);
299
310
  }
@@ -312,8 +323,8 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
312
323
  border-color: var(--red-border);
313
324
  transition-duration: var(--dur-reveal);
314
325
  }
315
- .field[aria-invalid="true"]:hover {
316
- border-color: var(--red-border-hover);
326
+ @media (hover: hover) and (pointer: fine) {
327
+ .field[aria-invalid="true"]:hover { border-color: var(--red-border-hover); }
317
328
  }
318
329
  .field[aria-invalid="true"]:focus,
319
330
  .field[aria-invalid="true"]:focus-visible {
@@ -381,8 +392,10 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
381
392
  text-decoration: none;
382
393
  white-space: nowrap;
383
394
  }
384
- .form-group > .label-action:hover,
385
- .form-group .label-row a:hover { color: var(--text); }
395
+ @media (hover: hover) and (pointer: fine) {
396
+ .form-group > .label-action:hover,
397
+ .form-group .label-row a:hover { color: var(--text); }
398
+ }
386
399
 
387
400
  /* ===========================================================
388
401
  Form feedback — one public recipe for block and inline status.
@@ -406,6 +419,7 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
406
419
  color: var(--alert-info-body);
407
420
  font-size: var(--fs-ui);
408
421
  line-height: var(--lh-normal);
422
+ overflow-wrap: anywhere;
409
423
  }
410
424
  .form-callout[hidden] { display: none; }
411
425
  .form-callout > :is(i, svg) {
@@ -484,6 +498,7 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
484
498
  gap: clamp(4px, 2vw, 8px);
485
499
  width: 100%;
486
500
  min-width: 0;
501
+ isolation: isolate;
487
502
  }
488
503
  .otp-input {
489
504
  position: absolute;
@@ -495,6 +510,7 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
495
510
  margin: 0;
496
511
  padding: 0;
497
512
  border: 0;
513
+ font-size: var(--fs-field);
498
514
  opacity: 0;
499
515
  cursor: text;
500
516
  }
@@ -531,6 +547,9 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
531
547
  border-color: var(--field-border-focus);
532
548
  box-shadow: var(--field-ring);
533
549
  }
550
+ @media (forced-colors: active) {
551
+ .otp:focus-within .otp-slot[data-state="active"] { outline: 2px solid Highlight; }
552
+ }
534
553
  .otp-separator {
535
554
  flex: 0 0 auto;
536
555
  padding: 0;
@@ -617,7 +636,7 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
617
636
  .auth-title {
618
637
  margin: 0 0 28px;
619
638
  font-size: var(--fs-title);
620
- line-height: 30px;
639
+ line-height: calc(30 / 22);
621
640
  font-weight: var(--fw-medium);
622
641
  letter-spacing: var(--tracking-snug);
623
642
  color: var(--text);
@@ -667,9 +686,11 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
667
686
  text-decoration: none;
668
687
  text-underline-offset: 3px;
669
688
  }
670
- .auth-links a:hover { text-decoration: underline; }
671
689
  .auth-links > a { color: var(--text-secondary); font-weight: inherit; }
672
- .auth-links > a:hover { color: var(--text); text-decoration: none; }
690
+ @media (hover: hover) and (pointer: fine) {
691
+ .auth-links a:hover { text-decoration: underline; }
692
+ .auth-links > a:hover { color: var(--text); text-decoration: none; }
693
+ }
673
694
  /* Two items split to the edges; a lone link or prompt centres. */
674
695
  .auth-links > :only-child { margin-inline: auto; text-align: center; }
675
696
  /* A back link leads with Phosphor's arrow-left, an inline SVG (or the icon font). */
@@ -685,7 +706,17 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
685
706
  font-size: var(--fs-caption);
686
707
  }
687
708
  .auth-footer a { color: var(--text-secondary); text-decoration: none; }
688
- .auth-footer a:hover { color: var(--text); }
709
+ @media (hover: hover) and (pointer: fine) {
710
+ .auth-footer a:hover { color: var(--text); }
711
+ }
712
+ .form-group > .label-action:focus-visible,
713
+ .form-group .label-row a:focus-visible,
714
+ .auth-links a:focus-visible,
715
+ .auth-footer a:focus-visible {
716
+ outline: 2px solid var(--solid-hover);
717
+ outline-offset: 2px;
718
+ border-radius: var(--radius-sm);
719
+ }
689
720
  /* A middot centres in the gap before each link after the first. The empty
690
721
  alt keeps it out of the link's accessible name. */
691
722
  .auth-footer > * + * { position: relative; }
@@ -742,25 +773,35 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
742
773
  `.on` is exactly the invented flag the state contract tells
743
774
  you not to add — prefer the attribute in new markup.
744
775
 
745
- .checkbox — div or <span>, data-state="checked" when on
776
+ .checkbox — <button role="checkbox">, aria-checked or data-state="checked"
746
777
  input[type="checkbox"] — native inputs get the brand accent
747
- .switch — div, data-state="checked" when on
778
+ .switch — <button role="switch">, aria-checked or data-state="checked".
779
+ Resize it with --switch-width and --switch-height; the thumb
780
+ and its travel follow.
748
781
  =========================================================== */
749
782
  .checkbox {
783
+ position: relative;
784
+ /* Buttons default to border-box; a span and a button draw the same box. */
785
+ box-sizing: content-box;
750
786
  width: 14px; height: 14px; flex-shrink: 0;
751
- border: 1.5px solid var(--border-hover);
787
+ padding: 0;
788
+ border: 1.5px solid var(--solid);
752
789
  border-radius: var(--radius-sm);
753
790
  background: transparent;
791
+ cursor: pointer;
754
792
  transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
755
793
  }
794
+ /* Pads the 14px box out to the 24px minimum target (WCAG 2.5.8). */
795
+ .checkbox::before { content: ''; position: absolute; inset: -5px; }
756
796
  .checkbox.checked,
757
- .checkbox[data-state="checked"] {
797
+ .checkbox[data-state="checked"],
798
+ .checkbox[aria-checked="true"] {
758
799
  background: var(--yellow-solid);
759
800
  border-color: var(--yellow-solid);
760
- position: relative;
761
801
  }
762
802
  .checkbox.checked::after,
763
- .checkbox[data-state="checked"]::after {
803
+ .checkbox[data-state="checked"]::after,
804
+ .checkbox[aria-checked="true"]::after {
764
805
  content: ''; position: absolute; left: 3px; top: 0;
765
806
  width: 5px; height: 9px;
766
807
  border: solid var(--primary-foreground); border-width: 0 2px 2px 0;
@@ -769,7 +810,9 @@ input, textarea, select { text-transform: none; letter-spacing: normal; }
769
810
  input[type="checkbox"], input[type="radio"] { accent-color: var(--yellow-solid); cursor: pointer; }
770
811
 
771
812
  .switch {
772
- width: 44px; height: 24px; flex-shrink: 0;
813
+ width: var(--switch-width, 44px); height: var(--switch-height, 24px); flex-shrink: 0;
814
+ padding: 0;
815
+ border: 0;
773
816
  border-radius: var(--radius-pill);
774
817
  background: var(--component-bg-active);
775
818
  position: relative; cursor: pointer;
@@ -777,13 +820,17 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--yellow-solid);
777
820
  }
778
821
  .switch::after {
779
822
  content: ''; position: absolute; top: 2px; left: 2px;
780
- width: 20px; height: 20px; border-radius: 50%;
823
+ width: calc(var(--switch-height, 24px) - 4px); height: calc(var(--switch-height, 24px) - 4px); border-radius: 50%;
781
824
  background: var(--text);
782
- transition: left var(--dur-fast) var(--ease-out);
825
+ transition: transform var(--dur-fast) var(--ease-out);
783
826
  }
784
827
  .switch.on,
785
- .switch[data-state="checked"] { background: var(--green-solid); }
828
+ .switch[data-state="checked"],
829
+ .switch[aria-checked="true"] { background: var(--green-solid); }
786
830
  /* The thumb rides the green selected track, so it takes the token for "what
787
831
  colour sits on green" rather than a raw white that no theme can reach. */
788
832
  .switch.on::after,
789
- .switch[data-state="checked"]::after { left: 22px; background: var(--button-success-fg); }
833
+ .switch[data-state="checked"]::after,
834
+ .switch[aria-checked="true"]::after { transform: translateX(calc(var(--switch-width, 44px) - var(--switch-height, 24px))); background: var(--button-success-fg); }
835
+ .checkbox:focus-visible,
836
+ .switch:focus-visible { outline: 2px solid transparent; outline-offset: 2px; box-shadow: var(--shadow-focus); }
@@ -1,4 +1,7 @@
1
1
  /* -- Document base ------------------------------------------------- */
2
+ /* Native scrollbars, controls and autofill follow the mode. */
3
+ :root { color-scheme: light; }
4
+ .dark { color-scheme: dark; }
2
5
  html {
3
6
  background: var(--html-bg);
4
7
  color: var(--text);
@@ -33,7 +36,6 @@ body {
33
36
  .num, .tabular {
34
37
  font-family: var(--font-mono);
35
38
  font-variant-numeric: tabular-nums;
36
- font-feature-settings: "tnum";
37
39
  }
38
40
 
39
41
  h1, .h1 {