@d4gger/design-system 4.0.0 → 4.0.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.
Files changed (147) hide show
  1. package/dist/brand-effects.css +5 -3
  2. package/dist/components/alert/Alert.cjs +17 -3
  3. package/dist/components/alert/Alert.cjs.map +2 -2
  4. package/dist/components/alert/Alert.d.cts +6 -0
  5. package/dist/components/alert/Alert.d.cts.map +1 -1
  6. package/dist/components/alert/Alert.d.mts +6 -0
  7. package/dist/components/alert/Alert.d.mts.map +1 -1
  8. package/dist/components/alert/Alert.d.ts +6 -0
  9. package/dist/components/alert/Alert.d.ts.map +1 -1
  10. package/dist/components/alert/Alert.js +17 -3
  11. package/dist/components/alert/Alert.js.map +2 -2
  12. package/dist/components/alert.css +62 -5
  13. package/dist/components/bottom-dock.css +8 -0
  14. package/dist/components/bottomDock/BottomDock.cjs +1 -1
  15. package/dist/components/bottomDock/BottomDock.cjs.map +2 -2
  16. package/dist/components/bottomDock/BottomDock.js +1 -1
  17. package/dist/components/bottomDock/BottomDock.js.map +2 -2
  18. package/dist/components/bottomDock/bottomDock.props.cjs +1 -1
  19. package/dist/components/bottomDock/bottomDock.props.cjs.map +2 -2
  20. package/dist/components/bottomDock/bottomDock.props.d.cts +2 -2
  21. package/dist/components/bottomDock/bottomDock.props.d.cts.map +1 -1
  22. package/dist/components/bottomDock/bottomDock.props.d.mts +2 -2
  23. package/dist/components/bottomDock/bottomDock.props.d.mts.map +1 -1
  24. package/dist/components/bottomDock/bottomDock.props.d.ts +2 -2
  25. package/dist/components/bottomDock/bottomDock.props.d.ts.map +1 -1
  26. package/dist/components/bottomDock/bottomDock.props.js +1 -1
  27. package/dist/components/bottomDock/bottomDock.props.js.map +2 -2
  28. package/dist/components/card.css +1 -1
  29. package/dist/components/collapsible/Collapsible.cjs +10 -0
  30. package/dist/components/collapsible/Collapsible.cjs.map +2 -2
  31. package/dist/components/collapsible/Collapsible.d.cts.map +1 -1
  32. package/dist/components/collapsible/Collapsible.d.mts.map +1 -1
  33. package/dist/components/collapsible/Collapsible.d.ts.map +1 -1
  34. package/dist/components/collapsible/Collapsible.js +10 -0
  35. package/dist/components/collapsible/Collapsible.js.map +2 -2
  36. package/dist/components/dialog.css +1 -1
  37. package/dist/components/environment-ribbon.css +73 -0
  38. package/dist/components/environmentRibbon/EnvironmentRibbon.cjs +69 -0
  39. package/dist/components/environmentRibbon/EnvironmentRibbon.cjs.map +7 -0
  40. package/dist/components/environmentRibbon/EnvironmentRibbon.d.cts +15 -0
  41. package/dist/components/environmentRibbon/EnvironmentRibbon.d.cts.map +1 -0
  42. package/dist/components/environmentRibbon/EnvironmentRibbon.d.mts +15 -0
  43. package/dist/components/environmentRibbon/EnvironmentRibbon.d.mts.map +1 -0
  44. package/dist/components/environmentRibbon/EnvironmentRibbon.d.ts +15 -0
  45. package/dist/components/environmentRibbon/EnvironmentRibbon.d.ts.map +1 -0
  46. package/dist/components/environmentRibbon/EnvironmentRibbon.js +39 -0
  47. package/dist/components/environmentRibbon/EnvironmentRibbon.js.map +7 -0
  48. package/dist/components/environmentRibbon/environmentRibbon.props.cjs +39 -0
  49. package/dist/components/environmentRibbon/environmentRibbon.props.cjs.map +7 -0
  50. package/dist/components/environmentRibbon/environmentRibbon.props.d.cts +18 -0
  51. package/dist/components/environmentRibbon/environmentRibbon.props.d.cts.map +1 -0
  52. package/dist/components/environmentRibbon/environmentRibbon.props.d.mts +18 -0
  53. package/dist/components/environmentRibbon/environmentRibbon.props.d.mts.map +1 -0
  54. package/dist/components/environmentRibbon/environmentRibbon.props.d.ts +18 -0
  55. package/dist/components/environmentRibbon/environmentRibbon.props.d.ts.map +1 -0
  56. package/dist/components/environmentRibbon/environmentRibbon.props.js +19 -0
  57. package/dist/components/environmentRibbon/environmentRibbon.props.js.map +7 -0
  58. package/dist/components/field.css +5 -0
  59. package/dist/components/icon/Icon.cjs +16 -0
  60. package/dist/components/icon/Icon.cjs.map +2 -2
  61. package/dist/components/icon/Icon.d.cts +25 -1
  62. package/dist/components/icon/Icon.d.cts.map +1 -1
  63. package/dist/components/icon/Icon.d.mts +25 -1
  64. package/dist/components/icon/Icon.d.mts.map +1 -1
  65. package/dist/components/icon/Icon.d.ts +25 -1
  66. package/dist/components/icon/Icon.d.ts.map +1 -1
  67. package/dist/components/icon/Icon.js +21 -0
  68. package/dist/components/icon/Icon.js.map +2 -2
  69. package/dist/components/icon/icons.cjs +12 -0
  70. package/dist/components/icon/icons.cjs.map +2 -2
  71. package/dist/components/icon/icons.d.cts +30 -0
  72. package/dist/components/icon/icons.d.cts.map +1 -1
  73. package/dist/components/icon/icons.d.mts +30 -0
  74. package/dist/components/icon/icons.d.mts.map +1 -1
  75. package/dist/components/icon/icons.d.ts +30 -0
  76. package/dist/components/icon/icons.d.ts.map +1 -1
  77. package/dist/components/icon/icons.js +12 -0
  78. package/dist/components/icon/icons.js.map +2 -2
  79. package/dist/components/index.cjs +1 -0
  80. package/dist/components/index.cjs.map +2 -2
  81. package/dist/components/index.d.cts +1 -0
  82. package/dist/components/index.d.cts.map +1 -1
  83. package/dist/components/index.d.mts +1 -0
  84. package/dist/components/index.d.mts.map +1 -1
  85. package/dist/components/index.d.ts +1 -0
  86. package/dist/components/index.d.ts.map +1 -1
  87. package/dist/components/index.js +1 -0
  88. package/dist/components/index.js.map +2 -2
  89. package/dist/components/notification-center.css +17 -9
  90. package/dist/components/notificationCenter/NotificationCenter.cjs +7 -2
  91. package/dist/components/notificationCenter/NotificationCenter.cjs.map +2 -2
  92. package/dist/components/notificationCenter/NotificationCenter.d.cts +2 -0
  93. package/dist/components/notificationCenter/NotificationCenter.d.cts.map +1 -1
  94. package/dist/components/notificationCenter/NotificationCenter.d.mts +2 -0
  95. package/dist/components/notificationCenter/NotificationCenter.d.mts.map +1 -1
  96. package/dist/components/notificationCenter/NotificationCenter.d.ts +2 -0
  97. package/dist/components/notificationCenter/NotificationCenter.d.ts.map +1 -1
  98. package/dist/components/notificationCenter/NotificationCenter.js +7 -2
  99. package/dist/components/notificationCenter/NotificationCenter.js.map +2 -2
  100. package/dist/components/page-header.css +21 -0
  101. package/dist/components/pageHeader/PageHeader.cjs +10 -2
  102. package/dist/components/pageHeader/PageHeader.cjs.map +2 -2
  103. package/dist/components/pageHeader/PageHeader.d.cts.map +1 -1
  104. package/dist/components/pageHeader/PageHeader.d.mts.map +1 -1
  105. package/dist/components/pageHeader/PageHeader.d.ts.map +1 -1
  106. package/dist/components/pageHeader/PageHeader.js +10 -2
  107. package/dist/components/pageHeader/PageHeader.js.map +2 -2
  108. package/dist/components/pageHeader/pageHeader.props.cjs +7 -1
  109. package/dist/components/pageHeader/pageHeader.props.cjs.map +2 -2
  110. package/dist/components/pageHeader/pageHeader.props.d.cts +5 -0
  111. package/dist/components/pageHeader/pageHeader.props.d.cts.map +1 -1
  112. package/dist/components/pageHeader/pageHeader.props.d.mts +5 -0
  113. package/dist/components/pageHeader/pageHeader.props.d.mts.map +1 -1
  114. package/dist/components/pageHeader/pageHeader.props.d.ts +5 -0
  115. package/dist/components/pageHeader/pageHeader.props.d.ts.map +1 -1
  116. package/dist/components/pageHeader/pageHeader.props.js +7 -1
  117. package/dist/components/pageHeader/pageHeader.props.js.map +2 -2
  118. package/dist/components/sidebar/Sidebar.cjs +6 -2
  119. package/dist/components/sidebar/Sidebar.cjs.map +2 -2
  120. package/dist/components/sidebar/Sidebar.d.cts +8 -0
  121. package/dist/components/sidebar/Sidebar.d.cts.map +1 -1
  122. package/dist/components/sidebar/Sidebar.d.mts +8 -0
  123. package/dist/components/sidebar/Sidebar.d.mts.map +1 -1
  124. package/dist/components/sidebar/Sidebar.d.ts +8 -0
  125. package/dist/components/sidebar/Sidebar.d.ts.map +1 -1
  126. package/dist/components/sidebar/Sidebar.js +6 -2
  127. package/dist/components/sidebar/Sidebar.js.map +2 -2
  128. package/dist/components/sidebar.css +58 -13
  129. package/dist/components.css +1 -0
  130. package/dist/docs/README.md +1 -0
  131. package/dist/docs/component-contract.md +5 -3
  132. package/dist/docs/component-index.json +9 -1
  133. package/dist/docs/components/alert.md +28 -8
  134. package/dist/docs/components/bottom-dock.md +4 -4
  135. package/dist/docs/components/card.md +1 -1
  136. package/dist/docs/components/collapsible.md +5 -0
  137. package/dist/docs/components/dialog.md +1 -1
  138. package/dist/docs/components/environment-ribbon.md +126 -0
  139. package/dist/docs/components/field.md +2 -0
  140. package/dist/docs/components/icon.md +1 -0
  141. package/dist/docs/components/notification-center.md +14 -9
  142. package/dist/docs/components/page-header.md +18 -6
  143. package/dist/docs/components/sidebar.md +17 -6
  144. package/dist/docs/consumer-guide.md +11 -4
  145. package/dist/styles.css +270 -30
  146. package/dist/tokens.css +2 -0
  147. package/package.json +1 -1
@@ -73,12 +73,23 @@
73
73
  .dsx-sidebar[data-effect='ambient'] {
74
74
  background-color: var(--dsx-color-surface);
75
75
  /* Softened 2026-09-19: accent-9 at 18% painted a visible color blob
76
- (worst on bright accents like spark) instead of an ambient wash. */
77
- background-image: radial-gradient(
78
- circle at var(--dsx-space-xl) var(--dsx-space-lg),
79
- color-mix(in srgb, var(--dsx-color-accent-9) 10%, transparent),
80
- transparent 58%
81
- );
76
+ (worst on bright accents like spark) instead of an ambient wash. The strength is a theme-aware token (G-16, 2026-10-05):
77
+ 10% in dark, as before; raised in light, where the same wash read about half as strong on the pale surface.
78
+ A second source at the foot (G-17, 2026-10-05): the corner radial alone reached about 35% of the panel and left the lower
79
+ half flat. The foot glow is the same colour at 0.9x the same strength, an ellipse anchored to the bottom edge, so the panel
80
+ is lit at both ends and the nav keeps a calm middle (the account row gets the light the logo gets). House-style, picked by
81
+ the maintainer in the look lab over a full-height column and a single tall radial. */
82
+ background-image:
83
+ radial-gradient(
84
+ circle at var(--dsx-space-xl) var(--dsx-space-lg),
85
+ color-mix(in srgb, var(--dsx-color-accent-9) var(--dsx-effect-ambient-strength), transparent),
86
+ transparent 58%
87
+ ),
88
+ radial-gradient(
89
+ ellipse 130% 38% at 85% 100%,
90
+ color-mix(in srgb, var(--dsx-color-accent-9) calc(var(--dsx-effect-ambient-strength) * 0.9), transparent),
91
+ transparent 100%
92
+ );
82
93
  background-repeat: no-repeat;
83
94
  }
84
95
 
@@ -217,8 +228,14 @@
217
228
  overflow: auto;
218
229
  }
219
230
 
231
+ /* G-12: the drawer is flush with the viewport on three sides, so only its free (inline-end) edge is a boundary. The
232
+ base dialog surface draws the structural border all round, which painted a light outline along the screen edge and
233
+ made the 100dvh nav 2px taller than the sheet's content box, so the sheet scrolled. The nav owns its own scrolling. */
220
234
  .dsx-sidebar__mobile-sheet {
221
235
  padding: 0;
236
+ border-block-width: 0;
237
+ border-inline-start-width: 0;
238
+ overflow: hidden;
222
239
  }
223
240
 
224
241
  /* The mobile Drawer carries drawer's own content padding; the X sits over
@@ -253,7 +270,7 @@
253
270
  .dsx-sidebar__mobile-sheet > .dsx-sidebar {
254
271
  position: relative;
255
272
  width: 100%;
256
- height: 100dvh;
273
+ height: 100%;
257
274
  box-shadow: none;
258
275
  }
259
276
 
@@ -278,6 +295,7 @@
278
295
 
279
296
  .dsx-sidebar__rail:focus-visible,
280
297
  .dsx-sidebar__menu-button:focus-visible,
298
+ .dsx-sidebar__menu-sub-trigger:focus-visible,
281
299
  .dsx-sidebar__menu-sub-button:focus-visible {
282
300
  outline: none;
283
301
  box-shadow: var(--dsx-shadow-focus-ring);
@@ -474,7 +492,11 @@
474
492
  opacity: 1;
475
493
  }
476
494
 
477
- .dsx-sidebar__menu-button {
495
+ /* One row geometry for a plain row and for a parent row that opens a sub-list (G-13, 2026-10-05). The parent is a `.dsx-button` too, and
496
+ without this base it kept the button's 16px padding (its icon sat 8px right of every other row) and the browser's grey button
497
+ fill whenever it was neither open nor active. */
498
+ .dsx-sidebar__menu-button,
499
+ .dsx-sidebar__menu-sub-trigger {
478
500
  display: flex;
479
501
  align-items: center;
480
502
  gap: var(--dsx-space-sm);
@@ -645,6 +667,17 @@
645
667
  box-shadow: var(--dsx-shadow-active-inset-outline);
646
668
  }
647
669
 
670
+ /* G-19 (2026-10-05): the active row's own wash in the light theme. Accent-3 at 50% over a tinted light panel is nearly the
671
+ panel's colour, so in light the row was marked only by its underline and text. Step 4 is the scale's "soft component" step
672
+ (selected / hover); it reads on Rift, Warp and Ray without a primary fill. Dark keeps the rules above, unchanged. The
673
+ `:where()` keeps the selector at (0,2,0) (the qa:selectors policy); the tie with the three rules above is decided by
674
+ source order, so this block stays after them. */
675
+ :where([data-dsx-theme='light']) .dsx-sidebar__menu-button[data-active='true'],
676
+ :where([data-dsx-theme='light']) .dsx-sidebar__menu-sub-button[data-active='true'],
677
+ :where([data-dsx-theme='light']) .dsx-sidebar__menu-sub-trigger[data-active='true'] {
678
+ background: var(--dsx-color-accent-4);
679
+ }
680
+
648
681
  .dsx-sidebar__menu-sub-trigger-chevron {
649
682
  margin-inline-start: auto;
650
683
  flex-shrink: 0;
@@ -692,6 +725,13 @@
692
725
  min-height: var(--dsx-size-sm);
693
726
  }
694
727
 
728
+ /* Inside a menu item (a flex row) the skeleton is an auto-width flex item, so its 100%-wide label collapses and the
729
+ row draws as two small pills. Let it take the item's width. */
730
+ .dsx-sidebar__menu-item > .dsx-sidebar__menu-skeleton {
731
+ flex: 1;
732
+ min-width: 0;
733
+ }
734
+
695
735
  .dsx-sidebar__menu-button-text {
696
736
  min-width: 0;
697
737
  overflow: hidden;
@@ -699,6 +739,11 @@
699
739
  white-space: nowrap;
700
740
  }
701
741
 
742
+ /* G-18 (2026-10-05): a quiet outline in the accent, not a raised disc. The disc was a `surface-raised` fill with a drop shadow:
743
+ shadows do not read on dark, and on the tinted panel the fill is nearly the panel's own colour, so the control vanished in
744
+ both themes. The ring is the scale's "normal border" step (7), the glyph its text step (11), the hover one step up (fill 3,
745
+ ring 8): the Radix 12-step jobs, one purposeful step per state. The ring is an inset shadow so the 32px box does not move,
746
+ and the focus ring below still replaces it. The pin / unpin glyphs of hover-expand mode live inside the same button. */
702
747
  .dsx-sidebar__trigger {
703
748
  display: inline-flex;
704
749
  align-items: center;
@@ -710,14 +755,14 @@
710
755
  padding: 0;
711
756
  border: 0;
712
757
  border-radius: var(--dsx-rounded-full);
713
- background: var(--dsx-color-surface-raised);
714
- color: var(--dsx-color-on-surface-variant);
715
- box-shadow: var(--dsx-effect-shadow-1);
758
+ background: transparent;
759
+ color: var(--dsx-color-accent-11);
760
+ box-shadow: inset 0 0 0 var(--dsx-border-width-sm) var(--dsx-color-accent-7);
716
761
  }
717
762
 
718
763
  .dsx-sidebar__trigger:hover {
719
- background: var(--dsx-effect-hover-ghost);
720
- color: var(--dsx-color-on-surface);
764
+ background: var(--dsx-color-accent-3);
765
+ box-shadow: inset 0 0 0 var(--dsx-border-width-sm) var(--dsx-color-accent-8);
721
766
  }
722
767
 
723
768
  .dsx-sidebar__trigger:focus-visible {
@@ -21,6 +21,7 @@
21
21
  @import 'components/color-swatch.css';
22
22
  @import 'components/dropdown-menu.css';
23
23
  @import 'components/empty-state.css';
24
+ @import 'components/environment-ribbon.css';
24
25
  @import 'components/editable.css';
25
26
  @import 'components/em.css';
26
27
  @import 'components/menu.css';
@@ -97,6 +97,7 @@ They describe public intent and contracts; TypeScript declarations remain the ex
97
97
  - [DropdownMenu](./components/dropdown-menu.md)
98
98
  - [Editable](./components/editable.md)
99
99
  - [EmptyState](./components/empty-state.md)
100
+ - [EnvironmentRibbon](./components/environment-ribbon.md)
100
101
  - [Field](./components/field.md)
101
102
  - [FileUpload](./components/file-upload.md)
102
103
  - [FilterBar](./components/filter-bar.md)
@@ -85,6 +85,7 @@ from the closest RT category (see legend).
85
85
  | Editable | Custom → TextField | ✓ | ✓ | — | ✓ | — | Inline edit; variant+size+radius |
86
86
  | Em | RT Em | — | — | — | — | — | Typographic; no props in RT |
87
87
  | EmptyState | Custom → none | ✓ | ✓ | — | — | — | Size + variant (`default`/`unavailable`) |
88
+ | EnvironmentRibbon | Custom → none | — | — | — | — | — | Floating non-production pill; `placement` / `haze` are not tracked styling attrs |
88
89
  | Field | Custom → none | — | ✓ | — | — | — | Form field wrapper; size only |
89
90
  | FileUpload | Custom → TextField | — | ✓ | — | — | — | Input-like; size only |
90
91
  | FilterBar | Custom → none | — | ✓ | — | — | — | Toolbar composition; size only on the root (Rules/Rule/Builder/Search write no V/C/R/H attrs) |
@@ -117,7 +118,7 @@ from the closest RT category (see legend).
117
118
  | PasswordInput | Custom → TextField | ✓ | ✓ | — | ✓ | — | Input-like; variant+size+radius |
118
119
  | PhoneInput | Custom → TextField | ✓ | ✓ | — | — | — | Input-like; variant+size (density writes data-size); no accent/radius/high-contrast |
119
120
  | PageContent | Custom → none | — | — | — | — | — | Padding is represented by classes, not tracked attributes |
120
- | PageHeader | Custom → none | — | — | — | — | — | Structural page composition; no styling attrs |
121
+ | PageHeader | Custom → none | ✓ | — | — | — | — | Variant only (`default`, `floating`); one fixed height, no size or accent attrs |
121
122
  | Pills | Custom → Badge | ✓ | ✓ | ✓ | ✓ | — | Badge-like chip list; V+S+C+R |
122
123
  | Popover | Primitive → Dialog | — | ✓ | — | — | — | Content size by Dialog analogy |
123
124
  | Progress | RT Progress | ✓ | ✓ | ✓ | — | ✓ | RT Progress: no radius |
@@ -188,13 +189,14 @@ from the closest RT category (see legend).
188
189
  | V+S+R | Editable, NumberInput, PasswordInput, SearchBar, TagsInput | 5 |
189
190
  | V+S+H | Toggle, ToggleGroup | 2 |
190
191
  | V+S | Card, DataList, NativeSelect, SegmentedControl, Select, StatCard, Table, TabNav, Tabs, Toast | 10 |
192
+ | V only | PageHeader | 1 |
191
193
  | S+C+H | Heading, Link, Text | 3 |
192
194
  | S+C | SegmentedMeter, Separator, Status | 3 |
193
195
  | S+R | InputOtp, ScrollArea | 2 |
194
196
  | S only | AlertDialog, Breadcrumb, ButtonGroup, Calendar (see above), Carousel, CodeInput,<br>ColorPicker, ColorSwatch, Command, Container, ContextMenu, CopyField, DataTable,<br>DatePicker, Dialog, Drawer, DropdownMenu, EmptyState, Field, FileUpload, FilterBar,<br>FloatingPanel, HoverCard, Icon, InputGroup, Kbd, Label, Menu, Menubar, Metric,<br>NavigationMenu, Pagination, Popover, QrCode, Section, Sheet, Spinner, Stepper,<br>Timeline, TimePicker, ToggleTip, Toolbar, TreeView | 43 |
195
- | None | AccessibleIcon, Accordion, ArticleNodes, AspectRatio, Banner, Blockquote, BottomDock, Box, Chart,<br>Collapsible, CommentThread, ConfigDrawer, DirectionProvider, Em, Flex, FontProvider,<br>Form, Grid, Inset, Item, Marquee, NotificationCenter, PageContent, PageHeader, Prose, Quote,<br>Resizable, Result, RichTextEditor, Sidebar, Skeleton, Slot, Sonner, SortableList, Stack, Strong,<br>TableOfContents, Theme, ThemePanel, ThemeProvider, Tooltip, VisuallyHidden | 41 |
197
+ | None | AccessibleIcon, Accordion, ArticleNodes, AspectRatio, Banner, Blockquote, BottomDock, Box, Chart,<br>Collapsible, CommentThread, ConfigDrawer, DirectionProvider, Em, EnvironmentRibbon, Flex, FontProvider,<br>Form, Grid, Inset, Item, Marquee, NotificationCenter, PageContent, Prose, Quote,<br>Resizable, Result, RichTextEditor, Sidebar, Skeleton, Slot, Sonner, SortableList, Stack, Strong,<br>TableOfContents, Theme, ThemePanel, ThemeProvider, Tooltip, VisuallyHidden | 41 |
196
198
 
197
- **Total components covered: 132**
199
+ **Total components covered: 133**
198
200
 
199
201
  ---
200
202
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "packageVersion": "4.0.0",
3
+ "packageVersion": "4.0.1",
4
4
  "generatedFrom": "docs/design-system",
5
5
  "components": [
6
6
  {
@@ -651,6 +651,14 @@
651
651
  "status": "canonical",
652
652
  "documentation": "./components/empty-state.md"
653
653
  },
654
+ {
655
+ "name": "EnvironmentRibbon",
656
+ "slug": "environment-ribbon",
657
+ "category": "Components",
658
+ "archetype": "primitive",
659
+ "status": "canonical",
660
+ "documentation": "./components/environment-ribbon.md"
661
+ },
654
662
  {
655
663
  "name": "Field",
656
664
  "slug": "field",
@@ -27,17 +27,20 @@ An inline status, warning, or info banner that lives in the page flow. Use Alert
27
27
  | `Alert` (root) | `<div>` (or `Slot` host) | Static surface + class | Writes `dsx-alert`, `data-variant`, `data-size`, `data-high-contrast`, `data-accent-color`. Defaults `role` to `"alert"`; consumers can override with a custom `role` prop. Hovering the status surface does not change its semantic color or elevation. |
28
28
  | `Alert.Title` | `<h5>` (or `Slot` host) | Slot class | Title text. Writes `dsx-alert__title`. |
29
29
  | `Alert.Description` | `<p>` (or `Slot` host) | Slot class | Description text. Writes `dsx-alert__description`. |
30
- | `Alert.Action` | `<div>` | Slot class | Trailing action container (typically a Button or IconButton). Writes `dsx-alert__action`. |
30
+ | `Alert.Action` | `<div>` | Slot class | Trailing action container (typically a Button or IconButton). Writes `dsx-alert__action`. A direct child of `Alert` is hoisted out of the content container into one `dsx-alert__actions` group: the group trails the row, and wraps below the text as a whole, under the text, when the alert is narrow. |
31
31
 
32
32
  **Internal layout:**
33
33
 
34
- The root renders a flex row with three regions, in this order:
34
+ The root renders a flex row of two columns, in this order:
35
35
 
36
36
  1. An icon container (`<span aria-hidden="true" class="dsx-alert__icon">`) that holds the resolved icon. The icon is hidden when `icon === null` and replaced wholesale when `icon` is a `ReactNode`.
37
- 2. A content container (`<div class="dsx-alert__content">`) that wraps whatever the consumer passes as children (typically `Alert.Title` and `Alert.Description`).
38
- 3. The optional `Alert.Action` slot, if the consumer composes it as a child.
37
+ 2. A main column (`<div class="dsx-alert__main">`), a wrapping flex row, that holds:
38
+ 1. a content container (`<div class="dsx-alert__content">`) that wraps whatever the consumer passes as children (typically `Alert.Title` and `Alert.Description`);
39
+ 2. when there is at least one direct-child `Alert.Action`, one actions group (`<div class="dsx-alert__actions">`) that holds each of them, in order.
39
40
 
40
- The `Alert.Action` slot is independent of the rest of the layout; the consumer can place it inside the content area or as a sibling of `Alert.Title` / `Alert.Description` to control alignment.
41
+ A direct-child `Alert.Action` is hoisted out of the content container (`Alert.Title` and `Alert.Description` stay inside it), also when the root is an `asChild` host. The group trails the row (the content grows to push it to the end). Only the main column wraps (`flex-wrap`), so when the text would fall under its readable width the group drops below it as a whole and starts at the text column, under the text and not under the icon; the actions never split across rows. The group has no auto margin, because that would push a wrapped group to the far edge. An action the consumer nests in their own wrapper is not hoisted and stays in the content container. (G-15, 2026-10-05.)
42
+
43
+ With a trailing action, the icon, the first line of text and the action share one centre at any width: all three get a control-height row (`var(--dsx-size-lg)`), and the first line's box is that tall (`::first-line`). An alert without an action is laid out as before.
41
44
 
42
45
  ## Props contract
43
46
 
@@ -63,6 +66,12 @@ The `Alert.Action` slot is independent of the rest of the layout; the consumer c
63
66
 
64
67
  `Alert.Title` and `Alert.Description` also accept the standard HTML attributes for their host elements (`<h5>` and `<p>` respectively). `Alert.Action` accepts standard `<div>` attributes.
65
68
 
69
+ **`Alert.Description` own prop:**
70
+
71
+ | Prop | Type | Default | Required | Description | Responsive |
72
+ |---|---|---|---|---|---|
73
+ | `lineClamp` | `number` | `undefined` | no | Clamp the text to this many lines; the rest is clipped with an ellipsis. Only a whole number of 1 or more clamps; anything else (0, a negative, a fraction, `NaN`) leaves the text unclamped. Writes `data-line-clamp` and the custom property `--dsx-alert-line-clamp` on the description (a consumer `style` is kept); the prop name itself is not written to the DOM. Whether the text is clipped, and how to reveal it, is the consumer's: measure the element (`scrollHeight > clientHeight`) and drop the prop to show everything. | no |
74
+
66
75
  **Variant default icons (when `icon` is `undefined`):**
67
76
 
68
77
  | `variant` | Icon `name` |
@@ -86,6 +95,7 @@ The icon is rendered with `size="md"` and `strokeWidth="var(--dsx-icon-stroke-wi
86
95
  | `data-size` | `sm`, `md`, `lg` | `.dsx-alert[data-size=…]` | `alert.css` |
87
96
  | `data-accent-color` | any string | (no rule in `alert.css`; attribute is forwarded) | n/a |
88
97
  | `data-high-contrast` | `true` | `.dsx-alert[data-high-contrast='true']` | `baseCard.css` |
98
+ | `data-line-clamp` (on `Alert.Description`) | a whole number of 1 or more | `.dsx-alert__description[data-line-clamp]` | `alert.css` |
89
99
 
90
100
  **Slot classes:**
91
101
 
@@ -93,14 +103,16 @@ The icon is rendered with `size="md"` and `strokeWidth="var(--dsx-icon-stroke-wi
93
103
  |---|---|---|
94
104
  | `Alert` (root) | `dsx-alert` | `alert.css` |
95
105
  | Root icon container | `dsx-alert__icon` | `alert.css` |
106
+ | Root main column (content and actions) | `dsx-alert__main` | `alert.css` |
96
107
  | Root content container | `dsx-alert__content` | `alert.css` |
108
+ | Root actions group | `dsx-alert__actions` | `alert.css` |
97
109
  | `Alert.Action` | `dsx-alert__action` | `alert.css` |
98
110
  | `Alert.Title` | `dsx-alert__title` | `alert.css` |
99
111
  | `Alert.Description` | `dsx-alert__description` | `alert.css` |
100
112
 
101
113
  **Responsive behavior:**
102
114
 
103
- - The component does not write `@media` rules. `size`, `variant`, `color`, and `highContrast` are single-value only.
115
+ - The component does not write `@media` rules. `size`, `variant`, `color`, and `highContrast` are single-value only. The action wrap is intrinsic (`flex-wrap` on the main column), not a breakpoint, so it follows the alert's own width, not the window's.
104
116
 
105
117
  **Reduced motion:**
106
118
 
@@ -120,6 +132,10 @@ Every CSS value the component paints is a `var(--dsx-*)` token.
120
132
  | Root text color | `var(--dsx-color-on-surface)` | Default text color |
121
133
  | Icon / action minimum size | `var(--dsx-size-md)` | Icon and action slot dimensions |
122
134
  | Icon / action color | `currentColor` | Inherits the alert surface color |
135
+ | Content basis | `min(calc(var(--dsx-size-xl) * 5), calc(100% - var(--dsx-size-md) - var(--dsx-space-md)))` | The readable width the text keeps before a trailing action wraps below it; the `min()` stops it forcing the text under the icon in a very narrow alert |
136
+ | Control row (with an action) | `var(--dsx-size-lg)` | Min height of the icon and action, and the line box of the first line, so the three share a centre |
137
+ | Clamped first line (with an action) | `padding-block-start: calc((var(--dsx-size-lg) - 1lh) / 2)` | `::first-line` cannot reach into the `-webkit-box` a clamped description is, so the first line is centred on the control row with padding instead |
138
+ | Line clamp | `--dsx-alert-line-clamp` (written by the component from `lineClamp`) | The number of lines the description keeps; read by `-webkit-line-clamp` |
123
139
  | Title type ramp | `var(--dsx-typography-label-large-*)` | Title text scale |
124
140
  | Description type ramp | `var(--dsx-typography-body-medium-*)` | Description text scale |
125
141
  | Description opacity | `var(--dsx-opacity-subtle, 0.85)` | Subtle fade for description text |
@@ -186,7 +202,11 @@ The component has no motion. It is a static inline surface.
186
202
 
187
203
  **Composition story:**
188
204
 
189
- - Renders an alert with `Alert.Title`, `Alert.Description`, and `Alert.Action` populated. Asserts the `dsx-alert__title`, `dsx-alert__description`, and `dsx-alert__action` classes are present.
205
+ - Renders an alert with `Alert.Title`, `Alert.Description`, and `Alert.Action` populated. Asserts the `dsx-alert__title`, `dsx-alert__description`, and `dsx-alert__action` classes are present, and that the action sits in the one `dsx-alert__actions` group after the content container inside `dsx-alert__main`, not inside the content (several actions share the one group). `src/test/styles/alert-wrap-layout.test.ts` guards the rules that make the group wrap as a whole under the text column. A second composition nests the action in the consumer's own wrapper and asserts it stays in the content.
206
+
207
+ **Clamp story:**
208
+
209
+ - Renders an alert whose `Alert.Description` has `lineClamp={2}` and a long text, with a trailing action. Asserts `data-line-clamp="2"` and the `--dsx-alert-line-clamp` property; the registry row shows the text clipped to two lines with an ellipsis and the icon, first line and action on one centre. A value that is not a whole number of 1 or more leaves the text unclamped (tested).
190
210
 
191
211
  **Token story:**
192
212
 
@@ -230,7 +250,7 @@ The component has no motion. It is a static inline surface.
230
250
  ## Overlay review composition
231
251
 
232
252
  Content has min-width=0 and wraps long copy; the optional icon does not shrink.
233
- Actions wrap and align to the content start. Light-mode semantic borders retain
253
+ A trailing action shares the row with the text and wraps below it, at the alert's start edge, when the text would drop under its readable width. Light-mode semantic borders retain
234
254
  warning/success/error roles after the shared card base rules. Default accent
235
255
  styling and highContrast remain token-driven. `/docs/alert` demonstrates all
236
256
  four tones, reconnect feedback and dismiss/restore through real actions.
@@ -46,7 +46,7 @@ navigation bar (2–5 destinations; the selected destination always shows its la
46
46
  | Prop | Type | Default | Required | Description | Responsive |
47
47
  |---|---|---|---|---|---|
48
48
  | `placement` | `'fixed' \| 'contained'` | `'fixed'` | no | `fixed` pins the dock to the viewport bottom and adds `--dsx-safe-area-inset-bottom`; `contained` positions it absolutely inside the nearest positioned ancestor (demos, framed compositions). | no |
49
- | `labels` | `'active' \| 'always'` | `'active'` | no | `active`: only the current item draws its label (Material 3); `always`: every item draws its label (HIG-style). Hidden labels stay in the accessible name. | no |
49
+ | `labels` | `'active' \| 'always' \| 'none'` | `'active'` | no | `active`: only the current item draws its label (Material 3); `always`: every item draws its label (HIG-style); `none`: no label is drawn and the items are icon-only, for widths where five labels do not fit (about 340px and below), picked by the app by width. Hidden labels stay in the accessible name in every mode. | no |
50
50
  | `asChild` | `boolean` | `false` | no | Renders the host through `Slot`. | no |
51
51
  | `aria-label` | `string` | `'Bottom navigation'` | no | The landmark name; override with product copy. | no |
52
52
  | `className` | `string` | `undefined` | no | Extra classes appended to `dsx-bottom-dock`. | no |
@@ -77,7 +77,7 @@ Rules:
77
77
  | Attribute | Allowed values | Hook | CSS file |
78
78
  |---|---|---|---|
79
79
  | `data-placement` | `fixed`, `contained` | `.dsx-bottom-dock:where([data-placement='contained'])` switches to absolute positioning | `bottom-dock.css` |
80
- | `data-labels` | `active`, `always` | recorded for consumers and tests; the visible/hidden decision is per item through `data-label` | `bottom-dock.css` |
80
+ | `data-labels` | `active`, `always`, `none` | `none` shares the width between icon-only items (`.dsx-bottom-dock:where([data-labels='none']) .dsx-bottom-dock__item`); the visible/hidden decision is per item through `data-label` | `bottom-dock.css` |
81
81
  | `data-active` | `true` | `.dsx-bottom-dock__item:where([data-active='true'])` — the opaque accent container | `bottom-dock.css` |
82
82
  | `data-label` | `visible`, `hidden` | recorded on the item for consumers and tests; the hidden label is removed from the layout through `VisuallyHidden` | `bottom-dock.css` |
83
83
  | `data-slot` | `bottom-dock-*` | hooks | `bottom-dock.css` |
@@ -119,7 +119,7 @@ Rules: no raw colour, spacing, radius or motion values in `bottom-dock.css`.
119
119
  ## Accessibility
120
120
 
121
121
  - `BottomDock` is a named `nav` landmark.
122
- - The current item has `aria-current="page"`; the active state is also carried by the container fill and the visible label (not colour alone).
122
+ - The current item has `aria-current="page"`; the active state is also carried by the container fill and the visible label (not colour alone). With `labels="none"` no label is visible, so the container fill (the opaque primary container, not the accent colour alone) and `aria-current` carry it, and every label stays in the accessible name.
123
123
  - Every item has an accessible name: its `BottomDock.Label` (kept in the DOM through `VisuallyHidden` when not drawn) or its own `aria-label`.
124
124
  - Touch targets are at least 44px; focus is a visible ring; the whole dock is reachable in normal tab order.
125
125
  - A badge with text is part of the item's accessible name ("Devices 3"); an empty dot badge adds nothing. Place the badge inside `BottomDock.Icon` to sit on the glyph (the icon wrapper is `aria-hidden`, so a count placed there is not announced).
@@ -127,7 +127,7 @@ Rules: no raw colour, spacing, radius or motion values in `bottom-dock.css`.
127
127
  ## Playground demo spec
128
128
 
129
129
  **Default story:** four `BottomDock.Item`s (icon + label) in a `contained` dock, the first active.
130
- **Variant stories:** `labels="always"` beside the default `active`; `placement="contained"` is the demo default.
130
+ **Variant stories:** `labels="always"` and `labels="none"` beside the default `active`; `placement="contained"` is the demo default. The `none` fixture sits in a 320px-wide frame with five items.
131
131
  **State stories:** an active item; an item with a count badge; an item with a dot badge; keyboard focus.
132
132
  **Composition story:** the dock with a "More" item that opens a `Sidebar` drawer (mobile) — documented, driven by `useSidebar()`.
133
133
  **Token story:** inside `Theme` with each of the five accents (the active container and the badge vary by accent).
@@ -126,7 +126,7 @@ Every CSS value the component paints is a `var(--dsx-*)` token.
126
126
  | Glass inset rim | `var(--dsx-effect-panel-glass-rim)` | Theme-aware subtle inset perimeter |
127
127
  | Glass border | `var(--dsx-effect-panel-glass-border)` | Theme-aware outer edge |
128
128
  | Shadow (glass) | `var(--dsx-shadow-panel-shadow)` | Glass shadow |
129
- | Glass backdrop | `var(--dsx-effect-panel-glass-backdrop-filter)` | Glass blur |
129
+ | Glass backdrop | `var(--dsx-effect-panel-glass-backdrop-filter)` | Glass blur. Declared as `-webkit-backdrop-filter` first and the standard `backdrop-filter` last: the Next/Turbopack CSS pipeline drops the standard line when it comes first, and the blur silently disappears (B-1, 2026-10-04). This spec directs that order in `_internal/baseCard.css`; `src/test/styles/backdrop-filter-order.test.ts` guards it for every rule in the package. |
130
130
  | Surface (elevated) | `var(--dsx-color-surface-raised)` | Elevated surface |
131
131
  | Shadow (elevated) | `var(--dsx-shadow-elevation-3, var(--dsx-effect-shadow-3))` (hover: `--elevation-4`) | Elevated shadow |
132
132
  | Surface (filled) | `var(--dsx-color-surface)` | Filled surface (no shadow) |
@@ -88,6 +88,10 @@ Rules:
88
88
  ```
89
89
  The expand/collapse keyframes (`dsx-collapsible-expand`, `dsx-collapsible-collapse`) are gated off when the user prefers reduced motion. The chevron rotation uses `var(--dsx-motion-duration-fast)` and is disabled in component CSS under reduced motion.
90
90
 
91
+ **Force-mounted content** (`Collapsible.Content forceMount`, used by `Sidebar.MenuSubTrigger`):
92
+
93
+ - The closed look of the content is the end frame of `dsx-collapsible-collapse` (`height: 0`, `opacity: 0`, `visibility: hidden`, held by the `both` fill). A force-mounted panel that first mounts closed never plays that animation, because Radix suppresses the mount animation by writing `animation-name: none` on the element. The runtime-inserted style block therefore hides it at rest: `.dsx-collapsible__content[data-state='closed']:where([style*='animation-name: none']) { display: none; }`. After any toggle Radix puts the animation back and the panel animates out as usual. Without this rule a closed sidebar parent listed all of its children (G-14, 2026-10-05).
94
+
91
95
  ## Token mapping
92
96
 
93
97
  Every CSS value the component paints is a `var(--dsx-*)` token.
@@ -132,6 +136,7 @@ Content height and vertical padding animate together from zero to their measured
132
136
  **Focus:**
133
137
 
134
138
  - The trigger renders a visible focus ring via `box-shadow: var(--dsx-shadow-focus-ring)` on `:focus-visible`.
139
+ - A collapsed force-mounted panel is out of the tab order: the end frame of the collapse animation sets `visibility: hidden`, and a panel that mounted closed is `display: none`. Radix unmounts a panel that is not force-mounted.
135
140
 
136
141
  **Reduced motion:**
137
142
 
@@ -180,7 +180,7 @@ Rules:
180
180
  | Close button size | `var(--dsx-size-lg)` | Square tap target. |
181
181
  | Close button radius | `var(--dsx-rounded-full)` | Round close. |
182
182
  | Close button offset | `var(--dsx-space-md)` | Distance from the corner. |
183
- | Backdrop blur | `var(--dsx-blur-md, 8px)` | Optional blur. |
183
+ | Backdrop blur | `var(--dsx-blur-md, 8px)` | Optional blur. `-webkit-backdrop-filter` is declared before `backdrop-filter` so the bundler keeps the standard line (see `card.md`, B-1). |
184
184
  | Title typography | `var(--dsx-typography-headline-medium-*)` | Title. |
185
185
  | Description typography | `var(--dsx-typography-body-medium-*)` | Description. |
186
186
  | Description color | `var(--dsx-color-on-surface-variant)` | Body copy. |
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: EnvironmentRibbon
3
+ status: canonical
4
+ category: Components
5
+ archetype: primitive
6
+ depends_on:
7
+ - "@radix-ui/react-slot (powers `asChild`)"
8
+ ---
9
+
10
+ # EnvironmentRibbon
11
+
12
+ > Consumer note: this document describes public intent and contracts; the package TypeScript declarations are the exact API.
13
+
14
+ ## Purpose
15
+
16
+ A floating warning pill that says the app is not running in production, with a warm haze behind the top edge so the warning reads on
17
+ every page without taking layout. It is a static label: no close control, no action, no environment logic. The app decides when to
18
+ render it (off production only) and passes the label (for example "Staging environment"); the component is only the surface.
19
+
20
+ It is **not** a `Banner` (a hero mass: label, headline value), an `Alert` (an in-flow message with actions) or a `Badge` (an inline chip
21
+ that cannot float or carry a haze). One fixed hue, the `warning` ramp, for every environment: the label tells STAGE from DEV.
22
+
23
+ Wild references (decisions in `docs/feature/design-system/environment-ribbon/decisions.md`): `django-admin-env-notice` (a label and colour per
24
+ environment, fixed to the top or floating), `wp-environment-ribbon` (a slim ribbon on every admin screen, hidden in production), the GOV.UK
25
+ phase banner (a tag and a sentence on every page of a service).
26
+
27
+ ## Anatomy & slots
28
+
29
+ **Parent:** `EnvironmentRibbon`, a flat export with no slots. It renders two siblings: an optional decorative haze, then the pill.
30
+
31
+ | Part | Element | Renders | Notes |
32
+ |---|---|---|---|
33
+ | pill | `<div role="status">` (or `Slot` child when `asChild`) | `dsx-environment-ribbon` + `data-slot="environment-ribbon"` + `data-placement` | The label is its children; put an `Icon` inside for a glyph. |
34
+ | haze | `<span aria-hidden="true">` | `dsx-environment-ribbon__haze` + `data-slot="environment-ribbon-haze"` + `data-placement` | Decorative; omitted with `haze={false}`. Not a public slot. |
35
+
36
+ ## Props contract
37
+
38
+ | Prop | Type | Default | Required | Description | Responsive |
39
+ |---|---|---|---|---|---|
40
+ | `placement` | `'fixed' \| 'contained'` | `'fixed'` | no | `fixed` pins the pill to the viewport top and the haze to the viewport; `contained` positions both absolutely inside the nearest positioned ancestor (demos, framed compositions). | no |
41
+ | `haze` | `boolean` | `true` | no | Draws the warm haze behind the top edge. `false` draws the pill alone. | no |
42
+ | `asChild` | `boolean` | `false` | no | Renders the pill through `Slot`; the consumer supplies the host element. | no |
43
+ | `role` | `string` | `'status'` | no | Native attribute; override only with a reason. | no |
44
+ | `className` | `string` | `undefined` | no | Extra classes appended to `dsx-environment-ribbon`. | no |
45
+
46
+ Rules:
47
+
48
+ - Children are the label: one short line. A label wider than the room truncates with an ellipsis; shorten the label rather than expect a wrap.
49
+ - The component knows no environment. The app renders it only when the environment is not production.
50
+ - The haze needs an isolated ancestor (`isolation: isolate`, as `.dsx-brand-ambient` is). It paints behind in-flow content and glass panels, over
51
+ that ancestor's background. Without an isolated ancestor the haze can fall behind an opaque wrapper background and not show.
52
+ - Render it once per frame, not per page.
53
+
54
+ ## CSS class contract
55
+
56
+ **Root class:** `dsx-environment-ribbon`
57
+
58
+ | Attribute | Allowed values | Hook | CSS file |
59
+ |---|---|---|---|
60
+ | `data-placement` | `fixed`, `contained` | `.dsx-environment-ribbon:where([data-placement='contained'])` and the haze's twin switch to absolute positioning | `environment-ribbon.css` |
61
+ | `data-slot` | `environment-ribbon`, `environment-ribbon-haze` | hooks | `environment-ribbon.css` |
62
+
63
+ **Classes:** `dsx-environment-ribbon`, `dsx-environment-ribbon__haze`.
64
+
65
+ No `data-variant`, `data-size`, `data-accent-color`, `data-radius` or `data-high-contrast` is written (contract profile: none).
66
+
67
+ **Responsive behavior:** none in CSS. On a phone the frame reserves the row the pill occupies; the component writes no breakpoints.
68
+
69
+ **Reduced motion:** not applicable; the ribbon has no motion.
70
+
71
+ ## Token mapping
72
+
73
+ | Property | Token | Role |
74
+ |---|---|---|
75
+ | Position (fixed) | `inset-block-start: var(--dsx-space-sm)`; `inset-inline: var(--dsx-space-3)`, `margin-inline: auto`, `inline-size: fit-content` | centred with logical properties, so it holds in right-to-left |
76
+ | Width cap | `max-inline-size: calc(100% - 2 * var(--dsx-space-3))` | the containing block less the two gutters; without it `nowrap` makes the pill refuse to shrink (min-content equals max-content), so a long label would spill instead of truncating |
77
+ | Stacking | `var(--dsx-z-index-fixed)` | above sticky chrome, below overlays and modals |
78
+ | Surface | `var(--dsx-color-warning-container)` | the `Badge` warning recipe, the look passed in the vision |
79
+ | Ink | `var(--dsx-color-on-warning-container)` | paired ink |
80
+ | Border | `var(--dsx-border-width-sm) solid var(--dsx-color-warning)` | keeps the edge over the haze; survives forced-colors |
81
+ | Shadow | `var(--dsx-panel-shadow)` | floating elevation |
82
+ | Radius | `var(--dsx-rounded-full)` | pill |
83
+ | Padding | `var(--dsx-space-xs)` block, `var(--dsx-space-sm)` inline | as `Badge` |
84
+ | Min height | `var(--dsx-size-md)` | as `Badge` size md |
85
+ | Type | `var(--dsx-typography-label-small-*)` | compact label |
86
+ | Haze colour | `color-mix(in srgb, var(--dsx-color-warning) calc(var(--dsx-effect-ambient-strength) * <multiple>), transparent)` | a multiple (3.2, written inline in the rule, as Sidebar's ambient wash does) of the theme-aware accent-haze strength: 32% in dark, about 51% in light. Tuned in the browser and kept (batch `decisions.md` D-10); stronger than the accent haze's primary wash (2.2) on purpose, since a warning has to read on every page |
87
+ | Haze stacking | `z-index: -1` | behind panels and in-flow content, over the isolated ancestor's background |
88
+
89
+ The haze geometry is a radial gradient in rem, anchored at the top centre and fading to transparent; no raw colour.
90
+
91
+ Rules: no raw colour, spacing, radius or motion values in `environment-ribbon.css`.
92
+
93
+ ## Accessibility
94
+
95
+ - The pill is `role="status"`: advisory information, not an alert (MDN, `status` role). It is a polite live region by definition. The
96
+ component does not rely on a screen reader announcing it when it appears (MDN does not say whether static content at load is announced);
97
+ its text is the region's content, in the reading order. The `status` role takes no accessible name from content and needs none; a consumer
98
+ who wants a name passes `aria-label`. Verification reads the browser's accessibility tree.
99
+ - Never focusable and never takes focus. Not dismissible (PASSPORT R-13: it must read as a warning on every page).
100
+ - The meaning is in the words, not the colour: the label says which environment. The haze is `aria-hidden` and decorative.
101
+ - Contrast: the pill uses the container pair that `Badge variant="warning"` uses (`qa:contrast` covers the pair); the haze carries no text.
102
+ - Forced colors: the hairline border keeps the pill's edge when fills are replaced.
103
+
104
+ ## Playground demo spec
105
+
106
+ **Default story:** a framed, isolated box with a `contained` ribbon reading "Staging environment", haze on.
107
+ **Variant stories:** `haze={false}` (the pill alone) beside the default; `placement="contained"` is the demo default (`fixed` pins to the playground
108
+ viewport and is documented only).
109
+ **State stories:** a long label in a 320px frame (truncation, no overflow).
110
+ **Composition story:** the ribbon over a glass panel inside a `dsx-brand-ambient` wrapper, showing the layering (haze behind the panel's blur, pill on top).
111
+ **Token story:** inside `Theme` with each of the five accents (the warning hue does not change; the accent haze beside it does).
112
+ **A11y story:** `EnvironmentRibbon.test.tsx` runs `vitest-axe`.
113
+
114
+ | Prop | Control | Default |
115
+ |---|---|---|
116
+ | `placement` | select | `contained` (demo) |
117
+ | `haze` | boolean | `true` |
118
+
119
+ ## Wiring notes
120
+
121
+ **Composes from:** `@radix-ui/react-slot`.
122
+
123
+ **State boundaries:** owned by `EnvironmentRibbon`: nothing persistent. Owned by the consumer: whether to render it (not in production), the label and its
124
+ translation, the room the pill takes at phone widths, and the isolated ancestor the haze needs.
125
+
126
+ **Used by (planned):** the `frontend-admin` shell frame (`frontend/admin/shell`), once per frame, only when the admin context's environment is STAGE or DEV.
@@ -92,6 +92,7 @@ Rules:
92
92
  **Responsive behavior:**
93
93
 
94
94
  - The `responsive` orientation uses `grid-template-columns: repeat(auto-fit, minmax(var(--dsx-size-popover-min-width), 1fr))`. The CSS does not write `@media` rules directly. None of the prop defs are `responsive: true`.
95
+ - The `vertical` orientation, the control region (`Field.Content`), `Field.Group` and `Field.Set` have one column, `minmax(0, 1fr)`, so a control with a wide intrinsic width (a password input at about 300px) shrinks with a narrow container instead of widening the row past it (G-22, `docs/feature/design-system/field-narrow-track/`).
95
96
 
96
97
  **Reduced motion:**
97
98
 
@@ -105,6 +106,7 @@ Every CSS value the component paints is a `var(--dsx-*)` token.
105
106
  |---|---|---|
106
107
  | Row gap | `var(--dsx-space-xs)` | Default row gap. |
107
108
  | Row min-width | `0` | Allows the grid to shrink. |
109
+ | Column (vertical orientation, control region, group, fieldset) | `minmax(0, 1fr)` | One column that fills the container and never asks for more. Without it the implicit `auto` track is sized from the controls' min-content (a native input is about 20 characters wide), so a field spilled past a narrow card. |
108
110
  | Horizontal label column | `minmax(var(--dsx-size-field-label-min), var(--dsx-size-field-label-max))` | Label column size for `horizontal` orientation. |
109
111
  | Horizontal label alignment | `end` | Label text magnetizes toward the control in `horizontal` orientation. |
110
112
  | Horizontal row column gap | `var(--dsx-space-md)` | Row gap for `horizontal` orientation. |
@@ -40,6 +40,7 @@ Rules:
40
40
  - `Icon` extends `Omit<React.ComponentPropsWithoutRef<'svg'>, 'color'>` because `color` is redefined. The runtime uses `LucideComponent` or `SimpleIconComponent` for the underlying SVG; both forward standard SVG attributes.
41
41
  - The `name` prop is the only visual contract. The internal `isDSXIconName` guard returns `null` for unknown names so callers do not need to handle a missing icon fallback at the call site.
42
42
  - The component always writes `data-size` and `class="dsx-icon"`. The `aria-hidden` and `role` attributes are managed by the runtime based on whether `title` is provided.
43
+ - A name lives in four hand-kept lists: the component map, the `DSXIconName` tuple, `dsxIconCatalog` and one named export in `icons.tsx` (`check` → `CheckIcon`). `Icon.test.tsx` keeps them in step (every catalog name renders an `svg`, and the catalog names and the named exports are the same set). One glyph has one name: `dots-horizontal` is Lucide's `Ellipsis`, and no `ellipsis` alias exists.
43
44
 
44
45
  ## CSS class contract
45
46