@starci/eslint-canon-fe 9.8.5 → 9.9.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 (39) hide show
  1. package/CHANGELOG.md +24 -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/architecture/unit-spec-providers.mjs +12 -5
  39. package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
@@ -0,0 +1,230 @@
1
+ import { defineRule } from "./lib/rule.js";
2
+ const pathOf = (context) => context.filename.replaceAll("\\", "/");
3
+ /** A hook that reads data for a view: under `hooks/`, and not the SWR layer that makes the reads (`hooks/swr`). */
4
+ const isViewHook = (context) => {
5
+ const path = pathOf(context);
6
+ return /\/hooks\//.test(path) && !/\/hooks\/swr\//.test(path) && !/\.(?:spec|test)\.[cm]?tsx?$/.test(path);
7
+ };
8
+ const isApp = (context) => /\/(?:components|features|hooks)\//.test(pathOf(context)) && !/\.(?:spec|test|stories)\.[cm]?tsx?$/.test(pathOf(context));
9
+ const unwrap = (node) => {
10
+ let current = node;
11
+ while (current && (current.type === "ChainExpression" || current.type === "TSNonNullExpression" || current.type === "TSAsExpression"))
12
+ current = current.expression;
13
+ return current;
14
+ };
15
+ /** The identifier at the root of `a.b?.c()` style access, or null. */
16
+ const rootOf = (input) => {
17
+ let node = unwrap(input);
18
+ while (node) {
19
+ if (node.type === "Identifier")
20
+ return node;
21
+ if (node.type === "MemberExpression")
22
+ node = unwrap(node.object);
23
+ else if (node.type === "CallExpression")
24
+ node = unwrap(node.callee);
25
+ else
26
+ return null;
27
+ }
28
+ return null;
29
+ };
30
+ const enclosingFunction = (node) => {
31
+ for (let current = node.parent; current; current = current.parent) {
32
+ if (current.type === "ArrowFunctionExpression" || current.type === "FunctionDeclaration" || current.type === "FunctionExpression")
33
+ return current;
34
+ }
35
+ return null;
36
+ };
37
+ const nameOfKey = (key) => (key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : null);
38
+ // -- no-swr-default-array -----------------------------------------------------------------------------------
39
+ /** Whether an identifier is bound to the result of a hook call (`const read = useX()`, `const { data } = useX()`): a read, not a plain value. */
40
+ const isHookResult = (context, node, identifier) => {
41
+ for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
42
+ const variable = scope.set.get(identifier.name);
43
+ if (!variable)
44
+ continue;
45
+ return variable.defs.some((definition) => {
46
+ const declarator = definition.node;
47
+ if (declarator.type !== "VariableDeclarator" || !declarator.init)
48
+ return false;
49
+ const init = unwrap(declarator.init);
50
+ const callee = init?.type === "CallExpression" ? unwrap(init.callee) : null;
51
+ const name = callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" && callee.property.type === "Identifier" ? callee.property.name : null;
52
+ return name !== null && /^use[A-Z]/.test(name);
53
+ });
54
+ }
55
+ return false;
56
+ };
57
+ const noSwrDefaultArray = defineRule({
58
+ meta: {
59
+ type: "problem",
60
+ docs: { description: "R367: a hook that folds a read to [] says whether that read is still loading." },
61
+ schema: [],
62
+ messages: {
63
+ fold: "FE_SWR_DEFAULT_ARRAY: `{{read}}` is folded to `[]` and this hook never returns `{{read}}.isLoading`, so the view cannot tell \"still loading\" from \"none\" and draws its empty state while the read is in flight (and a failed read as empty). Return the read's isLoading and error with the list, or return the read itself.",
64
+ },
65
+ },
66
+ create(context) {
67
+ if (!isViewHook(context))
68
+ return {};
69
+ const source = context.sourceCode;
70
+ return {
71
+ LogicalExpression(node) {
72
+ if (node.operator !== "??" || node.right.type !== "ArrayExpression" || node.right.elements.length > 0)
73
+ return;
74
+ const root = rootOf(node.left);
75
+ if (!root || !isHookResult(context, node, root))
76
+ return;
77
+ const fn = enclosingFunction(node);
78
+ if (!fn)
79
+ return;
80
+ const text = source.getText(fn);
81
+ const escaped = root.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ // The read's own status is read in the same hook: `read.isLoading`, or destructured next to `data`.
83
+ const readsStatus = new RegExp(`\\b${escaped}\\??\\.(?:isLoading|error|isError)\\b`).test(text)
84
+ || new RegExp(`\\{[^}]*\\b(?:isLoading|error)\\b[^}]*\\}\\s*=\\s*${escaped}\\b`).test(text)
85
+ || (root.name === "data" && /\bisLoading\b/.test(text));
86
+ if (readsStatus)
87
+ return;
88
+ context.report({ node, messageId: "fold", data: { read: source.getText(node.left).slice(0, 40) } });
89
+ },
90
+ };
91
+ },
92
+ });
93
+ // -- no-foreign-slot-status ---------------------------------------------------------------------------------
94
+ const SLOT_NAME = /slot$/i;
95
+ const noForeignSlotStatus = defineRule({
96
+ meta: {
97
+ type: "problem",
98
+ docs: { description: "R367: a slot takes its loading and error from its own reads, never from another slot's." },
99
+ schema: [],
100
+ messages: {
101
+ foreign: "FE_FOREIGN_SLOT_STATUS: this slot copies `{{from}}` from another slot, so a region shows another read's loading or error (a list skeleton on a thread read, an error notice in the wrong pane). Build the slot from every read it draws with the one `slotOfReads(...)` helper of hooks/slot.",
102
+ },
103
+ },
104
+ create(context) {
105
+ if (!isApp(context))
106
+ return {};
107
+ const source = context.sourceCode;
108
+ return {
109
+ Property(node) {
110
+ const key = nameOfKey(node.key);
111
+ if (key !== "isLoading" && key !== "isError" && key !== "error")
112
+ return;
113
+ const value = unwrap(node.value);
114
+ if (!value || value.type !== "MemberExpression")
115
+ return;
116
+ const property = value.property.type === "Identifier" ? value.property.name : null;
117
+ const ownerNode = unwrap(value.object);
118
+ const owner = ownerNode?.type === "Identifier" ? ownerNode.name : ownerNode?.type === "MemberExpression" && ownerNode.property.type === "Identifier" ? ownerNode.property.name : null;
119
+ if (!property || !owner || !SLOT_NAME.test(owner) || (property !== "isLoading" && property !== "isError" && property !== "error"))
120
+ return;
121
+ if (node.parent.type === "ObjectPattern")
122
+ return;
123
+ context.report({ node, messageId: "foreign", data: { from: source.getText(value).slice(0, 40) } });
124
+ },
125
+ };
126
+ },
127
+ });
128
+ // -- no-validating-as-loading -------------------------------------------------------------------------------
129
+ const LOADING_NAME = /^(?:isLoading|loading|isPending|status)$/;
130
+ const mentionsValidating = (context, node) => /\bisValidating\b/.test(context.sourceCode.getText(node));
131
+ const noValidatingAsLoading = defineRule({
132
+ meta: {
133
+ type: "problem",
134
+ docs: { description: "R367: a background refresh never drives a loading flag or a status." },
135
+ schema: [],
136
+ messages: {
137
+ validating: "FE_VALIDATING_AS_LOADING: `isValidating` is a background refresh, and it drives `{{name}}`, so the screen returns to its skeleton while the content it already has is being revalidated. A skeleton is `isLoading` (no data yet) alone; a refresh keeps the content.",
138
+ },
139
+ },
140
+ create(context) {
141
+ if (!isApp(context))
142
+ return {};
143
+ return {
144
+ Property(node) {
145
+ const key = nameOfKey(node.key);
146
+ if (key === null || !LOADING_NAME.test(key) || node.parent.type === "ObjectPattern")
147
+ return;
148
+ if (mentionsValidating(context, node.value))
149
+ context.report({ node, messageId: "validating", data: { name: key } });
150
+ },
151
+ VariableDeclarator(node) {
152
+ if (node.id.type !== "Identifier" || !LOADING_NAME.test(node.id.name) || !node.init)
153
+ return;
154
+ if (mentionsValidating(context, node.init))
155
+ context.report({ node, messageId: "validating", data: { name: node.id.name } });
156
+ },
157
+ };
158
+ },
159
+ });
160
+ // -- no-placeholder-object ----------------------------------------------------------------------------------
161
+ const PLACEHOLDER = /Placeholder$/;
162
+ const hasEmptyArray = (node) => node.properties.some((property) => property.type === "Property" && ((property.value.type === "ArrayExpression" && property.value.elements.length === 0)));
163
+ const noPlaceholderObject = defineRule({
164
+ meta: {
165
+ type: "problem",
166
+ docs: { description: "R367: a loading placeholder is not the ready data with its lists emptied." },
167
+ schema: [],
168
+ messages: {
169
+ placeholder: "FE_PLACEHOLDER_OBJECT: `{{name}}` is the ready tree with its data stripped to `[]`: a second description of the real shape that drifts from it (a missing chip, a different row count). Let the component rest as itself with a loading flag.",
170
+ },
171
+ },
172
+ create(context) {
173
+ if (!isApp(context))
174
+ return {};
175
+ return {
176
+ Property(node) {
177
+ const key = nameOfKey(node.key);
178
+ if (key === null || !PLACEHOLDER.test(key) || node.parent.type === "ObjectPattern")
179
+ return;
180
+ const value = unwrap(node.value);
181
+ if (value?.type === "ObjectExpression" && hasEmptyArray(value))
182
+ context.report({ node, messageId: "placeholder", data: { name: key } });
183
+ },
184
+ VariableDeclarator(node) {
185
+ if (node.id.type !== "Identifier" || !PLACEHOLDER.test(node.id.name))
186
+ return;
187
+ const init = unwrap(node.init);
188
+ if (init?.type === "ObjectExpression" && hasEmptyArray(init))
189
+ context.report({ node, messageId: "placeholder", data: { name: node.id.name } });
190
+ },
191
+ };
192
+ },
193
+ });
194
+ // -- no-hand-skeleton ---------------------------------------------------------------------------------------
195
+ const HAND_SKELETONS = new Set(["Skeleton", "ChatBubbleSkeletons", "MessageBubbleSkeletons"]);
196
+ /** Published with the Grammar `isLoading` flag: the Grammar components draw their own loading structure. */
197
+ export const noHandSkeleton = defineRule({
198
+ meta: {
199
+ type: "problem",
200
+ docs: { description: "R367: an app draws no skeleton by hand; the Grammar derives it from the ready structure." },
201
+ schema: [],
202
+ messages: {
203
+ hand: "FE_HAND_SKELETON: `<{{name}}>` is a skeleton drawn by hand, so its shape is a guess the ready screen will not keep. Pass the loading flag to the Grammar component and let it derive the skeleton from the structure it draws.",
204
+ },
205
+ },
206
+ create(context) {
207
+ if (!isApp(context))
208
+ return {};
209
+ return {
210
+ JSXOpeningElement(node) {
211
+ const name = node.name.type === "JSXIdentifier" ? node.name.name : null;
212
+ if (name !== null && HAND_SKELETONS.has(name))
213
+ context.report({ node, messageId: "hand", data: { name } });
214
+ },
215
+ };
216
+ },
217
+ });
218
+ /** The rules this law contributes to the plugin. */
219
+ export const rules = {
220
+ "no-swr-default-array": noSwrDefaultArray,
221
+ "no-foreign-slot-status": noForeignSlotStatus,
222
+ "no-validating-as-loading": noValidatingAsLoading,
223
+ "no-placeholder-object": noPlaceholderObject,
224
+ "no-hand-skeleton": noHandSkeleton,
225
+ };
226
+ /**
227
+ * The level this law asks for: every rule at error. `no-hand-skeleton` joined when the Grammar took the loading shape (`isLoading` on
228
+ * Section, the cards, ListBox, Timeline, DataTable, MessageBubble and PaneSection, with the `error` slot).
229
+ */
230
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -1,6 +1,4 @@
1
1
  import { type Recommended } from "./lib/rule.js";
2
- /** ANATOMY-2: field variants belong to Grammar's context, including across app component boundaries. */
3
- export declare const nestedControlVariant: import("./lib/rule.js").Rule<"variant" | "tabs", []>;
4
2
  /** App classes carry layout; Grammar owns surface paint. */
5
3
  export declare const noAppSurfacePaint: import("./lib/rule.js").Rule<"surface", []>;
6
4
  /** PADDING-0: app padding wrappers cannot add a second horizontal inset to Grammar's list or section caption. */
@@ -9,18 +7,17 @@ export declare const noListPanelPadding: import("./lib/rule.js").Rule<"padding",
9
7
  export declare const dividerFullBleed: import("./lib/rule.js").Rule<"padding", []>;
10
8
  /** A multi-pane frame is a PaneGrid: a vertical divider between columns, or columns inside a joined card, is a pane frame drawn by hand. */
11
9
  export declare const noHandBuiltPanes: import("./lib/rule.js").Rule<"seam" | "joined", []>;
12
- /** A table is not placed in a SurfaceCard: plain rows are a SurfaceListCard with the note in its footer. */
10
+ /** A table is not placed in a SurfaceCard: plain rows are a `SurfaceCard kind="list"` with the note in its footer. */
13
11
  export declare const noTableInCard: import("./lib/rule.js").Rule<"table", []>;
14
12
  /** A page or pane command hugs its label and sits at the end of its header row: a Button in a header slot is never `width="fill"`. */
15
13
  export declare const noFillCommandInHeader: import("./lib/rule.js").Rule<"fill", []>;
16
14
  /** A TagGroup holds values the user entered (remove, reorder); a filter over a collection is a FilterButtonGroup. */
17
15
  export declare const noSelectableTagGroup: import("./lib/rule.js").Rule<"selection", []>;
18
- /** A card has a label by design: a SectionHeader above it is a hand heading. */
16
+ /** A card has a label by design: a Section holding one card is a hand heading over that card. */
19
17
  export declare const noHeadingAboveCard: import("./lib/rule.js").Rule<"name" | "header", []>;
20
18
  /** The surface laws ship at error in every composing source tree. */
21
19
  export declare const rules: {
22
20
  "divider-full-bleed": import("./lib/rule.js").Rule<"padding", []>;
23
- "nested-control-variant": import("./lib/rule.js").Rule<"variant" | "tabs", []>;
24
21
  "no-hand-built-panes": import("./lib/rule.js").Rule<"seam" | "joined", []>;
25
22
  "no-fill-command-in-header": import("./lib/rule.js").Rule<"fill", []>;
26
23
  "no-selectable-tag-group": import("./lib/rule.js").Rule<"selection", []>;
@@ -13,13 +13,9 @@ const memberName = (node) => (node.computed ? stringOf(node.property) : node.pro
13
13
  const present = (value) => Boolean(value);
14
14
  const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/;
15
15
  const SURFACES = new Set([
16
- "SurfaceCard", "SurfaceListCard", "SurfaceAccordionCard", "Sidebar",
16
+ "SurfaceCard", "Sidebar",
17
17
  "Dialog", "Drawer", "AlertDialog", "Popover",
18
- "WorkspaceShell", "TopBar", "NavigationFeatureNav", "Pane", "PaneSection",
19
- ]);
20
- const FIELDS = new Set([
21
- "Input", "TextField", "Textarea", "TextArea", "SearchField", "NumberField", "DateField", "TimeField",
22
- "Select", "ComboBox", "DatePicker", "DateRangePicker", "Checkbox", "CheckboxGroup", "RadioGroup", "PressableField", "OtpInput", "CopyField",
18
+ "WorkspaceShell", "TopBar", "Pane", "PaneSection",
23
19
  ]);
24
20
  const CLASS_BUILDERS = new Set(["cn", "clsx", "classNames", "cva", "tv"]);
25
21
  /** Resolve a JSX name to its Grammar export and optional compound parts, preserving lexical import identity. */
@@ -104,16 +100,6 @@ const isSurface = (context, node, descendant) => {
104
100
  const name = grammarName(context, node.name);
105
101
  if (name?.length !== 1)
106
102
  return false;
107
- if (name[0] === "SurfaceAccordionCard") {
108
- let frame = "painted";
109
- for (const entry of node.attributes) {
110
- const value = entry.type === "JSXSpreadAttribute" ? frameEffect(context, entry.argument, frame)
111
- : entry.name.name === "frame" ? unpaintedFrame(context, attributeValue(entry)) ? "unpainted" : "painted" : "absent";
112
- if (value !== "absent")
113
- frame = value;
114
- }
115
- return frame !== "unpainted";
116
- }
117
103
  if (name[0] === "WorkspaceShell") {
118
104
  for (let current = descendant; current && current !== node.parent; current = current.parent) {
119
105
  if (current.type === "JSXAttribute" && current.parent === node && current.name.name === "header")
@@ -202,45 +188,6 @@ const propStrings = (context, entry, name, mode = "classes") => {
202
188
  }
203
189
  return entry.name.name === name ? authoredStrings(context, attributeValue(entry), new Set(), mode) : [];
204
190
  };
205
- /** Only a fully known unpainted frame removes the accordion's bounded surface context. */
206
- const unpaintedFrame = (context, input, seen = new Set()) => {
207
- const node = unwrap(input);
208
- if (!node || seen.has(node))
209
- return false;
210
- seen.add(node);
211
- const text = stringOf(node);
212
- if (text !== null)
213
- return text === "unpainted";
214
- if (node.type === "ConditionalExpression")
215
- return [node.consequent, node.alternate].every((branch) => unpaintedFrame(context, branch, new Set(seen)));
216
- const values = node.type === "Identifier" ? bindingValues(context, node)
217
- : node.type === "MemberExpression" ? propertyValues(context, node.object, memberName(node)) : [];
218
- return values.length > 0 && values.every((value) => unpaintedFrame(context, value, new Set(seen)));
219
- };
220
- /** A spread overrides preceding frame props; an unresolved spread may install a bounded frame. */
221
- const frameEffect = (context, input, prior, seen = new Set()) => {
222
- const node = unwrap(input);
223
- if (!node || seen.has(node))
224
- return "painted";
225
- seen.add(node);
226
- if (node.type === "ObjectExpression") {
227
- let effect = prior;
228
- for (const property of node.properties) {
229
- const key = property.type === "Property" ? propertyName(context, property) : null;
230
- const value = property.type === "SpreadElement" ? frameEffect(context, property.argument, effect, new Set(seen))
231
- : key === "frame" ? unpaintedFrame(context, property.value) ? "unpainted" : "painted" : key === null ? "painted" : "absent";
232
- if (value !== "absent")
233
- effect = value;
234
- }
235
- return effect;
236
- }
237
- const values = node.type === "Identifier" ? bindingValues(context, node)
238
- : node.type === "MemberExpression" ? propertyValues(context, node.object, memberName(node))
239
- : node.type === "ConditionalExpression" ? [node.consequent, node.alternate] : [];
240
- const effects = values.map((value) => frameEffect(context, value, prior, new Set(seen)));
241
- const [first] = effects;
242
- return first !== undefined && effects.every((effect) => effect === first) ? first : "painted";
243
- };
244
191
  /** Resolve authored style objects without treating unrelated object siblings or conditions as CSS. */
245
192
  const stylePaint = (context, input, seen = new Set()) => {
246
193
  const node = unwrap(input?.type === "JSXExpressionContainer" ? input.expression : input);
@@ -285,37 +232,6 @@ const inSectionCaption = (context, node) => {
285
232
  }
286
233
  return false;
287
234
  };
288
- /** ANATOMY-2: field variants belong to Grammar's context, including across app component boundaries. */
289
- export const nestedControlVariant = defineRule({
290
- meta: {
291
- type: "problem",
292
- docs: { description: "ANATOMY-2: Grammar context supplies field variants; Tabs are the underline bar, never the pill." },
293
- schema: [],
294
- messages: {
295
- variant: "FE_NESTED_VARIANT: app fields cannot set a static variant. Omit variant so Grammar's surface context supplies the appropriate default.",
296
- tabs: "FE_NESTED_VARIANT: Tabs are the underline bar, their labels text on the content edge; variant=\"secondary\" is the pill, a filter's shape. Omit variant, or filter a collection with a selectable TagGroup.",
297
- },
298
- },
299
- create(context) {
300
- return {
301
- JSXOpeningElement(node) {
302
- const name = grammarName(context, node.name);
303
- if (name?.length !== 1)
304
- return;
305
- if (FIELDS.has(name[0])) {
306
- const variant = node.attributes.find((entry) => propStrings(context, entry, "variant", "variant").length > 0);
307
- if (variant)
308
- context.report({ node: variant, messageId: "variant" });
309
- }
310
- else if (name[0] === "Tabs") {
311
- const pill = node.attributes.find((entry) => propStrings(context, entry, "variant", "variant").some((value) => value.text === "secondary"));
312
- if (pill)
313
- context.report({ node: pill, messageId: "tabs" });
314
- }
315
- },
316
- };
317
- },
318
- });
319
235
  /** App classes carry layout; Grammar owns surface paint. */
320
236
  export const noAppSurfacePaint = exemptFreestyle(defineRule({
321
237
  meta: {
@@ -506,8 +422,8 @@ const elementsOfChild = (child) => {
506
422
  };
507
423
  /** The children that render: whitespace-only text and empty expressions are left out. */
508
424
  const renderedChildren = (element) => element.children.filter((child) => child.type === "JSXText" ? child.value.trim() !== "" : child.type !== "JSXExpressionContainer" || child.expression.type !== "JSXEmptyExpression");
509
- /** A column of a hand-built split: a plain block element (`div`, `aside`) or a Grammar `Region`. */
510
- const isColumn = (context, element) => rawElement(element.openingElement) || grammarName(context, element.openingElement.name)?.[0] === "Region";
425
+ /** A column of a hand-built split: a plain block element (`div`, `aside`) or a Grammar `Section`. */
426
+ const isColumn = (context, element) => rawElement(element.openingElement) || grammarName(context, element.openingElement.name)?.[0] === "Section";
511
427
  /** How a plain element lays out its children, read from its authored classes: `row`, `stack` (a column), `none` (not flex or grid), or `unknown` (no static class). */
512
428
  const flowOf = (context, opening) => {
513
429
  const texts = opening.attributes.flatMap((entry) => ["className", "class"].flatMap((name) => propStrings(context, entry, name).map((value) => value.text)));
@@ -631,14 +547,14 @@ const standsInCard = (context, node, seen) => {
631
547
  });
632
548
  return usages.some((usage) => standsInCard(context, usage, seen));
633
549
  };
634
- /** A table is not placed in a SurfaceCard: plain rows are a SurfaceListCard with the note in its footer. */
550
+ /** A table is not placed in a SurfaceCard: plain rows are a `SurfaceCard kind="list"` with the note in its footer. */
635
551
  export const noTableInCard = defineRule({
636
552
  meta: {
637
553
  type: "problem",
638
554
  docs: { description: "A DataTable is not placed inside a SurfaceCard." },
639
555
  schema: [],
640
556
  messages: {
641
- table: "FE_TABLE_IN_CARD: this DataTable stands inside a SurfaceCard (here or in the component that renders it). Plain rows with a few facts are a `SurfaceListCard` of `ListRow` with the label outside and the note in its `footer`; a table that compares records across many columns sits in its section with its label outside, with no card around it.",
557
+ table: "FE_TABLE_IN_CARD: this DataTable stands inside a SurfaceCard (here or in the component that renders it). Plain rows with a few facts are a `SurfaceCard kind=\"list\"` with the label outside and the note in its `footer`; a table that compares records across many columns sits in its section with its label outside, with no card around it.",
642
558
  },
643
559
  },
644
560
  create(context) {
@@ -657,7 +573,6 @@ export const noTableInCard = defineRule({
657
573
  const HEADER_SLOTS = new Map([
658
574
  ["PageHeader", ["actions"]],
659
575
  ["SurfaceCard", ["labelEnd"]],
660
- ["SurfaceListCard", ["labelEnd"]],
661
576
  ]);
662
577
  /** True when the Button's own opening element sets `width="fill"`. */
663
578
  const fillsWidth = (context, opening) => opening.attributes.some((entry) => propStrings(context, entry, "width", "variant").some((value) => value.text === "fill"));
@@ -725,7 +640,7 @@ export const noSelectableTagGroup = defineRule({
725
640
  },
726
641
  });
727
642
  /** The bounded surfaces that carry their own label. */
728
- const LABELLED_CARDS = new Set(["SurfaceCard", "SurfaceListCard", "SurfaceAccordionCard"]);
643
+ const LABELLED_CARDS = new Set(["SurfaceCard"]);
729
644
  /** The source text of a prop value, or null when the prop is absent: `"a"` and `{"a"}` read the same, an expression reads as written. */
730
645
  const propText = (context, opening, name) => {
731
646
  const attribute = opening.attributes.find((entry) => entry.type === "JSXAttribute" && entry.name.type === "JSXIdentifier" && entry.name.name === name);
@@ -742,54 +657,46 @@ const propText = (context, opening, name) => {
742
657
  };
743
658
  /** The element children of a JSX parent, whitespace-only text and empty expressions left out. */
744
659
  const meaningfulChildren = (parent) => parent.children.filter((child) => !(child.type === "JSXText" && child.value.trim() === "") && !(child.type === "JSXExpressionContainer" && child.expression.type === "JSXEmptyExpression"));
745
- /** A card has a label by design: a SectionHeader above it is a hand heading. */
660
+ /** A card has a label by design: a Section holding one card is a hand heading over that card. */
746
661
  export const noHeadingAboveCard = defineRule({
747
662
  meta: {
748
663
  type: "problem",
749
- docs: { description: "A SectionHeader directly above a single card is a hand heading: the card has a label." },
664
+ docs: { description: "A Section that holds a single card is a hand heading: the card has a label." },
750
665
  schema: [],
751
666
  messages: {
752
- header: "FE_HEADING_ABOVE_CARD: this SectionHeader is directly followed by a single card, and 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`. Several cards under one header stay legal.",
753
- name: "FE_HEADING_ABOVE_CARD: this card's `ariaLabel` repeats the title of the SectionHeader above it. Give the card that title as its `label` (count in `fact`, action in `labelEnd`, note in `footer`) and drop the header.",
667
+ header: "FE_HEADING_ABOVE_CARD: this Section holds a single card, and a card has a label by design. Drop the Section: move its title to the card's `label`, the count to `fact`, the action to `labelEnd` and the note to `footer`. Several cards under one Section stay legal.",
668
+ name: "FE_HEADING_ABOVE_CARD: this card's `ariaLabel` repeats the title of the Section around it. Give the card that title as its `label` (count in `fact`, action in `labelEnd`, note in `footer`) and drop the Section.",
754
669
  },
755
670
  },
756
671
  create(context) {
757
- const check = (parent) => {
758
- const children = meaningfulChildren(parent);
759
- children.forEach((child, index) => {
760
- if (child.type !== "JSXElement")
672
+ return {
673
+ JSXElement(element) {
674
+ const section = grammarName(context, element.openingElement.name);
675
+ if (section?.length !== 1 || section[0] !== "Section")
761
676
  return;
762
- const header = grammarName(context, child.openingElement.name);
763
- if (header?.length !== 1 || header[0] !== "SectionHeader")
677
+ if (propText(context, element.openingElement, "kind") === "list")
764
678
  return;
765
- const cards = [];
766
- for (let next = index + 1; next < children.length; next += 1) {
767
- const sibling = children[next];
768
- if (sibling === undefined || sibling.type !== "JSXElement")
769
- break;
770
- const name = grammarName(context, sibling.openingElement.name);
771
- if (name?.length !== 1 || name[0] === undefined || !LABELLED_CARDS.has(name[0]))
772
- break;
773
- cards.push(sibling);
774
- }
775
- if (cards.length === 1)
776
- context.report({ node: child.openingElement, messageId: "header" });
777
- const title = propText(context, child.openingElement, "title");
679
+ const children = meaningfulChildren(element);
680
+ const cards = children.filter((child) => {
681
+ if (child.type !== "JSXElement")
682
+ return false;
683
+ const name = grammarName(context, child.openingElement.name);
684
+ return name?.length === 1 && name[0] !== undefined && LABELLED_CARDS.has(name[0]);
685
+ });
686
+ if (cards.length === 1 && children.length === 1)
687
+ context.report({ node: element.openingElement, messageId: "header" });
688
+ const title = propText(context, element.openingElement, "title");
778
689
  if (title === null)
779
690
  return;
780
691
  for (const card of cards) {
781
692
  if (propText(context, card.openingElement, "ariaLabel") === title)
782
693
  context.report({ node: card.openingElement, messageId: "name" });
783
694
  }
784
- });
785
- };
786
- return {
787
- JSXElement: check,
788
- JSXFragment: check,
695
+ },
789
696
  };
790
697
  },
791
698
  });
792
699
  /** The surface laws ship at error in every composing source tree. */
793
- export const rules = { "divider-full-bleed": dividerFullBleed, "nested-control-variant": nestedControlVariant, "no-hand-built-panes": noHandBuiltPanes, "no-fill-command-in-header": noFillCommandInHeader, "no-selectable-tag-group": noSelectableTagGroup, "no-heading-above-card": noHeadingAboveCard, "no-table-in-card": noTableInCard, "no-list-panel-padding": noListPanelPadding, "no-app-surface-paint": noAppSurfacePaint };
700
+ export const rules = { "divider-full-bleed": dividerFullBleed, "no-hand-built-panes": noHandBuiltPanes, "no-fill-command-in-header": noFillCommandInHeader, "no-selectable-tag-group": noSelectableTagGroup, "no-heading-above-card": noHeadingAboveCard, "no-table-in-card": noTableInCard, "no-list-panel-padding": noListPanelPadding, "no-app-surface-paint": noAppSurfacePaint };
794
701
  /** No app can lower or omit a surface law. */
795
702
  export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -81,7 +81,7 @@ export const noGrammarInternalSelector = defineRule({
81
81
  },
82
82
  schema: [],
83
83
  messages: {
84
- internal: "Remove this starci-core- selector. Compose the Grammar public API; message actions belong in the ChatBubble floating toolbar.",
84
+ internal: "Remove this starci-core- selector. Compose the Grammar public API; message actions belong in the MessageBubble floating toolbar.",
85
85
  },
86
86
  },
87
87
  create(context) {
@@ -7,7 +7,6 @@ App `Button` and `IconButton` commands declare `intent`. Grammar derives their v
7
7
  | Rule | Finding code | Required behavior |
8
8
  | --- | --- | --- |
9
9
  | `starci-fe/button-intent-required` | `FE_BUTTON_INTENT_REQUIRED` | Pass the command's `intent`. A known props spread may supply it. |
10
- | `starci-fe/button-no-variant` | `FE_BUTTON_NO_VARIANT` | Omit `variant`, including variants in known props spreads. |
11
10
  | `starci-fe/destructive-confirm-in-dialog` | `FE_DESTRUCTIVE_CONFIRM_IN_DIALOG` | Put `intent="destructive" step="confirm"` inside the same-file `AlertDialog` or `ConfirmDialog` content subtree. Its trigger uses `step="entry"`. A separate component's definition does not inherit its caller's JSX ancestry. |
12
11
  | `starci-fe/single-commit-per-render` | `FE_SINGLE_COMMIT_PER_RENDER` | Render at most one commit per component path. Different return paths, opposite conditional arms and opposite guards are mutually exclusive. |
13
12
  | `starci-fe/no-commit-in-collection-item` | `FE_NO_COMMIT_IN_COLLECTION_ITEM` | Use `alternative` for an item's next action. A `.map` callback or explicit Grammar collection item context cannot expose commit. |
@@ -92,4 +92,4 @@ const UpdatedAt = () => <RelativeTime dateTime={instant} locale={locale} mode="r
92
92
 
93
93
  **Finding code:** `FE_SIZE_AND_STATE_BUDGET`
94
94
 
95
- **Fix:** Render the Grammar primitive and pass the instant; its single shared ticker owns updates and its deterministic fallback owns the server render.
95
+ **Fix:** Render the Grammar RelativeTime leaf and pass the instant; its single shared ticker owns updates and its deterministic fallback owns the server render.
@@ -2,24 +2,6 @@
2
2
 
3
3
  Law module: `surface-rules.mjs`. HFS rule: R318. Canon: ANATOMY-2 and PADDING-0.
4
4
 
5
- ## `starci-fe/nested-control-variant`
6
-
7
- Finding code: `FE_NESTED_VARIANT`. Grammar context supplies field variants on the page background and inside painted surfaces. App code omits every static `variant` on layer-aware fields, including fields rendered by imported or helper components. Named import aliases, namespace imports, static constants and props spreads preserve Grammar ownership. `Tabs` are the underline bar everywhere, their labels text on the content edge, so the pill (`variant="secondary"`) is refused wherever it is set: a pill is a filter's shape, and a filter over a collection is a selectable `TagGroup`.
8
-
9
- Invalid:
10
-
11
- ```tsx
12
- import { SearchField } from "@starci/grammar/core"
13
- const Search = () => <SearchField variant="secondary" />
14
- ```
15
-
16
- Valid:
17
-
18
- ```tsx
19
- import { SurfaceCard, SearchField } from "@starci/grammar/core"
20
- const Panel = () => <SurfaceCard><SearchField /></SurfaceCard>
21
- ```
22
-
23
5
  ## `starci-fe/no-app-surface-paint`
24
6
 
25
7
  Finding code: `FE_GRAMMAR_LAYOUT_ONLY`. App classes carry layout. A raw `bg-surface` utility paints a surface and is refused, including conditional classes, class builders, responsive variants, important modifiers and opacity suffixes. Compose a painted Grammar surface such as `SurfaceCard`. Static props spreads keep the same obligations. Typed app lint follows imported colocated class declarations as well as local constants.
@@ -77,7 +59,7 @@ const Frame = () => (
77
59
 
78
60
  ## `starci-fe/no-table-in-card`
79
61
 
80
- Finding code: `FE_TABLE_IN_CARD` (R351). A `DataTable` is not placed in a `SurfaceCard`. Plain rows with a few facts are a `SurfaceListCard` of `ListRow` with the label outside and the note in its `footer`; a table that compares records across many columns sits in its section with no card around it. The rule reads the file: a Grammar `DataTable` enclosed by a Grammar `SurfaceCard` element is a finding, and so is a `DataTable` rendered by a component of the same file (a declaration, a variable's function, a `memo` or `forwardRef` wrapper, followed through other components of the file) that a `SurfaceCard` encloses. A card in another file that renders a table component is judged where the card is written. There is no inline exemption; a table the owner rules complex enough to sit in a card is named in this rule.
62
+ Finding code: `FE_TABLE_IN_CARD` (R351). A `DataTable` is not placed in a `SurfaceCard`. Plain rows with a few facts are a `SurfaceCard kind="list"` with the label outside and the note in its `footer`; a table that compares records across many columns sits in its section with no card around it. The rule reads the file: a Grammar `DataTable` enclosed by a Grammar `SurfaceCard` element is a finding, and so is a `DataTable` rendered by a component of the same file (a declaration, a variable's function, a `memo` or `forwardRef` wrapper, followed through other components of the file) that a `SurfaceCard` encloses. A card in another file that renders a table component is judged where the card is written. There is no inline exemption; a table the owner rules complex enough to sit in a card is named in this rule.
81
63
 
82
64
  Invalid:
83
65
 
@@ -95,17 +77,21 @@ const Rules = () => (
95
77
  Valid:
96
78
 
97
79
  ```tsx
98
- import { SurfaceListCard, ListRow } from "@starci/grammar/core"
80
+ import { SurfaceCard } from "@starci/grammar/core"
99
81
  const Rules = () => (
100
- <SurfaceListCard label="Rules" footer={footnote}>
101
- {rules.map((rule) => <ListRow key={rule.id} title={rule.title} description={rule.summary} />)}
102
- </SurfaceListCard>
82
+ <SurfaceCard
83
+ kind="list"
84
+ label="Rules"
85
+ footer={footnote}
86
+ items={rules.map((rule) => ({ id: rule.id, title: rule.title, description: rule.summary }))}
87
+ empty={<EmptyNotice message="No rules." />}
88
+ />
103
89
  )
104
90
  ```
105
91
 
106
92
  ## `starci-fe/no-fill-command-in-header`
107
93
 
108
- Finding code: `FE_COMMAND_FILLS_HEADER` (R352). A page or pane command hugs its label and sits at the end of its header row: a Grammar `Button` with `width="fill"` written in the `actions` of a `PageHeader` or in the `labelEnd` of a `SurfaceCard` or `SurfaceListCard` is refused. `width="fill"` is for a form submit, an auth panel and a phone action bar (`PinnedActionBar`); the rule judges the header slots only, since a form, an auth panel and a phone action bar are not decidable from the file.
94
+ Finding code: `FE_COMMAND_FILLS_HEADER` (R352). A page or pane command hugs its label and sits at the end of its header row: a Grammar `Button` with `width="fill"` written in the `actions` of a `PageHeader` or in the `labelEnd` of a `SurfaceCard` is refused. `width="fill"` is for a form submit, an auth panel and a phone action bar (`PinnedActionBar`); the rule judges the header slots only, since a form, an auth panel and a phone action bar are not decidable from the file.
109
95
 
110
96
  Invalid:
111
97
 
@@ -141,24 +127,23 @@ const Filter = () => <FilterButtonGroup label="Team" options={options} value={te
141
127
 
142
128
  ## `starci-fe/no-heading-above-card`
143
129
 
144
- Finding code: `FE_HEADING_ABOVE_CARD` (R354). A card has a label by design (ruling `card-carries-its-own-label`), so a hand heading above it is wrong. A Grammar `SectionHeader` directly followed, in the same parent, by one `SurfaceCard`, `SurfaceListCard` or `SurfaceAccordionCard` is refused, and so is a card whose `ariaLabel` is the header's `title`. Several cards under one header stay legal, and a header followed by anything else is not judged. The fix moves the title to the card's `label`, the count to `fact`, the action to `labelEnd` and the note to `footer`.
130
+ Finding code: `FE_HEADING_ABOVE_CARD` (R354). A card has a label by design (ruling `card-carries-its-own-label`), so a heading over it is wrong. A Grammar `Section` (not `kind="list"`) whose only child is one `SurfaceCard` is refused, and so is a card whose `ariaLabel` is the `Section`'s `title`. Several cards under one `Section` stay legal, and a `Section` holding anything else is not judged. The fix moves the title to the card's `label`, the count to `fact`, the action to `labelEnd` and the note to `footer`.
145
131
 
146
132
  Invalid:
147
133
 
148
134
  ```tsx
149
- import { SectionHeader, SurfaceListCard } from "@starci/grammar/core"
135
+ import { Section, SurfaceCard } from "@starci/grammar/core"
150
136
  const Rules = () => (
151
- <section>
152
- <SectionHeader title="Rules" count={3} action={add} />
153
- <SurfaceListCard ariaLabel="Rules">{rows}</SurfaceListCard>
154
- </section>
137
+ <Section title="Rules" count={3} action={add}>
138
+ <SurfaceCard kind="list" ariaLabel="Rules" items={items} empty={empty} />
139
+ </Section>
155
140
  )
156
141
  ```
157
142
 
158
143
  Valid:
159
144
 
160
145
  ```tsx
161
- import { SurfaceListCard } from "@starci/grammar/core"
162
- const Rules = () => <SurfaceListCard label="Rules" fact="3" labelEnd={add} footer={note}>{rows}</SurfaceListCard>
146
+ import { SurfaceCard } from "@starci/grammar/core"
147
+ const Rules = () => <SurfaceCard kind="list" label="Rules" fact="3" labelEnd={add} footer={note} items={items} empty={empty} />
163
148
  ```
164
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.8.5",
3
+ "version": "9.9.2",
4
4
  "description": "Universal front-end lint laws for TypeScript and Next.js, independent of the selected Grammar family.",
5
5
  "type": "module",
6
6
  "license": "MIT",