@starci/hfs 4.16.5 → 4.17.2

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 (76) hide show
  1. package/CHANGELOG.md +36 -3
  2. package/README.md +4 -4
  3. package/codemod/loading-states.d.mts +50 -0
  4. package/codemod/loading-states.mjs +384 -0
  5. package/codemod/main.d.mts +1 -1
  6. package/codemod/main.mjs +46 -5
  7. package/codemod/page-navigation.d.mts +50 -0
  8. package/codemod/page-navigation.mjs +278 -0
  9. package/codemod/section-frame.d.mts +50 -0
  10. package/codemod/section-frame.mjs +483 -0
  11. package/codemod/small-renames.d.mts +54 -0
  12. package/codemod/small-renames.mjs +614 -0
  13. package/codemod/surface-card-kinds.d.mts +49 -0
  14. package/codemod/surface-card-kinds.mjs +404 -0
  15. package/codemod/theme-tokens.d.mts +45 -0
  16. package/codemod/theme-tokens.mjs +114 -0
  17. package/codemod/tier-binding.d.mts +50 -0
  18. package/codemod/tier-binding.mjs +214 -0
  19. package/package.json +1 -1
  20. package/runtime/knowledge/grammars/core/playbook.yaml +41 -51
  21. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +5 -5
  22. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  23. package/runtime/knowledge/hfs/rules.yaml +255 -30
  24. package/runtime/knowledge/hfs/slots.yaml +12 -5
  25. package/runtime/knowledge/patterns/fe/folder.yaml +1 -1
  26. package/runtime/modules/kernel/failure-codes.yaml +23 -13
  27. package/runtime/scripts/hfs/architecture/clones.mjs +12 -2
  28. package/runtime/scripts/hfs/architecture/component-tiers.d.mts +46 -0
  29. package/runtime/scripts/hfs/architecture/component-tiers.mjs +161 -0
  30. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  31. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/frontend.mjs +2 -2
  33. package/runtime/scripts/hfs/architecture/index.mjs +6 -0
  34. package/runtime/scripts/hfs/architecture/render-graph.d.mts +125 -0
  35. package/runtime/scripts/hfs/architecture/render-graph.mjs +449 -0
  36. package/runtime/scripts/hfs/architecture/render-prop-across-server.d.mts +23 -0
  37. package/runtime/scripts/hfs/architecture/render-prop-across-server.mjs +58 -0
  38. package/runtime/scripts/hfs/architecture/surface-nesting.d.mts +44 -0
  39. package/runtime/scripts/hfs/architecture/surface-nesting.mjs +127 -0
  40. package/runtime/scripts/hfs/architecture/unit-spec-providers.d.mts +2 -1
  41. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +12 -5
  42. package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
  43. package/runtime/scripts/work/draw/draw-dna.mjs +1 -1
  44. package/scaffold/add-fe-layout.mjs +3 -3
  45. package/scaffold/add-fe-overlay.mjs +4 -4
  46. package/scaffold/add-fe-page.mjs +1 -1
  47. package/scaffold/add-fe-unit.d.mts +2 -1
  48. package/scaffold/add-fe-unit.mjs +14 -12
  49. package/scaffold/add.d.mts +1 -1
  50. package/scaffold/add.mjs +1 -1
  51. package/src/main.mjs +4 -4
  52. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +3 -4
  53. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +8 -5
  54. package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +1 -3
  55. package/templates/fe/skeleton/packages/__project__-ui/src/branches/Providers/index.tsx +3 -3
  56. package/templates/fe/skeleton/packages/__project__-ui/src/composites/LocaleShell/index.tsx +3 -5
  57. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -1
  58. package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +1 -1
  59. package/templates/fe/skeleton-lite/apps/web/src/modules/brand/brand.css +1 -2
  60. package/templates/fe/units/block/component.tsx.tpl +2 -3
  61. package/templates/fe/units/branch/index.tsx.tpl +4 -3
  62. package/templates/fe/units/composite/index.tsx.tpl +3 -3
  63. package/templates/fe/units/frame/index.tsx.tpl +10 -0
  64. package/templates/fe/units/layout/index.tsx.tpl +2 -2
  65. package/templates/fe/units/leaf/index.tsx.tpl +3 -3
  66. package/templates/fe/units/page/component.tsx.tpl +2 -2
  67. package/templates/fe/units/page/index.tsx.tpl +2 -2
  68. package/templates/fe/units/page/stories.tsx.tpl +2 -2
  69. package/templates/fe/units/slot/SlotView.tsx.tpl +11 -10
  70. package/templates/fe/units/slot/data-slot.ts.tpl +20 -8
  71. package/templates/fe/units/slot/slot.shared.ts.tpl +52 -11
  72. package/templates/fe/units/slot/useSlotOfReads.ts.tpl +19 -0
  73. /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/classNames.ts +0 -0
  74. /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/index.tsx +0 -0
  75. /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/classNames.ts +0 -0
  76. /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/index.tsx +0 -0
@@ -0,0 +1,50 @@
1
+ /** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
2
+ type Ts = typeof import('typescript');
3
+ /** One safe rewrite of the report. */
4
+ export interface TierBindingChange {
5
+ file: string;
6
+ line: number;
7
+ column: number;
8
+ code: string;
9
+ from: string;
10
+ to: string;
11
+ }
12
+ /** One case left for a person. */
13
+ export interface TierBindingFinding {
14
+ file: string;
15
+ line: number;
16
+ column: number;
17
+ code: string;
18
+ reason: string;
19
+ component?: string;
20
+ source?: string;
21
+ }
22
+ /** The report of a migration (`starci app codemod tier-binding`). */
23
+ export interface TierBindingReport {
24
+ schema: string;
25
+ root: string;
26
+ write: boolean;
27
+ filesScanned: number;
28
+ filesChanged: string[];
29
+ changes: TierBindingChange[];
30
+ findings: TierBindingFinding[];
31
+ }
32
+ /** Migrate nested JSX children to named props and report the cases the types now refuse; a case with no mechanical rewrite keeps its source. */
33
+ export declare function transformTierBinding({ ts, text, file }: {
34
+ ts: Ts;
35
+ text: string;
36
+ file?: string;
37
+ }): {
38
+ text: string;
39
+ changes: TierBindingChange[];
40
+ findings: TierBindingFinding[];
41
+ };
42
+ /** Preview or apply the safe rewrites in one app's FE tree (`fe/`, or `src/` and `stories/` of a single-side app); every unresolved case is kept in the returned report. */
43
+ export declare function migrateTierBinding({ root, write, ts }: {
44
+ root: string;
45
+ write?: boolean;
46
+ ts?: Ts | null;
47
+ }): TierBindingReport;
48
+ /** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
49
+ export declare function tierBindingMarkdown(report: TierBindingReport): string;
50
+ export {};
@@ -0,0 +1,214 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
5
+ import { sourceLocation } from '../runtime/scripts/lib/ts-ast.mjs';
6
+ import { shadowed } from './small-renames.mjs';
7
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ const GRAMMAR = /^@starci\/grammar(?:\/|$)/;
9
+ const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
10
+ /** Components whose child became a named prop: the JSX child moves into it. `text` takes a string, the rest a node. */
11
+ const CHILD_TO_PROP = new Map([
12
+ ['Tooltip', { prop: 'trigger' }], ['Disclosure', { prop: 'body' }], ['DragItem', { prop: 'body' }], ['MessageBubble', { prop: 'body' }],
13
+ ['MediaFrame', { prop: 'media' }], ['Field', { prop: 'control' }], ['Quote', { prop: 'text', text: true }],
14
+ ]);
15
+ /** Leaves whose only child is their text label (`LabelChild`). */
16
+ const LABEL_ONLY = new Set(['Button', 'Badge', 'Heading', 'Link', 'TextAction', 'Code']);
17
+ /** Components that take no children at all (`NoChildren`): their anatomy is fields and named slots. */
18
+ const NO_CHILDREN = new Set([
19
+ 'Avatar', 'AvatarButton', 'Checkbox', 'DateField', 'Divider', 'Icon', 'IconButton', 'IconTile', 'Image', 'Kbd', 'Mascot', 'Meter', 'NumberField', 'PressableField',
20
+ 'Progress', 'ProgressCircle', 'RelativeTime', 'SearchField', 'Skeleton', 'Slider', 'Spinner', 'StatusDot', 'Switch', 'TimeField', 'Video',
21
+ 'Accordion', 'BubbleMap', 'Calendar', 'ComboBox', 'CopyField', 'DataTable', 'DatePicker', 'DateRangePicker', 'DropdownMenu', 'FileDropzone', 'FlowGraph', 'Input', 'ListBox',
22
+ 'OtpInput', 'Rating', 'Select', 'Tabs', 'Terminal', 'Textarea',
23
+ 'Alert', 'AvatarGroup', 'ButtonGroup', 'CheckboxGroup', 'EmptyNotice', 'FilterButtonGroup', 'Footer', 'IdentityLockup', 'Pagination', 'RadioGroup',
24
+ 'SegmentedControl', 'Sidebar', 'Stepper', 'TagGroup', 'Timeline', 'Toaster', 'TopBar',
25
+ ]);
26
+ /** Components that lost `className`: the stylesheet and the structure decide the look. `Freestyle` is the one hole and keeps it. */
27
+ const NO_CLASS_NAME = new Set([...NO_CHILDREN, ...LABEL_ONLY, ...CHILD_TO_PROP.keys(), 'Page', 'PaneGrid', 'NavLandmark', 'GrammarRoot', 'HorizontalScrollRegion', 'VerticalScrollRegion', 'WorkspaceShell', 'Stack', 'Cluster', 'Grid']);
28
+ /** The JSX text between two tags as React reads it: lines trimmed and joined by one space, blank lines dropped. */
29
+ function jsxText(raw) {
30
+ return raw.split(/\r?\n/).map(line => line.trim()).filter(Boolean).join(' ');
31
+ }
32
+ /** Migrate nested JSX children to named props and report the cases the types now refuse; a case with no mechanical rewrite keeps its source. */
33
+ export function transformTierBinding({ ts, text, file = 'component.tsx' }) {
34
+ const changes = [];
35
+ const findings = [];
36
+ if (!text.includes('@starci/grammar'))
37
+ return { text, changes, findings };
38
+ const syntax = ts.transpileModule(text, { fileName: file, reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest } }).diagnostics ?? [];
39
+ if (syntax.length)
40
+ return { text, changes, findings: [{ file, line: 1, column: 1, code: 'PARSE_ERROR', reason: ts.flattenDiagnosticMessageText(syntax[0]?.messageText ?? '', '\n') }] };
41
+ let current = text;
42
+ // A child moves into a prop of its parent; the parent's own parent may then be rewritten too, so passes run until nothing changes.
43
+ for (let pass = 0; pass < 12; pass += 1) {
44
+ const result = onePass({ ts, text: current, file });
45
+ if (pass === 0)
46
+ findings.push(...result.findings);
47
+ changes.push(...result.changes);
48
+ if (result.text === current)
49
+ break;
50
+ current = result.text;
51
+ }
52
+ return { text: current, changes, findings };
53
+ }
54
+ function onePass({ ts, text, file }) {
55
+ const changes = [];
56
+ const findings = [];
57
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
58
+ const locals = new Map();
59
+ const namespaces = new Set();
60
+ for (const statement of source.statements) {
61
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !GRAMMAR.test(statement.moduleSpecifier.text))
62
+ continue;
63
+ const named = statement.importClause?.namedBindings;
64
+ if (named && ts.isNamespaceImport(named))
65
+ namespaces.add(named.name.text);
66
+ if (named && ts.isNamedImports(named))
67
+ for (const specifier of named.elements)
68
+ locals.set(specifier.name.text, specifier.propertyName?.text ?? specifier.name.text);
69
+ }
70
+ if (!locals.size && !namespaces.size)
71
+ return { text, changes, findings };
72
+ const resolve = (tag) => {
73
+ if (ts.isIdentifier(tag)) {
74
+ const imported = locals.get(tag.text);
75
+ return imported !== undefined && !shadowed(ts, tag) ? { name: imported } : null;
76
+ }
77
+ if (ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) && namespaces.has(tag.expression.text) && !shadowed(ts, tag.expression))
78
+ return { name: tag.name.text };
79
+ return null;
80
+ };
81
+ const flag = (node, code, reason, component) => {
82
+ findings.push({ file, ...sourceLocation(source, node), code, reason, component, source: node.getText(source).slice(0, 200) });
83
+ };
84
+ const rewrites = [];
85
+ const visitElement = (element) => {
86
+ const opening = ts.isJsxElement(element) ? element.openingElement : element;
87
+ const resolved = resolve(opening.tagName);
88
+ if (resolved === null)
89
+ return;
90
+ const { name } = resolved;
91
+ const attributes = opening.attributes.properties;
92
+ const hasSpread = attributes.some(ts.isJsxSpreadAttribute);
93
+ if (NO_CLASS_NAME.has(name) && attributes.some(attribute => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === 'className')) {
94
+ flag(opening, 'CLASS_NAME_REMOVED', `${name} takes no className: the structure and the role props decide the look; remove it, or state the role with the component that owns it`, name);
95
+ }
96
+ if (name === 'Tooltip' && attributes.some(attribute => ts.isJsxAttribute(attribute) && ['isDisabledTrigger', 'triggerDescribedBy'].includes(attribute.name.getText(source)))) {
97
+ flag(opening, 'TOOLTIP_DISABLED_TRIGGER', 'Tooltip no longer wraps a disabled control: give the Button `disabledReason`, which shows these words itself', name);
98
+ }
99
+ if (!ts.isJsxElement(element))
100
+ return;
101
+ const children = element.children.filter(child => !(ts.isJsxText(child) && child.containsOnlyTriviaWhiteSpaces));
102
+ if (!children.length)
103
+ return;
104
+ const toProp = CHILD_TO_PROP.get(name);
105
+ if (toProp !== undefined) {
106
+ if (hasSpread) {
107
+ flag(element, 'SPREAD_PROPS', `${name} takes a spread: move the child into \`${toProp.prop}\` by hand`, name);
108
+ return;
109
+ }
110
+ let value = null;
111
+ const only = children.length === 1 ? children[0] : undefined;
112
+ if (toProp.text === true) {
113
+ if (only !== undefined && ts.isJsxText(only))
114
+ value = `{${JSON.stringify(jsxText(only.getText(source)))}}`;
115
+ else if (only !== undefined && ts.isJsxExpression(only) && only.expression !== undefined)
116
+ value = `{${only.expression.getText(source)}}`;
117
+ else {
118
+ flag(element, 'CHILD_NOT_TEXT', `${name} \`${toProp.prop}\` is a string: the child is not plain text, move it by hand`, name);
119
+ return;
120
+ }
121
+ }
122
+ else if (only !== undefined && ts.isJsxExpression(only) && only.expression !== undefined)
123
+ value = `{${only.expression.getText(source)}}`;
124
+ else if (only !== undefined && ts.isJsxText(only))
125
+ value = `{${JSON.stringify(jsxText(only.getText(source)))}}`;
126
+ else
127
+ value = `{<>${element.children.map(child => source.text.slice(child.pos, child.end)).join('')}</>}`;
128
+ if (only !== undefined && (ts.isJsxElement(only) || ts.isJsxSelfClosingElement(only) || ts.isJsxFragment(only)))
129
+ value = `{${only.getText(source)}}`;
130
+ const head = opening.getText(source).replace(/>$/, '').trimEnd();
131
+ const replacement = `${head} ${toProp.prop}=${value} />`;
132
+ rewrites.push({ element, edit: { start: element.getStart(source), end: element.end, text: replacement }, change: { file, ...sourceLocation(source, element), code: 'CHILD_TO_PROP', from: `<${name}>…</${name}>`, to: `<${name} ${toProp.prop}={…} />` } });
133
+ return;
134
+ }
135
+ if (LABEL_ONLY.has(name)) {
136
+ const first = children[0];
137
+ const textual = children.length === 1 && first !== undefined && (ts.isJsxText(first) || ts.isJsxExpression(first));
138
+ if (!textual)
139
+ flag(element, 'LABEL_NOT_TEXT', `${name} takes one text label as its child; its content is not plain text, move the glyph to startContent or endContent and keep the words`, name);
140
+ return;
141
+ }
142
+ if (NO_CHILDREN.has(name))
143
+ flag(element, 'TAKES_NO_CHILDREN', `${name} draws a fixed anatomy from its props and takes no children: place the content in the prop or slot that names its role`, name);
144
+ };
145
+ const innermost = new Set();
146
+ const visit = (node) => {
147
+ if (ts.isJsxElement(node))
148
+ visitElement(node);
149
+ else if (ts.isJsxSelfClosingElement(node))
150
+ visitElement(node);
151
+ ts.forEachChild(node, visit);
152
+ };
153
+ visit(source);
154
+ // Only a rewrite with no other rewrite inside it runs in this pass; the next pass takes the parent.
155
+ for (const entry of rewrites)
156
+ if (!rewrites.some(other => other !== entry && other.edit.start >= entry.edit.start && other.edit.end <= entry.edit.end))
157
+ innermost.add(entry.element);
158
+ const applied = rewrites.filter(entry => innermost.has(entry.element)).sort((a, b) => b.edit.start - a.edit.start);
159
+ let output = text;
160
+ for (const entry of applied) {
161
+ output = output.slice(0, entry.edit.start) + entry.edit.text + output.slice(entry.edit.end);
162
+ changes.push(entry.change);
163
+ }
164
+ return { text: output, changes, findings };
165
+ }
166
+ function sourceFiles(directory) {
167
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
168
+ const target = path.join(directory, entry.name);
169
+ if (entry.isDirectory())
170
+ return GENERATED.has(entry.name) ? [] : sourceFiles(target);
171
+ return entry.isFile() && /\.[jt]sx$/i.test(entry.name) ? [target] : [];
172
+ }).sort();
173
+ }
174
+ /** Preview or apply the safe rewrites in one app's FE tree (`fe/`, or `src/` and `stories/` of a single-side app); every unresolved case is kept in the returned report. */
175
+ export function migrateTierBinding({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
176
+ if (!ts)
177
+ throw new Error('tier-binding needs TypeScript installed in the app or @starci/hfs development checkout');
178
+ const folders = ['fe', 'src', 'stories'].map(name => path.join(root, name)).filter(folder => fs.statSync(folder, { throwIfNoEntry: false })?.isDirectory());
179
+ if (!folders.length)
180
+ throw new Error('tier-binding runs at an app root with a fe/ directory');
181
+ const report = { schema: 'starci/tier-binding-report@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
182
+ for (const target of folders.flatMap(sourceFiles)) {
183
+ const file = path.relative(root, target).split(path.sep).join('/');
184
+ const original = fs.readFileSync(target, 'utf8');
185
+ const result = transformTierBinding({ ts, text: original, file });
186
+ report.filesScanned += 1;
187
+ report.changes.push(...result.changes);
188
+ report.findings.push(...result.findings);
189
+ if (result.text !== original) {
190
+ report.filesChanged.push(file);
191
+ if (write)
192
+ fs.writeFileSync(target, result.text);
193
+ }
194
+ }
195
+ return report;
196
+ }
197
+ const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
198
+ /** Render the migration and what is left for a person as Markdown; JSON consumers read the same report object. */
199
+ export function tierBindingMarkdown(report) {
200
+ const out = ['# tier-binding', '', `Scanned ${report.filesScanned} files, ${report.filesChanged.length} ${report.write ? 'changed' : 'would change'}, ${report.changes.length} rewrites, ${report.findings.length} left for a person.`, ''];
201
+ if (report.findings.length) {
202
+ out.push('## Left for a person', '', '| location | code | reason |', '|---|---|---|');
203
+ for (const finding of report.findings)
204
+ out.push(`| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`);
205
+ out.push('');
206
+ }
207
+ if (report.changes.length) {
208
+ out.push('## Rewrites', '', '| location | code | from | to |', '|---|---|---|---|');
209
+ for (const item of report.changes)
210
+ out.push(`| ${cell(`${item.file}:${item.line}:${item.column}`)} | ${item.code} | \`${cell(item.from)}\` | \`${cell(item.to)}\` |`);
211
+ out.push('');
212
+ }
213
+ return `${out.join('\n')}\n`;
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.16.5",
3
+ "version": "4.17.2",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -171,10 +171,6 @@ shapes:
171
171
  Both opener paths default visibility to wide; an explicit visibility also resolves to wide.
172
172
  Under viewport="fixed",
173
173
  navigationTrack defaults to intrinsic so a collapsed sidebar leaves no empty track.
174
- - renderer: NavigationFeatureNav
175
- role: |
176
- The navbar may use this renderer or the WorkspaceShell header with TopBar. Its family CSS
177
- supplies sticky top placement, a 4rem band, a solid surface and a full-width bottom separator.
178
174
  - renderer: Sidebar
179
175
  role: |
180
176
  Desktop navigation uses the expanded or collapsed rail; the left mobile Drawer composes
@@ -201,10 +197,10 @@ shapes:
201
197
  - id: workspace-identity
202
198
  implements: [FE-APP-SHELL-2]
203
199
  compose: |
204
- IdentityLockup sits inside PlainPressable size="control". Its square logo mark has a
200
+ IdentityLockup is itself the pressable (size="control"). Its square logo mark has a
205
201
  transparent background, no radius and object-fit contain; without an image it uses Avatar's
206
202
  bundled DiceBear initials. The title
207
- has a muted role subtitle and no caret. PlainPressable has no button shape or hover effect;
203
+ has a muted role subtitle and no caret. The lockup has no button shape or hover effect;
208
204
  press uses scale(0.96), focus remains visible and reduced motion removes the zoom.
209
205
  - id: navbar-right-cluster
210
206
  implements: [FE-APP-SHELL-3]
@@ -343,7 +339,7 @@ guidance:
343
339
  LAYOUT-1 case-10 (owner:joined-multi-pane): the selected family realizes one touching pane
344
340
  surface with vertical Dividers. SURFACE-1 case-4 (owner:scroll-region-background-tone):
345
341
  VerticalScrollRegion tone="background" under messages and log entries, with the header and
346
- composer on the surface. ACTION-3 case-5 (owner:hover-actions-floating-toolbar): ChatBubble's
342
+ composer on the surface. ACTION-3 case-5 (owner:hover-actions-floating-toolbar): MessageBubble's
347
343
  public floating toolbar owns the IconButtons and tooltips beside the text; apps do not target
348
344
  starci-core internals. CTA-6 case-4 (owner:button-variant-follows-intent): ordinary repeated
349
345
  item commands are neutral and render ghost.
@@ -351,23 +347,22 @@ guidance:
351
347
  ownership: FE-RULE-OWNERSHIP
352
348
  implements: [ANATOMY-2, PADDING-0]
353
349
  requirement: |
354
- SurfaceCard (always painted), SurfaceListCard, a painted SurfaceAccordionCard, Sidebar, Dialog,
355
- AlertDialog, Drawer and Popover establish the surface nesting context; Rail and an unpainted
356
- SurfaceAccordionCard pass their parent's context through. Input, SearchField, Textarea,
350
+ SurfaceCard (always painted, of any kind, the accordion kind when painted), Sidebar, Dialog,
351
+ AlertDialog, Drawer and Popover establish the surface nesting context; Rail and
352
+ an unpainted accordion SurfaceCard pass their parent's context through. Input, SearchField, Textarea,
357
353
  NumberField, DateField, TimeField, Select, ComboBox, DatePicker, DateRangePicker, Checkbox,
358
354
  CheckboxGroup, RadioGroup, OtpInput and PressableField resolve variant="secondary" inside it and
359
355
  variant="primary" outside. Compose the surface and omit the field variant.
360
356
  Switch and Slider have no layer variant; a Radio follows its RadioGroup. PaneGrid's
361
- collapsed-pane drawer also establishes the context. SurfaceCard and bounded SurfaceAccordionCard derive
357
+ collapsed-pane drawer also establishes the context. SurfaceCard and a bounded accordion SurfaceCard derive
362
358
  data-grammar-surface-depth from the parent context. The top layer is raised and a nested layer is
363
359
  subordinate; never raise both. The family DNA owns their surface paint.
364
- FE_NESTED_VARIANT rejects a static field variant
365
- anywhere in app code and primary Tabs inside a painted surface; FE_GRAMMAR_LAYOUT_ONLY rejects
360
+ The field `variant` is not in the prop types, so an app cannot set it; FE_GRAMMAR_LAYOUT_ONLY rejects
366
361
  application paint utilities, arbitrary paint properties and inline style paint.
367
362
  DRAW_NESTED_VARIANT checks the corresponding rendered variant and
368
363
  surface stamps.
369
364
  ListBox resolves inset="panel" inside that context; callers may select the same value explicitly
370
- elsewhere. Its rows and sections captions share the panel edge. SurfaceListCard owns its list
365
+ elsewhere. Its rows and sections captions share the panel edge. The list kind of SurfaceCard owns its list
371
366
  shell with no added inset; SurfaceCard composition="joined" owns flush bands. A grouping wrapper
372
367
  adds no padding. An extra px-*, ps-* or pe-* around ListBox or a section caption stacks the inset
373
368
  and fails FE_LIST_PANEL_PADDING.
@@ -440,27 +435,27 @@ guidance:
440
435
  uiRules: [STATE-2]
441
436
  render: |
442
437
  The ready tree, drawn from placeholder data of the slot's type, in the shape the unit's
443
- `xDefaultState` names, with isSkeleton on every owner: same card, same bands, same row count
438
+ `xDefaultState` names, with isLoading on every owner: same card, same bands, same row count
444
439
  (idiom pending-same-tree). Never a spinner and never a different tree. The skeleton renders
445
440
  inert geometry and states no value; an unknown number is a bar, never a rendered zero.
446
441
  skeletonMap:
447
442
  - part: Text line or Heading
448
- render: "`isSkeleton` on the owner: one bar at that type-scale step's line height, as long as the placeholder text"
443
+ render: "`isLoading` on the owner: one bar at that type-scale step's line height, as long as the placeholder text"
449
444
  - part: Media (Image, MediaFrame viewport)
450
445
  render: "`Skeleton shape=\"rect\"` at the frame's `ratio` (square, landscape, wide); `shape=\"circle\"` for an avatar. A block at the ratio, never a collapsed box"
451
446
  - part: Button
452
447
  render: |
453
448
  A bar at the Button's height with the Button's pill corner (family.yaml button-pill):
454
- `isSkeleton` where the installed Button publishes it, otherwise `Skeleton shape="rect"` in
449
+ `isLoading` where the installed Button publishes it, otherwise `Skeleton shape="rect"` in
455
450
  the Button's box. It never fires (idiom pending-same-tree action-must-not-fire)
456
451
  - part: A region that exists only on ready data (a disclosure of the loaded lines)
457
- render: Absent while isSkeleton (STATE-3)
452
+ render: Absent while isLoading (STATE-3)
458
453
  - status: unauthorized
459
454
  uiRules: [STATE-3]
460
455
  render: |
461
456
  401 is its own status, never forbidden and never an error. The slot shows one sign-in prompt in
462
457
  place: `EmptyNotice message={labels.unauthorized} actionLabel={labels.signIn}
463
- actionVariant="primary" onAction={signIn}`, where `signIn` opens the product's sign-in route and
458
+ onAction={signIn}`, where `signIn` opens the product's sign-in route and
464
459
  returns to this address. The prompt discloses none of the slot's data. A secondary slot hides, as
465
460
  forbidden does, so a page whose reads all need a session shows the prompt once, in its primary slot.
466
461
  - status: forbidden
@@ -475,7 +470,7 @@ guidance:
475
470
  render: |
476
471
  Retry at the slot, re-running that slot's read only (the query's `mutate`), never a page
477
472
  reload. Block form, where the slot is a region: `EmptyNotice message={labels.error}
478
- actionLabel={labels.retry} actionVariant="secondary" onAction={retry}` with `isActionPending`
473
+ actionLabel={labels.retry} onAction={retry}` with `isActionPending`
479
474
  bound while the retry runs. Inline form, where the slot is one line inside a row or band too
480
475
  small for a notice: `Alert tone="negative"` in the slot's place, carrying the retry action. A
481
476
  command that is unsafe to repeat is not retried (FEEDBACK-2 case-4); a rejected field value
@@ -486,7 +481,7 @@ guidance:
486
481
  `EmptyNotice message={labels.empty}` with the slot's own empty sentence; `actionLabel` and
487
482
  `onAction` only when a real next step exists, otherwise both are omitted.
488
483
  - status: ready
489
- render: The slot's items through the same tree, isSkeleton false.
484
+ render: The slot's items through the same tree, isLoading false.
490
485
  notFound: |
491
486
  A route whose resource does not exist calls `notFound()` in its route adapter (FE-FUNCTION-7
492
487
  case-2). 404 is never a page state, a shape or a slot status.
@@ -615,9 +610,6 @@ presentation:
615
610
  - component: HorizontalScrollRegion
616
611
  element: root
617
612
  rule: PADDING-1
618
- - component: NavigationFeatureNav
619
- element: primary
620
- rule: PADDING-3
621
613
  - component: OtpInput
622
614
  element: root
623
615
  rule: PADDING-1
@@ -669,22 +661,22 @@ presentation:
669
661
  - component: Subnav
670
662
  element: root
671
663
  rule: PADDING-3
672
- - component: SurfaceAccordionCard
664
+ - component: SurfaceCard (accordion kind)
673
665
  element: accordion panel
674
666
  rule: PADDING-0
675
- - component: SurfaceAccordionCard
667
+ - component: SurfaceCard (accordion kind)
676
668
  element: accordion trigger
677
669
  rule: PADDING-4
678
- - component: SurfaceAccordionCard
670
+ - component: SurfaceCard (accordion kind)
679
671
  element: body padding-top
680
672
  rule: PADDING-0
681
- - component: SurfaceAccordionCard
673
+ - component: SurfaceCard (accordion kind)
682
674
  element: body padding-bottom
683
675
  rule: PADDING-3
684
- - component: SurfaceAccordionCard
676
+ - component: SurfaceCard (accordion kind)
685
677
  element: body padding-right
686
678
  rule: PADDING-4
687
- - component: SurfaceAccordionCard
679
+ - component: SurfaceCard (accordion kind)
688
680
  element: body padding-left
689
681
  rule: PADDING-5
690
682
  - component: SurfaceCard
@@ -693,7 +685,7 @@ presentation:
693
685
  - component: SurfaceCard
694
686
  element: content, composition="joined"
695
687
  rule: PADDING-0
696
- - component: SurfaceListCard
688
+ - component: SurfaceCard (list kind)
697
689
  element: root
698
690
  rule: PADDING-0
699
691
  - component: Tabs
@@ -776,7 +768,7 @@ presentation:
776
768
  render: '`composition="joined"` already sets `0`'
777
769
  - id: case-3
778
770
  when: A Grammar list sits inside a panel or its rows own their own inset
779
- owner: "`SurfaceListCard` / `ListBox`"
771
+ owner: "`SurfaceCard` list kind / `ListBox`"
780
772
  render: ListBox resolves inset="panel" in a surface nesting context, or callers select it explicitly. Its
781
773
  row boxes align with the panel content edge, like the search field and tabs boxes, without
782
774
  negative-margin bleed (owner:panel-list-box-edge). The list retains HeroUI p-1; items retain
@@ -842,7 +834,7 @@ presentation:
842
834
  render: Compose the card; no padding
843
835
  - id: case-3
844
836
  when: A disclosure trigger and its expanded panel
845
- owner: "`SurfaceAccordionCard`"
837
+ owner: "`SurfaceCard` accordion kind"
846
838
  render: Compose the card; no padding
847
839
  - id: case-4
848
840
  when: An empty-state notice
@@ -995,9 +987,6 @@ presentation:
995
987
  - component: Text
996
988
  element: root, size="md"
997
989
  rule: FONT-3
998
- - component: Text
999
- element: root, size="metric-lead"
1000
- rule: FONT-5
1001
990
  - component: Text
1002
991
  element: root, size="sm"
1003
992
  rule: FONT-2
@@ -1016,10 +1005,10 @@ presentation:
1016
1005
  - component: Tooltip
1017
1006
  element: content
1018
1007
  rule: FONT-1
1019
- - component: SurfaceListCard
1008
+ - component: SurfaceCard (list kind)
1020
1009
  element: fact, label is visible and fact is present, depth="nested"
1021
1010
  rule: FONT-1
1022
- - component: SurfaceListCard
1011
+ - component: SurfaceCard (list kind)
1023
1012
  element: fact, label is visible and fact is present, depth!="nested"
1024
1013
  rule: FONT-2
1025
1014
  guidance:
@@ -1029,11 +1018,14 @@ presentation:
1029
1018
  Weight is a second axis, not a position on the size scale. Its public set is closed.
1030
1019
  | Prop | Rendered | Use for |
1031
1020
  | --- | --- | --- |
1032
- | `weight="normal"` | `font-normal`, the default | Reading copy and ordinary facts |
1033
- | `weight="medium"` | `font-medium` | A compact label, or a title above its own description |
1034
- | `weight="semibold"` | `font-semibold` | A fact that must be found first within its region |
1021
+ | Where | Rendered | Use for |
1022
+ | --- | --- | --- |
1023
+ | `Text`, no prop | `font-normal`, the default | Reading copy and ordinary facts |
1024
+ | `Heading` levels 3 and 4, and labels | `font-medium` | A compact label, or a title above its own description |
1025
+ | `Text isStrong` | `font-semibold` | A value or a term that must be found first inside its line |
1035
1026
  Weight refines a rank the application has already chosen. It never manufactures a new hierarchy
1036
- level, and it never substitutes for a heading. `Heading` applies its own weight per level and takes
1027
+ level, and it never substitutes for a heading: a group is headed by its `Section` title or its
1028
+ card label, never by a strong `Text`. `Heading` applies its own weight per level and takes
1037
1029
  no weight prop.
1038
1030
  Bold beyond `semibold` has no public prop. Treat an intent that needs it as a gap rather than writing
1039
1031
  a class.
@@ -1089,8 +1081,8 @@ presentation:
1089
1081
  cases:
1090
1082
  - id: case-1
1091
1083
  when: The single leading metric or short value that is the main fact of its region
1092
- owner: "`Text`"
1093
- render: '`<Text size="metric-lead" weight="semibold">84%</Text>`'
1084
+ owner: "`DescriptionList` layout=\"stat-strip\""
1085
+ render: '`<DescriptionList layout="stat-strip" items={[{ id: "rate", term: "Resolved", description: "84%" }]} />`'
1094
1086
  notThisRule: sentences, decoration, or imitating a heading. It creates no heading semantics.
1095
1087
  - rule: FONT-6
1096
1088
  title: "`text-4xl` / `leading-tight`"
@@ -1105,7 +1097,7 @@ anatomyObservations:
1105
1097
  lane: ui
1106
1098
  kind: proven-template
1107
1099
  summary: |-
1108
- Verified in the draw lab against the real SurfaceListCard render — the prompt shape that
1100
+ Verified in the draw lab against the real list SurfaceCard render — the prompt shape that
1109
1101
  produced a direction both faithful and beautiful:
1110
1102
  1. Frame: flat product UI screenshot, size, light theme.
1111
1103
  2. SUBJECT: screen name + product name.
@@ -1122,11 +1114,9 @@ anatomyObservations:
1122
1114
  lane: ui
1123
1115
  kind: anatomy-detail
1124
1116
  summary: |-
1125
- The two card components place their labels differently, and a prompt that treats them
1126
- alike invents one of them. `SurfaceCard` paints one material box around label AND
1127
- content — its label ("New task") sits inside the card at the top. `SurfaceListCard`
1128
- renders its label and fact as siblings on the page background ABOVE the list shell —
1129
- the card bounds only the rows, the footer sits below it.
1117
+ Every `SurfaceCard` kind except `tile` places its label outside: an unpainted row above the material
1118
+ with the fact and the end action, and the card bounds only the content, the footer note below it.
1119
+ A prompt that draws the label inside the card invents a placement the grammar does not have.
1130
1120
  - id: list-separator-bleed
1131
1121
  lane: ui
1132
1122
  kind: anatomy-detail
@@ -1140,7 +1130,7 @@ anatomyObservations:
1140
1130
  lane: ui
1141
1131
  kind: anatomy-detail
1142
1132
  summary: |-
1143
- An interactive row in a SurfaceListCard is the grammar `ListRow` (@starci/grammar 0.9.1), never an
1133
+ An interactive row in a list SurfaceCard is the grammar `ListRow` (@starci/grammar 0.9.1), never an
1144
1134
  app-owned `<li>` and never a second `<ul>` nested in the card (the card already renders the list,
1145
1135
  so a nested list loses the row inset and the separator - a defect that leaves rows flush against
1146
1136
  the card edge). Its slots, in reading order: `leading`
@@ -69,8 +69,8 @@ shapes:
69
69
  Keep: which sections exist and which one is the next step. Never crosses over: layout grid, card
70
70
  chrome, or a section that would need a renderer the family does not publish.
71
71
  composition:
72
- - renderer: SectionHeader
73
- role: The greeting or section title; its eyebrow reads `accentText`, never the pink
72
+ - renderer: Section
73
+ role: The greeting or section title
74
74
  - renderer: SurfaceCard
75
75
  role: |
76
76
  One top-level sticker for the next step (`isHighlight` on that card only), holding the title
@@ -78,7 +78,7 @@ shapes:
78
78
  Button variant="primary" — the view's single pink fill
79
79
  - renderer: "Progress / ProgressCircle / Meter"
80
80
  role: Only a verified value with a truthful `label` (ACCENT-4); an unknown value is not drawn
81
- - renderer: SurfaceListCard
81
+ - renderer: SurfaceCard (list kind)
82
82
  role: Peers (recent items, suggestions) as static rows; no second primary Button
83
83
  - renderer: "Skeleton / EmptyNotice"
84
84
  role: Pending is the same tree at rest (Skeleton in place of content); empty or failed is EmptyNotice
@@ -182,10 +182,10 @@ shapes:
182
182
  role: The query, labelled
183
183
  - renderer: TagGroup
184
184
  role: Filters as outlined chips; selected chips turn pink and lift
185
- - renderer: "ListBox / SurfaceListCard"
185
+ - renderer: "ListBox / SurfaceCard (list kind)"
186
186
  role: |
187
187
  ListBox when picking is the task (yellow hover wash, pink focus ring, soft-pink selected row
188
- with a pink leading edge); SurfaceListCard when rows only report facts
188
+ with a pink leading edge); the list kind of SurfaceCard when rows only report facts
189
189
  - renderer: Pagination
190
190
  role: Current page is the raised pink pill
191
191
  - renderer: "Skeleton / EmptyNotice"
@@ -31,42 +31,42 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.20.2
34
+ version: 0.21.2
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.11.6
41
+ version: 3.11.9
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.8.5
48
+ version: 9.9.2
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.20.2
55
+ version: 0.21.2
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.20.2
62
+ version: 0.21.2
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
66
66
  source: packages/eslint-grammar-offset-pop/package.json
67
67
  why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
68
68
  '@starci/stylelint-canon':
69
- version: 2.4.0
69
+ version: 2.5.0
70
70
  group: starci
71
71
  install: registry
72
72
  side: fe
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.16.5
109
+ version: 4.17.2
110
110
  group: starci
111
111
  install: registry
112
112
  side: both