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

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 (73) hide show
  1. package/README.md +8 -6
  2. package/ai/application-ui-diagnostic-ids-v1.json +1 -0
  3. package/ai/compile-time-contracts-v1.json +3 -3
  4. package/ai/component-catalog.json +31 -22
  5. package/ai/component-composition-rules.json +6 -14
  6. package/ai/component-composition.json +21 -23
  7. package/ai/components/README.md +1 -1
  8. package/ai/components/document-baseline.md +1 -1
  9. package/ai/components/nav-stack.md +3 -3
  10. package/ai/components/pane.md +3 -1
  11. package/ai/components/{tab-scaffold.md → tab-navigator.md} +14 -14
  12. package/ai/components/token-search-field.md +1 -0
  13. package/ai/components/toolbar.md +4 -8
  14. package/ai/public-api-signatures-v1.md +20 -20
  15. package/ai/skill.md +7 -6
  16. package/ai/webawesome-jsx-signatures-v1.md +1 -1
  17. package/dist/browser/tab-navigator.js +4 -0
  18. package/dist/{chunk-QYXKMLHO.js → chunk-3CXRQAYT.js} +2 -2
  19. package/dist/chunk-3CXRQAYT.js.map +1 -0
  20. package/dist/{chunk-G4VKQP7B.js → chunk-3SXWV2IR.js} +5 -1
  21. package/dist/chunk-3SXWV2IR.js.map +1 -0
  22. package/dist/{chunk-VLPE6OPN.js → chunk-62VMNBV2.js} +6 -2
  23. package/dist/chunk-62VMNBV2.js.map +1 -0
  24. package/dist/index.js +3 -3
  25. package/dist/styles/components/actions/toolbar/toolbar.css +7 -14
  26. package/dist/styles/components/actions/toolbar-control-group/toolbar-control-group.css +3 -0
  27. package/dist/styles/components/actions/toolbar-text/toolbar-text.css +1 -0
  28. package/dist/styles/components/forms/token-search-field/token-search-field.css +12 -1
  29. package/dist/styles/components/layout/pane/pane.css +13 -3
  30. package/dist/styles/components/navigation/tab-scaffold/tab-scaffold.css +1 -1
  31. package/dist/styles/pane.css +13 -3
  32. package/dist/styles/{tab-scaffold.css → tab-navigator.css} +1 -1
  33. package/dist/styles/token-search-field.css +12 -1
  34. package/dist/styles/toolbar-control-group.css +3 -0
  35. package/dist/styles/toolbar-text.css +1 -0
  36. package/dist/styles/toolbar.css +7 -14
  37. package/dist/{tab-scaffold.d.ts → tab-navigator.d.ts} +10 -10
  38. package/dist/{tab-scaffold.js → tab-navigator.js} +3 -3
  39. package/dist/tab-navigator.js.map +1 -0
  40. package/dist/token-search-field.js +1 -1
  41. package/dist/wire-catalog.js +1 -1
  42. package/dist/wire-scroll-dividers.d.ts +3 -3
  43. package/dist/wire-scroll-dividers.js +1 -1
  44. package/dist/wire-tab-navigator.d.ts +11 -0
  45. package/dist/{wire-tab-scaffold.js → wire-tab-navigator.js} +3 -3
  46. package/dist/wire-tab-navigator.js.map +1 -0
  47. package/dist/wire-token-search-fields.js +1 -1
  48. package/docs/app-layouts.md +44 -11
  49. package/docs/component-contract.md +1 -1
  50. package/docs/component-selection.md +16 -14
  51. package/docs/design/templates/token-search-field/empty-dark.svg +1 -1
  52. package/docs/design/templates/token-search-field/empty.svg +1 -1
  53. package/docs/design/templates/token-search-field-dark.svg +1 -1
  54. package/docs/design/templates/token-search-field.svg +1 -1
  55. package/docs/design/templates.md +1 -1
  56. package/docs/examples/application-ui-profile.json +4 -4
  57. package/docs/layout.md +11 -8
  58. package/docs/recipes.md +1 -1
  59. package/docs/{tab-scaffold.md → tab-navigator.md} +32 -17
  60. package/docs/type-contracts.md +3 -3
  61. package/docs/ui-analyzer.md +5 -0
  62. package/docs/ui-doctor.md +18 -8
  63. package/doctor/component-style-checks.mjs +253 -20
  64. package/doctor/index.mjs +1 -0
  65. package/llms.txt +10 -8
  66. package/package.json +10 -10
  67. package/dist/browser/tab-scaffold.js +0 -4
  68. package/dist/chunk-G4VKQP7B.js.map +0 -1
  69. package/dist/chunk-QYXKMLHO.js.map +0 -1
  70. package/dist/chunk-VLPE6OPN.js.map +0 -1
  71. package/dist/tab-scaffold.js.map +0 -1
  72. package/dist/wire-tab-scaffold.d.ts +0 -11
  73. package/dist/wire-tab-scaffold.js.map +0 -1
@@ -1,32 +1,47 @@
1
- # Tab scaffold
1
+ # Tab navigator
2
2
 
3
- `@kerfjs/ui/tab-scaffold` is a mobile-first, iOS-like bottom tab bar that switches
3
+ `@kerfjs/ui/tab-navigator` is a mobile-first, iOS-like bottom tab bar that switches
4
4
  between major app sections, where **each tab keeps its own content mounted** —
5
5
  usually a `NavStack`, so each tab's stack and scroll survive a switch. One of the
6
6
  opt-in app layouts (see [`../../docs/23-app-layouts.md`](../../docs/23-app-layouts.md)).
7
7
  It is distinct from `TabBar` (document-oriented, reorderable strips).
8
8
 
9
+ ## Migration from TabScaffold
10
+
11
+ The deprecated `TabScaffold` API was removed during the 5.0 beta series. Replace names and import
12
+ paths when updating an app:
13
+
14
+ | Previous | Current |
15
+ | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
16
+ | `TabScaffold`, `TabScaffoldTab`, `TabScaffoldProps` from `@kerfjs/ui/tab-scaffold` | `TabNavigator`, `TabNavigatorTab`, `TabNavigatorProps` from `@kerfjs/ui/tab-navigator` |
17
+ | `wireTabScaffold`, `WireTabScaffoldOptions` from `@kerfjs/ui/wire-tab-scaffold` | `wireTabNavigator`, `WireTabNavigatorOptions` from `@kerfjs/ui/wire-tab-navigator` |
18
+ | `@kerfjs/ui/tab-scaffold.css` | `@kerfjs/ui/tab-navigator.css` |
19
+
20
+ Props, rendered DOM classes, data attributes, and wiring behavior are unchanged.
21
+ The old JS and CSS subpaths are no longer exported. Import the new CSS path
22
+ when your build does not use the package's `browser` condition.
23
+
9
24
  ```ts
10
- import { TabScaffold } from "@kerfjs/ui/tab-scaffold";
11
- import { wireTabScaffold } from "@kerfjs/ui/wire-tab-scaffold";
25
+ import { TabNavigator } from "@kerfjs/ui/tab-navigator";
26
+ import { wireTabNavigator } from "@kerfjs/ui/wire-tab-navigator";
12
27
  ```
13
28
 
14
29
  In a browser bundler that honors the `browser` export condition (Vite, esbuild, and
15
30
  webpack do by default), the import above also
16
- loads TabScaffold's stylesheet and those of the components it renders internally
31
+ loads TabNavigator's stylesheet and those of the components it renders internally
17
32
  (the tab `Badge`). Without that condition, import the manual stylesheets instead:
18
- `@kerfjs/ui/tab-scaffold.css` plus those components' CSS, or `@kerfjs/ui/styles.css`.
33
+ `@kerfjs/ui/tab-navigator.css` plus those components' CSS, or `@kerfjs/ui/styles.css`.
19
34
 
20
35
  ## Controlled selection
21
36
 
22
- The app owns the active tab (a signal); `TabScaffold` renders every tab's scene
23
- (only the active one visible) plus the bottom bar, and `wireTabScaffold` reports
37
+ The app owns the active tab (a signal); `TabNavigator` renders every tab's scene
38
+ (only the active one visible) plus the bottom bar, and `wireTabNavigator` reports
24
39
  clicks.
25
40
 
26
41
  ```tsx
27
42
  const active = signal("home");
28
43
 
29
- <TabScaffold
44
+ <TabNavigator
30
45
  id="app"
31
46
  label="Sections"
32
47
  active={active.value}
@@ -42,14 +57,14 @@ const active = signal("home");
42
57
  />;
43
58
 
44
59
  // once, after first render:
45
- const dispose = wireTabScaffold(root, {
60
+ const dispose = wireTabNavigator(root, {
46
61
  onSelect: (id) => {
47
62
  active.value = id;
48
63
  },
49
64
  });
50
65
  ```
51
66
 
52
- Each `TabScaffoldTab` has an `id`, `label`, optional `icon`, optional `badge`
67
+ Each `TabNavigatorTab` has an `id`, `label`, optional `icon`, optional `badge`
53
68
  and `badgeLabel` (see [Tab badges](#tab-badges)), and `content`. The
54
69
  bottom bar paints through the home-indicator safe area and pads for it and for
55
70
  the side insets, while each scene pads for the top and side insets (a scene
@@ -63,16 +78,16 @@ Set `deepInset: true` on a plain tab scene to add an 8px outer inline gutter
63
78
  to its scrolling content, giving ordinary content items a 16px edge inset.
64
79
  The default is `false`. A scene whose only child is a `Pane` or layout lets
65
80
  that child own its content gutter: for a nested `NavStack`, set
66
- `pane: { deepInset: true }` on its views instead. The scaffold bar and a
81
+ `pane: { deepInset: true }` on its views instead. The navigator bar and a
67
82
  stack's top toolbar remain full width.
68
83
 
69
84
  ## A NavStack with a top toolbar and Pane
70
85
 
71
86
  Put the `NavStack` directly in a tab's `content`. Give each view a structured
72
87
  `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
88
+ and the stack: the navigator's tab bar is the only bottom chrome. The view's
74
89
  Pane owns scrolling, while the stack keeps its toolbar above it and the
75
- scaffold keeps the tab bar below it.
90
+ navigator keeps the tab bar below it.
76
91
 
77
92
  ```tsx
78
93
  import { List } from "@kerfjs/ui/list";
@@ -88,7 +103,7 @@ const projectViews: NavStackView[] = [
88
103
  },
89
104
  ];
90
105
 
91
- <TabScaffold
106
+ <TabNavigator
92
107
  id="app"
93
108
  label="Sections"
94
109
  active={active.value}
@@ -109,8 +124,8 @@ const projectViews: NavStackView[] = [
109
124
  />;
110
125
  ```
111
126
 
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
127
+ The app owns each stack's view array and the navigator's active tab. Call
128
+ `wireTabNavigator` for tab selection and `wireNavStack` for back navigation
114
129
  and transitions, each on its own component root. Push a detail by appending
115
130
  a view with its own `toolbar` and `pane`; switching tabs keeps that view
116
131
  mounted. The catalog's **NavStack inside a tab** example demonstrates the
@@ -20,7 +20,7 @@ public-signature artifact.
20
20
  | `KUI-T005` | `expanded`, `expandAction`, and `expandLabel` exist only on `TokenSearchField({ collapsible: true })`. |
21
21
  | `KUI-T006` | `TokenSearchEditorAttributes` accepts application `data-*` metadata but rejects component-owned identity, morph, count, and placeholder attributes. |
22
22
  | `KUI-T007` | `ToolbarText.maxLines` requires `wrap: true`; it is never silently ignored in typed code. |
23
- | `KUI-T008` | A literal `TabScaffold.active` id belongs to its literal `tabs`. |
23
+ | `KUI-T008` | A literal `TabNavigator.active` id belongs to its literal `tabs`. |
24
24
  | `KUI-T009` | Adjacent-token keyboard removal requires `onRemoveToken`; disabling removal rejects the now-meaningless callback. |
25
25
  | `KUI-T010` | Finite public variants have named exported union types, including divider sides, tab activation, banner urgency, and all AppTab, TabBar, ToolbarControlGroup, and Select presentation axes; the convenience root barrel re-exports them. |
26
26
  | `KUI-T011` | Semantic component zones use the recursive `KerfUiContent` type: `SafeHtml`, runtime-empty booleans/nullish values, and readonly nested arrays are valid; arbitrary strings, numbers, and signals are rejected. Explicit text positions such as `ListInsetText` retain their text exception. |
@@ -51,7 +51,7 @@ When choices or ids arrive at runtime and the state signal is correctly typed as
51
51
  ```tsx
52
52
  <Select<string> value={loadedValue.value} choices={loadedChoices.value} ... />
53
53
  <SegmentedControl<string> value={mode.value} choices={loadedModes.value} ... />
54
- <TabScaffold<string> active={active.value} tabs={loadedTabs.value} ... />
54
+ <TabNavigator<string> active={active.value} tabs={loadedTabs.value} ... />
55
55
  ```
56
56
 
57
57
  The widening is an honest statement that TypeScript cannot know the runtime
@@ -69,7 +69,7 @@ ordinary source/type gates:
69
69
  `FloatingToolbar`, `Pane`, `ResizableRegion`, `SegmentedControl`, `StateBanner`,
70
70
  `SunkenPanel`, `ToolbarText`, `CollapsiblePanel`, `Catalog`, and the wire helpers;
71
71
  - required controlled-state callbacks on `wireResizableRegions`, `wireTabBars`,
72
- and `wireTabScaffold`, and the required app-owned size signals on
72
+ and `wireTabNavigator`, and the required app-owned size signals on
73
73
  `wireWorkbench`;
74
74
  - mutually exclusive count/badge metadata on `ListHeader`; and
75
75
  - protected application metadata boundaries on `AppTab`, `ListItem`,
@@ -289,6 +289,11 @@ the id stays in `ai/application-ui-diagnostic-ids-v1.json` (listed under
289
289
  `retired`) so a profile exception that already names it still loads. Remove
290
290
  such exceptions at your convenience.
291
291
 
292
+ `KUI-C101` was likewise retired when Toolbar zones were aligned with their
293
+ `KerfUiContent` props. Direct content is valid; `ToolbarControlGroup` remains a
294
+ presentation choice for shared control chrome. The id stays in the registry so
295
+ existing profile exceptions still load, but new profiles should not use it.
296
+
292
297
  ## Loud fill / on-loud pairing (`KUI-L018`)
293
298
 
294
299
  The shipped Web Awesome theme pairs every `--wa-color-{tone}-fill-loud` with a
package/docs/ui-doctor.md CHANGED
@@ -64,15 +64,25 @@ so the same root hook-class check applies in those files.
64
64
  The analyzer stage also checks colocated JSX/TSX components and stylesheets.
65
65
  When a stylesheet has a same-basename component module, or exactly one
66
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`).
67
+ the component's kebab-case name (`KUI-D031`). A component whose returned root
68
+ is its own HTML element must put that kebab-case class on rendered markup
69
+ (`KUI-D030`). Static base tokens in template, concatenated, and conditional
70
+ class expressions count only when every path contains the token.
71
+ An opaque class expression stays unclassified instead of producing a missing
72
+ root-class error.
69
73
  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
74
+ `.workspace-header`. A local composition catalog can explicitly declare a
75
+ different `boundaries.rootClass` or `publicClasses` and list its stylesheet
76
+ under the selection entry's `styleSources`; Doctor honors that declared alias
77
+ for both checks. The catalog stage remains responsible for validating the
78
+ metadata. Thin components that return another component need no root class,
79
+ even when they render nested HTML as children. Helpers declared in the same
80
+ module share that module's CSS ownership; a module exporting several visual
81
+ components can use a shared stylesheet name. Shared stylesheets declared in
82
+ `ownershipGroups` are exempt from the naming check.
83
+
84
+ Doctor reports `KUI-D032` when a paired stylesheet selects another module's
85
+ local component class or its BEM descendants or modifiers. Move that
76
86
  rule to the owning component's stylesheet or use the component's public
77
87
  configuration. This check uses source declarations and CSS selectors; it does
78
88
  not execute component code. In changed mode, it reads other component sources
@@ -4,17 +4,197 @@ import { basename, dirname, extname, relative, resolve } from 'node:path';
4
4
  import postcss from 'postcss';
5
5
  import ts from 'typescript';
6
6
 
7
+ import { loadApplicationUiProfile } from '../ai/application-ui-profile.mjs';
7
8
  import { isUiTraversalExcluded } from '../traversal-exclusions.mjs';
8
9
 
9
10
  const scriptExtensions = new Set(['.tsx', '.jsx']);
10
11
  const kebab = (name) =>
11
- name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
12
+ name
13
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
14
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
15
+ .toLowerCase();
12
16
  const componentName = (name) => /^[A-Z][A-Za-z0-9]*$/.test(name);
13
17
  const classNames = (value) =>
14
18
  [...value.matchAll(/(?:^|\s)([a-z][a-z0-9_-]*)(?=\s|$)/g)].map(
15
19
  (match) => match[1],
16
20
  );
17
21
  const block = (name) => name.split(/__|--/)[0];
22
+ const unknown = '\u0000';
23
+
24
+ function combinations(left, right) {
25
+ if (left.length * right.length > 32) return [unknown];
26
+ return left.flatMap((a) => right.map((b) => a + b));
27
+ }
28
+
29
+ function staticPatterns(node, bindings = new Map(), seen = new Set()) {
30
+ if (!node) return [unknown];
31
+ if (ts.isIdentifier(node) && bindings.has(node.text) && !seen.has(node.text))
32
+ return staticPatterns(
33
+ bindings.get(node.text),
34
+ bindings,
35
+ new Set([...seen, node.text]),
36
+ );
37
+ if (
38
+ ts.isCallExpression(node) &&
39
+ ts.isPropertyAccessExpression(node.expression) &&
40
+ node.expression.name.text === 'join'
41
+ ) {
42
+ const target = node.expression.expression;
43
+ const array = ts.isIdentifier(target) ? bindings.get(target.text) : target;
44
+ if (array && ts.isArrayLiteralExpression(array)) {
45
+ const separator =
46
+ node.arguments[0] && ts.isStringLiteral(node.arguments[0])
47
+ ? node.arguments[0].text
48
+ : ',';
49
+ let patterns = [''];
50
+ for (const [index, element] of array.elements.entries())
51
+ patterns = combinations(
52
+ patterns,
53
+ staticPatterns(element, bindings, seen).map(
54
+ (part) => `${index ? separator : ''}${part}`,
55
+ ),
56
+ );
57
+ return patterns;
58
+ }
59
+ }
60
+ if (
61
+ ts.isParenthesizedExpression(node) ||
62
+ ts.isAsExpression(node) ||
63
+ ts.isTypeAssertionExpression(node)
64
+ )
65
+ return staticPatterns(node.expression, bindings, seen);
66
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
67
+ return [node.text];
68
+ if (ts.isConditionalExpression(node))
69
+ return [
70
+ ...new Set([
71
+ ...staticPatterns(node.whenTrue, bindings, seen),
72
+ ...staticPatterns(node.whenFalse, bindings, seen),
73
+ ]),
74
+ ];
75
+ if (ts.isTemplateExpression(node)) {
76
+ let patterns = [node.head.text];
77
+ for (const span of node.templateSpans)
78
+ patterns = combinations(
79
+ combinations(patterns, staticPatterns(span.expression, bindings, seen)),
80
+ [span.literal.text],
81
+ );
82
+ return patterns;
83
+ }
84
+ if (
85
+ ts.isBinaryExpression(node) &&
86
+ node.operatorToken.kind === ts.SyntaxKind.PlusToken
87
+ )
88
+ return combinations(
89
+ staticPatterns(node.left, bindings, seen),
90
+ staticPatterns(node.right, bindings, seen),
91
+ );
92
+ return [unknown];
93
+ }
94
+
95
+ function guaranteedClasses(node, bindings) {
96
+ if (!node) return new Set();
97
+ if (
98
+ ts.isParenthesizedExpression(node) ||
99
+ ts.isAsExpression(node) ||
100
+ ts.isTypeAssertionExpression(node)
101
+ )
102
+ return guaranteedClasses(node.expression, bindings);
103
+ const patterns = staticPatterns(node, bindings);
104
+ const classes = new Set(classNames(patterns[0]));
105
+ for (const pattern of patterns.slice(1)) {
106
+ const other = new Set(classNames(pattern));
107
+ for (const name of classes) if (!other.has(name)) classes.delete(name);
108
+ }
109
+ return classes;
110
+ }
111
+
112
+ function returnedIntrinsic(node) {
113
+ while (node && ts.isParenthesizedExpression(node)) node = node.expression;
114
+ if (node && ts.isConditionalExpression(node))
115
+ return (
116
+ returnedIntrinsic(node.whenTrue) && returnedIntrinsic(node.whenFalse)
117
+ );
118
+ const tag =
119
+ node && ts.isJsxElement(node)
120
+ ? node.openingElement.tagName
121
+ : node && ts.isJsxSelfClosingElement(node)
122
+ ? node.tagName
123
+ : undefined;
124
+ return Boolean(tag && ts.isIdentifier(tag) && /^[a-z]/.test(tag.text));
125
+ }
126
+
127
+ function belongsToComponentReturn(node, owner) {
128
+ let scope = node.parent;
129
+ while (scope && !ts.isFunctionLike(scope)) scope = scope.parent;
130
+ if (!scope) return false;
131
+ if (ts.isFunctionDeclaration(scope)) return scope.name?.text === owner;
132
+ return (
133
+ ts.isArrowFunction(scope) &&
134
+ ts.isVariableDeclaration(scope.parent) &&
135
+ ts.isIdentifier(scope.parent.name) &&
136
+ scope.parent.name.text === owner
137
+ );
138
+ }
139
+
140
+ async function catalogAliases(workspaceRoot, packageRoot) {
141
+ const aliases = new Map();
142
+ const profile = await loadApplicationUiProfile({
143
+ workspaceRoot,
144
+ startDirectory: packageRoot,
145
+ knownRules: [],
146
+ packageProfile: resolve(
147
+ import.meta.dirname,
148
+ '../ai/application-ui-profile.defaults.json',
149
+ ),
150
+ });
151
+ for (const catalog of profile.profile?.catalogs ?? []) {
152
+ const owner = profile.provenance?.[`$catalogs.${catalog.package}`];
153
+ if (!owner || !catalog.selection?.path) continue;
154
+ try {
155
+ const selection = JSON.parse(
156
+ await readFile(resolve(dirname(owner), catalog.selection.path), 'utf8'),
157
+ );
158
+ const composition = catalog.composition?.path
159
+ ? JSON.parse(
160
+ await readFile(
161
+ resolve(dirname(owner), catalog.composition.path),
162
+ 'utf8',
163
+ ),
164
+ )
165
+ : { entries: [] };
166
+ const composed = new Map(
167
+ (composition.entries ?? []).map((entry) => [entry.key, entry]),
168
+ );
169
+ for (const entry of selection.entries ?? []) {
170
+ if (!entry.source || !entry.name) continue;
171
+ const source = resolve(dirname(owner), entry.source);
172
+ const relativeSource = relative(packageRoot, source);
173
+ if (relativeSource === '..' || relativeSource.startsWith('../'))
174
+ continue;
175
+ const detail = composed.get(
176
+ `${selection.package ?? catalog.package}:${entry.id}`,
177
+ );
178
+ const classes = [
179
+ detail?.boundaries?.rootClass,
180
+ ...(detail?.boundaries?.publicClasses ?? []),
181
+ ...(entry.publicClasses ?? []),
182
+ ].filter(Boolean);
183
+ const styleSources = [
184
+ ...(detail?.styleSources ?? []),
185
+ ...(entry.styleSources ?? []),
186
+ ].map((style) => resolve(dirname(owner), style));
187
+ aliases.set(`${source}:${entry.name}`, {
188
+ classes: new Set(classes),
189
+ styles: new Set(styleSources),
190
+ });
191
+ }
192
+ } catch {
193
+ // The catalog stage reports invalid or missing artifacts.
194
+ }
195
+ }
196
+ return aliases;
197
+ }
18
198
 
19
199
  function componentFacts(file, source) {
20
200
  const syntax = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.JSX;
@@ -39,12 +219,18 @@ function componentFacts(file, source) {
39
219
  componentName(node.name.text)
40
220
  )
41
221
  owner = node.name.text;
222
+ else if (
223
+ owner &&
224
+ ts.isVariableDeclaration(node) &&
225
+ ts.isIdentifier(node.name) &&
226
+ node.initializer
227
+ )
228
+ components.get(owner)?.bindings.set(node.name.text, node.initializer);
42
229
  if (
43
230
  owner &&
44
231
  (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node))
45
232
  ) {
46
233
  if (ts.isIdentifier(node.tagName) && /^[a-z]/.test(node.tagName.text)) {
47
- if (components.has(owner)) components.get(owner).intrinsic = true;
48
234
  for (const attribute of node.attributes.properties) {
49
235
  if (
50
236
  !ts.isJsxAttribute(attribute) ||
@@ -52,22 +238,44 @@ function componentFacts(file, source) {
52
238
  )
53
239
  continue;
54
240
  const value = attribute.initializer;
55
- const literal =
241
+ const classes =
56
242
  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);
243
+ ? classNames(value.text)
244
+ : value && ts.isJsxExpression(value)
245
+ ? guaranteedClasses(
246
+ value.expression,
247
+ components.get(owner)?.bindings,
248
+ )
249
+ : [];
250
+ if (
251
+ value &&
252
+ ts.isJsxExpression(value) &&
253
+ staticPatterns(
254
+ value.expression,
255
+ components.get(owner)?.bindings,
256
+ ).every((pattern) => pattern === unknown)
257
+ )
258
+ components.get(owner).uncertain = true;
259
+ for (const name of classes) components.get(owner)?.add(name);
68
260
  }
69
261
  }
70
262
  }
263
+ if (
264
+ owner &&
265
+ ts.isReturnStatement(node) &&
266
+ belongsToComponentReturn(node, owner) &&
267
+ returnedIntrinsic(node.expression)
268
+ )
269
+ components.get(owner).intrinsic = true;
270
+ if (
271
+ owner &&
272
+ ts.isArrowFunction(node) &&
273
+ ts.isVariableDeclaration(node.parent) &&
274
+ ts.isIdentifier(node.parent.name) &&
275
+ node.parent.name.text === owner &&
276
+ returnedIntrinsic(node.body)
277
+ )
278
+ components.get(owner).intrinsic = true;
71
279
  ts.forEachChild(node, (child) => visit(child, owner));
72
280
  };
73
281
  const declarations = (node) => {
@@ -76,13 +284,19 @@ function componentFacts(file, source) {
76
284
  node.name &&
77
285
  componentName(node.name.text)
78
286
  )
79
- components.set(node.name.text, new Set());
287
+ components.set(
288
+ node.name.text,
289
+ Object.assign(new Set(), { bindings: new Map() }),
290
+ );
80
291
  if (
81
292
  ts.isVariableDeclaration(node) &&
82
293
  ts.isIdentifier(node.name) &&
83
294
  componentName(node.name.text)
84
295
  )
85
- components.set(node.name.text, new Set());
296
+ components.set(
297
+ node.name.text,
298
+ Object.assign(new Set(), { bindings: new Map() }),
299
+ );
86
300
  ts.forEachChild(node, declarations);
87
301
  };
88
302
  declarations(ast);
@@ -99,6 +313,11 @@ function diagnostic(id, file, line, message, action) {
99
313
  location: { file, line, column: 1 },
100
314
  action,
101
315
  documentation: '@kerfjs/ui/docs/ui-doctor.md#component-style-naming',
316
+ meaning: {
317
+ 'KUI-D030': 'Component root class does not match its name',
318
+ 'KUI-D031': 'Component stylesheet does not match its name',
319
+ 'KUI-D032': 'Stylesheet selects another component class',
320
+ }[id],
102
321
  };
103
322
  }
104
323
 
@@ -107,7 +326,9 @@ export async function checkComponentStyles(
107
326
  inputs,
108
327
  ownershipGroups = [],
109
328
  selectedPaths,
329
+ workspaceRoot = packageRoot,
110
330
  ) {
331
+ const aliases = await catalogAliases(workspaceRoot, packageRoot);
111
332
  const scripts = new Map();
112
333
  const styles = new Map();
113
334
  const importedBy = new Map();
@@ -160,10 +381,14 @@ export async function checkComponentStyles(
160
381
  )
161
382
  continue;
162
383
  const components = scripts.get(pair);
163
- const matching = [...components].find(([name]) => kebab(name) === stem);
384
+ const matching =
385
+ [...components].find(([name]) => kebab(name) === stem) ??
386
+ [...components].find(([name]) =>
387
+ aliases.get(`${pair}:${name}`)?.styles.has(file),
388
+ );
164
389
  if (!matching) {
165
390
  const names = [...components.keys()];
166
- if (names.length)
391
+ if (names.length === 1)
167
392
  diagnostics.push(
168
393
  diagnostic(
169
394
  'KUI-D031',
@@ -176,7 +401,14 @@ export async function checkComponentStyles(
176
401
  continue;
177
402
  }
178
403
  const [name, renderedClasses] = matching;
179
- if (renderedClasses.intrinsic && !renderedClasses.has(stem))
404
+ const declaredClasses =
405
+ aliases.get(`${pair}:${name}`)?.classes ?? new Set();
406
+ if (
407
+ renderedClasses.intrinsic &&
408
+ !renderedClasses.uncertain &&
409
+ !renderedClasses.has(stem) &&
410
+ ![...declaredClasses].some((className) => renderedClasses.has(className))
411
+ )
180
412
  diagnostics.push(
181
413
  diagnostic(
182
414
  'KUI-D030',
@@ -207,7 +439,8 @@ export async function checkComponentStyles(
207
439
  ];
208
440
  for (const className of selected) {
209
441
  const foreign = owners.get(block(className));
210
- if (!foreign || foreign.name === name) continue;
442
+ if (!foreign || foreign.name === name || foreign.file === pair)
443
+ continue;
211
444
  diagnostics.push(
212
445
  diagnostic(
213
446
  'KUI-D032',
package/doctor/index.mjs CHANGED
@@ -1254,6 +1254,7 @@ export async function runUiDoctor({
1254
1254
  await collectInputs(packageRoot),
1255
1255
  config.ownershipGroups ?? [],
1256
1256
  selectedPaths,
1257
+ root,
1257
1258
  )),
1258
1259
  ],
1259
1260
  }))(),
package/llms.txt CHANGED
@@ -349,6 +349,7 @@ headingLevel`, `TokenSearchField clearIcon`, `ContentItem frame="framed"` for a
349
349
  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.
350
350
 
351
351
  `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.
352
+ 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.
352
353
  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.
353
354
 
354
355
  - **Space by the five-step scale, by relationship not by eye.** `0`
@@ -380,10 +381,10 @@ headingLevel`, `TokenSearchField clearIcon`, `ContentItem frame="framed"` for a
380
381
  with `chromeDividers="scroll"` then draws the line under its header only while content is scrolled beneath
381
382
  it and the line over its footer only while more content lies below (never
382
383
  when the content fits), every `NavStack`'s top chrome and bottom toolbar and
383
- every `TabScaffold` bar do the same around the active view or scene, and
384
+ every `TabNavigator` bar do the same around the active view or scene, and
384
385
  every `TabBar` marks each side of its strip with tabs scrolled out of view. Configure a pane with `chromeDividers`
385
386
  (`scroll` for a plain pane by default, `always` for a sunken pane by default,
386
- or explicit `scroll`, `always`, `none`), and a `NavStack` or `TabScaffold`
387
+ or explicit `scroll`, `always`, `none`), and a `NavStack` or `TabNavigator`
387
388
  with its own `chromeDividers` (same values; a `SplitView` forwards it through
388
389
  `compactStack`); name app-owned chrome in `targets`.
389
390
  Never add a border, or a permanent `dividerSides`, to fake one.
@@ -487,7 +488,7 @@ headingLevel`, `TokenSearchField clearIcon`, `ContentItem frame="framed"` for a
487
488
  consuming component. Use a real button or link for interactive content.
488
489
  For new content without a count, use the text-free `size="dot"` form: it
489
490
  takes no children, and needs a `label` (announced as an image) or
490
- `ariaHidden` when its owner announces it. A `TabScaffold` tab shows the dot
491
+ `ariaHidden` when its owner announces it. A `TabNavigator` tab shows the dot
491
492
  with `badge: true` plus its required `badgeLabel`.
492
493
  - **Use `Chip` for removable tags.** Give the native remove button a delegated
493
494
  `removeAction` and a specific `removeLabel`; the application updates the tag
@@ -502,13 +503,13 @@ headingLevel`, `TokenSearchField clearIcon`, `ContentItem frame="framed"` for a
502
503
  `Select`, `TokenSearchField` (+ `readTokenSearchField`, `placeTokenSearchCaret`,
503
504
  `wireTokenSearchFields`), `AppTab`, `TabBar` (+ `wireTabBars`, `reorderTabs`),
504
505
  `wireScrollDividers` (scroll-state dividers for every `Pane`, `NavStack`,
505
- `TabScaffold`, and `TabBar`),
506
+ `TabNavigator`, and `TabBar`),
506
507
  `ResizableRegion` (+ `wireResizableRegions`), `StateBanner`, `EmptyState`,
507
508
  `LoadingSpinner`, `Skeleton`, `ValueTable`, `ValueTableRow`. Whole-screen layouts (opt-in,
508
509
  tree-shakeable, declarative with `wire…` helpers; the browser build of each layout
509
510
  subpath loads its CSS and that of the components it renders, and a companion
510
511
  CSS subpath remains for pipelines without the `browser` condition):
511
- `NavStack`, `SplitView`, `Workbench`, `TabScaffold`, driven by `deviceClass()` from
512
+ `NavStack`, `SplitView`, `Workbench`, `TabNavigator`, driven by `deviceClass()` from
512
513
  `@kerfjs/ui/device-class`. For a standalone collapsible rail/drawer outside a full
513
514
  shell, `@kerfjs/ui/collapsible-panel` (`CollapsiblePanel`, `CollapsiblePanelToggle`)
514
515
 
@@ -534,7 +535,7 @@ shell, `@kerfjs/ui/collapsible-panel` (`CollapsiblePanel`, `CollapsiblePanelTogg
534
535
  - [accessibility.md](./docs/accessibility.md): keyboard and ARIA contracts per component.
535
536
  - [recipes.md](./docs/recipes.md): production compositions (app shell, list-detail dialog, composer form, …) as copyable references.
536
537
  - [workbench.md](./docs/workbench.md): the `Workbench` multi-panel shell, its collapse policies, and opt-in `resizable` panels driven by `wireWorkbench` (`@kerfjs/ui/wire-workbench`: drag, keyboard, min/max, persistence, compact suspension), the `mainMinSize` / `mainMinHeight` work-area minimums (default 320px wide beside any inline rail, 120px tall above the drawer) that resizable panels stop at and inline panels shrink around, and a panel's `responsiveOverlayAt` (`narrow` / `compact`, rails and the bottom drawer) that presents it as an overlay below a Workbench container breakpoint without a device-class check; a panel given its `collapsed` signal is a transient overlay (collapsed on entering the breakpoint, takes focus and keeps Tab inside while open, closed by Escape or an outside press, focus returned; `dismissOverlays`), and overlays are exclusive by default (opening one closes the others; `exclusiveOverlays: false` opts out).
537
- - [app-layouts.md](./docs/app-layouts.md): choosing `NavStack`/`SplitView`/`Workbench`/`TabScaffold` by data + interaction + device, and dialog presentation, plus the safe-area model (surfaces paint through unsafe areas; content pads only the edges it reaches; `Pane.safeAreaEdges` for app-owned arrangements, `Toolbar.safeAreaEdges` for an app bar or bottom bar at a screen edge).
538
+ - [app-layouts.md](./docs/app-layouts.md): choosing `NavStack`/`SplitView`/`Workbench`/`TabNavigator` by data + interaction + device, and dialog presentation, plus the safe-area model (surfaces paint through unsafe areas; content pads only the edges it reaches; `Pane.safeAreaEdges` for app-owned arrangements, `Toolbar.safeAreaEdges` for an app bar or bottom bar at a screen edge).
538
539
  - [webawesome-theme.md](./docs/webawesome-theme.md): the optional Web Awesome theme; catalog coverage means themed-and-supported, not preferred (prefer the Kerf primitive when one exists). The UX sidebar visibly tags superseded and exceptional Web Awesome choices as `Discouraged`; Popup remains an available conditional positioning primitive.
539
540
  - [ai/components/README.md](./ai/components/README.md): the generated per-component markdown reference — the assistant entry point over both JSON catalogs.
540
541
  - [component-catalog.json](./ai/component-catalog.json) + [component-catalog-extension.schema.json](./ai/component-catalog-extension.schema.json) + [catalog-authoring.json](./ai/catalog-authoring.json) + [public-api-signatures-v1.md](./ai/public-api-signatures-v1.md) + [webawesome-jsx-signatures-v1.md](./ai/webawesome-jsx-signatures-v1.md): machine-readable selection facts, a reusable app-component extension contract, Catalog authoring discovery, and exact signatures.
@@ -545,9 +546,10 @@ Neutral lowered surfaces and decorative neutral fills/borders are translucent th
545
546
 
546
547
  Navigation transition continuity: `wireNavStack` slides the incoming view over the preserved previous view on push and the outgoing view off on pop, cross-fades both live chrome surfaces with inert snapshots, and settles/cancels an interrupted transition before starting the next. Reduced motion and duration zero settle immediately.
547
548
 
548
- For a tab scene with drill-down, put a `NavStack` directly in `TabScaffoldTab.content`, configure each view's top `toolbar` and `pane`, and omit `bottomToolbar` at both levels so the scaffold bar remains the only bottom chrome. Wire both components on their own roots. The catalog's **NavStack inside a tab** example exercises push and tab switching.
549
+ For a tab scene with drill-down, put a `NavStack` directly in `TabNavigatorTab.content`, configure each view's top `toolbar` and `pane`, and omit `bottomToolbar` at both levels so the scaffold bar remains the only bottom chrome. Wire both components on their own roots. The catalog's **NavStack inside a tab** example exercises push and tab switching.
549
550
 
550
- `Pane.deepInset` (and `PaneConfig.deepInset`) or `TabScaffoldTab.deepInset` on a plain scene adds an 8px outer gutter to the scroll content, so ordinary 8px item margins land 16px from the edge. Nested Row/List/Grid spacing stays at its default. Set a nested scene's Pane option instead of insetting its entire NavStack or toolbar.
551
+ `Pane.deepInset` (and `PaneConfig.deepInset`) or `TabNavigatorTab.deepInset` on a plain scene adds an 8px outer gutter to the scroll content, so ordinary 8px item margins land 16px from the edge. Nested Row/List/Grid spacing stays at its default. Set a nested scene's Pane option instead of insetting its entire NavStack or toolbar.
552
+ For a child table whose decoration spans the Pane while its text stays aligned, use the Pane content slot's inherited `--kui-pane-content-inset-inline-start` and `--kui-pane-content-inset-inline-end`: negate them for the child's logical margins and add them to cell padding. They include safe areas and `deepInset`; see `docs/app-layouts.md#edge-to-edge-content-inside-a-pane`.
551
553
 
552
554
  `ToolbarText` and `ToolbarControlGroup` accept typed `hideBelow`/`showBelow`
553
555
  lengths (for example `px(416)`). Wire `wireToolbarVisibility(root)` from