@godxjp/ui 18.15.4 → 19.0.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 (145) hide show
  1. package/dist/app/date-format-labels.d.ts +1 -1
  2. package/dist/app/time-format-labels.d.ts +1 -1
  3. package/dist/components/data-display/badge.d.ts +1 -1
  4. package/dist/components/data-display/card.d.ts +4 -13
  5. package/dist/components/data-display/card.js +2 -15
  6. package/dist/components/data-display/data-table.d.ts +3 -8
  7. package/dist/components/data-display/data-table.js +106 -68
  8. package/dist/components/data-display/descriptions.js +34 -5
  9. package/dist/components/data-display/empty-state.js +1 -1
  10. package/dist/components/data-display/permission-matrix.js +7 -7
  11. package/dist/components/data-display/popover.js +2 -9
  12. package/dist/components/data-display/qr-code.d.ts +1 -1
  13. package/dist/components/data-display/scroll-area.d.ts +3 -1
  14. package/dist/components/data-display/scroll-area.js +150 -16
  15. package/dist/components/data-display/table.js +12 -3
  16. package/dist/components/data-entry/branch-scope-picker.js +4 -4
  17. package/dist/components/data-entry/calendar.js +15 -24
  18. package/dist/components/data-entry/cascader.js +67 -71
  19. package/dist/components/data-entry/checkbox.js +9 -1
  20. package/dist/components/data-entry/command.d.ts +6 -6
  21. package/dist/components/data-entry/date-picker.js +28 -15
  22. package/dist/components/data-entry/date-range-picker.js +10 -10
  23. package/dist/components/data-entry/form-field.js +7 -3
  24. package/dist/components/data-entry/index.d.ts +2 -1
  25. package/dist/components/data-entry/input.js +8 -19
  26. package/dist/components/data-entry/label.js +4 -2
  27. package/dist/components/data-entry/month-picker.js +16 -15
  28. package/dist/components/data-entry/month-range-picker.js +17 -16
  29. package/dist/components/data-entry/radio.js +6 -1
  30. package/dist/components/data-entry/search-select.js +29 -29
  31. package/dist/components/data-entry/select.js +15 -18
  32. package/dist/components/data-entry/switch.js +7 -1
  33. package/dist/components/data-entry/textarea.d.ts +56 -0
  34. package/dist/components/data-entry/textarea.js +103 -40
  35. package/dist/components/data-entry/time-picker.js +52 -52
  36. package/dist/components/data-entry/toggle-group.d.ts +1 -0
  37. package/dist/components/data-entry/toggle.d.ts +1 -1
  38. package/dist/components/data-entry/transfer.js +14 -15
  39. package/dist/components/data-entry/tree-select.js +16 -22
  40. package/dist/components/data-entry/upload-crop-dialog.js +4 -4
  41. package/dist/components/data-entry/upload.d.ts +1 -1
  42. package/dist/components/data-entry/upload.js +46 -46
  43. package/dist/components/feedback/alert.js +7 -14
  44. package/dist/components/feedback/dialog.d.ts +23 -32
  45. package/dist/components/feedback/dialog.js +16 -13
  46. package/dist/components/feedback/sheet.d.ts +1 -1
  47. package/dist/components/feedback/sheet.js +14 -14
  48. package/dist/components/feedback/skeleton.js +13 -10
  49. package/dist/components/feedback/sonner.d.ts +1 -1
  50. package/dist/components/feedback/sonner.js +17 -8
  51. package/dist/components/feedback/tooltip.js +1 -1
  52. package/dist/components/general/activity.d.ts +44 -0
  53. package/dist/components/general/activity.js +65 -0
  54. package/dist/components/general/button.d.ts +2 -2
  55. package/dist/components/general/button.js +11 -24
  56. package/dist/components/general/index.d.ts +2 -0
  57. package/dist/components/general/index.js +2 -0
  58. package/dist/components/layout/app-shell.js +5 -3
  59. package/dist/components/layout/auth-divider.d.ts +9 -2
  60. package/dist/components/layout/auth-divider.js +7 -11
  61. package/dist/components/layout/index.d.ts +1 -0
  62. package/dist/components/layout/page-container.d.ts +1 -1
  63. package/dist/components/layout/page-container.js +32 -20
  64. package/dist/components/layout/resizable.d.ts +12 -12
  65. package/dist/components/layout/separator.d.ts +1 -0
  66. package/dist/components/layout/service-role-panel.js +47 -42
  67. package/dist/components/layout/sidebar.js +13 -1
  68. package/dist/components/layout/split-pane.d.ts +12 -1
  69. package/dist/components/layout/split-pane.js +13 -4
  70. package/dist/components/navigation/app-setting-picker.js +5 -14
  71. package/dist/components/navigation/dropdown-menu.js +10 -16
  72. package/dist/components/navigation/menubar.d.ts +3 -6
  73. package/dist/components/navigation/pagination-utils.d.ts +15 -2
  74. package/dist/components/navigation/pagination-utils.js +27 -21
  75. package/dist/components/navigation/pagination.d.ts +4 -4
  76. package/dist/components/navigation/pagination.js +2 -2
  77. package/dist/components/navigation/steps.js +31 -67
  78. package/dist/components/navigation/tabs.js +17 -5
  79. package/dist/components/query/infinite-query-state.js +2 -2
  80. package/dist/components/ui/accordion.d.ts +1 -1
  81. package/dist/components/ui/avatar.d.ts +1 -1
  82. package/dist/components/ui/avatar.js +29 -10
  83. package/dist/components/ui/input-otp.d.ts +8 -6
  84. package/dist/components/ui/rating.js +1 -1
  85. package/dist/components/ui/separator.d.ts +27 -2
  86. package/dist/components/ui/separator.js +42 -11
  87. package/dist/components/ui/toggle-group.d.ts +15 -5
  88. package/dist/components/ui/toggle-group.js +43 -18
  89. package/dist/components/ui/toggle.d.ts +66 -5
  90. package/dist/components/ui/toggle.js +64 -9
  91. package/dist/i18n/messages/en.json +8 -0
  92. package/dist/i18n/messages/ja.json +8 -0
  93. package/dist/i18n/messages/vi.json +8 -0
  94. package/dist/lib/control-styles.d.ts +14 -0
  95. package/dist/lib/control-styles.js +2 -0
  96. package/dist/props/components/data-display.prop.d.ts +85 -3
  97. package/dist/props/components/data-entry.prop.d.ts +17 -0
  98. package/dist/props/components/general.prop.d.ts +52 -1
  99. package/dist/props/components/layout.prop.d.ts +137 -2
  100. package/dist/props/registry.d.ts +116 -5
  101. package/dist/props/registry.js +165 -4
  102. package/dist/props/vocabulary/index.d.ts +2 -2
  103. package/dist/props/vocabulary/interaction.prop.d.ts +15 -0
  104. package/dist/props/vocabulary/layout.prop.d.ts +8 -0
  105. package/dist/styles/alert-layout.css +69 -3
  106. package/dist/styles/badge-layout.css +2 -2
  107. package/dist/styles/base.css +6 -0
  108. package/dist/styles/card-layout.css +25 -8
  109. package/dist/styles/control.css +1150 -29
  110. package/dist/styles/data-display-layout.css +185 -11
  111. package/dist/styles/data-entry-layout.css +352 -1
  112. package/dist/styles/density.css +2 -2
  113. package/dist/styles/dialog-layout.css +203 -4
  114. package/dist/styles/focus-ring.css +155 -0
  115. package/dist/styles/form-layout.css +50 -5
  116. package/dist/styles/index.css +1 -0
  117. package/dist/styles/layout.css +412 -23
  118. package/dist/styles/motion.css +178 -0
  119. package/dist/styles/navigation-layout.css +398 -22
  120. package/dist/styles/shell-layout.css +137 -39
  121. package/dist/styles/table-layout.css +153 -22
  122. package/dist/styles/toggle.css +59 -0
  123. package/dist/tokens/base.css +5 -0
  124. package/dist/tokens/components/activity.css +64 -0
  125. package/dist/tokens/components/badge.css +7 -0
  126. package/dist/tokens/components/card.css +14 -10
  127. package/dist/tokens/components/chart.css +1 -1
  128. package/dist/tokens/components/control.css +358 -8
  129. package/dist/tokens/components/data-display.css +75 -0
  130. package/dist/tokens/components/data-entry.css +25 -0
  131. package/dist/tokens/components/descriptions.css +22 -0
  132. package/dist/tokens/components/feedback.css +143 -4
  133. package/dist/tokens/components/form.css +16 -0
  134. package/dist/tokens/components/legal-document.css +5 -1
  135. package/dist/tokens/components/navigation.css +109 -0
  136. package/dist/tokens/components/scroll-area.css +21 -0
  137. package/dist/tokens/components/separator.css +52 -0
  138. package/dist/tokens/components/shell.css +132 -20
  139. package/dist/tokens/components/table.css +52 -3
  140. package/dist/tokens/components/toggle.css +82 -0
  141. package/dist/tokens/components/upload.css +107 -0
  142. package/dist/tokens/foundation.css +171 -10
  143. package/dist/tokens/semantic/layout.css +120 -1
  144. package/package.json +84 -68
  145. package/scripts/ui-audit.mjs +51 -2
@@ -4,19 +4,94 @@
4
4
  */
5
5
 
6
6
  @layer components {
7
+ /* SEPARATOR — a tokenized rule, optionally INTERRUPTED by a label (gh#308).
8
+ *
9
+ * Two shapes share one class. WITHOUT a label the element IS the rule, exactly as before: the
10
+ * `:not([data-labelled])` guard keeps the plain form byte-identical (the `1px` and the `--border`
11
+ * are now `--separator-rule-{size,color}`, whose defaults are those same values). WITH a label
12
+ * the root becomes the three-cell grid `rule · label · rule` — `minmax(0, 1fr)` on both halves so
13
+ * a long ja/vi label clamps the rules instead of overflowing the container, and `auto` on the
14
+ * label so it is never squeezed to zero. `labelAlign` only re-measures the SHORT half, and it is
15
+ * a grid TRACK on the inline axis, so start/end swap under `dir="rtl"` with no extra rule.
16
+ *
17
+ * `tone` re-points the two colour knobs on the root rather than restating `background`/`color`
18
+ * per tone, so one declaration paints both shapes and a service override still wins. */
7
19
  .ui-separator {
8
20
  flex-shrink: 0;
9
- background: hsl(var(--border));
10
21
  }
11
22
 
12
- .ui-separator[data-orientation="horizontal"] {
23
+ .ui-separator:not([data-labelled])[data-orientation="horizontal"] {
13
24
  width: 100%;
14
- height: 1px;
25
+ height: var(--separator-rule-size);
26
+ background: hsl(var(--separator-rule-color, var(--border)));
15
27
  }
16
28
 
17
- .ui-separator[data-orientation="vertical"] {
18
- width: 1px;
29
+ .ui-separator:not([data-labelled])[data-orientation="vertical"] {
30
+ width: var(--separator-rule-size);
19
31
  height: 100%;
32
+ background: hsl(var(--separator-rule-color, var(--border)));
33
+ }
34
+
35
+ .ui-separator[data-labelled] {
36
+ display: grid;
37
+ width: 100%;
38
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
39
+ align-items: center;
40
+ gap: var(--separator-label-gap, var(--space-3));
41
+ }
42
+
43
+ .ui-separator[data-labelled][data-label-align="start"] {
44
+ grid-template-columns: var(--separator-label-inset, var(--space-6)) auto minmax(0, 1fr);
45
+ }
46
+
47
+ .ui-separator[data-labelled][data-label-align="end"] {
48
+ grid-template-columns: minmax(0, 1fr) auto var(--separator-label-inset, var(--space-6));
49
+ }
50
+
51
+ .ui-separator-rule {
52
+ block-size: var(--separator-rule-size);
53
+ background: hsl(var(--separator-rule-color, var(--border)));
54
+ }
55
+
56
+ .ui-separator-label {
57
+ min-inline-size: 0;
58
+ color: hsl(var(--separator-label-color, var(--muted-foreground)));
59
+ font-size: var(--separator-label-font-size);
60
+ font-weight: var(--separator-label-font-weight);
61
+ line-height: var(--separator-label-line-height);
62
+ /* A single unbroken token (a URL, a long ja compound) must still wrap rather than push the
63
+ * grid past its container. */
64
+ overflow-wrap: anywhere;
65
+ }
66
+
67
+ .ui-separator[data-tone="muted"] {
68
+ --separator-rule-color: var(--separator-tone-muted-rule-color, var(--border));
69
+ --separator-label-color: var(--separator-tone-muted-label-color, var(--muted-foreground));
70
+ }
71
+
72
+ .ui-separator[data-tone="primary"] {
73
+ --separator-rule-color: var(--separator-tone-primary-rule-color, var(--primary));
74
+ --separator-label-color: var(--separator-tone-primary-label-color, var(--primary));
75
+ }
76
+
77
+ .ui-separator[data-tone="success"] {
78
+ --separator-rule-color: var(--separator-tone-success-rule-color, var(--success));
79
+ --separator-label-color: var(--separator-tone-success-label-color, var(--success));
80
+ }
81
+
82
+ .ui-separator[data-tone="warning"] {
83
+ --separator-rule-color: var(--separator-tone-warning-rule-color, var(--warning));
84
+ --separator-label-color: var(--separator-tone-warning-label-color, var(--warning));
85
+ }
86
+
87
+ .ui-separator[data-tone="destructive"] {
88
+ --separator-rule-color: var(--separator-tone-destructive-rule-color, var(--destructive));
89
+ --separator-label-color: var(--separator-tone-destructive-label-color, var(--destructive));
90
+ }
91
+
92
+ .ui-separator[data-tone="info"] {
93
+ --separator-rule-color: var(--separator-tone-info-rule-color, var(--info));
94
+ --separator-label-color: var(--separator-tone-info-label-color, var(--info));
20
95
  }
21
96
 
22
97
  .ui-aspect-ratio {
@@ -291,6 +366,53 @@
291
366
  max-block-size: var(--master-detail-master-viewport-standard);
292
367
  }
293
368
 
369
+ /* ── ServiceRolePanel (gh#319) ─────────────────────────────────────────────────
370
+ * MasterDetail above owns every bit of the two-track geometry; what follows is only the role
371
+ * LIST that sits in the master rail. `.ui-service-role-panel` was a bare hook (class emitted,
372
+ * no rule anywhere), so all of this used to be Tailwind literals on the component. */
373
+ .ui-service-role-panel-list {
374
+ display: flex;
375
+ flex-direction: column;
376
+ gap: var(--service-role-panel-list-gap);
377
+ list-style: none;
378
+ }
379
+
380
+ /* One role: the select button plus the optional delete button, as SIBLINGS (never nested
381
+ * interactive controls). Deliberately NOT `.ui-inline-xs` — that helper wraps, and the icon
382
+ * button must stay on the row beside the flex-1 select button at every width. */
383
+ .ui-service-role-panel-row {
384
+ display: flex;
385
+ align-items: center;
386
+ gap: var(--service-role-panel-row-gap);
387
+ }
388
+
389
+ /* ORDERING TRAP (gh#319): this needs the `.ui-service-role-panel` ancestor. The role button is
390
+ * a real `<Button>`, and `.ui-button--md { padding-block: var(--button-space-block) }` lives in
391
+ * control.css, which styles/index.css imports AFTER layout.css — same `@layer components`, so
392
+ * at equal (0,1,0) specificity control.css would win and the row would snap back to the single
393
+ * control height. (0,2,0) settles it. `h-auto` and `flex-1` stay UTILITIES on the component for
394
+ * the same reason inverted: they must beat the same rule from a layer that always outranks it. */
395
+ .ui-service-role-panel .ui-service-role-panel-item {
396
+ padding-block: var(--service-role-panel-item-padding-block);
397
+ }
398
+
399
+ /* The button's label column. `min-inline-size: 0` is what lets the name and the meta line
400
+ * truncate instead of forcing the rail wider (the flexbox min-content default). */
401
+ .ui-service-role-panel-item-body {
402
+ display: flex;
403
+ flex-direction: column;
404
+ align-items: flex-start;
405
+ min-inline-size: 0;
406
+ line-height: var(--line-height-tight);
407
+ }
408
+
409
+ .ui-service-role-panel-item-title {
410
+ display: inline-flex;
411
+ align-items: center;
412
+ gap: var(--service-role-panel-item-title-gap);
413
+ max-inline-size: 100%;
414
+ }
415
+
294
416
  @container responsive-grid (min-width: 40rem) {
295
417
  .ui-responsive-grid {
296
418
  grid-template-columns: repeat(var(--responsive-grid-sm, 2), minmax(0, 1fr));
@@ -324,6 +446,17 @@
324
446
  grid-template-columns: minmax(0, 1fr);
325
447
  }
326
448
 
449
+ /* CLOSED rail — `aside={null}`. The `<aside>` element is gone, so the pane is one full-width
450
+ * column and there is nothing left for the gap to separate. The wrappers stay mounted (that is
451
+ * what keeps `children` from remounting), so this attribute is the ONLY thing that changes.
452
+ * The second attribute is deliberate: it lifts the rule above the `[data-aside-width]` presets
453
+ * in the container queries below, which sit at equal specificity, so a closed pane stays one
454
+ * column no matter where those blocks end up in source order. */
455
+ .ui-split-pane[data-aside="closed"][data-aside-width] {
456
+ grid-template-columns: minmax(0, 1fr);
457
+ gap: 0;
458
+ }
459
+
327
460
  .ui-resizable-panel-group {
328
461
  display: flex;
329
462
  min-height: 0;
@@ -400,18 +533,82 @@
400
533
  min-width: 0;
401
534
  }
402
535
 
536
+ /* `--page-band-gap` is INTERNAL plumbing, not a theming knob (the same shape as
537
+ * --master-detail-rail-size above): the ONE resolved number that separates the page's bands,
538
+ * so the container's `gap` and the toolbar band's negation of it can never disagree. It has to
539
+ * be a named variable because the number is not always --space-section-active — `ghost` swaps
540
+ * it for --space-stack-md, while the 720px step and the admin-collection preset swap the
541
+ * section token underneath it. A toolbar rule that hard-coded
542
+ * `calc(-1 * var(--space-section-active))` would look correct only because those two tokens
543
+ * both resolve to 16px today: retune --space-section in a service theme and a ghost page's
544
+ * band would overlap or float by the difference, silently. Services still retune the SPACING
545
+ * through --space-section / --space-section-compact, exactly as before. */
403
546
  .ui-page-container {
547
+ --page-band-gap: var(--space-section-active);
548
+
404
549
  display: flex;
405
550
  flex-direction: column;
406
551
  min-height: 100%;
407
552
  padding: var(--space-page-active-y) 0;
408
- gap: var(--space-section-active);
553
+ gap: var(--page-band-gap);
554
+ }
555
+
556
+ /* `headerScale="chrome"` — the top band is CHROME, so it sits ON the frame's edge, not in a
557
+ * document's top margin. The shorthand above opens every page with --space-page-active-y (24px,
558
+ * 16px below the 720px step), which is right for a document: a title needs air above it. Chrome
559
+ * does not. A chat channel head, a mail thread's subject bar, an IDE tab strip are the FRAME —
560
+ * measured against the design of a consumer chat screen at 1512x805, the design's channel head
561
+ * occupies y 0..47 while the app's identical-height head started at y 24, pushing the whole top
562
+ * chrome down and taking those 24px straight off the scroll viewport (design 617px of transcript,
563
+ * app 587px). The band heights already matched; the page was simply floating.
564
+ *
565
+ * This is the SAME question `headerScale` already answers, not a second one: "is the first row a
566
+ * document title or the surface's own furniture". The type step (see .ui-page-title below) and
567
+ * the flush edge are two consequences of that one fact, which is why they ride the same
568
+ * attribute rather than a second prop that every real call site would have to set in lockstep.
569
+ *
570
+ * BLOCK-START ONLY, deliberately. `headerScale` names the HEADER, so it may only speak for the
571
+ * top edge; the bottom belongs to `stickyFooter`, which already zeroes `padding-block-end` for
572
+ * its own (documented) reason. A longhand, so that contract is untouched and the shorthand above
573
+ * still owns the default page.
574
+ *
575
+ * Chrome is a token, default quiet (rule #44): the inset is `--page-pad-block-start-chrome`,
576
+ * 0 by default (flush IS the quiet state for chrome), so a service whose design grid wants its
577
+ * chrome to breathe retunes it once instead of forking the page shell. The specificity (0,2,0)
578
+ * out-ranks both the base rule and the 720px block — which re-declares the TOKEN, never this
579
+ * padding — so the mechanism holds identically on both sides of that step. A page that never
580
+ * passes `headerScale` emits no attribute and is geometrically byte-identical. */
581
+ .ui-page-container[data-header-scale="chrome"] {
582
+ padding-block-start: var(--page-pad-block-start-chrome);
583
+ }
584
+
585
+ /* `headerScale="chrome"`, the BAND-HEIGHT consequence of that same fact (gh#331). A document
586
+ * header is content-height — correctly, a title is as tall as the title is. Chrome is furniture,
587
+ * and furniture has a band that things centre INTO; without one, the band's vertical centre is a
588
+ * function of the copy inside it (measured here: 42.02px with an `extra` control, 40.38px
589
+ * without), so nothing in the page can ever be aligned to it and no service can aim at a number.
590
+ *
591
+ * A MIN, not a height: an `lg` control or a wrapped JA/VI channel name must still fit, and
592
+ * `min-block-size` floors the band without ever clipping it. `justify-content: center` is the
593
+ * half that makes the floor useful — a taller band with top-packed content is a band with dead
594
+ * air under it, not a centred one — and it is inert while the knob is `auto`, because a column
595
+ * whose min IS its content height has nothing to distribute. Both declarations are therefore
596
+ * byte-identical for every page shipped before this token existed, chrome pages included.
597
+ *
598
+ * Scoped to the container attribute (0,2,0), like every other half of the chrome contract, so a
599
+ * document page never matches and the 720px block (which re-declares tokens, never these
600
+ * properties) cannot out-rank it. The band-height axis has ONE owner and it is
601
+ * `--app-shell-bar-height`: see tokens/semantic/layout.css for why the DEFAULT still is not it. */
602
+ .ui-page-container[data-header-scale="chrome"] .ui-page-header {
603
+ min-block-size: var(--page-header-min-block-size-chrome);
604
+ justify-content: center;
409
605
  }
410
606
 
411
607
  /* Horizontal page insets live on the header/body/footer — NOT the container —
412
608
  * so the header divider and footer border span the full page width
413
609
  * (full-bleed) while their content stays aligned with the body. */
414
610
  .ui-page-header,
611
+ .ui-page-toolbar,
415
612
  .ui-page-body,
416
613
  .ui-page-footer {
417
614
  padding-inline-start: var(--space-page-active-x);
@@ -436,16 +633,21 @@
436
633
  * the full compact-gutter surface. The footer is deliberately NOT capped: its border/background
437
634
  * is page-level chrome that must span the page when `stickyFooter` pins it. */
438
635
  .ui-page-container[data-measure="narrow"] .ui-page-header,
636
+ .ui-page-container[data-measure="narrow"] .ui-page-toolbar,
439
637
  .ui-page-container[data-measure="narrow"] .ui-page-body {
440
638
  max-inline-size: var(--page-measure-narrow);
441
639
  }
442
640
 
443
641
  .ui-page-container[data-measure="medium"] .ui-page-header,
642
+ .ui-page-container[data-measure="medium"] .ui-page-toolbar,
444
643
  .ui-page-container[data-measure="medium"] .ui-page-body {
445
644
  max-inline-size: var(--page-measure-medium);
446
645
  }
447
646
 
448
- /* flush = full-bleed body (DataTable); header/footer keep their inset. */
647
+ /* flush = full-bleed body AND toolbar band (DataTable, a full-width filter strip);
648
+ * header/footer keep their inset. `PageContainer.Inset` re-applies the page gutter to any
649
+ * padded strip inside either one, so a toolbar's own content can still align with the title. */
650
+ .ui-page-container--flush .ui-page-toolbar,
449
651
  .ui-page-container--flush .ui-page-body {
450
652
  padding-inline-start: 0;
451
653
  padding-inline-end: 0;
@@ -457,21 +659,64 @@
457
659
  padding-inline-end: var(--space-page-active-x);
458
660
  }
459
661
 
662
+ /* `ghost` is the quiet chrome WEIGHT: no header rule, no header bottom pad. The two halves are
663
+ * NOT the same kind of decision, and the border half used to get it wrong.
664
+ *
665
+ * Hard-setting `none` here threw away an EXPLICIT `--page-header-divider` as well as the
666
+ * nothing it was meant to block — so a service could opt the page chrome into a rule and a
667
+ * ghost page would still refuse to draw it. Measured against the design of a consumer chat
668
+ * screen: the design carries TWO horizontal rules in the top chrome (channel head at y=47, work
669
+ * band at y=88) and the app rendered ONE (y=133) — the missing y=47 is this line eating the
670
+ * divider the service had already turned on. It is the identical bug that was fixed on
671
+ * `.ui-page-toolbar` below, and it takes the identical cure: re-declare the property from the
672
+ * SAME knob with a `none` fallback. Nothing inherits in, the opt-in passes through, and the
673
+ * default (`--page-header-divider: none`) is byte-identical to the hard-set version.
674
+ *
675
+ * `padding-bottom: 0` is untouched — THAT is the real quiet half of ghost, it answers "how loud
676
+ * is the chrome", not "did the service ask for a rule", and no token mediates it. */
460
677
  .ui-page-container--ghost .ui-page-header {
461
- border-bottom: none;
678
+ border-bottom: var(--page-header-divider, none);
462
679
  padding-bottom: 0;
463
680
  }
464
681
 
682
+ /* The band's half of the same contract, and it must stay in step with the header rule above:
683
+ * neither one lets a divider INHERIT in, and both let an explicit opt-in through — otherwise a
684
+ * ghost page would draw a rule under the band that the header itself refuses to draw, or vice
685
+ * versa.
686
+ *
687
+ * Inherited, and only inherited. An earlier version hard-set `none` here, which also threw away
688
+ * an EXPLICIT `--page-toolbar-divider` — so a service could paint the band (ghost deliberately
689
+ * leaves the ground alone) but could never rule it, and the two knobs disagreed about who ghost
690
+ * outranks. A chat shell is the case that exposed it: a quiet header, and a workflow band that
691
+ * has to read as a separate surface from the transcript below it. Re-declaring the property with
692
+ * a `none` fallback keeps the original guarantee (nothing inherits in) while letting the opt-in
693
+ * through, which is what the paragraph above always meant. */
694
+ .ui-page-container--ghost .ui-page-toolbar {
695
+ border-block-end: var(--page-toolbar-divider, none);
696
+ }
697
+
465
698
  .ui-page-container--ghost {
466
- gap: var(--space-stack-md);
699
+ --page-band-gap: var(--space-stack-md);
467
700
  }
468
701
 
469
702
  .ui-page-container--sticky-footer {
470
703
  min-height: 100%;
704
+
705
+ /* The container's own block-end padding must NOT stay under a sticky
706
+ * footer. A sticky box cannot travel into its containing block's padding
707
+ * area, so `bottom: 0` resolves that much ABOVE the true bottom edge —
708
+ * the footer floats short of the fold and page content scrolls through
709
+ * the gap behind it. Measured in Chromium at the 24px page padding: the
710
+ * footer pinned at 837px inside an 861px scroller.
711
+ *
712
+ * The space itself is still wanted, so it moves onto the body, which is
713
+ * what needs breathing room above the footer anyway. */
714
+ padding-block-end: 0;
471
715
  }
472
716
 
473
717
  .ui-page-container--sticky-footer .ui-page-body {
474
718
  flex: 1;
719
+ padding-block-end: var(--space-page-active-y);
475
720
  }
476
721
 
477
722
  .ui-page-container--sticky-footer .ui-page-footer {
@@ -518,6 +763,53 @@
518
763
  background-image: var(--gradient-hero);
519
764
  }
520
765
 
766
+ /* `toolbar` — the FIXED chrome band between the page header and the body: a filter strip, a
767
+ * status bar, a channel workflow rail. There was no slot for it before, so a page either put
768
+ * the strip inside the body (where `fill` scrolls it away) or hand-laid `position: sticky` at
769
+ * the call site — the exact "never hand-lay page chrome" the system forbids.
770
+ *
771
+ * `flex: none` is the whole contract: under `fill` the BODY is the scroll viewport (see
772
+ * .ui-page-container--fill below), so the band is a plain flex item OUTSIDE the scroller and
773
+ * cannot be squeezed by it. Sticky could not give that — sticky keeps content flowing
774
+ * BENEATH the pinned box, which is the half-sliced row a hand-rolled strip always shows.
775
+ *
776
+ * Chrome is a token, default quiet (rule #44): the band draws NO rule of its own, and its
777
+ * bottom rule falls back — at the CALL SITE, never bound at :root — to whatever the page
778
+ * header's divider is, so a service opting into `--page-header-divider` gets a consistent
779
+ * page chrome in ONE declaration and can still silence just this band with
780
+ * `--page-toolbar-divider: none`.
781
+ *
782
+ * The GROUND is a knob too, defaulting to `transparent` so the band looks exactly as it did
783
+ * before the token existed. Without it the only way to lift the band off the page ground was a
784
+ * `bg-card` utility at the call site — hand-laid page chrome, which is what this whole slot
785
+ * exists to abolish, and which cannot be re-themed per tenant. It is the `background`
786
+ * shorthand, not `background-color`, so a service may equally hand it a gradient.
787
+ *
788
+ * The NEGATIVE block margins are what make it read as chrome rather than as a third section.
789
+ * The container is a flex column with a `--page-band-gap` between every band, which is right
790
+ * for a document page (header / body / footer are three separate blocks) and wrong here: a
791
+ * band that carries a ground and a bottom rule and then FLOATS between two 16px voids divides
792
+ * nothing — measured on a 1512×805 chat page, 24/69 header · 85/134 band · 150/685 body, i.e.
793
+ * 32px of dead air wrapped around the one element whose job is to be attached. Chrome sits
794
+ * between the header and the scrolling body; chrome is flush. Cancelling the gap from the band
795
+ * itself keeps that in ONE declaration, leaves the container's rhythm intact for every page
796
+ * that passes no `toolbar`, and keeps `--page-toolbar-pad-block` as what it always was: the
797
+ * band's own INSIDE, the only place it is allowed to breathe. */
798
+ .ui-page-toolbar {
799
+ flex: none;
800
+ margin-block: calc(-1 * var(--page-band-gap));
801
+ padding-block: var(--page-toolbar-pad-block);
802
+ background: var(--page-toolbar-background);
803
+ border-block-end: var(--page-toolbar-divider, var(--page-header-divider));
804
+ }
805
+
806
+ /* The band is never the FIRST child — the header always renders — but it CAN be the last one
807
+ * (a page given a `toolbar` and no `children`). There is no gap under a last flex item to
808
+ * cancel, so the negation would eat the container's own bottom padding instead. */
809
+ .ui-page-toolbar:last-child {
810
+ margin-block-end: 0;
811
+ }
812
+
521
813
  .ui-page-header-row {
522
814
  display: flex;
523
815
  flex-direction: column;
@@ -525,6 +817,16 @@
525
817
  gap: var(--space-stack-sm);
526
818
  }
527
819
 
820
+ /* The title/subtitle column is the SHRINKABLE side of the header row (gh#300): a flex item's
821
+ * automatic minimum size is its content, so without this the heading refuses to shrink and the
822
+ * whole overflow is charged to `extra`. The component used to carry it as a bare `min-w-0`
823
+ * utility, which meant the >=640px arrangement depended on a Tailwind class while the compact
824
+ * arrangement below declared it in CSS — the same property owned in two places (gh#319).
825
+ * Unconditional, so both arrangements read one rule. */
826
+ .ui-page-header-heading {
827
+ min-inline-size: 0;
828
+ }
829
+
528
830
  .ui-page-header-extra {
529
831
  display: flex;
530
832
  flex-wrap: wrap;
@@ -563,6 +865,20 @@
563
865
  overflow-wrap: anywhere;
564
866
  }
565
867
 
868
+ /* `headerScale="chrome"` — the page's top row is CHROME (a chat channel, a mail thread, an IDE
869
+ * tab), not a document title, so the `<h1>` takes the body type step. The ELEMENT is untouched:
870
+ * still an `<h1>`, still the page's one level-1 heading, because the screen-reader outline is
871
+ * what a page is navigated by — only the type step moves, and only through a token.
872
+ *
873
+ * The compound selector's specificity (0,2,0) is load-bearing, not decoration: the 720px block
874
+ * below re-declares `.ui-page-title` (0,1,0) at the COMPACT DOCUMENT step, which is h2 (18px) —
875
+ * BIGGER than this. Without out-ranking it, a chrome header would swell on a phone, i.e. exactly
876
+ * where the height matters most. The responsive step itself is untouched and still governs every
877
+ * document-scaled page. */
878
+ .ui-page-container[data-header-scale="chrome"] .ui-page-title {
879
+ font-size: var(--page-title-font-size-chrome);
880
+ }
881
+
566
882
  /* Compact viewports use a responsive type step, not compact density: body/control text and
567
883
  * touch targets remain readable while page chrome stops competing with primary actions. A
568
884
  * single Flex action group must wrap even when the consumer omitted `wrap`, because clipping
@@ -644,6 +960,25 @@
644
960
  min-inline-size: 0;
645
961
  justify-content: flex-end;
646
962
  }
963
+
964
+ /* `headerScale="chrome"`, second consequence of the SAME fact. The row's `align-items:
965
+ * flex-start` above is right for a DOCUMENT: a `Save`/`Publish` group belongs on the first
966
+ * line of a tall `<h1>`, not floating beside its second line. A chrome band has no tall
967
+ * heading to align to — it is one bar, and its actions sit on the bar's middle. Measured
968
+ * against the reference chat screen the difference was 8.65px: the header row is 45.3px, the
969
+ * icon buttons 28px, so top-packing put their centres at y=14 while the title block's centre
970
+ * sat at y=22.65. That gap is what reads, correctly, as "the icons are not centred".
971
+ *
972
+ * `align-self` on the EXTRA box, not `align-items` on the row: the heading keeps the stretch
973
+ * it has today (nothing about the title's own alignment is in question), and the two axes stay
974
+ * independent. Scoped INSIDE the 640px block on purpose — below it the row is a COLUMN, where
975
+ * the cross axis is horizontal and this same declaration would centre `extra` sideways and
976
+ * release its full-width stretch. `headerLayout="responsive-inline"` runs its own row
977
+ * arrangement under 640px and keeps its own flex-start; that arrangement exists to hold ONE
978
+ * compact control beside a wrapping title band, which is the document case again. */
979
+ .ui-page-container[data-header-scale="chrome"] .ui-page-header-extra {
980
+ align-self: center;
981
+ }
647
982
  }
648
983
 
649
984
  .ui-page-subtitle {
@@ -652,6 +987,24 @@
652
987
  margin-top: var(--space-1);
653
988
  }
654
989
 
990
+ /* `headerScale="chrome"`, third consequence of the SAME fact: if the top row is chrome, the line
991
+ * under it is a caption ON that chrome (a channel purpose, a mail preview), not a document's
992
+ * standfirst. The document step is --font-size-base — the identical step the chrome TITLE takes —
993
+ * so a chrome header was shipping a title and its subtitle at one size, which is not a hierarchy.
994
+ * Same size is also not the expensive half: the step drives the LINE BOX (14px × 1.7 = 23.8px vs
995
+ * 11px × 1.7 = 18.9px), and a band that exists to give the transcript room was spending 5px of it
996
+ * on a caption. Type only — no line-height, no colour, no weight: `--line-height-body` still owns
997
+ * the rhythm so a wrapped JA/VI purpose line stays readable at the smaller step.
998
+ *
999
+ * The specificity is load-bearing exactly as it is on the title above: the 720px block declares
1000
+ * `.ui-page-header .ui-page-subtitle` (0,2,0) — already compounded, unlike the bare `.ui-page-title`
1001
+ * — so this rule out-ranks it only because the container attribute makes it (0,3,0). Without that
1002
+ * the compact step would win below 720px and a chrome subtitle would step UP to 14px on a phone,
1003
+ * i.e. exactly where the band's height is worth the most. */
1004
+ .ui-page-container[data-header-scale="chrome"] .ui-page-subtitle {
1005
+ font-size: var(--page-subtitle-font-size-chrome);
1006
+ }
1007
+
655
1008
  /* `headerLoading` placeholders — the real `<h1>`/`<p>` wearing the shared `.ui-skeleton-block`
656
1009
  * skin, so the pending band occupies the same rhythm the settled band will. `max-inline-size:
657
1010
  * 100%` keeps them inside a 390px column; the block size replaces the line box the (sr-only)
@@ -680,18 +1033,46 @@
680
1033
  /* fill = body grows to fill the remaining shell height (full-height DataTable,
681
1034
  * split-pane, or a chat surface whose message list scrolls and whose composer
682
1035
  * is pinned to the bottom via `footer` + `stickyFooter`). */
1036
+ /* `fill` means the body OCCUPIES the remaining height — so when its content is
1037
+ * taller, the content scrolls INSIDE it. The container therefore flexes to the
1038
+ * region rather than growing past it, and the body owns the scroll.
1039
+ *
1040
+ * This is what makes a chat surface behave: header and footer are plain flex
1041
+ * items at the two ends, outside the scroller, so transcript content can never
1042
+ * travel underneath them. Sticky bands cannot give that — sticky keeps the
1043
+ * content flowing beneath the pinned box, which is exactly the half-sliced
1044
+ * message line the chat page was showing. */
1045
+ .ui-page-container--fill {
1046
+ flex: 1;
1047
+ min-height: 0;
1048
+ }
1049
+
683
1050
  .ui-page-container--fill .ui-page-body {
684
1051
  flex: 1;
685
1052
  min-height: 0;
1053
+ overflow-y: auto;
686
1054
  }
687
1055
 
1056
+ /* The THIRD chrome band, and until now the only one whose rule was not a knob. The header reads
1057
+ * --page-header-divider and the toolbar reads --page-toolbar-divider, but this line was a
1058
+ * hard-copied literal, so a page could not turn it off at all. Measured on a consumer chat
1059
+ * screen: the composer lives in this slot and is itself a bordered Card, so the full-width rule
1060
+ * landed straight above it as a SECOND line — a pixel diff against the design caught a 100%-wide
1061
+ * rule at y=701 that the design does not have.
1062
+ *
1063
+ * Unlike the other two, the default here is a rule rather than silence, and that is deliberate:
1064
+ * `footer` is the shared slot a form's Save/Cancel bar lands in, where the line separating the
1065
+ * actions from the content is the behaviour every existing page already depends on. The literal
1066
+ * simply moves into the fallback, so `--page-footer-divider` unset is byte-identical to what
1067
+ * this rule drew before, and `--page-footer-divider: none` is the opt-OUT the chat composer
1068
+ * needed. */
688
1069
  .ui-page-footer {
689
1070
  display: flex;
690
1071
  flex-direction: column-reverse;
691
1072
  align-items: stretch;
692
1073
  gap: var(--space-inline-sm);
693
1074
  padding-top: var(--space-stack-md);
694
- border-top: 1px solid hsl(var(--border));
1075
+ border-top: var(--page-footer-divider, 1px solid hsl(var(--border)));
695
1076
  }
696
1077
 
697
1078
  .ui-page-footer > * {
@@ -770,8 +1151,8 @@
770
1151
  display: flex;
771
1152
  align-items: center;
772
1153
  justify-content: center;
773
- width: 3rem;
774
- height: 3rem;
1154
+ inline-size: var(--empty-state-icon-size);
1155
+ block-size: var(--empty-state-icon-size);
775
1156
  border-radius: var(--radius-pill);
776
1157
  /* Medallion fill + glyph colour read their knobs, defaulting to the LIVE --muted /
777
1158
  * --muted-foreground roles at the call site (re-resolves under a scoped theme). */
@@ -779,6 +1160,11 @@
779
1160
  color: var(--empty-state-icon-foreground, hsl(var(--muted-foreground)));
780
1161
  }
781
1162
 
1163
+ .ui-empty-state-icon svg {
1164
+ inline-size: var(--empty-state-icon-glyph-size);
1165
+ block-size: var(--empty-state-icon-glyph-size);
1166
+ }
1167
+
782
1168
  /* tone — set the medallion foreground + tint from the matching role token, so a consumer never
783
1169
  * hand-rolls a `.ui-success-state` class. `muted` (default) keeps the neutral role fallback. */
784
1170
  .ui-empty-state[data-tone="success"] {
@@ -884,18 +1270,31 @@
884
1270
  color: hsl(var(--muted-foreground));
885
1271
  }
886
1272
 
1273
+ /* The button GROWS with its label instead of being a rigid square. `width: var(--control-height)`
1274
+ * capped every control at one control-height, so a five-figure page number spilled out of its
1275
+ * own box (measured: `12345678` wanted 59.3px of text inside a 30px content box; `1043` cleared
1276
+ * a 32px box by 0.3px and clipped outright at the consumer's 29px density). Ant Design, MUI and
1277
+ * shadcn all size the same way — a min-width for the rhythm, padding for the overflow — so the
1278
+ * one- and two-digit buttons keep the square look while longer ones widen. Height stays pinned so
1279
+ * the row never changes rhythm. */
887
1280
  .ui-pagination-link {
888
1281
  display: inline-flex;
889
- width: var(--control-height);
1282
+ min-width: var(--control-height);
890
1283
  height: var(--control-height);
891
1284
  align-items: center;
892
1285
  justify-content: center;
1286
+ padding-inline: var(--pagination-page-padding-x);
893
1287
  border-radius: var(--radius-sm);
894
1288
  border: 1px solid hsl(var(--border));
895
1289
  color: hsl(var(--foreground));
896
1290
  text-decoration: none;
897
1291
  }
898
1292
 
1293
+ /* Keyboard focus: drawn by the single-source rule in styles/focus-ring.css
1294
+ * (`.ui-pagination-link` is in its shadow-form list). Before that, focus here was
1295
+ * left to the browser, which paints its OWN ring — measured
1296
+ * `outline: rgb(0, 95, 204) auto 1px`, a blue belonging to no theme. */
1297
+
899
1298
  .ui-pagination-link[data-active="true"] {
900
1299
  border-color: hsl(var(--ring) / 0.5);
901
1300
  background: hsl(var(--primary));
@@ -1214,11 +1613,6 @@
1214
1613
  background: hsl(var(--legal-document-toc-active-background, var(--accent)) / 0.6);
1215
1614
  }
1216
1615
 
1217
- .ui-legal-document-toc-link:focus-visible {
1218
- outline: none;
1219
- box-shadow: 0 0 0 var(--focus-ring-width) hsl(var(--focus-ring-color, var(--ring)));
1220
- }
1221
-
1222
1616
  /* Active entry: colour + a leading marker + a heavier weight, so the state survives a
1223
1617
  * monochrome / high-contrast rendering (never colour-only, WCAG 1.4.1). The anchor also
1224
1618
  * carries `aria-current="location"`. */
@@ -1250,11 +1644,6 @@
1250
1644
 
1251
1645
  /* The section is `tabIndex={-1}` and receives focus when its contents anchor is
1252
1646
  * activated, so the focus ring must be VISIBLE (WCAG 2.4.7 / 2.4.11). */
1253
- .ui-legal-document-section:focus-visible {
1254
- outline: var(--focus-ring-width) solid hsl(var(--focus-ring-color, var(--ring)));
1255
- outline-offset: var(--space-1);
1256
- }
1257
-
1258
1647
  .ui-legal-document-footer {
1259
1648
  display: flex;
1260
1649
  flex-wrap: wrap;