@kerfjs/ui 5.0.0-beta.77 → 5.0.0-beta.78

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 (86) hide show
  1. package/README.md +3 -0
  2. package/ai/application-ui-diagnostic-ids-v1.json +3 -0
  3. package/ai/component-catalog.json +121 -9
  4. package/ai/component-composition.json +102 -6
  5. package/ai/components/nav-stack.md +3 -3
  6. package/ai/components/surface-scaffold.md +11 -2
  7. package/ai/components/toolbar-control-group.md +2 -0
  8. package/ai/components/toolbar-text.md +2 -0
  9. package/ai/components/toolbar.md +2 -0
  10. package/ai/public-api-signatures-v1.md +49 -121
  11. package/ai/skill.md +11 -1
  12. package/ai/webawesome-jsx-signatures-v1.md +1 -1
  13. package/dist/browser/nav-stack.js +2 -0
  14. package/dist/browser/split-view.js +2 -0
  15. package/dist/catalog-resources.d.ts +3 -3
  16. package/dist/catalog.d.ts +3 -3
  17. package/dist/catalog.js +3 -3
  18. package/dist/{chunk-WJN3V2H5.js → chunk-2GOCOLQL.js} +18 -4
  19. package/dist/chunk-2GOCOLQL.js.map +1 -0
  20. package/dist/{chunk-XIZRXI7N.js → chunk-A7DYEERU.js} +13 -1
  21. package/dist/chunk-A7DYEERU.js.map +1 -0
  22. package/dist/{chunk-6VQPLV3O.js → chunk-DSLRNR43.js} +9 -8
  23. package/dist/chunk-DSLRNR43.js.map +1 -0
  24. package/dist/{chunk-AOAW55JN.js → chunk-DXQZ3BEO.js} +4 -1
  25. package/dist/chunk-DXQZ3BEO.js.map +1 -0
  26. package/dist/chunk-IKCUPTR2.js +170 -0
  27. package/dist/chunk-IKCUPTR2.js.map +1 -0
  28. package/dist/{chunk-Z6PE5T7N.js → chunk-JL4BBZP4.js} +4 -3
  29. package/dist/chunk-JL4BBZP4.js.map +1 -0
  30. package/dist/collapsible-panel.js +2 -2
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +5 -5
  33. package/dist/nav-stack-B1Qc2e5q.d.ts +101 -0
  34. package/dist/nav-stack.d.ts +11 -93
  35. package/dist/nav-stack.js +5 -1
  36. package/dist/pane.d.ts +5 -2
  37. package/dist/pane.js +1 -1
  38. package/dist/split-view.d.ts +9 -4
  39. package/dist/split-view.js +7 -3
  40. package/dist/split-view.js.map +1 -1
  41. package/dist/styles/components/actions/toolbar-control-group/toolbar-control-group.css +60 -13
  42. package/dist/styles/components/actions/toolbar-text/toolbar-text.css +7 -1
  43. package/dist/styles/components/layout/pane/pane.css +7 -0
  44. package/dist/styles/components/navigation/nav-stack/nav-stack.css +6 -12
  45. package/dist/styles/components/navigation/tab-scaffold/tab-scaffold.css +17 -0
  46. package/dist/styles/components/surfaces/surface-scaffold/surface-scaffold.css +55 -0
  47. package/dist/styles/nav-stack.css +6 -12
  48. package/dist/styles/pane.css +7 -0
  49. package/dist/styles/surface-scaffold.css +55 -0
  50. package/dist/styles/tab-scaffold.css +17 -0
  51. package/dist/styles/toolbar-control-group.css +60 -13
  52. package/dist/styles/toolbar-text.css +7 -1
  53. package/dist/surface-scaffold-register.d.ts +4 -0
  54. package/dist/surface-scaffold-register.js +59 -0
  55. package/dist/surface-scaffold-register.js.map +1 -0
  56. package/dist/surface-scaffold.d.ts +10 -2
  57. package/dist/surface-scaffold.js +1 -1
  58. package/dist/tab-scaffold.d.ts +2 -0
  59. package/dist/tab-scaffold.js +1 -0
  60. package/dist/tab-scaffold.js.map +1 -1
  61. package/dist/wire-toolbar-visibility.js +1 -1
  62. package/dist/workbench.d.ts +5 -13
  63. package/dist/workbench.js +1 -1
  64. package/docs/component-contract.md +8 -1
  65. package/docs/component-selection.md +1 -1
  66. package/docs/layout.md +20 -0
  67. package/docs/nav-stack.md +30 -18
  68. package/docs/recipes.md +5 -1
  69. package/docs/split-view.md +7 -4
  70. package/docs/surface-scaffold.md +46 -0
  71. package/docs/tab-scaffold.md +57 -0
  72. package/docs/ui-doctor.md +23 -0
  73. package/docs/workbench.md +3 -2
  74. package/doctor/component-style-checks.mjs +224 -0
  75. package/doctor/index.mjs +42 -13
  76. package/llms.txt +10 -0
  77. package/package.json +7 -2
  78. package/ux-demo/recipes/list-detail-dialog.tsx +4 -0
  79. package/ux-demo/recipes/navigation-stack.tsx +2 -2
  80. package/dist/chunk-6VQPLV3O.js.map +0 -1
  81. package/dist/chunk-AOAW55JN.js.map +0 -1
  82. package/dist/chunk-WJN3V2H5.js.map +0 -1
  83. package/dist/chunk-XIZRXI7N.js.map +0 -1
  84. package/dist/chunk-Z4EPMX25.js +0 -123
  85. package/dist/chunk-Z4EPMX25.js.map +0 -1
  86. package/dist/chunk-Z6PE5T7N.js.map +0 -1
@@ -183,7 +183,7 @@ beside a `ContentItem` value, and connect its id to the preview group's
183
183
  | One application tab — `AppTab` | A controlled app tab needs selection, close, drag, leading/trailing anatomy, compact/segmented/icon-only presentation, label truncation, safe domain metadata, or a product-specific close glyph. | Do not render it alone or use it for a small settings choice; compose in `TabBar`, or use `SegmentedControl`. Keep `closeIcon` noninteractive. | Compose in `TabBar`; let `wireTabBars` manage interaction. Put only domain `data-*` values in `rootAttributes`; use typed appearance props instead of descendant CSS. | Tab identity, order, selection, close policy, content, domain metadata values, and outer placement. | `@kerfjs/ui/app-tab` | [Tabs contract](./accessibility.md#tabs) |
184
184
  | Application tab strip — `TabBar`, `wireTabBars`, `reorderTabs` | Tabs switch page regions and may overflow, close, reorder, fill a segmented inspector strip, sit beside a trailing action, or keep that adjacent action distinct from a far-edge end action. | Do not use it for a compact local view toggle; use `SegmentedControl`. Do not use it for a long choice list; use `Select`. Prefer it over Web Awesome `wa-tab-group`, `wa-tab`, and `wa-tab-panel` for Kerf app tabs. | Call `wireTabBars` once, retain the disposer, and apply `onReorder` synchronously; `reorderTabs` is the default array helper. Combine `trailingPlacement="adjacent"` with `end` for split actions; TabBar owns the shrinking/scrolling tab geometry and keeps both actions visible. Choose typed presentation/allocation props while the parent owns outer placement. | Ordered tabs, selection, panels, routing, closing, persistence, action content, and outer placement. | `@kerfjs/ui/tab-bar` plus `@kerfjs/ui/wire-tab-bars` | [Tabs contract](./accessibility.md#tabs) |
185
185
  | Panel, dialog, or page heading — `Toolbar` + `ToolbarText` | A panel, dialog, or page needs an extra-large title, optional icon, grouped trailing actions, and optional supporting copy. | Do not add a private heading wrapper or bespoke layout. The toolbar supplies structure, not modal behavior; use an application overlay or Web Awesome `wa-dialog` for that behavior. In a narrow peripheral rail or drawer, where xlarge would truncate at the panel's minimum size, use the default `ToolbarText` size as pane identity. | Connect title/supporting-copy ids to the host, group controls, and delegate actions. **For a page or view title set `headingLevel` (usually `1`)** on `ToolbarText`; omit it for a dialog title referenced by `aria-labelledby`. | Open state, focus lifecycle, dismissal, controls, ids, copy, and heading level. | `@kerfjs/ui/toolbar` + `@kerfjs/ui/toolbar-text` | [Toolbar headings](./accessibility.md#toolbar-headings) |
186
- | Dialog or popup geometry — `DialogSurface`, `PopupSurface` | Recurring Web Awesome dialogs need standard sizes/presentations/body/footer insets, or dropdown menus need list-compatible inset ownership. | Do not use these wrappers as generic layout or to replace native dialog/dropdown behavior. Avoid consumer `::part()` overrides and do not stack a dialog body inset around list-owned child geometry. | Wrap one `wa-dialog` or `wa-dropdown`; choose the typed geometry while retaining native labeling, open state, focus, dismissal, and menu semantics. Compose a dialog body as a `List` by default, use `bodyInset="none"` when its children own list geometry, and wrap bare prose in `ListInsetText`. | State, focus lifecycle, dismissal, labels, content, and outer placement. | `@kerfjs/ui/surface-scaffold` | [Surface scaffolds](./surface-scaffold.md) |
186
+ | Dialog or popup geometry — `DialogSurface`, `PopupSurface` | Recurring Web Awesome dialogs need standard sizes/presentations/body/footer insets and typed modal viewport bounds, or dropdown menus need list-compatible inset ownership. | Do not use these wrappers as generic layout or to replace native dialog/dropdown behavior. Avoid consumer `::part()` overrides and do not stack a dialog body inset around list-owned child geometry. | Wrap one `wa-dialog` or `wa-dropdown`; choose the typed geometry while retaining native labeling, open state, focus, dismissal, and menu semantics. Compose a dialog body as a `List` by default, use `bodyInset="none"` when its children own list geometry, and wrap bare prose in `ListInsetText`. | State, focus lifecycle, dismissal, labels, content, and outer placement. | `@kerfjs/ui/surface-scaffold` | [Surface scaffolds](./surface-scaffold.md) |
187
187
  | Key/value facts — `ValueTable`, `ValueTableRow` | Read-only labels and values form a semantic definition list, optionally with a leading icon. | Do not use it for editable form fields or a row/column data grid; use native form or table semantics. | Compose typed `ValueTableRow` entries; pass `icon` when a 24px leading icon adds useful context. | Values, formatting, icon meaning, and empty/loading policy. | `@kerfjs/ui/value-table` | [Component ownership](./component-contract.md#ownership-boundaries) |
188
188
  | Indeterminate activity — `LoadingSpinner` | A Kerf surface needs compact, labeled or decorative indeterminate progress. | Do not use it for known progress; use Web Awesome `wa-progress-bar` or `wa-progress-ring`. Direct Web Awesome UI may use `wa-spinner`; do not mix spinner systems within one surface. | Pass a label when the spinner conveys status; use size for a named icon step or positive pixel size. | Loading lifecycle and adjacent status copy. | `@kerfjs/ui/loading-spinner` | [Accessibility](./accessibility.md#shared-rules) |
189
189
  | Loading placeholder — `Skeleton` + a component's `placeholder` prop | A value or a whole component is still loading and should hold its space as a subtle, unanimated block, keeping the layout stable — an inspector or detail view rendering its real chrome with per-record values absent. | Do not use it for known progress (use `LoadingSpinner`), do not animate it, and do not hand-rebuild a component's empty state — set `placeholder` on the component instead. Prefer it over `wa-skeleton`, which the pure-Kerf primitives avoid to stay Web-Awesome-free. | Set `placeholder` on a value-bearing component (`Select`, `ListHeader`, `ListItem`, `ValueTableRow`, `SegmentedControl`, `StateBanner`, `AppTab`, `ToolbarText`, `ListActionRow`) to render skeletons in its value slots with interactivity disabled; use the standalone `Skeleton` for a custom slot. | Loading lifecycle, which slots are unknown, and announcing the loading region. | `@kerfjs/ui/skeleton` | [Accessibility](./accessibility.md#shared-rules) |
package/docs/layout.md CHANGED
@@ -36,6 +36,26 @@ size) tall, and below that lets the whole pane scroll as one column, header,
36
36
  content, and footer together, so the content keeps its natural height. The
37
37
  default `fixed` always pins them.
38
38
 
39
+ ### Deep content inset
40
+
41
+ Set `deepInset` on a `Pane` to add one 8px inline gutter to its scrolling
42
+ content. A `ContentItem` or `ListItem` that normally sits 8px from the pane
43
+ edge then sits 16px from it. The default is `false`. This extra gutter adds
44
+ to any device safe-area inset, while the pane's header, footer, background,
45
+ and separators still reach their usual edges.
46
+
47
+ ```tsx
48
+ <Pane deepInset>
49
+ <ContentItem>Project Atlas</ContentItem>
50
+ </Pane>
51
+ ```
52
+
53
+ The pane leaves `--kui-layout-inline-margin` at its normal 8px value. Nested
54
+ `Row`, `List`, and `Grid` therefore keep their usual item spacing; they do
55
+ not inherit a doubled margin. `PaneConfig.deepInset` forwards through
56
+ Workbench `mainPane`, panel `pane`, CollapsiblePanel `pane`, and a
57
+ `NavStackView.pane`.
58
+
39
59
  ### Lowered work surfaces
40
60
 
41
61
  Set `appearance="sunken"` when the work area itself should use the lowered
package/docs/nav-stack.md CHANGED
@@ -15,7 +15,7 @@ import { wireNavStack } from "@kerfjs/ui/wire-nav-stack";
15
15
  In a browser bundler that honors the `browser` export condition (Vite, esbuild, and
16
16
  webpack do by default), the import above also
17
17
  loads NavStack's stylesheet and those of the components it renders internally
18
- (`Toolbar`, `ToolbarControlGroup`, `ToolbarText`, `LucideIcon`). Without that condition, import the manual stylesheets instead:
18
+ (`Toolbar`, `ToolbarControlGroup`, `ToolbarText`, `Pane`, `List`, `LucideIcon`). Without that condition, import the manual stylesheets instead:
19
19
  `@kerfjs/ui/nav-stack.css` plus those components' CSS, or `@kerfjs/ui/styles.css`.
20
20
 
21
21
  ## State lives in the app
@@ -28,9 +28,12 @@ stack as a `signal<NavStackView[]>`, `NavStack({ views })` renders it, and
28
28
  const views = signal<NavStackView[]>([
29
29
  {
30
30
  key: "inbox",
31
- title: "Inbox",
31
+ toolbar: { title: "Inbox" },
32
32
  content: <InboxView />,
33
- bottomToolbar: <InboxStatus />,
33
+ bottomToolbar: {
34
+ label: "Inbox status",
35
+ leading: <InboxStatus />,
36
+ },
34
37
  },
35
38
  ]);
36
39
 
@@ -47,35 +50,39 @@ const dispose = wireNavStack(root, {
47
50
  // push / pop by editing the signal:
48
51
  views.value = [
49
52
  ...views.value,
50
- { key: id, title: "Message", content: <MessageView id={id} /> },
53
+ { key: id, toolbar: { title: "Message" }, content: <MessageView id={id} /> },
51
54
  ];
52
55
  ```
53
56
 
54
57
  `NavStack` renders every entry stacked, the last one active and the rest kept
55
58
  mounted (so their DOM state survives) but hidden. Each entry carries a
56
- `key` (stable identity), `content`, an optional `title`, optional per-view
57
- `leading`, `center`, and trailing `toolbar` groups for the top toolbar, and an
58
- optional per-view `bottomToolbar`. The component-level
59
+ `key` (stable identity), `content`, an optional `toolbar` object with `title`,
60
+ `leading`, `center`, and `trailing` groups, and an optional structured
61
+ `bottomToolbar`. The component-level structured
59
62
  `bottomToolbar` remains a persistent fallback for views that do not provide one.
60
- Each view can also provide `header`, pinned below its top toolbar and above
61
- that view's single scroll area. A fixed heading, notice, or section selector
62
- can therefore change with the active screen without adding another scroller.
63
+ Each view can also provide `header`, `footer`, `headerList`, `footerList`, and
64
+ `pane` configuration, as a `WorkbenchStaticPanel` does. With any of these,
65
+ the view renders a `Pane`: its header and footer stay pinned around the view's
66
+ scrolling content, and slide with that view during navigation. The header and
67
+ footer list configuration forwards to their `List`s; `pane` forwards
68
+ `contentElement`, `contentLabel`, `separators`, `safeAreaEdges`,
69
+ `chromeDividers`, and `appearance` to the Pane. Without those fields, the view
70
+ keeps its simple direct scroll area. Use `pane: { appearance: "sunken" }` for a
71
+ lowered scrolling work surface.
63
72
  The back control appears automatically once the stack has more than one entry;
64
73
  `wireNavStack`'s `onBack` is where the app pops its own signal.
65
74
 
66
- Set a view's `appearance: "sunken"` to paint its scroll viewport with the
67
- shared lowered-surface color, including space after short content. When the
68
- view holds a `Pane` as its only child, set the appearance on the Pane instead
69
- if its content owns scrolling. See [Lowered work surfaces](layout.md#lowered-work-surfaces).
75
+ See [Lowered work surfaces](layout.md#lowered-work-surfaces) for nested Pane
76
+ and layout content.
70
77
 
71
78
  ## The top toolbar
72
79
 
73
80
  The top chrome is a real `Toolbar`, so it follows the same zone,
74
81
  group, and responsive rules as every other toolbar. Its leading zone holds the
75
82
  back control (a borderless `ToolbarControlGroup`, shown once the stack has
76
- depth), then the active view's `leading` groups, then the view's `title` as a
77
- `ToolbarText`. The view's `center` fills the center zone and its `toolbar`
78
- fills the trailing zone. Give each zone `ToolbarControlGroup`s or `ToolbarText`,
83
+ depth), then `toolbar.leading`, then `toolbar.title` as a `ToolbarText`.
84
+ `toolbar.center` fills the center zone and `toolbar.trailing` fills the
85
+ trailing zone. Give each zone `ToolbarControlGroup`s or `ToolbarText`,
79
86
  as in any toolbar.
80
87
 
81
88
  Configure the toolbar with `toolbarConfig` instead of styling it:
@@ -95,7 +102,12 @@ Configure the toolbar with `toolbarConfig` instead of styling it:
95
102
  />
96
103
  ```
97
104
 
98
- - `toolbarConfig` forwards the `Toolbar` configuration (`dividerSides`,
105
+ - Each view's `toolbar` can forward the `Toolbar` configuration (`dividerSides`,
106
+ `centerAlign`, `responsive`, `responsiveAt`, `safeAreaEdges`) and its `label`,
107
+ `titleSize`, and `headingLevel`. A view value overrides the corresponding
108
+ `toolbarConfig` default. Its `bottomToolbar` forwards the same `Toolbar`
109
+ configuration and has a required accessible `label`.
110
+ - `toolbarConfig` supplies stack-wide defaults (`dividerSides`,
99
111
  `centerAlign`, `responsive`, `responsiveAt`, `safeAreaEdges`) and adds
100
112
  `label` (the toolbar's accessible name), `titleSize` (the title's
101
113
  `ToolbarText` size, default `large`), and `headingLevel` (expose the title as
package/docs/recipes.md CHANGED
@@ -88,7 +88,11 @@ its label still names the modal — and the close button uses
88
88
  `data-dialog="close"`. Web Awesome keeps modal focus and Escape, and the thin
89
89
  recipe adapter restores the invoking control after the hide event.
90
90
  `DialogSurface` chooses the large modal with no body inset, because the panes
91
- own their geometry. On compact devices the dialog becomes a full-screen sheet
91
+ own their geometry. Its explicit 16px `viewportGutter` and `maxHeight="viewport"`
92
+ bound the roomy modal to the dynamic viewport; the compact fullscreen
93
+ presentation retains its existing geometry. Application render state chooses
94
+ the presentation and can likewise choose gutter/cap values for a compact modal.
95
+ On compact devices the dialog becomes a full-screen sheet
92
96
  with its own labeled header and footer actions, and `SplitView` becomes a
93
97
  `NavStack` drill-down whose back control the application pops. The
94
98
  application owns open state, selection, the pushed detail, dismissal policy,
@@ -86,15 +86,18 @@ resizable={{
86
86
 
87
87
  `compactStack` configures the compact `NavStack`: `toolbarConfig`, `backIcon`,
88
88
  `backText`, `hideToolbar`, the persistent `bottomToolbar`, and `chromeDividers`
89
- forward to the stack, and `list` / `detail` give each view its own top-toolbar groups
90
- (`leading`, `center`, trailing `toolbar`) and `bottomToolbar`:
89
+ forward to the stack, and `list` / `detail` give each view its own structured
90
+ top `toolbar` (`leading`, `center`, `trailing`) and `bottomToolbar`:
91
91
 
92
92
  ```tsx
93
93
  compactStack={{
94
94
  backText: "Threads",
95
95
  toolbarConfig: { headingLevel: 1 },
96
- list: { toolbar: <ComposeGroup /> },
97
- detail: { toolbar: <ReplyGroup />, bottomToolbar: <MessageActions /> },
96
+ list: { toolbar: { trailing: <ComposeGroup /> } },
97
+ detail: {
98
+ toolbar: { trailing: <ReplyGroup /> },
99
+ bottomToolbar: { label: "Message actions", leading: <MessageActions /> },
100
+ },
98
101
  }}
99
102
  ```
100
103
 
@@ -12,6 +12,50 @@ Wrap one `wa-dialog` with `DialogSurface`. Choose `size` (`small`, `medium`, or
12
12
  `bodyInset` / `footerInset` (`none`, `compact`, or `comfortable`). The defaults
13
13
  match Kerf's medium modal, 8px body, and 16px footer rhythm.
14
14
 
15
+ The modal size presets have preferred widths of 400px, 560px, and 800px,
16
+ respectively, before the viewport width cap. Set `preferredWidth` to a branded
17
+ `CssLength` when the desktop shell needs another width. It takes precedence over
18
+ `size`, while the viewport cap still applies. For example, 832px and 480px
19
+ desktop preferences can share the same responsive dialog without changing its
20
+ resource tracks or preview aspect sizing.
21
+
22
+ ### Modal viewport bounds
23
+
24
+ `viewportGutter` accepts a branded `CssLength` from `@kerfjs/ui/css-values`.
25
+ It sets the minimum horizontal clearance; `preferredWidth` overrides the `size`
26
+ preset when supplied. `maxHeight` accepts a branded length or `"viewport"`; explicit caps are
27
+ clamped to the dynamic viewport height minus twice the gutter (16px per edge
28
+ when the gutter is omitted). Negative gutters/caps clamp to zero. Omitting
29
+ both options preserves Web Awesome's existing width and phone height caps.
30
+ These options apply only to `presentation="modal"`; side sheets and fullscreen
31
+ surfaces keep their own geometry.
32
+
33
+ ```tsx
34
+ import '@kerfjs/ui/surface-scaffold/register';
35
+ import { remify } from '@kerfjs/ui/css-values';
36
+ import { DialogSurface } from '@kerfjs/ui/surface-scaffold';
37
+
38
+ <DialogSurface preferredWidth={remify(832)} viewportGutter={remify(8)} maxHeight="viewport" bodyInset="none">
39
+ <wa-dialog label="Close workspace">{/* Application-owned content and footer */}</wa-dialog>
40
+ </DialogSurface>;
41
+ ```
42
+
43
+ This gives a desktop modal an 832px preferred width and a phone modal an 8px edge gutter with a `calc(100dvh - 16px)` height
44
+ cap without application selectors on Kerf roots or Web Awesome parts. A cap
45
+ is not a fixed height: short content stays naturally sized, and taller content
46
+ scrolls in Web Awesome's body while its header/footer remain outside that
47
+ scroll owner. Choosing a larger cap cannot guarantee that arbitrary content
48
+ fits without scrolling; preview tracks, canvas aspect ratios and content sizing
49
+ remain application-owned.
50
+
51
+ Responsive policy is explicit application state: read `deviceClass()` during
52
+ the render to choose the gutter, cap or presentation. For example use
53
+ `viewportGutter={remify(device.value.handset ? 8 : 16)}` with `maxHeight="viewport"`.
54
+ The scaffold introduces no hidden breakpoint or automatic fullscreen switch.
55
+ An application can configure device-class breakpoints when its phone policy
56
+ differs from the default. Prefer a typed cap such as `maxHeight={remify(640)}`
57
+ when the modal should stop growing before the viewport bound.
58
+
15
59
  Treat a dialog body as a `List` by default. When its rows or sections own their
16
60
  standard list geometry, set `bodyInset="none"` so the dialog does not add a
17
61
  second inset. Bare prose is still a list child: wrap it in `ListInsetText` so its
@@ -26,3 +70,5 @@ padding for a child that already owns row insets, `compact` uses 4px, and
26
70
  preserve `data-morph-skip-children` when its upgraded light-DOM items must retain
27
71
  identity across Kerf rerenders. Import `@kerfjs/ui/popup-menu/register` once for
28
72
  the focus handoff fix when wrapping a raw `wa-dropdown`.
73
+
74
+ 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. Geometry-only markup stays CSS-free and does not register Web Awesome implicitly.
@@ -59,6 +59,63 @@ truncate horizontally with an ellipsis when a destination name exceeds its
59
59
  share of the bar. On larger device classes, promote the tab set to a
60
60
  `Workbench` rail or a persistent sidebar instead of a bottom bar.
61
61
 
62
+ Set `deepInset: true` on a plain tab scene to add an 8px outer inline gutter
63
+ to its scrolling content, giving ordinary content items a 16px edge inset.
64
+ The default is `false`. A scene whose only child is a `Pane` or layout lets
65
+ that child own its content gutter: for a nested `NavStack`, set
66
+ `pane: { deepInset: true }` on its views instead. The scaffold bar and a
67
+ stack's top toolbar remain full width.
68
+
69
+ ## A NavStack with a top toolbar and Pane
70
+
71
+ Put the `NavStack` directly in a tab's `content`. Give each view a structured
72
+ `toolbar` and `pane` configuration. Leave `bottomToolbar` off both the views
73
+ and the stack: the scaffold's tab bar is the only bottom chrome. The view's
74
+ Pane owns scrolling, while the stack keeps its toolbar above it and the
75
+ scaffold keeps the tab bar below it.
76
+
77
+ ```tsx
78
+ import { List } from "@kerfjs/ui/list";
79
+ import { ListItem } from "@kerfjs/ui/list-item";
80
+ import { NavStack, type NavStackView } from "@kerfjs/ui/nav-stack";
81
+
82
+ const projectViews: NavStackView[] = [
83
+ {
84
+ key: "projects",
85
+ toolbar: { title: "Projects" },
86
+ pane: { appearance: "sunken" },
87
+ content: <List><ListItem label="Project Atlas" /></List>,
88
+ },
89
+ ];
90
+
91
+ <TabScaffold
92
+ id="app"
93
+ label="Sections"
94
+ active={active.value}
95
+ tabs={[
96
+ {
97
+ id: "projects",
98
+ label: "Projects",
99
+ content: (
100
+ <NavStack
101
+ id="projects-stack"
102
+ label="Projects navigation"
103
+ views={projectViews}
104
+ />
105
+ ),
106
+ },
107
+ { id: "search", label: "Search", content: <SearchScene /> },
108
+ ]}
109
+ />;
110
+ ```
111
+
112
+ The app owns each stack's view array and the scaffold's active tab. Call
113
+ `wireTabScaffold` for tab selection and `wireNavStack` for back navigation
114
+ and transitions, each on its own component root. Push a detail by appending
115
+ a view with its own `toolbar` and `pane`; switching tabs keeps that view
116
+ mounted. The catalog's **NavStack inside a tab** example demonstrates the
117
+ push, tab switch, and return flow.
118
+
62
119
  Set a tab's `appearance: "sunken"` when its scene owns scrolling and should
63
120
  paint the lowered surface behind short or long content. For a scene whose
64
121
  only child is a `Pane` or `NavStack`, set appearance on that child's scroll
package/docs/ui-doctor.md CHANGED
@@ -58,6 +58,29 @@ without that marker remain app-owned.
58
58
  The analyzer resolves a cataloged application component imported through an
59
59
  exact or wildcard TypeScript `paths` alias from the nearest consumer tsconfig,
60
60
  so the same root hook-class check applies in those files.
61
+
62
+ ## Component style naming
63
+
64
+ The analyzer stage also checks colocated JSX/TSX components and stylesheets.
65
+ When a stylesheet has a same-basename component module, or exactly one
66
+ component module imports it, Doctor checks that the stylesheet filename is
67
+ the component's kebab-case name (`KUI-D031`). A component that renders its own
68
+ HTML element must put that kebab-case class on rendered markup (`KUI-D030`).
69
+ For example, `WorkspaceHeader` pairs with `workspace-header.css` and renders
70
+ `.workspace-header`. Thin components that only compose other components need
71
+ no root class. Shared stylesheets declared in `ownershipGroups` are exempt
72
+ from the naming check.
73
+
74
+ Doctor reports `KUI-D032` when a paired stylesheet selects another local
75
+ component's kebab-case class or its BEM descendants or modifiers. Move that
76
+ rule to the owning component's stylesheet or use the component's public
77
+ configuration. This check uses source declarations and CSS selectors; it does
78
+ not execute component code. In changed mode, it reads other component sources
79
+ to resolve ownership but reports only pairs containing a selected path. For
80
+ broader cross-component ownership, including
81
+ cataloged package components and hook classes, enable `ownership: "component"`
82
+ with `implicitComponentOwnership: true` as described above.
83
+
61
84
  Set `"ownership": "component"` in `.kerf-ui-doctor.json` to enforce the same
62
85
  boundary between a consumer package's own cataloged components. The default is
63
86
  `"package"`. Selection-only entries participate too. A component owns its
package/docs/workbench.md CHANGED
@@ -167,8 +167,9 @@ into `.kui-resizable-region__content`.
167
167
 
168
168
  A panel with `navStack` hosts a controlled `NavStack` in place of the static
169
169
  `content` and `Pane` slots. Give it a `toolbar` with a `toggle`; each
170
- `navStack.views` entry then supplies its own title, leading/center/trailing
171
- groups, fixed `header`, and scrolling `content`. The active view's toolbar is
170
+ `navStack.views` entry then supplies its own structured `toolbar` with a title
171
+ and leading/center/trailing groups, optional `header` / `footer` and `pane`
172
+ configuration, and scrolling `content`. The active view's toolbar is
172
173
  the panel's only toolbar row, with the standard toggle always last. On
173
174
  collapse, marked groups from the active view and the toggle relocate to the
174
175
  work-area toolbar; the view stack stays mounted. The app owns the view array
@@ -0,0 +1,224 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, relative, resolve } from 'node:path';
3
+
4
+ import postcss from 'postcss';
5
+ import ts from 'typescript';
6
+
7
+ import { isUiTraversalExcluded } from '../traversal-exclusions.mjs';
8
+
9
+ const scriptExtensions = new Set(['.tsx', '.jsx']);
10
+ const kebab = (name) =>
11
+ name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
12
+ const componentName = (name) => /^[A-Z][A-Za-z0-9]*$/.test(name);
13
+ const classNames = (value) =>
14
+ [...value.matchAll(/(?:^|\s)([a-z][a-z0-9_-]*)(?=\s|$)/g)].map(
15
+ (match) => match[1],
16
+ );
17
+ const block = (name) => name.split(/__|--/)[0];
18
+
19
+ function componentFacts(file, source) {
20
+ const syntax = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.JSX;
21
+ const ast = ts.createSourceFile(
22
+ file,
23
+ source,
24
+ ts.ScriptTarget.Latest,
25
+ true,
26
+ syntax,
27
+ );
28
+ const components = new Map();
29
+ const visit = (node, owner) => {
30
+ if (
31
+ ts.isFunctionDeclaration(node) &&
32
+ node.name &&
33
+ componentName(node.name.text)
34
+ )
35
+ owner = node.name.text;
36
+ if (
37
+ ts.isVariableDeclaration(node) &&
38
+ ts.isIdentifier(node.name) &&
39
+ componentName(node.name.text)
40
+ )
41
+ owner = node.name.text;
42
+ if (
43
+ owner &&
44
+ (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node))
45
+ ) {
46
+ if (ts.isIdentifier(node.tagName) && /^[a-z]/.test(node.tagName.text)) {
47
+ if (components.has(owner)) components.get(owner).intrinsic = true;
48
+ for (const attribute of node.attributes.properties) {
49
+ if (
50
+ !ts.isJsxAttribute(attribute) ||
51
+ !['class', 'className'].includes(attribute.name.text)
52
+ )
53
+ continue;
54
+ const value = attribute.initializer;
55
+ const literal =
56
+ value && ts.isStringLiteral(value)
57
+ ? value.text
58
+ : value &&
59
+ ts.isJsxExpression(value) &&
60
+ value.expression &&
61
+ (ts.isStringLiteral(value.expression) ||
62
+ ts.isNoSubstitutionTemplateLiteral(value.expression))
63
+ ? value.expression.text
64
+ : undefined;
65
+ if (literal)
66
+ for (const name of classNames(literal))
67
+ components.get(owner)?.add(name);
68
+ }
69
+ }
70
+ }
71
+ ts.forEachChild(node, (child) => visit(child, owner));
72
+ };
73
+ const declarations = (node) => {
74
+ if (
75
+ ts.isFunctionDeclaration(node) &&
76
+ node.name &&
77
+ componentName(node.name.text)
78
+ )
79
+ components.set(node.name.text, new Set());
80
+ if (
81
+ ts.isVariableDeclaration(node) &&
82
+ ts.isIdentifier(node.name) &&
83
+ componentName(node.name.text)
84
+ )
85
+ components.set(node.name.text, new Set());
86
+ ts.forEachChild(node, declarations);
87
+ };
88
+ declarations(ast);
89
+ visit(ast);
90
+ return components;
91
+ }
92
+
93
+ function diagnostic(id, file, line, message, action) {
94
+ return {
95
+ id,
96
+ severity: 'error',
97
+ stage: 'analyzer',
98
+ message,
99
+ location: { file, line, column: 1 },
100
+ action,
101
+ documentation: '@kerfjs/ui/docs/ui-doctor.md#component-style-naming',
102
+ };
103
+ }
104
+
105
+ export async function checkComponentStyles(
106
+ packageRoot,
107
+ inputs,
108
+ ownershipGroups = [],
109
+ selectedPaths,
110
+ ) {
111
+ const scripts = new Map();
112
+ const styles = new Map();
113
+ const importedBy = new Map();
114
+ for (const file of inputs) {
115
+ if (isUiTraversalExcluded(packageRoot, file)) continue;
116
+ const extension = extname(file);
117
+ if (scriptExtensions.has(extension)) {
118
+ const source = await readFile(file, 'utf8');
119
+ scripts.set(file, componentFacts(file, source));
120
+ for (const match of source.matchAll(
121
+ /\bimport\s*(?:['"]|[^;]*?\bfrom\s*['"])(\.[^'"]+\.css)['"]/g,
122
+ )) {
123
+ const style = resolve(dirname(file), match[1]);
124
+ const importers = importedBy.get(style) ?? [];
125
+ importers.push(file);
126
+ importedBy.set(style, importers);
127
+ }
128
+ } else if (extension === '.css')
129
+ styles.set(file, await readFile(file, 'utf8'));
130
+ }
131
+ const owners = new Map();
132
+ for (const [file, components] of scripts)
133
+ for (const [name, classes] of components)
134
+ if (classes.has(kebab(name)))
135
+ owners.set(kebab(name), { file, name, classes });
136
+ const diagnostics = [];
137
+ for (const [file, source] of styles) {
138
+ if (
139
+ ownershipGroups.some((group) =>
140
+ group.styleSources?.some(
141
+ (style) => resolve(packageRoot, style) === file,
142
+ ),
143
+ )
144
+ )
145
+ continue;
146
+ const stem = basename(file, '.css');
147
+ const pair =
148
+ [...scriptExtensions]
149
+ .map((extension) => resolve(dirname(file), `${stem}${extension}`))
150
+ .find((candidate) => scripts.has(candidate)) ??
151
+ (importedBy.get(file)?.length === 1
152
+ ? importedBy.get(file)[0]
153
+ : undefined);
154
+ if (!pair) continue;
155
+ const path = relative(packageRoot, file).replaceAll('\\', '/');
156
+ if (
157
+ selectedPaths?.length &&
158
+ !selectedPaths.includes(path) &&
159
+ !selectedPaths.includes(relative(packageRoot, pair).replaceAll('\\', '/'))
160
+ )
161
+ continue;
162
+ const components = scripts.get(pair);
163
+ const matching = [...components].find(([name]) => kebab(name) === stem);
164
+ if (!matching) {
165
+ const names = [...components.keys()];
166
+ if (names.length)
167
+ diagnostics.push(
168
+ diagnostic(
169
+ 'KUI-D031',
170
+ path,
171
+ 1,
172
+ `${basename(file)} is paired with ${basename(pair)}, but its component ${names[0]} calls for ${kebab(names[0])}.css.`,
173
+ `Rename the stylesheet and its import to ${kebab(names[0])}.css, or rename the component and source pair.`,
174
+ ),
175
+ );
176
+ continue;
177
+ }
178
+ const [name, renderedClasses] = matching;
179
+ if (renderedClasses.intrinsic && !renderedClasses.has(stem))
180
+ diagnostics.push(
181
+ diagnostic(
182
+ 'KUI-D030',
183
+ relative(packageRoot, pair).replaceAll('\\', '/'),
184
+ 1,
185
+ `${name} must render its own .${stem} root class.`,
186
+ `Add class="${stem}" to an element rendered by ${name}, then select that class in ${basename(file)}.`,
187
+ ),
188
+ );
189
+ let sheet;
190
+ try {
191
+ sheet = postcss.parse(source, { from: file });
192
+ } catch {
193
+ continue;
194
+ }
195
+ sheet.walkRules((rule) => {
196
+ if (
197
+ rule.parent?.type === 'atrule' &&
198
+ /keyframes$/i.test(rule.parent.name)
199
+ )
200
+ return;
201
+ const selected = [
202
+ ...new Set(
203
+ [...rule.selector.matchAll(/\.([a-z][a-z0-9_-]*)/gi)].map(
204
+ (match) => match[1],
205
+ ),
206
+ ),
207
+ ];
208
+ for (const className of selected) {
209
+ const foreign = owners.get(block(className));
210
+ if (!foreign || foreign.name === name) continue;
211
+ diagnostics.push(
212
+ diagnostic(
213
+ 'KUI-D032',
214
+ path,
215
+ rule.source?.start?.line ?? 1,
216
+ `${basename(file)} selects .${className}, which belongs to ${foreign.name} in ${relative(packageRoot, foreign.file).replaceAll('\\', '/')}.`,
217
+ `Move this rule to ${kebab(foreign.name)}.css or configure ${foreign.name} through its public API.`,
218
+ ),
219
+ );
220
+ }
221
+ });
222
+ }
223
+ return diagnostics;
224
+ }