@kerfjs/ui 5.0.0-beta.77 → 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 (128) hide show
  1. package/README.md +9 -4
  2. package/ai/application-ui-diagnostic-ids-v1.json +4 -0
  3. package/ai/compile-time-contracts-v1.json +3 -3
  4. package/ai/component-catalog.json +152 -31
  5. package/ai/component-composition-rules.json +6 -14
  6. package/ai/component-composition.json +123 -29
  7. package/ai/components/README.md +1 -1
  8. package/ai/components/document-baseline.md +1 -1
  9. package/ai/components/nav-stack.md +6 -6
  10. package/ai/components/pane.md +3 -1
  11. package/ai/components/surface-scaffold.md +11 -2
  12. package/ai/components/{tab-scaffold.md → tab-navigator.md} +14 -14
  13. package/ai/components/token-search-field.md +1 -0
  14. package/ai/components/toolbar-control-group.md +2 -0
  15. package/ai/components/toolbar-text.md +2 -0
  16. package/ai/components/toolbar.md +6 -8
  17. package/ai/public-api-signatures-v1.md +68 -140
  18. package/ai/skill.md +15 -4
  19. package/ai/webawesome-jsx-signatures-v1.md +1 -1
  20. package/dist/browser/nav-stack.js +2 -0
  21. package/dist/browser/split-view.js +2 -0
  22. package/dist/browser/tab-navigator.js +4 -0
  23. package/dist/catalog-resources.d.ts +3 -3
  24. package/dist/catalog.d.ts +3 -3
  25. package/dist/catalog.js +3 -3
  26. package/dist/{chunk-WJN3V2H5.js → chunk-2GOCOLQL.js} +18 -4
  27. package/dist/chunk-2GOCOLQL.js.map +1 -0
  28. package/dist/{chunk-QYXKMLHO.js → chunk-3CXRQAYT.js} +2 -2
  29. package/dist/chunk-3CXRQAYT.js.map +1 -0
  30. package/dist/{chunk-G4VKQP7B.js → chunk-3SXWV2IR.js} +5 -1
  31. package/dist/chunk-3SXWV2IR.js.map +1 -0
  32. package/dist/{chunk-VLPE6OPN.js → chunk-62VMNBV2.js} +6 -2
  33. package/dist/chunk-62VMNBV2.js.map +1 -0
  34. package/dist/{chunk-XIZRXI7N.js → chunk-A7DYEERU.js} +13 -1
  35. package/dist/chunk-A7DYEERU.js.map +1 -0
  36. package/dist/{chunk-6VQPLV3O.js → chunk-DSLRNR43.js} +9 -8
  37. package/dist/chunk-DSLRNR43.js.map +1 -0
  38. package/dist/{chunk-AOAW55JN.js → chunk-DXQZ3BEO.js} +4 -1
  39. package/dist/chunk-DXQZ3BEO.js.map +1 -0
  40. package/dist/chunk-IKCUPTR2.js +170 -0
  41. package/dist/chunk-IKCUPTR2.js.map +1 -0
  42. package/dist/{chunk-Z6PE5T7N.js → chunk-JL4BBZP4.js} +4 -3
  43. package/dist/chunk-JL4BBZP4.js.map +1 -0
  44. package/dist/collapsible-panel.js +2 -2
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.js +8 -8
  47. package/dist/nav-stack-B1Qc2e5q.d.ts +101 -0
  48. package/dist/nav-stack.d.ts +11 -93
  49. package/dist/nav-stack.js +5 -1
  50. package/dist/pane.d.ts +5 -2
  51. package/dist/pane.js +1 -1
  52. package/dist/split-view.d.ts +9 -4
  53. package/dist/split-view.js +7 -3
  54. package/dist/split-view.js.map +1 -1
  55. package/dist/styles/components/actions/toolbar/toolbar.css +7 -14
  56. package/dist/styles/components/actions/toolbar-control-group/toolbar-control-group.css +63 -13
  57. package/dist/styles/components/actions/toolbar-text/toolbar-text.css +8 -1
  58. package/dist/styles/components/forms/token-search-field/token-search-field.css +12 -1
  59. package/dist/styles/components/layout/pane/pane.css +18 -1
  60. package/dist/styles/components/navigation/nav-stack/nav-stack.css +6 -12
  61. package/dist/styles/components/navigation/tab-scaffold/tab-scaffold.css +18 -1
  62. package/dist/styles/components/surfaces/surface-scaffold/surface-scaffold.css +55 -0
  63. package/dist/styles/nav-stack.css +6 -12
  64. package/dist/styles/pane.css +18 -1
  65. package/dist/styles/surface-scaffold.css +55 -0
  66. package/dist/styles/{tab-scaffold.css → tab-navigator.css} +18 -1
  67. package/dist/styles/token-search-field.css +12 -1
  68. package/dist/styles/toolbar-control-group.css +63 -13
  69. package/dist/styles/toolbar-text.css +8 -1
  70. package/dist/styles/toolbar.css +7 -14
  71. package/dist/surface-scaffold-register.d.ts +4 -0
  72. package/dist/surface-scaffold-register.js +59 -0
  73. package/dist/surface-scaffold-register.js.map +1 -0
  74. package/dist/surface-scaffold.d.ts +10 -2
  75. package/dist/surface-scaffold.js +1 -1
  76. package/dist/{tab-scaffold.d.ts → tab-navigator.d.ts} +12 -10
  77. package/dist/{tab-scaffold.js → tab-navigator.js} +4 -3
  78. package/dist/tab-navigator.js.map +1 -0
  79. package/dist/token-search-field.js +1 -1
  80. package/dist/wire-catalog.js +1 -1
  81. package/dist/wire-scroll-dividers.d.ts +3 -3
  82. package/dist/wire-scroll-dividers.js +1 -1
  83. package/dist/wire-tab-navigator.d.ts +11 -0
  84. package/dist/{wire-tab-scaffold.js → wire-tab-navigator.js} +3 -3
  85. package/dist/wire-tab-navigator.js.map +1 -0
  86. package/dist/wire-token-search-fields.js +1 -1
  87. package/dist/wire-toolbar-visibility.js +1 -1
  88. package/dist/workbench.d.ts +5 -13
  89. package/dist/workbench.js +1 -1
  90. package/docs/app-layouts.md +44 -11
  91. package/docs/component-contract.md +9 -2
  92. package/docs/component-selection.md +17 -15
  93. package/docs/design/templates/token-search-field/empty-dark.svg +1 -1
  94. package/docs/design/templates/token-search-field/empty.svg +1 -1
  95. package/docs/design/templates/token-search-field-dark.svg +1 -1
  96. package/docs/design/templates/token-search-field.svg +1 -1
  97. package/docs/design/templates.md +1 -1
  98. package/docs/examples/application-ui-profile.json +4 -4
  99. package/docs/layout.md +31 -8
  100. package/docs/nav-stack.md +30 -18
  101. package/docs/recipes.md +6 -2
  102. package/docs/split-view.md +7 -4
  103. package/docs/surface-scaffold.md +46 -0
  104. package/docs/{tab-scaffold.md → tab-navigator.md} +83 -11
  105. package/docs/type-contracts.md +3 -3
  106. package/docs/ui-analyzer.md +5 -0
  107. package/docs/ui-doctor.md +33 -0
  108. package/docs/workbench.md +3 -2
  109. package/doctor/component-style-checks.mjs +457 -0
  110. package/doctor/index.mjs +43 -13
  111. package/llms.txt +18 -6
  112. package/package.json +16 -11
  113. package/ux-demo/recipes/list-detail-dialog.tsx +4 -0
  114. package/ux-demo/recipes/navigation-stack.tsx +2 -2
  115. package/dist/browser/tab-scaffold.js +0 -4
  116. package/dist/chunk-6VQPLV3O.js.map +0 -1
  117. package/dist/chunk-AOAW55JN.js.map +0 -1
  118. package/dist/chunk-G4VKQP7B.js.map +0 -1
  119. package/dist/chunk-QYXKMLHO.js.map +0 -1
  120. package/dist/chunk-VLPE6OPN.js.map +0 -1
  121. package/dist/chunk-WJN3V2H5.js.map +0 -1
  122. package/dist/chunk-XIZRXI7N.js.map +0 -1
  123. package/dist/chunk-Z4EPMX25.js +0 -123
  124. package/dist/chunk-Z4EPMX25.js.map +0 -1
  125. package/dist/chunk-Z6PE5T7N.js.map +0 -1
  126. package/dist/tab-scaffold.js.map +0 -1
  127. package/dist/wire-tab-scaffold.d.ts +0 -11
  128. 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
@@ -59,6 +74,63 @@ truncate horizontally with an ellipsis when a destination name exceeds its
59
74
  share of the bar. On larger device classes, promote the tab set to a
60
75
  `Workbench` rail or a persistent sidebar instead of a bottom bar.
61
76
 
77
+ Set `deepInset: true` on a plain tab scene to add an 8px outer inline gutter
78
+ to its scrolling content, giving ordinary content items a 16px edge inset.
79
+ The default is `false`. A scene whose only child is a `Pane` or layout lets
80
+ that child own its content gutter: for a nested `NavStack`, set
81
+ `pane: { deepInset: true }` on its views instead. The navigator bar and a
82
+ stack's top toolbar remain full width.
83
+
84
+ ## A NavStack with a top toolbar and Pane
85
+
86
+ Put the `NavStack` directly in a tab's `content`. Give each view a structured
87
+ `toolbar` and `pane` configuration. Leave `bottomToolbar` off both the views
88
+ and the stack: the navigator's tab bar is the only bottom chrome. The view's
89
+ Pane owns scrolling, while the stack keeps its toolbar above it and the
90
+ navigator keeps the tab bar below it.
91
+
92
+ ```tsx
93
+ import { List } from "@kerfjs/ui/list";
94
+ import { ListItem } from "@kerfjs/ui/list-item";
95
+ import { NavStack, type NavStackView } from "@kerfjs/ui/nav-stack";
96
+
97
+ const projectViews: NavStackView[] = [
98
+ {
99
+ key: "projects",
100
+ toolbar: { title: "Projects" },
101
+ pane: { appearance: "sunken" },
102
+ content: <List><ListItem label="Project Atlas" /></List>,
103
+ },
104
+ ];
105
+
106
+ <TabNavigator
107
+ id="app"
108
+ label="Sections"
109
+ active={active.value}
110
+ tabs={[
111
+ {
112
+ id: "projects",
113
+ label: "Projects",
114
+ content: (
115
+ <NavStack
116
+ id="projects-stack"
117
+ label="Projects navigation"
118
+ views={projectViews}
119
+ />
120
+ ),
121
+ },
122
+ { id: "search", label: "Search", content: <SearchScene /> },
123
+ ]}
124
+ />;
125
+ ```
126
+
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
129
+ and transitions, each on its own component root. Push a detail by appending
130
+ a view with its own `toolbar` and `pane`; switching tabs keeps that view
131
+ mounted. The catalog's **NavStack inside a tab** example demonstrates the
132
+ push, tab switch, and return flow.
133
+
62
134
  Set a tab's `appearance: "sunken"` when its scene owns scrolling and should
63
135
  paint the lowered surface behind short or long content. For a scene whose
64
136
  only child is a `Pane` or `NavStack`, set appearance on that child's scroll
@@ -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
@@ -58,6 +58,39 @@ 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 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.
73
+ For example, `WorkspaceHeader` pairs with `workspace-header.css` and renders
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
86
+ rule to the owning component's stylesheet or use the component's public
87
+ configuration. This check uses source declarations and CSS selectors; it does
88
+ not execute component code. In changed mode, it reads other component sources
89
+ to resolve ownership but reports only pairs containing a selected path. For
90
+ broader cross-component ownership, including
91
+ cataloged package components and hook classes, enable `ownership: "component"`
92
+ with `implicitComponentOwnership: true` as described above.
93
+
61
94
  Set `"ownership": "component"` in `.kerf-ui-doctor.json` to enforce the same
62
95
  boundary between a consumer package's own cataloged components. The default is
63
96
  `"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,457 @@
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 { loadApplicationUiProfile } from '../ai/application-ui-profile.mjs';
8
+ import { isUiTraversalExcluded } from '../traversal-exclusions.mjs';
9
+
10
+ const scriptExtensions = new Set(['.tsx', '.jsx']);
11
+ const kebab = (name) =>
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();
16
+ const componentName = (name) => /^[A-Z][A-Za-z0-9]*$/.test(name);
17
+ const classNames = (value) =>
18
+ [...value.matchAll(/(?:^|\s)([a-z][a-z0-9_-]*)(?=\s|$)/g)].map(
19
+ (match) => match[1],
20
+ );
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
+ }
198
+
199
+ function componentFacts(file, source) {
200
+ const syntax = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.JSX;
201
+ const ast = ts.createSourceFile(
202
+ file,
203
+ source,
204
+ ts.ScriptTarget.Latest,
205
+ true,
206
+ syntax,
207
+ );
208
+ const components = new Map();
209
+ const visit = (node, owner) => {
210
+ if (
211
+ ts.isFunctionDeclaration(node) &&
212
+ node.name &&
213
+ componentName(node.name.text)
214
+ )
215
+ owner = node.name.text;
216
+ if (
217
+ ts.isVariableDeclaration(node) &&
218
+ ts.isIdentifier(node.name) &&
219
+ componentName(node.name.text)
220
+ )
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);
229
+ if (
230
+ owner &&
231
+ (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node))
232
+ ) {
233
+ if (ts.isIdentifier(node.tagName) && /^[a-z]/.test(node.tagName.text)) {
234
+ for (const attribute of node.attributes.properties) {
235
+ if (
236
+ !ts.isJsxAttribute(attribute) ||
237
+ !['class', 'className'].includes(attribute.name.text)
238
+ )
239
+ continue;
240
+ const value = attribute.initializer;
241
+ const classes =
242
+ value && ts.isStringLiteral(value)
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);
260
+ }
261
+ }
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;
279
+ ts.forEachChild(node, (child) => visit(child, owner));
280
+ };
281
+ const declarations = (node) => {
282
+ if (
283
+ ts.isFunctionDeclaration(node) &&
284
+ node.name &&
285
+ componentName(node.name.text)
286
+ )
287
+ components.set(
288
+ node.name.text,
289
+ Object.assign(new Set(), { bindings: new Map() }),
290
+ );
291
+ if (
292
+ ts.isVariableDeclaration(node) &&
293
+ ts.isIdentifier(node.name) &&
294
+ componentName(node.name.text)
295
+ )
296
+ components.set(
297
+ node.name.text,
298
+ Object.assign(new Set(), { bindings: new Map() }),
299
+ );
300
+ ts.forEachChild(node, declarations);
301
+ };
302
+ declarations(ast);
303
+ visit(ast);
304
+ return components;
305
+ }
306
+
307
+ function diagnostic(id, file, line, message, action) {
308
+ return {
309
+ id,
310
+ severity: 'error',
311
+ stage: 'analyzer',
312
+ message,
313
+ location: { file, line, column: 1 },
314
+ action,
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],
321
+ };
322
+ }
323
+
324
+ export async function checkComponentStyles(
325
+ packageRoot,
326
+ inputs,
327
+ ownershipGroups = [],
328
+ selectedPaths,
329
+ workspaceRoot = packageRoot,
330
+ ) {
331
+ const aliases = await catalogAliases(workspaceRoot, packageRoot);
332
+ const scripts = new Map();
333
+ const styles = new Map();
334
+ const importedBy = new Map();
335
+ for (const file of inputs) {
336
+ if (isUiTraversalExcluded(packageRoot, file)) continue;
337
+ const extension = extname(file);
338
+ if (scriptExtensions.has(extension)) {
339
+ const source = await readFile(file, 'utf8');
340
+ scripts.set(file, componentFacts(file, source));
341
+ for (const match of source.matchAll(
342
+ /\bimport\s*(?:['"]|[^;]*?\bfrom\s*['"])(\.[^'"]+\.css)['"]/g,
343
+ )) {
344
+ const style = resolve(dirname(file), match[1]);
345
+ const importers = importedBy.get(style) ?? [];
346
+ importers.push(file);
347
+ importedBy.set(style, importers);
348
+ }
349
+ } else if (extension === '.css')
350
+ styles.set(file, await readFile(file, 'utf8'));
351
+ }
352
+ const owners = new Map();
353
+ for (const [file, components] of scripts)
354
+ for (const [name, classes] of components)
355
+ if (classes.has(kebab(name)))
356
+ owners.set(kebab(name), { file, name, classes });
357
+ const diagnostics = [];
358
+ for (const [file, source] of styles) {
359
+ if (
360
+ ownershipGroups.some((group) =>
361
+ group.styleSources?.some(
362
+ (style) => resolve(packageRoot, style) === file,
363
+ ),
364
+ )
365
+ )
366
+ continue;
367
+ const stem = basename(file, '.css');
368
+ const pair =
369
+ [...scriptExtensions]
370
+ .map((extension) => resolve(dirname(file), `${stem}${extension}`))
371
+ .find((candidate) => scripts.has(candidate)) ??
372
+ (importedBy.get(file)?.length === 1
373
+ ? importedBy.get(file)[0]
374
+ : undefined);
375
+ if (!pair) continue;
376
+ const path = relative(packageRoot, file).replaceAll('\\', '/');
377
+ if (
378
+ selectedPaths?.length &&
379
+ !selectedPaths.includes(path) &&
380
+ !selectedPaths.includes(relative(packageRoot, pair).replaceAll('\\', '/'))
381
+ )
382
+ continue;
383
+ const components = scripts.get(pair);
384
+ const matching =
385
+ [...components].find(([name]) => kebab(name) === stem) ??
386
+ [...components].find(([name]) =>
387
+ aliases.get(`${pair}:${name}`)?.styles.has(file),
388
+ );
389
+ if (!matching) {
390
+ const names = [...components.keys()];
391
+ if (names.length === 1)
392
+ diagnostics.push(
393
+ diagnostic(
394
+ 'KUI-D031',
395
+ path,
396
+ 1,
397
+ `${basename(file)} is paired with ${basename(pair)}, but its component ${names[0]} calls for ${kebab(names[0])}.css.`,
398
+ `Rename the stylesheet and its import to ${kebab(names[0])}.css, or rename the component and source pair.`,
399
+ ),
400
+ );
401
+ continue;
402
+ }
403
+ const [name, renderedClasses] = matching;
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
+ )
412
+ diagnostics.push(
413
+ diagnostic(
414
+ 'KUI-D030',
415
+ relative(packageRoot, pair).replaceAll('\\', '/'),
416
+ 1,
417
+ `${name} must render its own .${stem} root class.`,
418
+ `Add class="${stem}" to an element rendered by ${name}, then select that class in ${basename(file)}.`,
419
+ ),
420
+ );
421
+ let sheet;
422
+ try {
423
+ sheet = postcss.parse(source, { from: file });
424
+ } catch {
425
+ continue;
426
+ }
427
+ sheet.walkRules((rule) => {
428
+ if (
429
+ rule.parent?.type === 'atrule' &&
430
+ /keyframes$/i.test(rule.parent.name)
431
+ )
432
+ return;
433
+ const selected = [
434
+ ...new Set(
435
+ [...rule.selector.matchAll(/\.([a-z][a-z0-9_-]*)/gi)].map(
436
+ (match) => match[1],
437
+ ),
438
+ ),
439
+ ];
440
+ for (const className of selected) {
441
+ const foreign = owners.get(block(className));
442
+ if (!foreign || foreign.name === name || foreign.file === pair)
443
+ continue;
444
+ diagnostics.push(
445
+ diagnostic(
446
+ 'KUI-D032',
447
+ path,
448
+ rule.source?.start?.line ?? 1,
449
+ `${basename(file)} selects .${className}, which belongs to ${foreign.name} in ${relative(packageRoot, foreign.file).replaceAll('\\', '/')}.`,
450
+ `Move this rule to ${kebab(foreign.name)}.css or configure ${foreign.name} through its public API.`,
451
+ ),
452
+ );
453
+ }
454
+ });
455
+ }
456
+ return diagnostics;
457
+ }