@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.
- package/README.md +8 -6
- package/ai/application-ui-diagnostic-ids-v1.json +1 -0
- package/ai/compile-time-contracts-v1.json +3 -3
- package/ai/component-catalog.json +31 -22
- package/ai/component-composition-rules.json +6 -14
- package/ai/component-composition.json +21 -23
- package/ai/components/README.md +1 -1
- package/ai/components/document-baseline.md +1 -1
- package/ai/components/nav-stack.md +3 -3
- package/ai/components/pane.md +3 -1
- package/ai/components/{tab-scaffold.md → tab-navigator.md} +14 -14
- package/ai/components/token-search-field.md +1 -0
- package/ai/components/toolbar.md +4 -8
- package/ai/public-api-signatures-v1.md +20 -20
- package/ai/skill.md +7 -6
- package/ai/webawesome-jsx-signatures-v1.md +1 -1
- package/dist/browser/tab-navigator.js +4 -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/index.js +3 -3
- package/dist/styles/components/actions/toolbar/toolbar.css +7 -14
- package/dist/styles/components/actions/toolbar-control-group/toolbar-control-group.css +3 -0
- package/dist/styles/components/actions/toolbar-text/toolbar-text.css +1 -0
- package/dist/styles/components/forms/token-search-field/token-search-field.css +12 -1
- package/dist/styles/components/layout/pane/pane.css +13 -3
- package/dist/styles/components/navigation/tab-scaffold/tab-scaffold.css +1 -1
- package/dist/styles/pane.css +13 -3
- package/dist/styles/{tab-scaffold.css → tab-navigator.css} +1 -1
- package/dist/styles/token-search-field.css +12 -1
- package/dist/styles/toolbar-control-group.css +3 -0
- package/dist/styles/toolbar-text.css +1 -0
- package/dist/styles/toolbar.css +7 -14
- package/dist/{tab-scaffold.d.ts → tab-navigator.d.ts} +10 -10
- package/dist/{tab-scaffold.js → tab-navigator.js} +3 -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/docs/app-layouts.md +44 -11
- package/docs/component-contract.md +1 -1
- package/docs/component-selection.md +16 -14
- 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 +11 -8
- package/docs/recipes.md +1 -1
- package/docs/{tab-scaffold.md → tab-navigator.md} +32 -17
- package/docs/type-contracts.md +3 -3
- package/docs/ui-analyzer.md +5 -0
- package/docs/ui-doctor.md +18 -8
- package/doctor/component-style-checks.mjs +253 -20
- package/doctor/index.mjs +1 -0
- package/llms.txt +10 -8
- package/package.json +10 -10
- package/dist/browser/tab-scaffold.js +0 -4
- 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/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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
|
113
|
-
`
|
|
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
|
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
|
@@ -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
|
|
68
|
-
HTML element must put that kebab-case class on rendered markup
|
|
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`.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
|
241
|
+
const classes =
|
|
56
242
|
value && ts.isStringLiteral(value)
|
|
57
|
-
? value.text
|
|
58
|
-
: value &&
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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
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 `
|
|
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 `
|
|
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 `
|
|
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
|
-
`
|
|
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`, `
|
|
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`/`
|
|
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 `
|
|
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 `
|
|
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
|