@kerfjs/ui 5.0.0-beta.100 → 5.0.0-beta.101

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 (148) hide show
  1. package/README.md +3 -1
  2. package/ai/component-catalog.json +214 -18
  3. package/ai/component-composition.json +216 -8
  4. package/ai/components/README.md +2 -0
  5. package/ai/components/backdrop-image.md +84 -0
  6. package/ai/components/nav-stack.md +6 -1
  7. package/ai/components/pane.md +11 -1
  8. package/ai/components/recipe-media-detail.md +57 -0
  9. package/ai/components/surface-scaffold.md +13 -6
  10. package/ai/components/tab-navigator.md +6 -1
  11. package/ai/components/toolbar-control-group.md +1 -0
  12. package/ai/public-api-signatures-v1.md +356 -32
  13. package/ai/skill.md +53 -5
  14. package/ai/webawesome-jsx-signatures-v1.md +1 -1
  15. package/dist/backdrop-image.d.ts +132 -0
  16. package/dist/backdrop-image.js +5 -0
  17. package/dist/backdrop-image.js.map +1 -0
  18. package/dist/browser/backdrop-image.js +3 -0
  19. package/dist/catalog-resources.d.ts +2 -2
  20. package/dist/catalog.d.ts +2 -2
  21. package/dist/catalog.js +10 -9
  22. package/dist/catalog.js.map +1 -1
  23. package/dist/{chunk-QIG4IGUP.js → chunk-2PXZBFEC.js} +3 -1
  24. package/dist/chunk-2PXZBFEC.js.map +1 -0
  25. package/dist/{chunk-T4WJ5VGP.js → chunk-3SKAEPPZ.js} +2 -2
  26. package/dist/{chunk-T4WJ5VGP.js.map → chunk-3SKAEPPZ.js.map} +1 -1
  27. package/dist/chunk-5NQF35VO.js +67 -0
  28. package/dist/chunk-5NQF35VO.js.map +1 -0
  29. package/dist/{chunk-CMETHPKR.js → chunk-6E2H2E5A.js} +29 -8
  30. package/dist/chunk-6E2H2E5A.js.map +1 -0
  31. package/dist/{chunk-PIVY32AE.js → chunk-BYFTHA4I.js} +16 -3
  32. package/dist/chunk-BYFTHA4I.js.map +1 -0
  33. package/dist/{chunk-HGV7J4T6.js → chunk-EKHDLBRP.js} +1 -1
  34. package/dist/chunk-EKHDLBRP.js.map +1 -0
  35. package/dist/{chunk-QROYYGWU.js → chunk-FNZQOUZ5.js} +2 -2
  36. package/dist/{chunk-QROYYGWU.js.map → chunk-FNZQOUZ5.js.map} +1 -1
  37. package/dist/{chunk-EOO6PYGF.js → chunk-IXLUZHGT.js} +4 -4
  38. package/dist/{chunk-EOO6PYGF.js.map → chunk-IXLUZHGT.js.map} +1 -1
  39. package/dist/{chunk-UMKV7YVQ.js → chunk-LPHU44S3.js} +5 -1
  40. package/dist/chunk-LPHU44S3.js.map +1 -0
  41. package/dist/{chunk-INI4WDWC.js → chunk-M5KZ26VV.js} +27 -4
  42. package/dist/chunk-M5KZ26VV.js.map +1 -0
  43. package/dist/{chunk-TLMMFQWM.js → chunk-NS762OVM.js} +3 -3
  44. package/dist/{chunk-TLMMFQWM.js.map → chunk-NS762OVM.js.map} +1 -1
  45. package/dist/{chunk-CXZXHZTR.js → chunk-OZ6X76G5.js} +34 -1
  46. package/dist/chunk-OZ6X76G5.js.map +1 -0
  47. package/dist/{chunk-YOA5W5ZH.js → chunk-PKL6QJKK.js} +31 -3
  48. package/dist/chunk-PKL6QJKK.js.map +1 -0
  49. package/dist/chunk-QTSMHH5J.js +85 -0
  50. package/dist/chunk-QTSMHH5J.js.map +1 -0
  51. package/dist/chunk-SCDWURBO.js +136 -0
  52. package/dist/chunk-SCDWURBO.js.map +1 -0
  53. package/dist/{chunk-RHD7RKGI.js → chunk-SIW64V65.js} +3 -3
  54. package/dist/{chunk-RHD7RKGI.js.map → chunk-SIW64V65.js.map} +1 -1
  55. package/dist/chunk-SQ5DH66U.js +37 -0
  56. package/dist/chunk-SQ5DH66U.js.map +1 -0
  57. package/dist/{chunk-PYBBYSZ3.js → chunk-YBWQR2OK.js} +5 -5
  58. package/dist/{chunk-PYBBYSZ3.js.map → chunk-YBWQR2OK.js.map} +1 -1
  59. package/dist/{chunk-3GLYIIFR.js → chunk-ZYU5WRC3.js} +2 -2
  60. package/dist/{chunk-3GLYIIFR.js.map → chunk-ZYU5WRC3.js.map} +1 -1
  61. package/dist/collapsible-panel.js +3 -2
  62. package/dist/collapsible-panel.js.map +1 -1
  63. package/dist/index.d.ts +5 -3
  64. package/dist/index.js +24 -20
  65. package/dist/list-action-row.js +1 -1
  66. package/dist/list-header.js +1 -1
  67. package/dist/list-item.js +1 -1
  68. package/dist/{nav-stack-VXTumzKa.d.ts → nav-stack-CU_0dKtI.d.ts} +41 -5
  69. package/dist/nav-stack.d.ts +4 -4
  70. package/dist/nav-stack.js +2 -1
  71. package/dist/pane.d.ts +76 -4
  72. package/dist/pane.js +2 -1
  73. package/dist/select.js +1 -1
  74. package/dist/split-view.d.ts +4 -4
  75. package/dist/split-view.js +3 -2
  76. package/dist/split-view.js.map +1 -1
  77. package/dist/state-banner.js +1 -1
  78. package/dist/styles/backdrop-image.css +241 -0
  79. package/dist/styles/components/actions/toolbar/toolbar.css +9 -0
  80. package/dist/styles/components/actions/toolbar-control-group/toolbar-control-group.css +128 -0
  81. package/dist/styles/components/layout/pane/pane.css +121 -1
  82. package/dist/styles/components/navigation/nav-stack/nav-stack.css +207 -6
  83. package/dist/styles/components/navigation/tab-navigator/tab-navigator.css +121 -3
  84. package/dist/styles/components/surfaces/backdrop-image/backdrop-image.css +241 -0
  85. package/dist/styles/components/surfaces/surface-scaffold/surface-scaffold.css +166 -2
  86. package/dist/styles/foundation.css +165 -8
  87. package/dist/styles/nav-stack.css +207 -6
  88. package/dist/styles/overlay-sheet.css +211 -0
  89. package/dist/styles/pane.css +121 -1
  90. package/dist/styles/styles.css +1 -0
  91. package/dist/styles/surface-scaffold.css +166 -2
  92. package/dist/styles/tab-navigator.css +121 -3
  93. package/dist/styles/toolbar-control-group.css +128 -0
  94. package/dist/styles/toolbar.css +9 -0
  95. package/dist/styles/webawesome.css +10 -0
  96. package/dist/surface-scaffold-register.d.ts +3 -2
  97. package/dist/surface-scaffold-register.js +113 -10
  98. package/dist/surface-scaffold-register.js.map +1 -1
  99. package/dist/surface-scaffold.d.ts +38 -5
  100. package/dist/surface-scaffold.js +1 -1
  101. package/dist/tab-navigator.d.ts +43 -3
  102. package/dist/tab-navigator.js +35 -6
  103. package/dist/tab-navigator.js.map +1 -1
  104. package/dist/toolbar-control-group.d.ts +13 -2
  105. package/dist/toolbar-control-group.js +1 -1
  106. package/dist/toolbar-text.d.ts +6 -1
  107. package/dist/toolbar-text.js +1 -1
  108. package/dist/toolbar.d.ts +1 -0
  109. package/dist/wire-catalog.d.ts +2 -2
  110. package/dist/wire-catalog.js +1 -1
  111. package/dist/wire-nav-stack.js +29 -3
  112. package/dist/wire-nav-stack.js.map +1 -1
  113. package/dist/wire-overlay-sheets.d.ts +20 -0
  114. package/dist/wire-overlay-sheets.js +4 -0
  115. package/dist/wire-overlay-sheets.js.map +1 -0
  116. package/dist/wire-scroll-dividers.js +1 -1
  117. package/dist/wire-token-search-fields.js +1 -1
  118. package/dist/workbench.d.ts +2 -2
  119. package/dist/workbench.js +2 -1
  120. package/docs/accessibility.md +35 -0
  121. package/docs/app-layouts.md +13 -0
  122. package/docs/backdrop-image.md +128 -0
  123. package/docs/component-contract.md +2 -0
  124. package/docs/component-selection.md +11 -2
  125. package/docs/design/templates/toolbar-control-group/glass-dark.svg +1 -0
  126. package/docs/design/templates/toolbar-control-group/glass.svg +1 -0
  127. package/docs/design/templates/toolbar-control-group-dark.svg +1 -1
  128. package/docs/design/templates/toolbar-control-group.svg +1 -1
  129. package/docs/design/templates.md +8 -0
  130. package/docs/layout.md +135 -5
  131. package/docs/manual-test-plan.md +29 -0
  132. package/docs/nav-stack.md +37 -1
  133. package/docs/recipes.md +24 -0
  134. package/docs/surface-scaffold.md +149 -3
  135. package/docs/tab-navigator.md +36 -0
  136. package/docs/ux-demo.md +11 -1
  137. package/llms.txt +42 -4
  138. package/package.json +13 -1
  139. package/ux-demo/recipes/loaders.ts +2 -0
  140. package/ux-demo/recipes/media-detail.tsx +272 -0
  141. package/dist/chunk-CMETHPKR.js.map +0 -1
  142. package/dist/chunk-CXZXHZTR.js.map +0 -1
  143. package/dist/chunk-HGV7J4T6.js.map +0 -1
  144. package/dist/chunk-INI4WDWC.js.map +0 -1
  145. package/dist/chunk-PIVY32AE.js.map +0 -1
  146. package/dist/chunk-QIG4IGUP.js.map +0 -1
  147. package/dist/chunk-UMKV7YVQ.js.map +0 -1
  148. package/dist/chunk-YOA5W5ZH.js.map +0 -1
package/docs/layout.md CHANGED
@@ -106,6 +106,83 @@ The color uses `--kui-sunken-panel-background`, falling back to
106
106
  in a nested layout so a translucent override paints once. The public color
107
107
  override may be set at a Pane or layout boundary.
108
108
 
109
+ ### Background colors
110
+
111
+ Set `background` when a pane needs a surface color of its own, such as a
112
+ media detail page painted in its artwork's color. It takes a typed color from
113
+ `@kerfjs/ui/css-values`: `uiColor('surface-raised')` for a theme token,
114
+ `fillColor('#1f2a44')` for an app-chosen literal, or `colorVar('--app-hue')`
115
+ for an app-owned custom property. The color paints the whole pane, header and
116
+ footer included, and replaces the appearance's fill: a sunken pane with a
117
+ background drops its lowered color and grain. Omitted, the pane keeps its
118
+ default.
119
+
120
+ ```tsx
121
+ import { fillColor } from "@kerfjs/ui/css-values";
122
+
123
+ <Pane background={fillColor("#1f2a44")} header={<AlbumToolbar />}>
124
+ <AlbumTracks />
125
+ </Pane>;
126
+ ```
127
+
128
+ `PaneConfig.background` forwards like `appearance`, so a Workbench `mainPane`
129
+ or panel `pane`, a CollapsiblePanel `pane`, or a `NavStackView` `pane` takes
130
+ it. `NavStack` and `TabNavigator` take their own `background` for the stack
131
+ or navigator surface and their docked bars, and a `TabNavigatorTab` takes one
132
+ for its scene. Overlay chrome keeps the translucent chrome material over any
133
+ of them.
134
+
135
+ The app owns contrast: keep text at AA over the color you choose. A dark
136
+ color needs dark-tone controls or a scoped tint for its content. In forced
137
+ colors every background paints `Canvas`.
138
+
139
+ ### Tinted surfaces
140
+
141
+ Set `tint` when a whole region should take on a color, the way a media detail
142
+ page takes on its artwork's color: `tint={{ color, scheme }}`. `color` is a
143
+ `@kerfjs/ui/css-values` color, usually one the app already knows about its
144
+ artwork, and `scheme` says whether that color reads as a `'light'` or a
145
+ `'dark'` surface. Kerf does no color detection; the app declares the scheme.
146
+
147
+ ```tsx
148
+ import { fillColor } from "@kerfjs/ui/css-values";
149
+
150
+ <Pane tint={{ color: fillColor(album.bottomColor), scheme: "dark" }} header={<AlbumToolbar />}>
151
+ <AlbumTracks />
152
+ </Pane>;
153
+ ```
154
+
155
+ The pane paints its tinted surface, and everything inside it re-derives from
156
+ the color: the surface and raised surface, normal and quiet text, link and
157
+ brand text, and everything built on them (quiet fills, the lowered surface,
158
+ the chrome material behind overlay headers, popup rows). Each token's
159
+ lightness is clamped into a band that keeps text at WCAG AA over any color: a
160
+ light tint's surface stays at or above 90% OKLCH lightness under dark text,
161
+ and a dark tint's surface at or below 30% under light text. Chroma is capped,
162
+ so text stays near-neutral and the surface reads as a tint of the color. A
163
+ seed sweep across hue and lightness holds every text token at 4.5:1 or better
164
+ over the surface, raised surface, and quiet fills, with and without the Web
165
+ Awesome theme (`tests/browser/tone-contrast.spec.ts`).
166
+
167
+ The scope also takes the declared `color-scheme` and the Web Awesome
168
+ `wa-light` / `wa-dark` class, so Web Awesome components inside it re-derive
169
+ their own fills. Without relative color syntax, and under
170
+ `prefers-contrast: more`, the scope keeps the plain palette in its declared
171
+ scheme. `tint` and `background` combine: `background` paints the pane, and
172
+ `tint` still re-derives the tokens for its content.
173
+
174
+ Add `accent` (usually the artwork's most vivid color) to re-derive the brand
175
+ ramp too: `tint={{ color, scheme, accent }}`. Loud fills (primary buttons, the
176
+ current popup row, checked controls) and their text, brand text and links,
177
+ text on brand fills, and the focus ring all take the accent's hue. Lightness
178
+ is clamped so each pair keeps AA for any accent: in a light scope the fill
179
+ stays at or below 50% OKLCH lightness under white text and brand text at most
180
+ 45%; in a dark scope the fill stays between 72% and 85% under near-black text
181
+ and brand text at least 80%.
182
+
183
+ `PaneConfig.tint` forwards like `background`. `NavStack`, `TabNavigator`, and
184
+ each `TabNavigatorTab` take `tint` for the stack, the navigator, or one scene.
185
+
109
186
  Pass any combination of logical sides to `separators`: `block-start`,
110
187
  `block-end`, `inline-start`, and `inline-end`. Every line is off by default and
111
188
  each enabled side uses `--kui-pane-separator-width` (1px) and
@@ -205,8 +282,10 @@ The header paints the shared chrome material, `--kui-chrome-material` (the
205
282
  surface at 80% opacity) behind `--kui-chrome-material-filter` (a blur and
206
283
  saturation backdrop filter), so content passing beneath it reads as softened
207
284
  color, not as competing text. The material becomes the opaque surface under
208
- `prefers-reduced-transparency: reduce` or `prefers-contrast: more`, and where
209
- the browser lacks `backdrop-filter`. In forced colors the header paints
285
+ `prefers-reduced-transparency: reduce` or `prefers-contrast: more`, where
286
+ the browser lacks `backdrop-filter`, on a slow-updating display
287
+ (`update: slow`), and when the app sets `data-kui-effects="static"` on the
288
+ root element (see [Chrome material](accessibility.md#chrome-material)). In forced colors the header paints
210
289
  `Canvas` with a `CanvasText` bottom edge.
211
290
 
212
291
  `wireScrollDividers` measures each overlay header (its border-box height,
@@ -268,6 +347,54 @@ inline gutter instead of nesting them inside another padded content item. A
268
347
  conditional `StateBanner` remains visibly distinct because it communicates
269
348
  semantic status.
270
349
 
350
+ ### Clear header
351
+
352
+ `headerPresentation="clear"` is an overlay header with no fill: the same
353
+ measuring, placement, and scroll clearance as `overlay`, but fully
354
+ transparent, for a header laid over imagery such as a `BackdropImage` hero.
355
+ The header is not what keeps its controls legible over the image: put them
356
+ in glass control groups (`ToolbarControlGroup material="glass"`), or give the
357
+ content a scrim. A clear header draws no scroll divider. Set `underChrome` on a
358
+ `BackdropImage` that opens the content to run its artwork up beneath the
359
+ header.
360
+
361
+ A clear header stays clear while content scrolls beneath it. To fade a
362
+ backing in gradually instead, the scroll-edge effect of iOS, set
363
+ `scrollEdge="material"` (the translucent chrome material) or
364
+ `scrollEdge="surface"` (the opaque surface). The backing tracks the scroll:
365
+ it is fully clear at rest and fully in once content has scrolled
366
+ `scrollEdgeDistance` pixels beneath the header (default 60), and out again as
367
+ you return to the top. Only its opacity changes; the blur itself never
368
+ animates. `wireScrollDividers` publishes the progress
369
+ (`--kui-scroll-edge-progress`, 0 to 1) from the scroll it already tracks, so
370
+ it works in every engine with no extra wiring; unwired, the header stays
371
+ clear.
372
+
373
+ ```tsx
374
+ <Pane
375
+ label="Album"
376
+ headerPresentation="clear"
377
+ scrollEdge="material"
378
+ header={<Toolbar label="Album" leading={<BackGroup />} trailing={<AlbumActions />} />}
379
+ >
380
+ <BackdropImage src={art} alt="" fadeTo={uiColor("surface")} underChrome>
381
+ <Tracks />
382
+ </BackdropImage>
383
+ </Pane>
384
+ ```
385
+
386
+ Clear chrome turns opaque where transparency would hurt: under
387
+ `prefers-reduced-transparency: reduce` or `prefers-contrast: more` it paints
388
+ the opaque material, and in forced colors it paints `Canvas` with a
389
+ `CanvasText` edge. Under the static-effects switch or on a slow-updating
390
+ display, the scroll edge snaps on while content lies beneath the bar instead
391
+ of tracking the scroll.
392
+ `NavStack` (`chromePresentation`, `bottomToolbarPresentation`) and
393
+ `TabNavigator` (`barPresentation`) take the same `clear` value and
394
+ `scrollEdge` and `scrollEdgeDistance` props; a bottom bar fades its backing
395
+ out over the last `scrollEdgeDistance` pixels, clearing once the content
396
+ reaches its end.
397
+
271
398
  ## Content items
272
399
 
273
400
  Render an ordinary surface-like `.kui-content` child with `ContentItem` from
@@ -498,9 +625,12 @@ do not create line spacing.
498
625
  `Text` is also the answer for a normal-color subsection title. `ToolbarText`
499
626
  below `xlarge` (`large`, `default`, `small`) is deliberately quiet: it labels a
500
627
  toolbar's identity — a pane name or a status — and must not compete with the
501
- content and controls around it. Set `tone="dark"` on a loud or photo-backed
502
- toolbar surface to use `--kui-color-neutral-on-loud` at every size, matching
503
- dark control groups. Default tone keeps existing identity and heading colors. A subsection title is
628
+ content and controls around it. Set `tone="dark"` on a loud-filled toolbar
629
+ surface (the inverse `--kui-color-neutral-fill-loud`) to use
630
+ `--kui-color-neutral-on-loud` at every size, matching dark control groups; it
631
+ inverts with the color scheme. Over a dark photo or scrim keep the default
632
+ tone inside a `BackdropImage` overlay, which takes the dark scheme under its
633
+ `scrim`. Default tone keeps existing identity and heading colors. A subsection title is
504
634
  document content, not toolbar identity: use `Text` with the `h2`–`h6` variant
505
635
  the outline calls for (inside a `List` with `controlInsets`, its text lines up
506
636
  with content items). Use `ListHeader` when the section is a list or menu group
@@ -0,0 +1,29 @@
1
+ # Manual test plan
2
+
3
+ Checks that automation cannot make truthfully. Run the catalog with
4
+ `npm run dev:lan` and open the printed URL on the device (see
5
+ [`ux-demo.md`](ux-demo.md)). Record the device, OS, and browser versions and the
6
+ result on the tracking ticket.
7
+
8
+ ## DialogSurface bottom sheet: virtual keyboard and safe areas
9
+
10
+ Route: `?component=surface-scaffold`, example "Resizable Bottom Sheet". Run on
11
+ an iPhone (Safari) and an Android phone (Chrome).
12
+
13
+ 1. Open the sheet and focus the Message field. The keyboard pushes the sheet's
14
+ content above it, and the panel runs behind the keyboard with no gap.
15
+ 2. Rotate the phone, and pinch-zoom while the keyboard is open. The content
16
+ stays clear of the keyboard; while zoomed, the measured overlap is 0 and the
17
+ page relies on `dvh`.
18
+ 3. Dismiss the keyboard. The bottom padding returns to the home-indicator safe
19
+ area, and the sheet's square bottom edge meets the screen edge.
20
+ 4. Drag the grabber, with and without the keyboard open. The sheet resizes
21
+ between its minimum height and the screen less the top safe area.
22
+
23
+ Playwright cannot raise a virtual keyboard; the overlap formula is unit-tested
24
+ (`tests/unit/dialog-bottom-sheet.test.ts`) and a simulated keyboard is covered
25
+ in `tests/browser/dialog-bottom-sheet.spec.ts`.
26
+
27
+ ## Automated coverage summary
28
+
29
+ Nothing has moved from this plan into automation yet.
package/docs/nav-stack.md CHANGED
@@ -150,7 +150,11 @@ focusable descendant, and finally the view container itself. Use
150
150
  `data-nav-focus` for a heading or primary control when DOM order is not the best
151
151
  initial reading position; it may use `tabindex="-1"` for programmatic focus.
152
152
  When a remembered control was removed during the controlled rerender, the same
153
- fallback order applies. Disposing the helper stops future transitions and
153
+ fallback order applies. While focus rests on the view container itself (it
154
+ had nothing focusable, such as an inert loading placeholder), a later
155
+ re-render that morphs away the temporary `tabindex` keeps it there, and once
156
+ content with a focus target arrives under the same key, focus moves to it;
157
+ focus the person moved elsewhere is never reclaimed. Disposing the helper stops future transitions and
154
158
  cleans up any temporary fallback `tabindex` it added.
155
159
 
156
160
  ## Separators and scroll dividers
@@ -187,6 +191,23 @@ top Toolbar's own `dividerSides`.
187
191
  <NavStack id="settings" label="Settings" views={views.value} chromeDividers="always" />
188
192
  ```
189
193
 
194
+ ## Background colors
195
+
196
+ `background` paints the stack, its docked chrome and bottom toolbar, and every
197
+ view in an app-chosen color from `@kerfjs/ui/css-values` instead of the
198
+ surface. One view's own color goes in its `pane: { background }`, which paints
199
+ over the stack's. Overlay chrome keeps the chrome material. The app keeps text
200
+ at AA over the color; forced colors paint `Canvas`. See
201
+ [Background colors](layout.md#background-colors).
202
+
203
+ ```tsx
204
+ <NavStack id="music" label="Music" views={views.value} background={uiColor("surface-raised")} />
205
+ ```
206
+
207
+ `tint={{ color, scheme }}` re-derives the stack's surface and text tokens from
208
+ an app color instead, so its chrome, views, and content all take on the color
209
+ with text kept at AA. See [Tinted surfaces](layout.md#tinted-surfaces).
210
+
190
211
  ## Overlay chrome
191
212
 
192
213
  `chromePresentation="overlay"` lays the top chrome over the active view's top
@@ -296,6 +317,21 @@ edge.
296
317
  `SplitView`'s `compactStack.bottomToolbarPresentation` and a Workbench
297
318
  navigation panel's `navStack.bottomToolbarPresentation` forward it too.
298
319
 
320
+ ### Clear chrome
321
+
322
+ `chromePresentation="clear"` and `bottomToolbarPresentation="clear"` lay the
323
+ bars over the active view like `overlay`, with the same measuring and edge
324
+ insets, but fully transparent, for chrome over imagery. Keep their controls
325
+ in glass control groups (`ToolbarControlGroup material="glass"`); the stack's
326
+ own back control is one automatically under clear chrome. They stay
327
+ clear while content scrolls beneath them; with `scrollEdge="material"` (or
328
+ `"surface"`) a backing fades in gradually, opacity only, over
329
+ `scrollEdgeDistance` pixels of scroll (default 60): behind the top chrome as
330
+ the view's content scrolls beneath it, and behind the bottom toolbar until
331
+ the content nears its end. Clear bars draw no scroll
332
+ divider, turn opaque under reduced transparency or increased contrast, and
333
+ paint `Canvas` in forced colors. See [Clear header](layout.md#clear-header).
334
+
299
335
  ## Safe areas
300
336
 
301
337
  The stack paints through a device's unsafe areas. Its chrome pads for the top
package/docs/recipes.md CHANGED
@@ -200,6 +200,30 @@ ephemeral interaction. For a full three-pane shell use `Workbench` instead — s
200
200
  [`app-layouts.md`](app-layouts.md). This recipe is covered end-to-end across
201
201
  Chromium, Firefox, and WebKit by `tests/browser/collapsible-sidebar-recipe.spec.ts`.
202
202
 
203
+ ## Media Detail
204
+
205
+ [Open the recipe](../ux-demo/?component=recipe-media-detail) · [TSX source](../ux-demo/recipes/media-detail.tsx)
206
+
207
+ An Apple-Music-style album page composed only from components. A `NavStack`
208
+ with `chromePresentation="clear"` and `scrollEdge="material"` lays its bar
209
+ over the album, fully transparent at rest; its back control and the album
210
+ actions are glass control groups (`ToolbarControlGroup material="glass"`), so
211
+ they stay legible over the artwork, and the chrome material fades in once the
212
+ songs scroll beneath the bar. The stack takes a scoped `tint` from the
213
+ artwork's bottom color with `scheme: 'dark'`, so the surface, text, quiet
214
+ text, and the chrome material all re-derive from it at AA. The album view's
215
+ Pane opens with a `BackdropImage` that runs `underChrome` up beneath the clear
216
+ bar, lags the scroll (`parallax="subtle"`), blurs progressively as it scrolls
217
+ away, and fades into the tinted surface (`fadeTo={uiColor('surface')}`) that
218
+ continues under the song list. Every effect has the components' own
219
+ fallbacks: static under reduced motion, the static-effects switch, or without
220
+ scroll-driven animations; opaque under reduced transparency and increased
221
+ contrast; system colors in forced colors. The app owns the artwork and the
222
+ two colors it already knows about it (the tint and its scheme), the stack,
223
+ playback, and the data; the recipe's `wire` installs `wireScrollDividers` for
224
+ the scroll-edge state. See [`app-layouts.md`](app-layouts.md) for the layout
225
+ and [`backdrop-image.md`](backdrop-image.md) for the image.
226
+
203
227
  ## Rules shared by every recipe
204
228
 
205
229
  - Import `@kerfjs/ui/layout.css`; keep every pane unpadded and use exactly one
@@ -8,10 +8,17 @@ inside `PopupSurface` also keeps keyboard item navigation made during its show
8
8
  animation when Web Awesome completes its initial focus handoff.
9
9
 
10
10
  Wrap one `wa-dialog` with `DialogSurface`. Choose `size` (`small`, `medium`, or
11
- `large`), `presentation` (`modal`, `side-sheet`, or `fullscreen`), and independent
11
+ `large`), `presentation` (`modal`, `side-sheet`, `fullscreen`, or
12
+ `bottom-sheet`; see [Bottom sheets](#bottom-sheets)), and independent
12
13
  `bodyInset` / `footerInset` (`none`, `compact`, or `comfortable`). The defaults
13
14
  match Kerf's medium modal, 8px body, and 16px footer rhythm.
14
15
 
16
+ The panel corner is concentric with a pill `ToolbarControlGroup` (22px) resting
17
+ at the body inset: 30px for `compact` (and `none`) and 38px for `comfortable`,
18
+ so header and body controls nest into the curve. A side sheet uses the same
19
+ radius on its leading corners, and a bare `wa-dialog` or `PopoverDialogSurface`
20
+ takes the 30px default.
21
+
15
22
  The modal size presets have preferred widths of 400px, 560px, and 800px,
16
23
  respectively, before the viewport width cap. Set `preferredWidth` to a branded
17
24
  `CssLength` when the desktop shell needs another width. It takes precedence over
@@ -46,7 +53,8 @@ clamped to the dynamic viewport height minus twice the gutter (16px per edge
46
53
  when the gutter is omitted). Negative gutters/caps clamp to zero. Omitting
47
54
  both options preserves Web Awesome's existing width and phone height caps.
48
55
  These options apply only to `presentation="modal"`; side sheets and fullscreen
49
- surfaces keep their own geometry.
56
+ surfaces keep their own geometry, and a bottom sheet reads `height` and
57
+ `maxHeight` as described in [Bottom sheets](#bottom-sheets).
50
58
 
51
59
  ```tsx
52
60
  import '@kerfjs/ui/surface-scaffold/register';
@@ -74,6 +82,144 @@ An application can configure device-class breakpoints when its phone policy
74
82
  differs from the default. Prefer a typed cap such as `maxHeight={remify(640)}`
75
83
  when the modal should stop growing before the viewport bound.
76
84
 
85
+ ### Bottom sheets
86
+
87
+ `presentation="bottom-sheet"` attaches the dialog to the bottom edge of the
88
+ screen instead of centering it, the way a phone presents a compose or quick
89
+ edit sheet. The default `modal` presentation is unchanged.
90
+
91
+ - **Geometry.** The panel rests on the viewport's block-end edge. Its top
92
+ corners keep the dialog radius and its bottom corners are square. It is
93
+ full width on a phone and the `size` / `preferredWidth` width, centered, on
94
+ a wider screen.
95
+ - **Safe area and keyboard.** The panel runs to the edge, behind the home
96
+ indicator and any virtual keyboard, while a block-end padding keeps its
97
+ content above them. The padding is the largest of
98
+ `--kui-safe-area-block-end`, the keyboard overlap the register measures from
99
+ `visualViewport` (layout height minus the visual viewport's height and top
100
+ offset, ignored while pinch-zoomed), and the VirtualKeyboard API's
101
+ `env(keyboard-inset-height)`. A page whose viewport meta sets
102
+ `interactive-widget=resizes-content` shrinks the layout viewport instead, so
103
+ the measured overlap is zero and the sheet simply follows the dynamic
104
+ viewport. Kerf never sets `navigator.virtualKeyboard.overlaysContent`; an
105
+ application that opts into it gets the `env()` value for free.
106
+ - **Height.** `height`, `minHeight`, and `maxHeight` measure the visible
107
+ content height above that clearance. `height` is the opening height
108
+ (omit it to size to content), and `maxHeight` caps it. The panel never
109
+ exceeds the dynamic viewport minus the top safe area and one
110
+ `viewportGutter` (16px by default), so a tall keyboard shrinks the content
111
+ rather than pushing the sheet off the top.
112
+ - **Resizing.** `resizable` adds a grabber at the top edge: a focusable
113
+ horizontal `separator` named "Resize sheet" with `aria-valuemin`,
114
+ `aria-valuenow`, and `aria-valuemax` in visible px. Drag it, or use the
115
+ arrow keys (16px), Page Up/Down (64px), Home (`minHeight`, default 160px),
116
+ and End (the cap). The register injects the grabber into the dialog's
117
+ shadow panel, so it never disturbs `bodyLayout` children or a Kerf morph.
118
+ Each opening starts again at `height`.
119
+ - **Motion.** The sheet slides up from the bottom edge when it opens and back
120
+ down when it is dismissed. `enterMotion="standard"` or
121
+ `exitMotion="standard"` keeps Web Awesome's scale-and-fade for that
122
+ transition. The slide falls back to Web Awesome's motion under
123
+ `prefers-reduced-motion: reduce` and the document's static effects mode
124
+ (`:root[data-kui-effects="static"]`). A prevented close still pulses.
125
+
126
+ ```tsx
127
+ import '@kerfjs/ui/surface-scaffold/register';
128
+ import { remify } from '@kerfjs/ui/css-values';
129
+ import { DialogSurface } from '@kerfjs/ui/surface-scaffold';
130
+
131
+ <DialogSurface presentation="bottom-sheet" height={remify(360)} minHeight={remify(200)} resizable bodyInset="none">
132
+ <wa-dialog label="New Message" light-dismiss>
133
+ {/* Application-owned fields */}
134
+ </wa-dialog>
135
+ </DialogSurface>;
136
+ ```
137
+
138
+ The resize grabber, keyboard clearance, and slide need the `/register`
139
+ entry. Without it the sheet keeps its bottom-edge geometry and safe-area
140
+ padding, shows no grabber, and opens with Web Awesome's own animation.
141
+ `preferredWidth`, `bodyLayout`, the floating header and footer, and the
142
+ insets compose with a bottom sheet as they do with a modal.
143
+
144
+ ### Core dialogs as bottom sheets
145
+
146
+ The core `kerfjs/overlay` surfaces (`overlay()` and the `confirm`, `prompt`,
147
+ `form`, and `choice` promise dialogs) take `presentation: 'bottom-sheet'`, but
148
+ the core ships no CSS. Import `@kerfjs/ui/overlay-sheet.css` once to give
149
+ them this package's bottom-sheet geometry:
150
+
151
+ - the bottom edge, full width up to 560px and centered beyond;
152
+ - square bottom corners under the dialog radius;
153
+ - the 8px surface inset, padded at the block end by the larger of the safe
154
+ area and the `--kerf-keyboard-inset` the core publishes;
155
+ - a cap below the top safe area;
156
+ - a slide up as it opens.
157
+
158
+ Pass `exitClass: 'kui-overlay-sheet-exit'` (with an `exitDuration` cap such as
159
+ `400`) to slide it back down as it closes. The stylesheet covers the plain
160
+ host and `native: true`, honors reduced motion and the static-effects switch,
161
+ and paints `Canvas` in forced colors.
162
+
163
+ The core renders bare default markup, so compose the content from components
164
+ through the dialog's `render` slot and spread the wiring slots onto native
165
+ buttons:
166
+
167
+ ```tsx
168
+ import "@kerfjs/ui/overlay-sheet.css";
169
+
170
+ confirm("Discard this draft?", {
171
+ presentation: "bottom-sheet",
172
+ exitClass: "kui-overlay-sheet-exit",
173
+ exitDuration: 400,
174
+ render: ({ message, ok, cancel }) => (
175
+ <List gap="xs">
176
+ <Toolbar label="Discard Draft" leading={<ToolbarText text="Discard Draft" size="xlarge" headingLevel={2} />} />
177
+ <ListInsetText>{message}</ListInsetText>
178
+ <Row hAlign="right" controlInsets="rl">
179
+ <ToolbarControlGroup label="Discard Draft Actions" content="text">
180
+ <button type="button" {...cancel}>Keep Editing</button>
181
+ <button type="button" {...ok}>Discard</button>
182
+ </ToolbarControlGroup>
183
+ </Row>
184
+ </List>
185
+ ),
186
+ });
187
+ ```
188
+
189
+ The wired controls can be native buttons, as here, or Web Awesome buttons.
190
+ With kerfjs 5.0 or later, the core holds focus on the sheet until a Web
191
+ Awesome OK button has rendered, then moves focus to it. On kerfjs 4.x, use
192
+ native buttons for the wired controls: the core's first focus reaches a Web
193
+ Awesome button before it has rendered and aborts the open.
194
+
195
+ A core sheet can also be resizable, like a `resizable` `DialogSurface` sheet.
196
+ Add the `kui-overlay-sheet-resizable` class next to the default wrapper class,
197
+ and call `wireOverlaySheets()` from `@kerfjs/ui/wire-overlay-sheets` once for
198
+ each container sheets open in (`document.body`, the core's default):
199
+
200
+ ```tsx
201
+ import "@kerfjs/ui/overlay-sheet.css";
202
+ import { wireOverlaySheets } from "@kerfjs/ui/wire-overlay-sheets";
203
+
204
+ const stopSheets = wireOverlaySheets();
205
+
206
+ overlay(<UpNext />, {
207
+ className: "kerf-overlay kui-overlay-sheet-resizable",
208
+ presentation: "bottom-sheet",
209
+ });
210
+ ```
211
+
212
+ Each such sheet gets the same grabber at its top edge: a `role="separator"`
213
+ that resizes the visible height by drag, the arrow keys (16px), Page Up/Down
214
+ (64px), Home (the minimum), and End (the cap), and reports that height as its
215
+ ARIA values. The minimum is `--kui-overlay-sheet-min-height` (160px). The
216
+ sheet opens at its content height every time. The grabber sticks to the top
217
+ edge while the content scrolls, and stays the sheet's first child (and first
218
+ tab stop) across re-renders of the overlay's content. Compose the sheet's
219
+ content root as a block or column, since the grabber is its first child. The
220
+ wiring watches only the container's direct children, which is where the core
221
+ appends each overlay; call the disposer to remove every grabber.
222
+
77
223
  ### Filling the dialog
78
224
 
79
225
  `maxHeight` caps a modal that sizes to its content. A layout that fills the
@@ -267,4 +413,4 @@ preserve `data-morph-skip-children` when its upgraded light-DOM items must retai
267
413
  identity across Kerf rerenders. Import `@kerfjs/ui/popup-menu/register` once for
268
414
  the focus handoff fix when wrapping a raw `wa-dropdown`.
269
415
 
270
- Import `@kerfjs/ui/surface-scaffold/register` once when using `DialogSurface`: it registers `wa-dialog`, measures each surface's footer for a floating footer's body clearance, draws a floating header's divider while content is scrolled beneath it, and repairs Web Awesome 3.12 native dialog names before the first modal opening. Headed dialogs reference the rendered title, including live slotted labels; headerless dialogs use the public `label`. Explicit host `aria-label` and pre-existing native names are preserved. A host that is itself an explicit dialog (`role="dialog"` or `"alertdialog"` with its own name) keeps that name alone: the native panel is left unnamed, so one dialog carries the name. Geometry-only markup stays CSS-free and does not register Web Awesome implicitly.
416
+ Import `@kerfjs/ui/surface-scaffold/register` once when using `DialogSurface`: it registers `wa-dialog`, measures each surface's footer for a floating footer's body clearance, draws a floating header's divider while content is scrolled beneath it, wires a bottom sheet's resize grabber, keyboard clearance, and slide, and repairs Web Awesome 3.12 native dialog names before the first modal opening. Headed dialogs reference the rendered title, including live slotted labels; headerless dialogs use the public `label`. Explicit host `aria-label` and pre-existing native names are preserved. A host that is itself an explicit dialog (`role="dialog"` or `"alertdialog"` with its own name) keeps that name alone: the native panel is left unnamed, so one dialog carries the name. Geometry-only markup stays CSS-free and does not register Web Awesome implicitly.
@@ -169,6 +169,30 @@ so it never moves the bar or a tab.
169
169
  the wiring reports content below. It does not suppress the sunken-surface
170
170
  separator; use `separator="hidden"` for that.
171
171
 
172
+ ## Background colors
173
+
174
+ `background` paints the navigator and its docked bar in an app-chosen color
175
+ from `@kerfjs/ui/css-values` (`uiColor(...)`, `fillColor(...)`, or
176
+ `colorVar(...)`) instead of the surface. A tab's own `background` paints its
177
+ scene and replaces that scene's `appearance`. The overlay bar keeps the
178
+ chrome material over either. Keep the bar's labels and the scene's text at AA
179
+ over the colors you choose; forced colors paint `Canvas`. See
180
+ [Background colors](layout.md#background-colors).
181
+
182
+ ```tsx
183
+ <TabNavigator
184
+ id="library"
185
+ label="Library"
186
+ active={active.value}
187
+ background={uiColor("surface-raised")}
188
+ tabs={[{ id: "now", label: "Now Playing", content: <NowPlaying />, background: fillColor("#2b1d3a") }]}
189
+ />
190
+ ```
191
+
192
+ `tint={{ color, scheme }}` on the navigator, or on one tab for its scene,
193
+ re-derives the surface and text tokens from an app color, keeping text at AA.
194
+ See [Tinted surfaces](layout.md#tinted-surfaces).
195
+
172
196
  ## Translucent bar
173
197
 
174
198
  `barPresentation="overlay"` lays the tab bar over the active scene's bottom
@@ -206,6 +230,18 @@ shows only while more content lies below. Like the NavStack overlay bars, the
206
230
  tab bar ends where a classic (space-taking) scrollbar's track begins, so the
207
231
  active scene's thumb and buttons stay visible and usable.
208
232
 
233
+ ### Clear bar
234
+
235
+ `barPresentation="clear"` lays the bar over the active scene like `overlay`
236
+ but fully transparent, for a bar over imagery. It stays
237
+ clear while content scrolls beneath it; with `scrollEdge="material"` (or
238
+ `"surface"`) a backing fades in (opacity only) while the scene continues
239
+ beneath the bar and fades out over the last `scrollEdgeDistance` pixels
240
+ (default 60) before its end. A clear bar draws
241
+ no scroll divider, turns opaque under reduced transparency or increased
242
+ contrast, and paints `Canvas` in forced colors. See
243
+ [Clear header](layout.md#clear-header).
244
+
209
245
  ## Tab badges
210
246
 
211
247
  Give a tab a `badge` (a count or short string such as `3`, `"99+"`, or
package/docs/ux-demo.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # UX catalog contract
2
2
 
3
- Run `npm run dev` from `ui/`. The catalog is a development and review surface, not a second implementation. The development server reloads component source edits without a restart: component entries still load through their `dist/browser` wrappers, so each component's reachable CSS is exercised as consumers receive it, but the JavaScript those wrappers re-export (and every other `@kerfjs/ui` subpath) resolves to `src/`, and CSS resolves to `src/` as well. A component that gains a new CSS dependency needs a restart, because the wrapper listing its stylesheets is regenerated only by the build. Production demo builds, previews, and the browser tests use `dist/`. The development server allows repo-owned assets used by the catalog, and its production bundle uses relative asset URLs so the complete catalog remains intact below preview and proxy paths.
3
+ Run `npm run dev` from `ui/` (or `npm run dev:lan` from the repo root or `ui/`). The catalog is a development and review surface, not a second implementation. The development server reloads component source edits without a restart: component entries still load through their `dist/browser` wrappers, so each component's reachable CSS is exercised as consumers receive it, but the JavaScript those wrappers re-export (and every other `@kerfjs/ui` subpath) resolves to `src/`, and CSS resolves to `src/` as well. A component that gains a new CSS dependency needs a restart, because the wrapper listing its stylesheets is regenerated only by the build. Production demo builds, previews, and the browser tests use `dist/`. The development server allows repo-owned assets used by the catalog, and its production bundle uses relative asset URLs so the complete catalog remains intact below preview and proxy paths.
4
+
5
+ `npm run dev:lan` serves the same development catalog on every network
6
+ interface, for checks only a real device can make: a phone's virtual keyboard
7
+ and safe areas, touch dragging, and device rendering. It prints the catalog's
8
+ LAN and Tailscale URLs (opening the bottom-sheet route) and accepts Tailscale
9
+ MagicDNS (`*.ts.net`) and mDNS (`*.local`) host names; plain `npm run dev` stays
10
+ on `127.0.0.1`. The catalog page sets `viewport-fit=cover`, so on a notched
11
+ phone `env(safe-area-inset-*)` reports the real insets and the layouts' safe-area
12
+ model is visible. See [`manual-test-plan.md`](manual-test-plan.md) for the
13
+ device checks.
4
14
 
5
15
  Every Playwright entry point builds current source before the preview server
6
16
  starts. `npm run test:e2e -- tests/browser/row.spec.ts` and a direct focused
package/llms.txt CHANGED
@@ -24,6 +24,28 @@ depth; avoid a wrapper or extra background that paints the same visual surface
24
24
  twice. Check contrast in both color schemes over the actual backdrop. See
25
25
  [contextual transparency](./docs/webawesome-theme.md#contextual-transparency).
26
26
 
27
+ For chrome laid over imagery, use the `clear` presentation (Pane
28
+ `headerPresentation`, NavStack `chromePresentation` /
29
+ `bottomToolbarPresentation`, TabNavigator `barPresentation`) with glass
30
+ control groups, and `scrollEdge="material"` (or `"surface"`) to fade a
31
+ backing in gradually over `scrollEdgeDistance` (default 60px) as content
32
+ scrolls beneath (a first `BackdropImage` with `underChrome` runs its artwork beneath the header); see [Clear header](./docs/layout.md#clear-header).
33
+
34
+ For a `kerfjs/overlay` dialog with `presentation: 'bottom-sheet'`, import
35
+ `@kerfjs/ui/overlay-sheet.css` once (and pass `exitClass: 'kui-overlay-sheet-exit'`
36
+ for the slide out; add the `kui-overlay-sheet-resizable` class and call
37
+ `wireOverlaySheets()` for a resize grabber); see [Core dialogs as bottom sheets](./docs/surface-scaffold.md#core-dialogs-as-bottom-sheets).
38
+
39
+ For an app-chosen surface color, set the typed `background` prop (a
40
+ `@kerfjs/ui/css-values` color) on `Pane` (also `PaneConfig`), `NavStack`,
41
+ `TabNavigator`, or a `TabNavigatorTab` instead of styling the component. It
42
+ replaces the default or sunken fill; overlay chrome keeps its material; forced
43
+ colors paint `Canvas`. Keep content at AA over the color. See
44
+ [Background colors](./docs/layout.md#background-colors).
45
+ For a region that takes on a color with text kept at AA automatically, set
46
+ `tint={{ color, scheme }}` (the app declares the scheme; no color detection; an optional `accent` re-derives the brand ramp at AA);
47
+ see [Tinted surfaces](./docs/layout.md#tinted-surfaces).
48
+
27
49
  For arbitrary interactive help or popup content, use `openAnchoredSurface` or
28
50
  `openAnchoredSurfaceAt` from `@kerfjs/ui/anchored-surface`; see
29
51
  [AnchoredSurface](./docs/anchored-surface.md). The app owns content and actions.
@@ -40,8 +62,10 @@ the folder modules, and CSS only imports their owner stylesheets.
40
62
 
41
63
  ### Shared semantic tones
42
64
 
43
- Use `ToolbarText tone="dark"` on a loud or photo-backed toolbar surface to
44
- match dark control groups. It applies to read-only headings and actionable
65
+ Use `ToolbarText tone="dark"` on a loud-filled toolbar surface (the inverse
66
+ `--kui-color-neutral-fill-loud`) to match dark control groups; it inverts with
67
+ the color scheme. Over a dark photo or scrim keep the default tone inside a
68
+ `BackdropImage` overlay, which takes the dark scheme under its `scrim`. It applies to read-only headings and actionable
45
69
  titles at every size; default tone retains existing identity and heading colors.
46
70
 
47
71
  `SemanticTone` is the shared color-role type for `Badge`, `Chip`, `StateBanner`,
@@ -476,6 +500,7 @@ headingLevel`, `TokenSearchField clearIcon`, `ContentItem frame="framed"` for a
476
500
  Text supports `controlMargins` with canonical physical Sides for an exterior 8px control gutter; it preserves border/padding and avoids an isolated Row wrapper. Avoid stacking it with parent insets. Inline spans retain native inline margin behavior.
477
501
 
478
502
  `ToolbarControlGroup.tileTone` selects `neutral`, `brand`, `success`, `warning`, or `danger` quiet fill, matching quiet border, and on-quiet foreground for a non-interactive icon tile. Compose a contained `single` group whose only child is a direct decorative LucideIcon (`aria-hidden="true"`). Omission preserves the normal group palette. The prop does not recolor interactive, mixed, multi-control, or borderless groups; keep real actions in controls with their existing focus/hover/pressed treatments.
503
+ `ToolbarControlGroup` `material="glass"` paints a group that floats over imagery or a clear (transparent) toolbar on the translucent chrome material (`--kui-chrome-material` with the blur/saturate `--kui-chrome-material-filter`), with a specular edge in place of the gray border and a soft shadow. Its controls take `--kui-color-text`, so they stay at AA over any content; a raised or outline selection paints an opaque surface pill. The fill turns opaque under reduced transparency, increased contrast, static effects (`update: slow` or `:root[data-kui-effects="static"]`), or without backdrop-filter support, and paints Canvas/CanvasText in forced colors. A `tone="dark"` glass group keeps its loud fill and gains the blur and edge; a borderless group has no surface and ignores the material. Keep the solid default on an opaque toolbar, and never add your own `backdrop-filter` or translucent background to a group.
479
504
  `ListItem`, `ListItemLink`, and `ListActionRow` take `iconTile` to present their leading `icon` in a filled 32px rounded tile holding an 18px glyph, for settings or command rows that identify each entry by color. Pass a tone (`neutral`, `brand`, `success`, `warning`, or `danger`) for the same quiet fill, matching quiet border, and on-quiet foreground as `ToolbarControlGroup.tileTone`, or `{ color, foreground? }` for an application color: `color` is a `CssColor` (`uiColor()`, `colorVar()`, or `fillColor('#e5484d')`) and `foreground` a `CssForegroundColor`; without one, the glyph is black or white, whichever contrasts with the fill. The tile sits 6px clear of a standard 44px row (a compact row is at least 42px tall), moves the label to the tile's edge, and keeps its colors when the row is hovered or selected. Never wrap the icon in your own tile or resize the row's icon slot.
480
505
  Toolbar zones accept `KerfUiContent` directly. Use `ToolbarText` for identity and `ToolbarControlGroup` for related controls that need its shared chrome; a bare control keeps its own styling and accessibility responsibilities.
481
506
  Expanded search groups accept `expandedOverflow="visible"` to anchor app-owned suggestion/date/help surfaces below their relative root; collapsed/default groups retain existing clipping. Apps own surface placement/dismissal and use `data-token-search-keep-open` or `collapsible.keepOpenOn` for focus.
@@ -528,7 +553,8 @@ headingLevel`, `TokenSearchField clearIcon`, `ContentItem frame="framed"` for a
528
553
  For a header that floats over its scrolling content, set
529
554
  `headerPresentation="overlay"` on the `Pane` (or in `PaneConfig`). It paints
530
555
  the translucent chrome material (`--kui-chrome-material`, opaque under
531
- reduced transparency, increased contrast, and forced colors). The same
556
+ reduced transparency, increased contrast, forced colors, a slow-updating
557
+ display, and the app-set `data-kui-effects="static"` root switch). The same
532
558
  `wireScrollDividers` call measures the header and publishes its height as
533
559
  block-start scroll clearance, which the content clears automatically. Keep
534
560
  overlay-header text in the normal text color.
@@ -755,6 +781,18 @@ created, input }`. Never use a hand-styled `<input list>` + `<datalist>`.
755
781
  inverse chrome, place the chips in a dark appearance scope (`.wa-dark`)
756
782
  instead of restyling them.
757
783
 
784
+ - **Open a scrolling view with `BackdropImage`.** Place it first in a Pane or
785
+ NavStack view's scrolling content: `parallax` (`subtle` or a 0 to 1 factor)
786
+ lags the image on a compositor-only scroll timeline, `blur` (`radius`,
787
+ `blurredSrc`, `dim`) cross-fades in a once-blurred copy as it scrolls away
788
+ (only opacity animates, never the filter), `fadeTo` (a `CssColor`)
789
+ dissolves it into a color that keeps painting under its `children`, and
790
+ `overlay` content sits over its block end with an optional `scrim`. Pass
791
+ `alt=""` for a decorative image. It is static under reduced motion,
792
+ `data-kui-effects="static"`, or without scroll-driven animations, and ends on
793
+ a hard edge (overlay on a solid `fadeTo` band) under reduced transparency. Never hand-roll scroll listeners or
794
+ `background-attachment: fixed`.
795
+
758
796
  - **Use `Composer` for a chat or prompt composer.** `@kerfjs/ui/composer`
759
797
  renders a raised frame (standard border, large radius, `shadow-l`) whose
760
798
  border turns brand while focus is within, a borderless textarea that grows
@@ -844,7 +882,7 @@ details.
844
882
 
845
883
  Select menus use native viewport shifting with 10px gutters: default menus grow to content (up to 20rem), while `fitMenu` matches trigger width and long labels wrap. Keep popup sizing and position repairs out of consumer CSS.
846
884
 
847
- Key sibling `DialogSurface`s that render conditionally with `rootAttributes={{ "data-key": "…" }}` so a re-render morphs each dialog into itself; never wrap one in a keyed app `div`. Modal `DialogSurface` bounds use typed `viewportGutter` and `maxHeight` (`CssLength` or `"viewport"`); explicit caps stay within the dynamic viewport minus both gutters. Omitted props preserve Web Awesome defaults. Application render state chooses responsive values, and preview layout remains application-owned. `height` sets a definite bounded modal size and `bodyLayout="fill"` lets one child fill the body; `bodyLayout="fill-last"` keeps leading rows at their height above one filling last child, and `footerPresentation="floating"` (with a typed `footerOffset`) floats a chat composer over the body while the body reserves its measured height; give the transcript `scrollClearance="block-end"` (a `scrollable` `List` or a `Pane`) to let it scroll beneath the composer, with the last message still resting above it, through the published `--kui-scroll-clearance-block-end` context (overlaying chrome publishes `--kui-scroll-clearance-block-start` / `-end`; one scroller per covered edge opts in; a composed Pane opts in through `PaneConfig.scrollClearance` on a Workbench `mainPane` or panel `pane`, a `CollapsiblePanel` `pane`, or a `NavStack` view's `pane`). `headerPresentation="floating"` lays the title bar over the body on the translucent `--kui-chrome-material-raised` (opaque under reduced transparency, increased contrast, and forced colors); the register measures it and the body reserves it, and a `fill` body's only child with `scrollClearance="both"` scrolls beneath both the title bar and a floating composer through the published `--kui-scroll-clearance-block-start`. `PopoverDialogSurface` is the non-modal, light-dismiss counterpart on the native Popover API, with the same size props and an `embedded` in-flow presentation; `describedBy` sets its `aria-describedby` (usually a title `ToolbarText`'s `descriptionId`).
885
+ Key sibling `DialogSurface`s that render conditionally with `rootAttributes={{ "data-key": "…" }}` so a re-render morphs each dialog into itself; never wrap one in a keyed app `div`. Modal `DialogSurface` bounds use typed `viewportGutter` and `maxHeight` (`CssLength` or `"viewport"`); explicit caps stay within the dynamic viewport minus both gutters. Omitted props preserve Web Awesome defaults. Application render state chooses responsive values, and preview layout remains application-owned. `height` sets a definite bounded modal size and `bodyLayout="fill"` lets one child fill the body; `bodyLayout="fill-last"` keeps leading rows at their height above one filling last child, and `footerPresentation="floating"` (with a typed `footerOffset`) floats a chat composer over the body while the body reserves its measured height; give the transcript `scrollClearance="block-end"` (a `scrollable` `List` or a `Pane`) to let it scroll beneath the composer, with the last message still resting above it, through the published `--kui-scroll-clearance-block-end` context (overlaying chrome publishes `--kui-scroll-clearance-block-start` / `-end`; one scroller per covered edge opts in; a composed Pane opts in through `PaneConfig.scrollClearance` on a Workbench `mainPane` or panel `pane`, a `CollapsiblePanel` `pane`, or a `NavStack` view's `pane`). `headerPresentation="floating"` lays the title bar over the body on the translucent `--kui-chrome-material-raised` (opaque under reduced transparency, increased contrast, and forced colors); the register measures it and the body reserves it, and a `fill` body's only child with `scrollClearance="both"` scrolls beneath both the title bar and a floating composer through the published `--kui-scroll-clearance-block-start`. `presentation="bottom-sheet"` attaches the dialog to the bottom edge with square bottom corners, safe-area and virtual-keyboard clearance behind the panel, and a slide in/out (`enterMotion` / `exitMotion`); `resizable` adds a top grabber separator bounded by `minHeight` and `maxHeight`. `PopoverDialogSurface` is the non-modal, light-dismiss counterpart on the native Popover API, with the same size props and an `embedded` in-flow presentation; `describedBy` sets its `aria-describedby` (usually a title `ToolbarText`'s `descriptionId`).
848
886
 
849
887
  Import `@kerfjs/ui/surface-scaffold/register` once when using `DialogSurface`: it registers `wa-dialog` and repairs Web Awesome 3.12 native dialog names before the first modal opening. Headed dialogs reference the rendered title, including live slotted labels; headerless dialogs use the public `label`. Explicit host `aria-label` and pre-existing native names are preserved. A host that is itself an explicit dialog (`role="dialog"` or `"alertdialog"` with its own name) keeps that name alone: the native panel is left unnamed, so one dialog carries the name. Geometry-only markup stays CSS-free and does not register Web Awesome implicitly.
850
888