@starci/eslint-canon-fe 9.8.5 → 9.9.0

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 (38) hide show
  1. package/CHANGELOG.md +16 -3
  2. package/README.md +2 -4
  3. package/dist/button-intent.d.ts +0 -1
  4. package/dist/button-intent.js +1 -16
  5. package/dist/file-layout.js +2 -1
  6. package/dist/index.js +2 -0
  7. package/dist/lib/config.js +3 -2
  8. package/dist/lib/scope.d.ts +1 -1
  9. package/dist/lib/scope.js +1 -1
  10. package/dist/lib/why.d.ts +20 -5
  11. package/dist/lib/why.js +26 -11
  12. package/dist/loading.js +1 -1
  13. package/dist/props-and-slots.d.ts +6 -0
  14. package/dist/props-and-slots.js +41 -0
  15. package/dist/shape-slot.js +6 -6
  16. package/dist/skeleton-states.d.ts +16 -0
  17. package/dist/skeleton-states.js +230 -0
  18. package/dist/surface-rules.d.ts +2 -5
  19. package/dist/surface-rules.js +28 -121
  20. package/dist/ui-rulings.js +1 -1
  21. package/docs/button-intent.md +0 -1
  22. package/docs/size-and-state-budget.md +1 -1
  23. package/docs/surface-rules.md +17 -32
  24. package/package.json +1 -1
  25. package/runtime/bundle.json +4 -0
  26. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  27. package/runtime/knowledge/hfs/slots.yaml +5 -5
  28. package/runtime/knowledge/patterns/fe/folder.yaml +1 -1
  29. package/runtime/scripts/hfs/architecture/clones.mjs +12 -2
  30. package/runtime/scripts/hfs/architecture/component-tiers.mjs +161 -0
  31. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  33. package/runtime/scripts/hfs/architecture/frontend.mjs +2 -2
  34. package/runtime/scripts/hfs/architecture/index.mjs +6 -0
  35. package/runtime/scripts/hfs/architecture/render-graph.mjs +449 -0
  36. package/runtime/scripts/hfs/architecture/render-prop-across-server.mjs +58 -0
  37. package/runtime/scripts/hfs/architecture/surface-nesting.mjs +127 -0
  38. package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 9.9.0 - 2026-11-02
6
+
7
+ - Added: `no-hand-skeleton` is published (R367) and requested at error: an app draws no `Skeleton`, `ChatBubbleSkeletons` or `MessageBubbleSkeletons`, because the Grammar components draw their loading structure from `isLoading`. Changed: `no-data-status-prop` lets a leaf, branch or composite take `isLoading` (the one flag below SlotView) and refuses `isError`, `isForbidden`, `isEmpty`, `isValidating`; `slotOfReads` counts as a slot builder.
8
+
9
+ - Removed: `NavigationFeatureNav` from the surface names and the bundled Core playbook (the component is gone; `TopBar` takes `subnavigation`).
10
+ - Added: `tier-children` (R92): only a unit of `components/frames` takes `children`; a leaf takes `children: string`; a block, a composite and a branch take none (`children?: never`) and `PropsWithChildren` is refused there.
11
+ - Added: `no-swr-default-array`, `no-foreign-slot-status`, `no-validating-as-loading`, `no-placeholder-object` (R367; `FE_SWR_DEFAULT_ARRAY`, `FE_FOREIGN_SLOT_STATUS`, `FE_VALIDATING_AS_LOADING`, `FE_PLACEHOLDER_OBJECT`): a loading state is drawn from the page's own reads. `no-hand-skeleton` is written and tested but not published until the Grammar owns the loading shape.
12
+ - Removed: `button-no-variant` and `nested-control-variant` (the `variant` prop is out of the Grammar types, so the props they refused cannot be written).
13
+
14
+ - Changed: `no-heading-above-card` (R354) follows the removal of `SectionHeader`: it refuses a `Section` (not `kind="list"`) whose only child is one card, and a card whose `ariaLabel` repeats the `Section` title. `no-hand-built-panes` reads a `Section` as a column where it read `Region`, and `no-commit-in-collection-item` no longer names the removed `CollectionItem`, `ItemActions` and `ListItem`.
15
+
16
+ - eslint-canon-fe: the surface rules follow the one card (SurfaceListCard and SurfaceAccordionCard are gone).
17
+ - Added: `frames` joins the component tiers (`components/frames/<Name>`, `meta.shape = 'frame'`); a frame is a lower tier for the world check (it owns no data, session or navigation).
18
+
3
19
  ## 9.8.5 - 2026-11-01
4
20
 
5
21
  - Added: `no-heading-above-card` (R354, `FE_HEADING_ABOVE_CARD`): a `SectionHeader` directly above a single card, or a card whose `ariaLabel` repeats the header title, is refused; several cards under one header stay legal.
@@ -170,7 +186,6 @@
170
186
  - Removed: `callback-shape` (R287 `FE_CALLBACK_SHAPE`) with the `fe.route.callback` slot: the OAuth callback is a back-end door.
171
187
  - Changed: the bundled slot manifest 3.9.0 replaces the lite `app.supabase*` slots with `app.db` (`db/migrations`, `db/migrations/archive`, `db/seed.sql`, the managed `db/bootstrap.sql`), `be.integrations.supabase` with `be.integrations.passport` and `be.integrations.object-storage`, removes `fe.route.callback`, sets lite `schemaAuthority: sql`, gives lite TypeORM entities in `be.persistence`, adds `ruleParams.be.dbFunctions`, and moves the S3 SDKs to `integrations/object-storage` and the identity libraries to `integrations/passport` in `infraOwners` (`@supabase/*`, `minio` and `bcrypt` nowhere); the bundled rule catalog retires R216, R217, R219, R254 and R287 and adds R328-R334; the bundled canon pins name hfs 4.10.0, eslint-canon-be 3.7.0, eslint-canon-fe 9.5.0 and the Grammar family 0.15.11, pin `@nestjs/passport` 11.0.5 (CommonJS on the Nest 11 line), `passport` 0.7.0, `passport-google-oauth20` 2.0.0, `passport-local` 1.0.0, `bcryptjs` 3.0.3 and their types (tokens are signed and verified with `jose`; `@nestjs/jwt` stays allowed in `integrations/passport` but unpinned), and drop `@supabase/ssr`, `@supabase/supabase-js` and the `supabase` CLI.
172
188
 
173
-
174
189
  ## 9.4.9 - 2026-10-29
175
190
 
176
191
  - Changed: the bundled slot manifest gives the model libraries their owners in `ruleParams.be.infraOwners` (`@starci/ai-router` to `integrations/ai-gateway`, `@langchain/*` to `integrations/ai`; `openai`, `langchain`, `@langchain/langgraph`, `@langchain/community` and the provider SDKs nowhere) and lists R326 and R327 on `be.integrations`; the bundled canon pins name hfs 4.9.11, eslint-canon-be 3.6.8, eslint-canon-fe 9.4.9, the Grammar family 0.15.10 (grammar, eslint-grammar-core, eslint-grammar-offset-pop), and pin `@langchain/core` 1.3.1 and `@langchain/openai` 1.8.0. No rule changed.
@@ -433,7 +448,6 @@
433
448
  ## 8.2.17 - 2026-10-14
434
449
  - Changed: `component-line-budget` gives a pure re-export `index.ts` the slot's `barrel` budget (400); `basePropsAtom` admits named `render*` callbacks in `props` of non-block pure halves for feature composition; the bundled machine carries the kind-scoped feature tier edge, canon-shape clone exclusions and in-segment i18n template reads.
435
450
 
436
-
437
451
  - Changed: carry the runtime mirror of HFS manifest 3.1.0 (the back-end app composition standard, R245 and R246) and the canon pins of eslint-canon-be 3.3.0, HFS 4.3.0 and CLI 1.2.16. ESLint rule implementations are unchanged.
438
452
 
439
453
  ## 8.2.18 - 2026-10-14
@@ -686,7 +700,6 @@
686
700
 
687
701
  ## 5.1.2 - 2026-09-30
688
702
 
689
-
690
703
  ## 5.1.1 - 2026-09-30
691
704
 
692
705
  - **Fix: `use-client-only-at-boundary` knew only the app layout.** A workspace package keeps its grammar tiers directly under `src/` (`packages/<pkg>/src/{composites,branches,leaves}`, the `fe.package.ui` slot), but the rule accepted the directive only under `/components/...`, so nivo-fe's `packages/nivo-ui` reported 13 false findings. The tier names come from `PACKAGE_TIERS` in `lib/scope.mjs` (the slot's layers, no repo name); a package branch or leaf is a boundary, a package composite is not, and the app layout is unchanged.
package/README.md CHANGED
@@ -37,14 +37,12 @@ tree. `starciTypedSourceConfig({ root })` provides the shared typed, React Hooks
37
37
  configuration for source that has not adopted the product composition laws.
38
38
 
39
39
  The [surface composition law](docs/surface-rules.md) (R318) holds ANATOMY-2 and PADDING-0 for every
40
- family that publishes the shared HeroUI surface and field API: `nested-control-variant` refuses static
41
- variants on layer-aware fields and the pill (`variant="secondary"`) on Tabs, which are the underline bar (`FE_NESTED_VARIANT`),
42
- `no-app-surface-paint` keeps surface paint in Grammar (`FE_GRAMMAR_LAYOUT_ONLY`) and
40
+ family that publishes the shared HeroUI surface and field API: `no-app-surface-paint` keeps surface paint in Grammar (`FE_GRAMMAR_LAYOUT_ONLY`) and
43
41
  `no-list-panel-padding` refuses horizontal padding wrappers around Grammar lists and section captions
44
42
  within the nearest painted surface, a `Pane` or a `PaneSection` included (`FE_LIST_PANEL_PADDING`),
45
43
  `no-hand-built-panes` refuses a multi-pane frame drawn by hand, a vertical divider as a column seam or
46
44
  columns inside a joined card, where `PaneGrid` owns both (`FE_HAND_BUILT_PANES`), and
47
- `no-heading-above-card` refuses a `SectionHeader` above a single card (`FE_HEADING_ABOVE_CARD`), `no-selectable-tag-group` refuses a selection on a `TagGroup` (`FE_TAG_GROUP_SELECTION`), `no-fill-command-in-header` refuses a `width="fill"` Button in a page or card header slot (`FE_COMMAND_FILLS_HEADER`), and `no-table-in-card` refuses a `DataTable` inside a `SurfaceCard`, where a `SurfaceListCard` with its note in `footer` holds plain rows (`FE_TABLE_IN_CARD`). The R65 budget law's
45
+ `no-heading-above-card` refuses a `Section` around a single card (`FE_HEADING_ABOVE_CARD`), `no-selectable-tag-group` refuses a selection on a `TagGroup` (`FE_TAG_GROUP_SELECTION`), `no-fill-command-in-header` refuses a `width="fill"` Button in a page or card header slot (`FE_COMMAND_FILLS_HEADER`), and `no-table-in-card` refuses a `DataTable` inside a `SurfaceCard`, where a `SurfaceListCard` with its note in `footer` holds plain rows (`FE_TABLE_IN_CARD`). The R65 budget law's
48
46
  `no-app-relative-clock` leaves live relative time and countdowns to Grammar's shared clock.
49
47
 
50
48
  The spacing law (R350) leaves every distance to Grammar: `no-spacing-utility` refuses a padding, margin, gap
@@ -2,7 +2,6 @@ import { type Recommended } from "./lib/rule.js";
2
2
  /** Every intent law is universal across Grammar families and app editions. */
3
3
  export declare const rules: {
4
4
  "button-intent-required": import("./lib/rule.js").Rule<"FE_BUTTON_INTENT_REQUIRED", []>;
5
- "button-no-variant": import("./lib/rule.js").Rule<"FE_BUTTON_NO_VARIANT", []>;
6
5
  "destructive-confirm-in-dialog": import("./lib/rule.js").Rule<"FE_DESTRUCTIVE_CONFIRM_IN_DIALOG", []>;
7
6
  "single-commit-per-render": import("./lib/rule.js").Rule<"FE_SINGLE_COMMIT_PER_RENDER", []>;
8
7
  "no-commit-in-collection-item": import("./lib/rule.js").Rule<"FE_NO_COMMIT_IN_COLLECTION_ITEM", []>;
@@ -11,7 +11,7 @@ const memberName = (node) => (node.computed ? stringOf(node.property) : node.pro
11
11
  const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/;
12
12
  const BUTTONS = new Set(["Button", "IconButton"]);
13
13
  const DIALOGS = new Set(["AlertDialog", "ConfirmDialog"]);
14
- const ITEMS = new Set(["CollectionItem", "ItemActions", "ListItem", "ListRow", "ListBox.Item", "GridList.Item", "Table.Row", "TableRow"]);
14
+ const ITEMS = new Set(["ListBox.Item", "GridList.Item", "Table.Row", "ListRow"]);
15
15
  /** The consuming TypeScript program exposes literal intents and spread properties without executing app code. */
16
16
  const typedInfo = (context, node, key) => {
17
17
  const services = context.sourceCode.parserServices;
@@ -238,20 +238,6 @@ const canCoexist = (left, right) => {
238
238
  return false;
239
239
  return [...left.guards].every(([condition, value]) => !right.guards.has(condition) || right.guards.get(condition) === value);
240
240
  };
241
- /** No explicit visual prop: any authored variant, including a known props spread, is an app bypass. */
242
- const buttonNoVariant = defineRule({
243
- meta: { type: "problem", docs: { description: "CTA-6: app buttons never choose a variant." }, schema: [], messages: {
244
- FE_BUTTON_NO_VARIANT: "FE_BUTTON_NO_VARIANT: declare Button intent instead of variant; Grammar resolves the variant from intent and placement.",
245
- } },
246
- create(context) {
247
- return { JSXOpeningElement(opening) {
248
- if (!isButton(context, opening))
249
- return;
250
- for (const node of new Set(propsOf(context, opening, "variant").map((prop) => prop.node)))
251
- context.report({ node, messageId: "FE_BUTTON_NO_VARIANT" });
252
- } };
253
- },
254
- });
255
241
  /** Every app button states its command's intent explicitly. */
256
242
  const buttonIntentRequired = defineRule({
257
243
  meta: { type: "problem", docs: { description: "CTA-6: every app Button and IconButton declares intent." }, schema: [], messages: {
@@ -352,7 +338,6 @@ const noCommitInCollectionItem = defineRule({
352
338
  /** Every intent law is universal across Grammar families and app editions. */
353
339
  export const rules = {
354
340
  "button-intent-required": buttonIntentRequired,
355
- "button-no-variant": buttonNoVariant,
356
341
  "destructive-confirm-in-dialog": destructiveConfirmInDialog,
357
342
  "single-commit-per-render": singleCommitPerRender,
358
343
  "no-commit-in-collection-item": noCommitInCollectionItem,
@@ -216,7 +216,7 @@ export const monorepoTierBelongsToItsSide = defineRule({
216
216
  meta: {
217
217
  type: "problem",
218
218
  docs: {
219
- description: "With two or more apps, the shared package holds leaves, composites and branches; blocks, overlays, layouts and pages belong to the app that owns the feature.",
219
+ description: "With two or more apps, the shared package holds leaves, branches, composites and frames; blocks, overlays, layouts and pages belong to the app that owns the feature.",
220
220
  },
221
221
  schema: [],
222
222
  messages: {
@@ -312,6 +312,7 @@ export const sourceTierMarkerMatchesFolder = defineRule({
312
312
  blocks: "block",
313
313
  branches: "branch",
314
314
  composites: "composite",
315
+ frames: "frame",
315
316
  layouts: "layout",
316
317
  leaves: "leaf",
317
318
  overlays: "overlay",
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ import { recommended as hygieneRecommended, rules as hygieneRules } from "./hygi
27
27
  import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.js";
28
28
  import { recommended as listsRecommended, rules as listsRules } from "./lists.js";
29
29
  import { recommended as loadingRecommended, rules as loadingRules } from "./loading.js";
30
+ import { recommended as skeletonStatesRecommended, rules as skeletonStatesRules } from "./skeleton-states.js";
30
31
  import { recommended as lintEscapeRecommended, rules as lintEscapeRules } from "./lint-escape-hatch.js";
31
32
  import { recommended as namingRecommended, rules as namingRules } from "./naming.js";
32
33
  import { recommended as nativeControlsRecommended, rules as nativeControlsRules } from "./native-controls.js";
@@ -73,6 +74,7 @@ const CONTRIBUTIONS = [
73
74
  { law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
74
75
  { law: "lists", rules: listsRules, recommended: listsRecommended },
75
76
  { law: "loading", rules: loadingRules, recommended: loadingRecommended },
77
+ { law: "skeleton-states", rules: skeletonStatesRules, recommended: skeletonStatesRecommended },
76
78
  { law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
77
79
  { law: "naming", rules: namingRules, recommended: namingRecommended },
78
80
  { law: "native-controls", rules: nativeControlsRules, recommended: nativeControlsRecommended },
@@ -104,8 +104,9 @@ export const SLOT_FREE_RULES = Object.freeze([
104
104
  "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "no-copy-in-call", "no-alert", "no-failure-collapse",
105
105
  "outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
106
106
  "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
107
- "nested-control-variant", "no-list-panel-padding", "no-hand-built-panes", "no-table-in-card", "no-heading-above-card", "no-selectable-tag-group", "no-fill-command-in-header", "no-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
108
- "button-intent-required", "button-no-variant", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
107
+ "no-list-panel-padding", "no-hand-built-panes", "no-table-in-card", "no-heading-above-card", "no-selectable-tag-group", "no-fill-command-in-header", "no-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
108
+ "no-swr-default-array", "no-foreign-slot-status", "no-validating-as-loading", "no-placeholder-object", "no-hand-skeleton",
109
+ "button-intent-required", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
109
110
  ]);
110
111
  /** The source of a plain front-end tree: its `src/`. */
111
112
  export const PLAIN_SOURCE_FILES = Object.freeze(["src/**/*.{ts,tsx}"]);
@@ -35,7 +35,7 @@ export declare const kindOfFile: (context: AnyContext) => string | null;
35
35
  export declare const roleOfFile: (context: AnyContext) => string | null;
36
36
  /** Product source: a file of a product tier. */
37
37
  export declare const isProductSource: (context: AnyContext) => boolean;
38
- /** A file of a component tier: slot `fe.components` or `fe.package.ui`, inside one of its layer folders (blocks, composites, branches, leaves). */
38
+ /** A file of a component tier: slot `fe.components` or `fe.package.ui`, inside one of its layer folders (blocks, frames, composites, branches, leaves). */
39
39
  export declare const isComponentFile: (context: AnyContext) => boolean;
40
40
  /** True when the given file (an absolute or repo-relative name, e.g. where an import resolves to) sits in a component layer of slot `fe.components` or `fe.package.ui`. */
41
41
  export declare const isComponentPath: (context: AnyContext, file: string) => boolean;
package/dist/lib/scope.js CHANGED
@@ -41,7 +41,7 @@ export const roleOfFile = (context) => classOf(context).role ?? null;
41
41
  const PRODUCT_TIERS = new Set(["route", "feature", "components", "hooks", "foundation", "modules", "transport", "package"]);
42
42
  /** Product source: a file of a product tier. */
43
43
  export const isProductSource = (context) => PRODUCT_TIERS.has(tierOfFile(context));
44
- /** A file of a component tier: slot `fe.components` or `fe.package.ui`, inside one of its layer folders (blocks, composites, branches, leaves). */
44
+ /** A file of a component tier: slot `fe.components` or `fe.package.ui`, inside one of its layer folders (blocks, frames, composites, branches, leaves). */
45
45
  export const isComponentFile = (context) => inSlot(context, "fe.components", "fe.package.ui") && kindOfFile(context) !== null;
46
46
  /** True when the given file (an absolute or repo-relative name, e.g. where an import resolves to) sits in a component layer of slot `fe.components` or `fe.package.ui`. */
47
47
  export const isComponentPath = (context, file) => {
package/dist/lib/why.d.ts CHANGED
@@ -77,27 +77,42 @@ export declare const why: {
77
77
  en: string;
78
78
  fix: string;
79
79
  };
80
- "button-no-variant": {
80
+ "no-swr-default-array": {
81
81
  code: string;
82
82
  en: string;
83
83
  fix: string;
84
84
  };
85
- "destructive-confirm-in-dialog": {
85
+ "no-foreign-slot-status": {
86
86
  code: string;
87
87
  en: string;
88
88
  fix: string;
89
89
  };
90
- "single-commit-per-render": {
90
+ "no-validating-as-loading": {
91
91
  code: string;
92
92
  en: string;
93
93
  fix: string;
94
94
  };
95
- "no-commit-in-collection-item": {
95
+ "no-placeholder-object": {
96
+ code: string;
97
+ en: string;
98
+ fix: string;
99
+ };
100
+ "no-hand-skeleton": {
101
+ code: string;
102
+ en: string;
103
+ fix: string;
104
+ };
105
+ "destructive-confirm-in-dialog": {
96
106
  code: string;
97
107
  en: string;
98
108
  fix: string;
99
109
  };
100
- "nested-control-variant": {
110
+ "single-commit-per-render": {
111
+ code: string;
112
+ en: string;
113
+ fix: string;
114
+ };
115
+ "no-commit-in-collection-item": {
101
116
  code: string;
102
117
  en: string;
103
118
  fix: string;
package/dist/lib/why.js CHANGED
@@ -54,13 +54,13 @@ export const why = {
54
54
  },
55
55
  "no-heading-above-card": {
56
56
  code: "FE_HEADING_ABOVE_CARD",
57
- en: "`<file>` puts a SectionHeader directly above a single SurfaceCard, SurfaceListCard or SurfaceAccordionCard, or gives the card an ariaLabel that repeats the header's title.",
57
+ en: "`<file>` wraps a single SurfaceCard in a Section, or gives the card an ariaLabel that repeats the Section's title.",
58
58
  fix: "A card has a label by design: move the title to the card's label, the count to fact, the action to labelEnd and the note to footer.",
59
59
  },
60
60
  "no-table-in-card": {
61
61
  code: "FE_TABLE_IN_CARD",
62
62
  en: "`<file>` puts a DataTable inside a SurfaceCard (directly, or in a component of the file that a SurfaceCard encloses).",
63
- fix: "Plain rows with a few facts are a SurfaceListCard of ListRow with the label outside and the note in its footer; a comparison table sits in its section with no card around it.",
63
+ fix: "Plain rows with a few facts are a SurfaceCard kind=\"list\" with the label outside and the note in its footer; a comparison table sits in its section with no card around it.",
64
64
  },
65
65
  "button-intent-required": {
66
66
  code: "FE_BUTTON_INTENT_REQUIRED",
@@ -77,10 +77,30 @@ export const why = {
77
77
  en: "`<file>` re-exports a Grammar component under the app's own name.",
78
78
  fix: "Delete the shim and import the component from the Grammar where it is rendered; report the Grammar defect the shim worked around (the op files blocked) so the Grammar fixes it.",
79
79
  },
80
- "button-no-variant": {
81
- code: "FE_BUTTON_NO_VARIANT",
82
- en: "`<file>` chooses a Button or IconButton variant directly.",
83
- fix: "Replace variant with intent and let Grammar resolve placement and visual treatment.",
80
+ "no-swr-default-array": {
81
+ code: "FE_SWR_DEFAULT_ARRAY",
82
+ en: "`<file>` folds a read to an empty array and never returns whether that read is loading.",
83
+ fix: "Return the read's isLoading and error with the list, so the view draws a skeleton while loading and an error when it fails, never an empty state.",
84
+ },
85
+ "no-foreign-slot-status": {
86
+ code: "FE_FOREIGN_SLOT_STATUS",
87
+ en: "`<file>` builds a slot from another slot's loading or error.",
88
+ fix: "Build the slot from every read it draws with the one slotOfReads helper, so a key change reloads only the region keyed by it.",
89
+ },
90
+ "no-validating-as-loading": {
91
+ code: "FE_VALIDATING_AS_LOADING",
92
+ en: "`<file>` lets a background refresh (isValidating) drive a loading flag or a status.",
93
+ fix: "Drive the skeleton from isLoading alone; a refresh keeps the content on screen.",
94
+ },
95
+ "no-placeholder-object": {
96
+ code: "FE_PLACEHOLDER_OBJECT",
97
+ en: "`<file>` holds a placeholder object that is the ready data with its lists emptied.",
98
+ fix: "Delete the placeholder; pass a loading flag and let the Grammar component derive the skeleton from the structure it draws.",
99
+ },
100
+ "no-hand-skeleton": {
101
+ code: "FE_HAND_SKELETON",
102
+ en: "`<file>` draws a skeleton by hand.",
103
+ fix: "Pass isLoading to the Grammar component and let it draw its loading structure; the block hands it the same-shape placeholder (FE-ERROR-1).",
84
104
  },
85
105
  "destructive-confirm-in-dialog": {
86
106
  code: "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG",
@@ -97,11 +117,6 @@ export const why = {
97
117
  en: "`<file>` repeats a commit inside a collection item render.",
98
118
  fix: "Use alternative for the item's next action and keep the enclosing view's commit unique.",
99
119
  },
100
- "nested-control-variant": {
101
- code: "FE_NESTED_VARIANT",
102
- en: "`<file>` sets a static Grammar field variant, or forces primary Tabs inside a painted surface.",
103
- fix: "Omit the field or nested Tabs variant so Grammar supplies the default from its surface context.",
104
- },
105
120
  "no-app-surface-paint": {
106
121
  code: "FE_GRAMMAR_LAYOUT_ONLY",
107
122
  en: "`<file>` sets paint or typography in app CSS instead of composing Grammar's published components and props.",
package/dist/loading.js CHANGED
@@ -91,7 +91,7 @@ export const noPlaceholderProp = defineRule({
91
91
  return;
92
92
  for (const specifier of node.specifiers || []) {
93
93
  const local = specifier.local && specifier.local.name;
94
- // A bare `Skeleton` is the primitive a component rests WITH, not a twin of one.
94
+ // A bare `Skeleton` is the leaf a component rests WITH, not a twin of one.
95
95
  if (!local || local === "Skeleton" || !TWIN_NAME.test(local))
96
96
  continue;
97
97
  context.report({ node: specifier, messageId: "import", data: { name: local } });
@@ -14,6 +14,12 @@ export declare const noPublicFrameCssProps: import("./lib/rule.js").Rule<"css",
14
14
  export declare const noCssDoorTypeLaundering: import("./lib/rule.js").Rule<"utility", []>;
15
15
  /** Props are readonly all the way down: every field, index signature, nested object and collection (FE-TYPING-2). */
16
16
  export declare const propsFieldsReadonly: import("./lib/rule.js").Rule<"index" | "field" | "collection", []>;
17
+ /**
18
+ * Children belong to frames. A frame owns a region and draws what its caller places in it; a leaf's only child is its text label; a
19
+ * block, a composite and a branch draw a fixed anatomy from fields and named slots, so nested JSX under them is a type error for every
20
+ * caller (`children?: never`). The tier is the layer folder of the unit (FE-FUNCTION-2).
21
+ */
22
+ export declare const tierChildren: import("./lib/rule.js").Rule<"leaf" | "none" | "withChildren", []>;
17
23
  export declare const rules: Rules;
18
24
  /** All rules in this law are mechanical errors once adopted. */
19
25
  export declare const recommended: Recommended;
@@ -307,6 +307,46 @@ export const propsFieldsReadonly = defineRule({
307
307
  };
308
308
  },
309
309
  });
310
+ /**
311
+ * Children belong to frames. A frame owns a region and draws what its caller places in it; a leaf's only child is its text label; a
312
+ * block, a composite and a branch draw a fixed anatomy from fields and named slots, so nested JSX under them is a type error for every
313
+ * caller (`children?: never`). The tier is the layer folder of the unit (FE-FUNCTION-2).
314
+ */
315
+ export const tierChildren = defineRule({
316
+ meta: {
317
+ type: "problem",
318
+ docs: { description: "Only a frame takes children; a leaf takes its text label; blocks, composites and branches take none." },
319
+ schema: [],
320
+ messages: {
321
+ none: "A {{layer}} unit takes no children: name the slot for its role, or move the unit to components/frames when its job is to host content.",
322
+ leaf: "A leaf takes its text label as children: string, never a node: name the slot for the node, or move the unit to components/frames.",
323
+ withChildren: "PropsWithChildren gives a {{layer}} unit nested JSX: declare children?: never, or children: string for a leaf.",
324
+ },
325
+ },
326
+ create(context) {
327
+ if (!isComponentFile(context))
328
+ return {};
329
+ const layer = kindOfFile(context);
330
+ if (layer === null || layer === "frames")
331
+ return {};
332
+ const text = layer === "leaves";
333
+ return {
334
+ TSPropertySignature(node) {
335
+ if (propertyName(node) !== "children")
336
+ return;
337
+ const type = node.typeAnnotation?.typeAnnotation;
338
+ if (type?.type === "TSNeverKeyword" || (text && type?.type === "TSStringKeyword"))
339
+ return;
340
+ context.report({ node, messageId: text ? "leaf" : "none", data: { layer: layer.replace(/s$/, "") } });
341
+ },
342
+ TSTypeReference(node) {
343
+ const name = node.typeName.type === "Identifier" ? node.typeName.name : node.typeName.type === "TSQualifiedName" ? node.typeName.right.name : null;
344
+ if (name === "PropsWithChildren")
345
+ context.report({ node, messageId: "withChildren", data: { layer: layer.replace(/s$/, "") } });
346
+ },
347
+ };
348
+ },
349
+ });
310
350
  export const rules = {
311
351
  "no-inline-parameter-type": noInlineParameterType,
312
352
  "props-fields-readonly": propsFieldsReadonly,
@@ -315,6 +355,7 @@ export const rules = {
315
355
  "no-public-classname-prop": noPublicClassNameProp,
316
356
  "no-public-frame-css-props": noPublicFrameCssProps,
317
357
  "no-css-door-type-laundering": noCssDoorTypeLaundering,
358
+ "tier-children": tierChildren,
318
359
  };
319
360
  /** All rules in this law are mechanical errors once adopted. */
320
361
  export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -146,19 +146,19 @@ export const exhaustiveShapeSwitch = defineRule({
146
146
  },
147
147
  });
148
148
  // -- SLOT-1 -----------------------------------------------------------------------------------------
149
- /** The data-status props only SlotView takes; a lower unit gets `isSkeleton` and nothing else. */
150
- const STATUS_PROPS = new Set(["isLoading", "isError", "isForbidden", "isFailed", "failure"]);
149
+ /** The data-status props only SlotView takes; a lower unit gets `isLoading` (the one flag a leaf, branch or composite knows) and nothing else. */
150
+ const STATUS_PROPS = new Set(["isError", "isForbidden", "isEmpty", "isValidating", "isFailed", "failure"]);
151
151
  /** The units that own the status recipe: the status view itself and a route's loading page. */
152
152
  const isStatusOwner = (name) => name === "SlotView" || /LoadingPage$/.test(name);
153
153
  /** A unit below the status view takes no data-status prop. */
154
154
  export const noDataStatusProp = defineRule({
155
155
  meta: {
156
156
  type: "problem",
157
- docs: { description: "A unit other than SlotView (and a route LoadingPage) takes no `isLoading`, `isError`, `isForbidden`, `isFailed` or `failure` prop; a leaf takes only `isSkeleton`." },
157
+ docs: { description: "A unit other than SlotView (and a route LoadingPage) takes no `isError`, `isForbidden`, `isEmpty`, `isValidating`, `isFailed` or `failure` prop; a leaf, branch or composite takes only `isLoading`." },
158
158
  schema: [],
159
159
  messages: {
160
- status: "`{{key}}` is a data status handed to `{{unit}}`. A read's status is a DataSlot rendered through SlotView in the region's place; the parts below it take `isSkeleton` and rest as themselves.",
161
- leaf: "`{{key}}` is a data status handed to the leaf `{{unit}}`. A leaf takes only `isSkeleton`: SlotView passes it while the region waits.",
160
+ status: "`{{key}}` is a data status handed to `{{unit}}`. A read's status is a DataSlot rendered through SlotView in the region's place; the parts below it take `isLoading` and rest as themselves.",
161
+ leaf: "`{{key}}` is a data status handed to the leaf `{{unit}}`. A leaf takes only `isLoading`: SlotView passes it while the region waits.",
162
162
  },
163
163
  },
164
164
  create(context) {
@@ -180,7 +180,7 @@ export const noDataStatusProp = defineRule({
180
180
  /** A data hook of `hooks/swr`: `useQueryOrdersSwr`, `useGetHandoffSwr`. */
181
181
  const SWR_HOOK = /^use[A-Za-z0-9]*Swr$/;
182
182
  /** The builders that fold a read into a DataSlot (`hooks/slot/slot.shared.ts`). */
183
- const SLOT_BUILDER = /^(?:slotOfSwr|mapSlot|loadingSlot|toSlot)$/;
183
+ const SLOT_BUILDER = /^(?:slotOfSwr|slotOfReads|useSlotOfReads|mapSlot|loadingSlot|toSlot)$/;
184
184
  /** A region that reads data hands its Base a DataSlot, and the Base renders it through SlotView. */
185
185
  export const regionReadsThroughSlot = defineRule({
186
186
  meta: {
@@ -0,0 +1,16 @@
1
+ import { type Recommended } from "./lib/rule.js";
2
+ /** Published with the Grammar `isLoading` flag: the Grammar components draw their own loading structure. */
3
+ export declare const noHandSkeleton: import("./lib/rule.js").Rule<"hand", []>;
4
+ /** The rules this law contributes to the plugin. */
5
+ export declare const rules: {
6
+ "no-swr-default-array": import("./lib/rule.js").Rule<"fold", []>;
7
+ "no-foreign-slot-status": import("./lib/rule.js").Rule<"foreign", []>;
8
+ "no-validating-as-loading": import("./lib/rule.js").Rule<"validating", []>;
9
+ "no-placeholder-object": import("./lib/rule.js").Rule<"placeholder", []>;
10
+ "no-hand-skeleton": import("./lib/rule.js").Rule<"hand", []>;
11
+ };
12
+ /**
13
+ * The level this law asks for: every rule at error. `no-hand-skeleton` joined when the Grammar took the loading shape (`isLoading` on
14
+ * Section, the cards, ListBox, Timeline, DataTable, MessageBubble and PaneSection, with the `error` slot).
15
+ */
16
+ export declare const recommended: Recommended;