@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.
- package/README.md +9 -4
- package/ai/application-ui-diagnostic-ids-v1.json +4 -0
- package/ai/compile-time-contracts-v1.json +3 -3
- package/ai/component-catalog.json +152 -31
- package/ai/component-composition-rules.json +6 -14
- package/ai/component-composition.json +123 -29
- package/ai/components/README.md +1 -1
- package/ai/components/document-baseline.md +1 -1
- package/ai/components/nav-stack.md +6 -6
- package/ai/components/pane.md +3 -1
- package/ai/components/surface-scaffold.md +11 -2
- package/ai/components/{tab-scaffold.md → tab-navigator.md} +14 -14
- package/ai/components/token-search-field.md +1 -0
- package/ai/components/toolbar-control-group.md +2 -0
- package/ai/components/toolbar-text.md +2 -0
- package/ai/components/toolbar.md +6 -8
- package/ai/public-api-signatures-v1.md +68 -140
- package/ai/skill.md +15 -4
- package/ai/webawesome-jsx-signatures-v1.md +1 -1
- package/dist/browser/nav-stack.js +2 -0
- package/dist/browser/split-view.js +2 -0
- package/dist/browser/tab-navigator.js +4 -0
- package/dist/catalog-resources.d.ts +3 -3
- package/dist/catalog.d.ts +3 -3
- package/dist/catalog.js +3 -3
- package/dist/{chunk-WJN3V2H5.js → chunk-2GOCOLQL.js} +18 -4
- package/dist/chunk-2GOCOLQL.js.map +1 -0
- package/dist/{chunk-QYXKMLHO.js → chunk-3CXRQAYT.js} +2 -2
- package/dist/chunk-3CXRQAYT.js.map +1 -0
- package/dist/{chunk-G4VKQP7B.js → chunk-3SXWV2IR.js} +5 -1
- package/dist/chunk-3SXWV2IR.js.map +1 -0
- package/dist/{chunk-VLPE6OPN.js → chunk-62VMNBV2.js} +6 -2
- package/dist/chunk-62VMNBV2.js.map +1 -0
- package/dist/{chunk-XIZRXI7N.js → chunk-A7DYEERU.js} +13 -1
- package/dist/chunk-A7DYEERU.js.map +1 -0
- package/dist/{chunk-6VQPLV3O.js → chunk-DSLRNR43.js} +9 -8
- package/dist/chunk-DSLRNR43.js.map +1 -0
- package/dist/{chunk-AOAW55JN.js → chunk-DXQZ3BEO.js} +4 -1
- package/dist/chunk-DXQZ3BEO.js.map +1 -0
- package/dist/chunk-IKCUPTR2.js +170 -0
- package/dist/chunk-IKCUPTR2.js.map +1 -0
- package/dist/{chunk-Z6PE5T7N.js → chunk-JL4BBZP4.js} +4 -3
- package/dist/chunk-JL4BBZP4.js.map +1 -0
- package/dist/collapsible-panel.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +8 -8
- package/dist/nav-stack-B1Qc2e5q.d.ts +101 -0
- package/dist/nav-stack.d.ts +11 -93
- package/dist/nav-stack.js +5 -1
- package/dist/pane.d.ts +5 -2
- package/dist/pane.js +1 -1
- package/dist/split-view.d.ts +9 -4
- package/dist/split-view.js +7 -3
- package/dist/split-view.js.map +1 -1
- package/dist/styles/components/actions/toolbar/toolbar.css +7 -14
- package/dist/styles/components/actions/toolbar-control-group/toolbar-control-group.css +63 -13
- package/dist/styles/components/actions/toolbar-text/toolbar-text.css +8 -1
- package/dist/styles/components/forms/token-search-field/token-search-field.css +12 -1
- package/dist/styles/components/layout/pane/pane.css +18 -1
- package/dist/styles/components/navigation/nav-stack/nav-stack.css +6 -12
- package/dist/styles/components/navigation/tab-scaffold/tab-scaffold.css +18 -1
- package/dist/styles/components/surfaces/surface-scaffold/surface-scaffold.css +55 -0
- package/dist/styles/nav-stack.css +6 -12
- package/dist/styles/pane.css +18 -1
- package/dist/styles/surface-scaffold.css +55 -0
- package/dist/styles/{tab-scaffold.css → tab-navigator.css} +18 -1
- package/dist/styles/token-search-field.css +12 -1
- package/dist/styles/toolbar-control-group.css +63 -13
- package/dist/styles/toolbar-text.css +8 -1
- package/dist/styles/toolbar.css +7 -14
- package/dist/surface-scaffold-register.d.ts +4 -0
- package/dist/surface-scaffold-register.js +59 -0
- package/dist/surface-scaffold-register.js.map +1 -0
- package/dist/surface-scaffold.d.ts +10 -2
- package/dist/surface-scaffold.js +1 -1
- package/dist/{tab-scaffold.d.ts → tab-navigator.d.ts} +12 -10
- package/dist/{tab-scaffold.js → tab-navigator.js} +4 -3
- package/dist/tab-navigator.js.map +1 -0
- package/dist/token-search-field.js +1 -1
- package/dist/wire-catalog.js +1 -1
- package/dist/wire-scroll-dividers.d.ts +3 -3
- package/dist/wire-scroll-dividers.js +1 -1
- package/dist/wire-tab-navigator.d.ts +11 -0
- package/dist/{wire-tab-scaffold.js → wire-tab-navigator.js} +3 -3
- package/dist/wire-tab-navigator.js.map +1 -0
- package/dist/wire-token-search-fields.js +1 -1
- package/dist/wire-toolbar-visibility.js +1 -1
- package/dist/workbench.d.ts +5 -13
- package/dist/workbench.js +1 -1
- package/docs/app-layouts.md +44 -11
- package/docs/component-contract.md +9 -2
- package/docs/component-selection.md +17 -15
- package/docs/design/templates/token-search-field/empty-dark.svg +1 -1
- package/docs/design/templates/token-search-field/empty.svg +1 -1
- package/docs/design/templates/token-search-field-dark.svg +1 -1
- package/docs/design/templates/token-search-field.svg +1 -1
- package/docs/design/templates.md +1 -1
- package/docs/examples/application-ui-profile.json +4 -4
- package/docs/layout.md +31 -8
- package/docs/nav-stack.md +30 -18
- package/docs/recipes.md +6 -2
- package/docs/split-view.md +7 -4
- package/docs/surface-scaffold.md +46 -0
- package/docs/{tab-scaffold.md → tab-navigator.md} +83 -11
- package/docs/type-contracts.md +3 -3
- package/docs/ui-analyzer.md +5 -0
- package/docs/ui-doctor.md +33 -0
- package/docs/workbench.md +3 -2
- package/doctor/component-style-checks.mjs +457 -0
- package/doctor/index.mjs +43 -13
- package/llms.txt +18 -6
- package/package.json +16 -11
- package/ux-demo/recipes/list-detail-dialog.tsx +4 -0
- package/ux-demo/recipes/navigation-stack.tsx +2 -2
- package/dist/browser/tab-scaffold.js +0 -4
- package/dist/chunk-6VQPLV3O.js.map +0 -1
- package/dist/chunk-AOAW55JN.js.map +0 -1
- package/dist/chunk-G4VKQP7B.js.map +0 -1
- package/dist/chunk-QYXKMLHO.js.map +0 -1
- package/dist/chunk-VLPE6OPN.js.map +0 -1
- package/dist/chunk-WJN3V2H5.js.map +0 -1
- package/dist/chunk-XIZRXI7N.js.map +0 -1
- package/dist/chunk-Z4EPMX25.js +0 -123
- package/dist/chunk-Z4EPMX25.js.map +0 -1
- package/dist/chunk-Z6PE5T7N.js.map +0 -1
- package/dist/tab-scaffold.js.map +0 -1
- package/dist/wire-tab-scaffold.d.ts +0 -11
- package/dist/wire-tab-scaffold.js.map +0 -1
|
@@ -1,32 +1,47 @@
|
|
|
1
|
-
# Tab
|
|
1
|
+
# Tab navigator
|
|
2
2
|
|
|
3
|
-
`@kerfjs/ui/tab-
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
|
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-
|
|
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); `
|
|
23
|
-
(only the active one visible) plus the bottom bar, and `
|
|
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
|
-
<
|
|
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 =
|
|
60
|
+
const dispose = wireTabNavigator(root, {
|
|
46
61
|
onSelect: (id) => {
|
|
47
62
|
active.value = id;
|
|
48
63
|
},
|
|
49
64
|
});
|
|
50
65
|
```
|
|
51
66
|
|
|
52
|
-
Each `
|
|
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
|
package/docs/type-contracts.md
CHANGED
|
@@ -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 `
|
|
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
|
-
<
|
|
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 `
|
|
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`,
|
package/docs/ui-analyzer.md
CHANGED
|
@@ -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
|
|
171
|
-
groups,
|
|
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
|
+
}
|