@hattisar/design-tokens 0.3.5 → 0.3.6

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 (2) hide show
  1. package/package.json +1 -1
  2. package/tokens.css +217 -108
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hattisar/design-tokens",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Shared design tokens (colors, type, radius, spacing, motion) and base primitives (reset, buttons, inputs, cards, modals, auth shell) for every Hattisar frontend.",
5
5
  "main": "tokens.css",
6
6
  "files": [
package/tokens.css CHANGED
@@ -32,22 +32,30 @@
32
32
  >=4.5:1) and for a real 3-step surface ladder — surface-tint used
33
33
  to equal background-global exactly, which meant hover states had
34
34
  no visible target to land on. */
35
- --color-background-global: #f6f7f9;
35
+ --color-background-global: #f7f8fa;
36
36
  --color-background-card: #ffffff;
37
- --color-surface-tint: #eef0f3;
38
- --color-input: #fdfdfd;
39
- --color-text-primary: #1c2127;
37
+ --color-surface-tint: #f0f2f6;
38
+ --color-input: #ffffff;
39
+ --color-text-primary: #10141a;
40
40
  /* ~6.4:1 on card */
41
- --color-text-secondary: #56606f;
41
+ --color-text-secondary: #4b5563;
42
42
  /* ~4.6:1 on card — was #8f99a8 at 2.9:1, failing AA body text */
43
- --color-text-muted: #6b7684;
43
+ --color-text-muted: #5f6874;
44
44
  /* Derived from text-primary's own RGB (28,33,39) rather than a
45
45
  separately maintained literal, so border and text can never drift
46
46
  apart the way they used to (border was rgb(17,20,24), a different
47
47
  dark from text-primary's rgb(28,33,39)). */
48
- --color-border: rgba(28, 33, 39, 0.1);
49
- --color-border-strong: rgba(28, 33, 39, 0.2);
48
+ --color-border: rgba(16, 20, 26, 0.09);
49
+ --color-border-strong: rgba(16, 20, 26, 0.16);
50
50
  --color-hover: var(--color-surface-tint);
51
+ --color-focus-ring: rgba(233, 235, 239, 0.2);
52
+ --color-focus-ring-danger: rgba(255, 138, 156, 0.24);
53
+ /* The ring drawn around a focused control. Ink at low alpha rather
54
+ than a saturated blue: this palette has no brand hue, and a blue
55
+ ring here would be the one place colour appeared without meaning
56
+ anything — the ten status tiers are what colour is for. */
57
+ --color-focus-ring: rgba(16, 20, 26, 0.14);
58
+ --color-focus-ring-danger: rgba(199, 10, 36, 0.2);
51
59
 
52
60
  /* Named accents — the same 8 hues most of the status tiers below
53
61
  already used, promoted into reusable tokens instead of each tier repeating
@@ -69,7 +77,7 @@
69
77
  --accent-amber: #8a6600;
70
78
  --accent-bronze: #a35200;
71
79
  --accent-blue: #005bd3;
72
- --accent-cyan: #007cb4;
80
+ --accent-cyan: #0a6d99;
73
81
  --accent-mint: #047b5d;
74
82
  --accent-fuchsia: #984062;
75
83
 
@@ -119,28 +127,28 @@
119
127
  computed. See StatusBadge.tsx for the status keyword -> tier
120
128
  mapping — ten distinct hues, deliberately no collisions. */
121
129
  --status-draft-text: var(--accent-slate);
122
- --status-draft-bg: #f0f4f9;
130
+ --status-draft-bg: #eef1f6;
123
131
  --status-backordered-text: var(--accent-violet);
124
- --status-backordered-bg: #f7ecfe;
132
+ --status-backordered-bg: #f3ecfd;
125
133
  --status-low-stock-text: var(--accent-amber);
126
- --status-low-stock-bg: #ffd289;
134
+ --status-low-stock-bg: #fff1d9;
127
135
  /* Near-empty stock — more urgent than low-stock's reorder-point
128
136
  nudge, not yet fully out. Bg sits between low-stock's punchy
129
137
  amber-gold and out-of-stock's pale peach; text is --color-critical. */
130
138
  --status-critical-stock-text: var(--color-critical);
131
- --status-critical-stock-bg: #ffe0c2;
139
+ --status-critical-stock-bg: #ffe7d1;
132
140
  --status-out-of-stock-text: var(--accent-bronze);
133
- --status-out-of-stock-bg: #ffede1;
141
+ --status-out-of-stock-bg: #ffeee4;
134
142
  --status-partially-fulfilled-text: var(--accent-blue);
135
- --status-partially-fulfilled-bg: #e7f2ff;
143
+ --status-partially-fulfilled-bg: #e8f1fe;
136
144
  --status-shipped-text: var(--accent-cyan);
137
- --status-shipped-bg: #92e5ff;
145
+ --status-shipped-bg: #ddf0fb;
138
146
  --status-delivered-text: var(--accent-mint);
139
- --status-delivered-bg: #ddf8d8;
147
+ --status-delivered-bg: #e1f5e6;
140
148
  --status-cancelled-text: var(--accent-fuchsia);
141
- --status-cancelled-bg: #ffcbda;
149
+ --status-cancelled-bg: #fde7ee;
142
150
  --status-overdue-text: var(--color-danger);
143
- --status-overdue-bg: #ffecee;
151
+ --status-overdue-bg: #feebec;
144
152
 
145
153
  /* Page-level warning/danger/success banners — distinct from the row
146
154
  badges above: a border plus a matching soft fill, no separate text
@@ -149,11 +157,11 @@
149
157
  repeating their own literal hex) so they can never drift out of
150
158
  sync the way they briefly did here. Success intentionally doesn't:
151
159
  it was never meant to mirror --color-success, it's its own shade. */
152
- --banner-warning-bg: #fff1e3;
160
+ --banner-warning-bg: #fff3e6;
153
161
  --banner-warning-border: var(--color-warning);
154
- --banner-danger-bg: #fee8eb;
162
+ --banner-danger-bg: #fdeaed;
155
163
  --banner-danger-border: var(--color-danger);
156
- --banner-success-bg: #cdfed4;
164
+ --banner-success-bg: #e2f7e6;
157
165
  --banner-success-border: var(--color-success);
158
166
 
159
167
  /* A restrained scale, not flat — every step deliberately small, since
@@ -165,17 +173,28 @@
165
173
  remove-buttons inside chips, the settings toggle switch) — never
166
174
  applied to a badge/tag/chip just for a rounder look; those use
167
175
  --radius-sm like everything else (see .status-badge and friends). */
168
- --radius-xs: 4px;
169
- --radius-sm: 6px;
170
- --radius-md: 8px;
171
- --radius-lg: 10px;
172
- --radius-xl: 12px;
176
+ --radius-xs: 5px;
177
+ --radius-sm: 7px;
178
+ --radius-md: 10px;
179
+ --radius-lg: 13px;
180
+ --radius-xl: 16px;
173
181
  --radius-full: 9999px;
174
182
 
175
- --shadow-xs: none;
176
- --shadow-sm: none;
177
- --shadow-md: none;
178
- --shadow-lg: none;
183
+ /* Depth, deliberately restrained. These were all `none`, which left
184
+ every surface separated by border alone — correct, legible, and
185
+ completely flat: a card, a dropdown and a modal all sat on the page
186
+ at exactly the same height. The 32 places already reaching for
187
+ these tokens were asking for a hierarchy the tokens declined to
188
+ give them.
189
+ Four tiers, each tied to a job: xs for a resting control, sm for a
190
+ card, md for anything that floats over the page (dropdowns,
191
+ popovers), lg for a modal. Tinted with the ink colour rather than
192
+ pure black so a shadow reads as the surface's own shade instead of
193
+ a grey smudge. */
194
+ --shadow-xs: 0 1px 2px rgba(16, 20, 26, 0.04);
195
+ --shadow-sm: 0 1px 2px rgba(16, 20, 26, 0.05), 0 2px 8px -3px rgba(16, 20, 26, 0.08);
196
+ --shadow-md: 0 2px 4px -1px rgba(16, 20, 26, 0.04), 0 8px 20px -4px rgba(16, 20, 26, 0.12);
197
+ --shadow-lg: 0 4px 8px -2px rgba(16, 20, 26, 0.06), 0 20px 48px -12px rgba(16, 20, 26, 0.22);
179
198
 
180
199
  /* One shared tier for every FloatingPanel-based dropdown/menu
181
200
  (row actions, column picker, combobox, select, table-actions,
@@ -204,18 +223,30 @@
204
223
  --space-8: 32px;
205
224
  --space-10: 40px;
206
225
  --space-12: 48px;
207
-
208
- /* Type scale — matches sizes already in use across the app (body
209
- 14.5px, h2 19px, h1 23px, ...) plus one new step (--text-3xl) for
210
- bento hero stats. */
211
- --text-xs: 12px;
212
- --text-sm: 13.5px;
213
- --text-base: 14.5px;
226
+ /* Two steps past the old ceiling, for the gaps that separate whole
227
+ regions of a page rather than elements inside one. Without them
228
+ every large gap in the app was a hardcoded number, and they were
229
+ all slightly different. */
230
+ --space-16: 64px;
231
+ --space-20: 80px;
232
+
233
+ /* Type scale. The old one grew out of whatever each screen happened
234
+ to be using — 13.5, 14.5, 15 — three steps inside a point and a
235
+ half of each other, which is not a scale, it is three sizes that
236
+ could not agree. This is a proper ~1.2 ratio off a 14px body, so
237
+ the steps are actually distinguishable and a heading reads as a
238
+ heading rather than as slightly larger body text.
239
+ Body moves 14.5 -> 14: at Inter's x-height 14px is the more
240
+ comfortable long-session size, and it buys back the density the
241
+ roomier spacing below spends. */
242
+ --text-xs: 11.5px;
243
+ --text-sm: 13px;
244
+ --text-base: 14px;
214
245
  --text-md: 15px;
215
246
  --text-lg: 17px;
216
- --text-xl: 19px;
217
- --text-2xl: 23px;
218
- --text-3xl: 29px;
247
+ --text-xl: 20px;
248
+ --text-2xl: 24px;
249
+ --text-3xl: 30px;
219
250
 
220
251
  /* A real weight scale — headings, card titles, and KPI/stat values
221
252
  use these to stand apart from the body text around them; dense
@@ -244,10 +275,10 @@
244
275
  --hd-card-bg as every card (bordered, not tonally separate),
245
276
  mirrored by dark theme's own literal Hattisar Design values below. */
246
277
  --sidebar-bg: var(--color-background-card);
247
- --sidebar-text: #1c2127;
248
- --sidebar-text-muted: #5f6b7c;
278
+ --sidebar-text: var(--color-text-primary);
279
+ --sidebar-text-muted: var(--color-text-muted);
249
280
  --sidebar-active-bg: var(--color-surface-tint);
250
- --sidebar-active-text: #1c2127;
281
+ --sidebar-active-text: var(--color-text-primary);
251
282
  --sidebar-hover-bg: var(--color-surface-tint);
252
283
  /* Icon tint for the active nav row — not a full fill, so
253
284
  --sidebar-active-bg/text above stay the "you're here" pill and
@@ -289,22 +320,22 @@
289
320
  sits below the hover/tint tier. Modest steps between each (not a
290
321
  dramatic jump) plus real borders is what keeps a dark theme
291
322
  reading crisp instead of washed out — see --color-border below. */
292
- --color-background-global: #121214;
293
- --color-background-card: #1b1b1f;
294
- --color-surface-tint: #232328;
323
+ --color-background-global: #0d0f13;
324
+ --color-background-card: #16191e;
325
+ --color-surface-tint: #1f232a;
295
326
  --color-input: var(--color-background-card);
296
327
  /* Not pure white — pure white text on a near-black surface causes
297
328
  halation (a glow/vibrate effect on the eye over long sessions).
298
329
  ~92% white instead. */
299
- --color-text-primary: #e9e9ec;
330
+ --color-text-primary: #e9ebef;
300
331
  /* ~7:1 on card */
301
- --color-text-secondary: #9c9da5;
332
+ --color-text-secondary: #9ba4b2;
302
333
  /* ~4.7:1 on card — was #5f6b7c at 2.7:1, failing AA body text */
303
- --color-text-muted: #7e7f87;
334
+ --color-text-muted: #8b94a2;
304
335
  /* Derived from text-primary's own RGB (233,233,236) — same
305
336
  single-source-of-truth reasoning as :root's own --color-border. */
306
- --color-border: rgba(233, 233, 236, 0.09);
307
- --color-border-strong: rgba(233, 233, 236, 0.16);
337
+ --color-border: rgba(233, 235, 239, 0.10);
338
+ --color-border-strong: rgba(233, 235, 239, 0.18);
308
339
  --color-hover: var(--color-surface-tint);
309
340
 
310
341
  /* Brighter counterparts of :root's accent set — a dark badge fill
@@ -317,7 +348,7 @@
317
348
  --accent-violet: #d4ccff;
318
349
  --accent-amber: #ffc879;
319
350
  --accent-bronze: #ffab73;
320
- --accent-blue: #4188ff;
351
+ --accent-blue: #7ba9ff;
321
352
  --accent-cyan: #91d0ff;
322
353
  --accent-mint: #2ced83;
323
354
  --accent-fuchsia: #ff9dbd;
@@ -329,10 +360,10 @@
329
360
  --color-brand: var(--color-text-primary);
330
361
  --color-brand-hover: color-mix(in srgb, var(--color-brand) 90%, black);
331
362
  --color-brand-active: color-mix(in srgb, var(--color-brand) 75%, black);
332
- --color-brand-text: #121212;
363
+ --color-brand-text: #0d0f13;
333
364
 
334
- --shadow-btn-primary: none;
335
- --shadow-btn-secondary: none;
365
+ --shadow-btn-primary: 0 1px 2px rgba(16, 20, 26, 0.12);
366
+ --shadow-btn-secondary: 0 1px 2px rgba(16, 20, 26, 0.05);
336
367
 
337
368
  --color-secondary-fill: var(--color-surface-tint);
338
369
  --color-secondary-border: var(--color-border-strong);
@@ -350,37 +381,41 @@
350
381
  --color-success: var(--accent-mint);
351
382
 
352
383
  --status-draft-text: var(--accent-slate);
353
- --status-draft-bg: #2a2d2f;
384
+ --status-draft-bg: #262a31;
354
385
  --status-backordered-text: var(--accent-violet);
355
- --status-backordered-bg: #35294a;
386
+ --status-backordered-bg: #312747;
356
387
  --status-low-stock-text: var(--accent-amber);
357
- --status-low-stock-bg: #43380f;
388
+ --status-low-stock-bg: #3f3411;
358
389
  --status-critical-stock-text: var(--color-critical);
359
- --status-critical-stock-bg: #4a3010;
390
+ --status-critical-stock-bg: #452c12;
360
391
  --status-out-of-stock-text: var(--accent-bronze);
361
- --status-out-of-stock-bg: #4a2c14;
392
+ --status-out-of-stock-bg: #452916;
362
393
  --status-partially-fulfilled-text: var(--accent-blue);
363
- --status-partially-fulfilled-bg: #1c2c4a;
394
+ --status-partially-fulfilled-bg: #1b2740;
364
395
  --status-shipped-text: var(--accent-cyan);
365
- --status-shipped-bg: #103846;
396
+ --status-shipped-bg: #0f3341;
366
397
  --status-delivered-text: var(--accent-mint);
367
- --status-delivered-bg: #163c22;
398
+ --status-delivered-bg: #133620;
368
399
  --status-cancelled-text: var(--accent-fuchsia);
369
- --status-cancelled-bg: #472233;
400
+ --status-cancelled-bg: #421f2f;
370
401
  --status-overdue-text: var(--color-danger);
371
- --status-overdue-bg: #452023;
402
+ --status-overdue-bg: #401d21;
372
403
 
373
- --banner-warning-bg: #412d00;
404
+ --banner-warning-bg: #3a2907;
374
405
  --banner-warning-border: var(--color-warning);
375
- --banner-danger-bg: #5f0716;
406
+ --banner-danger-bg: #4a1119;
376
407
  --banner-danger-border: var(--color-danger);
377
- --banner-success-bg: #073630;
408
+ --banner-success-bg: #0a3129;
378
409
  --banner-success-border: var(--color-success);
379
410
 
380
- --shadow-xs: none;
381
- --shadow-sm: none;
382
- --shadow-md: none;
383
- --shadow-lg: none;
411
+ /* A dark surface cannot be lifted by a soft grey shadow — there is
412
+ nothing lighter behind it to catch the light. So these go darker
413
+ and deeper than their light-theme counterparts, which is what
414
+ actually reads as elevation on a near-black page. */
415
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.32);
416
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px -3px rgba(0, 0, 0, 0.5);
417
+ --shadow-md: 0 2px 4px -1px rgba(0, 0, 0, 0.4), 0 8px 22px -4px rgba(0, 0, 0, 0.6);
418
+ --shadow-lg: 0 4px 8px -2px rgba(0, 0, 0, 0.45), 0 24px 56px -12px rgba(0, 0, 0, 0.75);
384
419
 
385
420
  /* Deliberately NOT --color-background-card — the sidebar is now a
386
421
  distinct chrome rail, true black, sitting a full step below the
@@ -409,15 +444,15 @@
409
444
  takes the nearest declaration in the tree, so relying on :root
410
445
  alone would lose to a dark attribute set higher up. */
411
446
  [data-theme="light"] {
412
- --color-background-global: #f6f7f9;
447
+ --color-background-global: #f7f8fa;
413
448
  --color-background-card: #ffffff;
414
- --color-surface-tint: #eef0f3;
415
- --color-input: #fdfdfd;
416
- --color-text-primary: #1c2127;
417
- --color-text-secondary: #56606f;
418
- --color-text-muted: #6b7684;
419
- --color-border: rgba(28, 33, 39, 0.1);
420
- --color-border-strong: rgba(28, 33, 39, 0.2);
449
+ --color-surface-tint: #f0f2f6;
450
+ --color-input: #ffffff;
451
+ --color-text-primary: #10141a;
452
+ --color-text-secondary: #4b5563;
453
+ --color-text-muted: #5f6874;
454
+ --color-border: rgba(16, 20, 26, 0.09);
455
+ --color-border-strong: rgba(16, 20, 26, 0.16);
421
456
  --color-hover: var(--color-surface-tint);
422
457
 
423
458
  --color-brand: var(--color-text-primary);
@@ -425,8 +460,8 @@
425
460
  --color-brand-active: color-mix(in srgb, var(--color-brand) 70%, black);
426
461
  --color-brand-text: #ffffff;
427
462
 
428
- --shadow-btn-primary: none;
429
- --shadow-btn-secondary: none;
463
+ --shadow-btn-primary: 0 1px 2px rgba(0, 0, 0, 0.4);
464
+ --shadow-btn-secondary: 0 1px 2px rgba(0, 0, 0, 0.3);
430
465
 
431
466
  --color-secondary-fill: var(--color-surface-tint);
432
467
  --color-secondary-border: var(--color-border-strong);
@@ -443,43 +478,49 @@
443
478
  --color-success: #047b5d;
444
479
 
445
480
  --status-draft-text: var(--accent-slate);
446
- --status-draft-bg: #f0f4f9;
481
+ --status-draft-bg: #eef1f6;
447
482
  --status-backordered-text: var(--accent-violet);
448
- --status-backordered-bg: #f7ecfe;
483
+ --status-backordered-bg: #f3ecfd;
449
484
  --status-low-stock-text: var(--accent-amber);
450
- --status-low-stock-bg: #ffd289;
485
+ --status-low-stock-bg: #fff1d9;
451
486
  --status-critical-stock-text: var(--color-critical);
452
- --status-critical-stock-bg: #ffe0c2;
487
+ --status-critical-stock-bg: #ffe7d1;
453
488
  --status-out-of-stock-text: var(--accent-bronze);
454
- --status-out-of-stock-bg: #ffede1;
489
+ --status-out-of-stock-bg: #ffeee4;
455
490
  --status-partially-fulfilled-text: var(--accent-blue);
456
- --status-partially-fulfilled-bg: #e7f2ff;
491
+ --status-partially-fulfilled-bg: #e8f1fe;
457
492
  --status-shipped-text: var(--accent-cyan);
458
- --status-shipped-bg: #92e5ff;
493
+ --status-shipped-bg: #ddf0fb;
459
494
  --status-delivered-text: var(--accent-mint);
460
- --status-delivered-bg: #ddf8d8;
495
+ --status-delivered-bg: #e1f5e6;
461
496
  --status-cancelled-text: var(--accent-fuchsia);
462
- --status-cancelled-bg: #ffcbda;
497
+ --status-cancelled-bg: #fde7ee;
463
498
  --status-overdue-text: var(--color-danger);
464
- --status-overdue-bg: #ffecee;
499
+ --status-overdue-bg: #feebec;
465
500
 
466
- --banner-warning-bg: #fff1e3;
501
+ --banner-warning-bg: #fff3e6;
467
502
  --banner-warning-border: var(--color-warning);
468
- --banner-danger-bg: #fee8eb;
503
+ --banner-danger-bg: #fdeaed;
469
504
  --banner-danger-border: var(--color-danger);
470
- --banner-success-bg: #cdfed4;
505
+ --banner-success-bg: #e2f7e6;
471
506
  --banner-success-border: #047b5d;
472
507
 
508
+ /* Deliberately still flat, unlike :root's. The only thing that pins
509
+ itself to light is a print page, and a shadow on paper is a grey
510
+ smear. This block exists to restate the light palette inside a dark
511
+ ancestor — it is a copy of :root's values by necessity (custom
512
+ properties cannot be re-pointed at another block), so anything
513
+ changed above has to be changed here too. */
473
514
  --shadow-xs: none;
474
515
  --shadow-sm: none;
475
516
  --shadow-md: none;
476
517
  --shadow-lg: none;
477
518
 
478
519
  --sidebar-bg: var(--color-background-card);
479
- --sidebar-text: #1c2127;
480
- --sidebar-text-muted: #5f6b7c;
520
+ --sidebar-text: #10141a;
521
+ --sidebar-text-muted: var(--color-text-muted);
481
522
  --sidebar-active-bg: var(--color-surface-tint);
482
- --sidebar-active-text: #1c2127;
523
+ --sidebar-active-text: var(--color-text-primary);
483
524
  --sidebar-hover-bg: var(--color-surface-tint);
484
525
  --sidebar-active-icon: var(--sidebar-active-text);
485
526
  --sidebar-rail-bg: var(--color-background-card);
@@ -600,9 +641,24 @@ td a:hover {
600
641
  font-weight: var(--font-weight-semibold);
601
642
  }
602
643
 
644
+ /* A label should recede so the value it names is what gets scanned.
645
+ Medium weight at a step down from body, in secondary ink — quieter
646
+ than the data, still comfortably above AA. */
603
647
  label {
604
648
  font-size: var(--text-sm);
605
- font-weight: 400;
649
+ font-weight: var(--font-weight-medium);
650
+ color: var(--color-text-secondary);
651
+ line-height: 1.4;
652
+ }
653
+
654
+ /* A required field says so once, here, rather than each form inventing
655
+ its own asterisk-or-not. Marked on the label so it reads before the
656
+ field rather than after it. */
657
+ label[data-required]::after {
658
+ content: "*";
659
+ margin-left: 3px;
660
+ color: var(--color-danger);
661
+ font-weight: var(--font-weight-regular);
606
662
  }
607
663
 
608
664
  /* Primary action — highest-contrast neutral fill (--color-brand),
@@ -717,14 +773,24 @@ button:focus-visible {
717
773
  background: var(--color-destructive-hover);
718
774
  }
719
775
 
776
+ /* font-family is inherited; size, weight and colour are stated.
777
+ `font: inherit` alone meant a field took its type from whatever
778
+ wrapped it — and most fields in this app are wrapped in their own
779
+ <label>, which is a step smaller and now medium weight. So a field
780
+ inside a label rendered at 13px and one outside at 14px, and the
781
+ label change would have made the wrapped ones semibold. The value a
782
+ person typed should look the same everywhere it appears. */
720
783
  input,
721
784
  select,
722
785
  textarea {
723
- font: inherit;
786
+ font-family: inherit;
787
+ font-size: var(--text-base);
788
+ font-weight: var(--font-weight-regular);
789
+ line-height: 1.4;
724
790
  width: 100%;
725
- padding: 7px 10px;
726
- margin-top: 4px;
727
- border: 1px solid var(--color-border);
791
+ padding: 8px 12px;
792
+ margin-top: 6px;
793
+ border: 1px solid var(--color-border-strong);
728
794
  border-radius: var(--radius-sm);
729
795
  background: var(--color-input);
730
796
  color: var(--color-text-primary);
@@ -733,6 +799,11 @@ textarea {
733
799
  box-shadow 0.15s ease;
734
800
  }
735
801
 
802
+ input::placeholder,
803
+ textarea::placeholder {
804
+ color: var(--color-text-muted);
805
+ }
806
+
736
807
  /* The browser's native select arrow renders at its own baseline/size
737
808
  per-OS (a mismatched-color chevron and off-center text) —
738
809
  appearance:none strips it entirely and this draws a fixed, always-
@@ -751,10 +822,23 @@ select {
751
822
  background-position: right 10px center;
752
823
  }
753
824
 
754
- /* Focus state: just the field's own border switching to brand color —
755
- no outline ring outside the field's edge (an outline-offset ring
756
- there used to double up as a second, floating rectangle around the
757
- input instead of reading as one focused box). */
825
+ /* Focus: the border goes to brand ink and a soft ring is drawn *by the
826
+ field itself*, with box-shadow rather than outline.
827
+ The border alone was the previous answer, after an `outline` with an
828
+ offset had read as a second rectangle floating around the input. A
829
+ box-shadow spread follows the border-radius exactly, so it reads as
830
+ one thickened edge — which fixes the original complaint without
831
+ leaving keyboard users hunting for which field they are in. */
832
+ input:focus-visible,
833
+ select:focus-visible,
834
+ textarea:focus-visible {
835
+ outline: none;
836
+ border-color: var(--color-brand);
837
+ box-shadow: 0 0 0 3px var(--color-focus-ring);
838
+ }
839
+
840
+ /* Kept for pointer focus too — a click into a field should still show
841
+ which one is live, just without the ring shouting. */
758
842
  input:focus,
759
843
  select:focus,
760
844
  textarea:focus {
@@ -762,6 +846,31 @@ textarea:focus {
762
846
  border-color: var(--color-brand);
763
847
  }
764
848
 
849
+ /* A field the browser or the form has judged invalid.
850
+ There was no error state at all: a rejected field looked exactly
851
+ like an accepted one, and the only signal was whatever the server
852
+ said afterwards. :user-invalid rather than :invalid deliberately —
853
+ it waits until somebody has actually interacted, so an untouched
854
+ form does not open covered in red. */
855
+ input[aria-invalid="true"],
856
+ select[aria-invalid="true"],
857
+ textarea[aria-invalid="true"],
858
+ input:user-invalid,
859
+ select:user-invalid,
860
+ textarea:user-invalid {
861
+ border-color: var(--color-danger);
862
+ }
863
+
864
+ input[aria-invalid="true"]:focus-visible,
865
+ select[aria-invalid="true"]:focus-visible,
866
+ textarea[aria-invalid="true"]:focus-visible,
867
+ input:user-invalid:focus-visible,
868
+ select:user-invalid:focus-visible,
869
+ textarea:user-invalid:focus-visible {
870
+ border-color: var(--color-danger);
871
+ box-shadow: 0 0 0 3px var(--color-focus-ring-danger);
872
+ }
873
+
765
874
  input:disabled,
766
875
  select:disabled,
767
876
  textarea:disabled {