@starci/eslint-canon-fe 9.8.4 → 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 +25 -4
  2. package/README.md +4 -5
  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 +40 -5
  11. package/dist/lib/why.js +44 -9
  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 +12 -3
  19. package/dist/surface-rules.js +190 -89
  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 +91 -18
  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
@@ -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,11 +7,22 @@ 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", []>;
10
+ /** A table is not placed in a SurfaceCard: plain rows are a `SurfaceCard kind="list"` with the note in its footer. */
11
+ export declare const noTableInCard: import("./lib/rule.js").Rule<"table", []>;
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"`. */
13
+ export declare const noFillCommandInHeader: import("./lib/rule.js").Rule<"fill", []>;
14
+ /** A TagGroup holds values the user entered (remove, reorder); a filter over a collection is a FilterButtonGroup. */
15
+ export declare const noSelectableTagGroup: import("./lib/rule.js").Rule<"selection", []>;
16
+ /** A card has a label by design: a Section holding one card is a hand heading over that card. */
17
+ export declare const noHeadingAboveCard: import("./lib/rule.js").Rule<"name" | "header", []>;
12
18
  /** The surface laws ship at error in every composing source tree. */
13
19
  export declare const rules: {
14
20
  "divider-full-bleed": import("./lib/rule.js").Rule<"padding", []>;
15
- "nested-control-variant": import("./lib/rule.js").Rule<"variant" | "tabs", []>;
16
21
  "no-hand-built-panes": import("./lib/rule.js").Rule<"seam" | "joined", []>;
22
+ "no-fill-command-in-header": import("./lib/rule.js").Rule<"fill", []>;
23
+ "no-selectable-tag-group": import("./lib/rule.js").Rule<"selection", []>;
24
+ "no-heading-above-card": import("./lib/rule.js").Rule<"name" | "header", []>;
25
+ "no-table-in-card": import("./lib/rule.js").Rule<"table", []>;
17
26
  "no-list-panel-padding": import("./lib/rule.js").Rule<"padding", []>;
18
27
  "no-app-surface-paint": import("./lib/rule.js").Rule<"surface", []>;
19
28
  };
@@ -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)));
@@ -595,7 +511,192 @@ export const noHandBuiltPanes = defineRule({
595
511
  };
596
512
  },
597
513
  });
514
+ /** The capitalised same-file component a node renders inside (a declaration, a variable's function, or a wrapped one), or null. */
515
+ const componentNameOf = (node) => {
516
+ for (let current = node.parent; current; current = current.parent) {
517
+ if (current.type === "FunctionDeclaration") {
518
+ if (current.id !== null && /^[A-Z]/.test(current.id.name))
519
+ return current.id.name;
520
+ continue;
521
+ }
522
+ if (current.type !== "ArrowFunctionExpression" && current.type !== "FunctionExpression")
523
+ continue;
524
+ const holder = current.parent.type === "CallExpression" ? current.parent.parent : current.parent;
525
+ if (holder.type === "VariableDeclarator" && holder.id.type === "Identifier" && /^[A-Z]/.test(holder.id.name))
526
+ return holder.id.name;
527
+ }
528
+ return null;
529
+ };
530
+ /** True when a SurfaceCard element encloses the node, or a component of this file that renders the node is itself rendered inside one. */
531
+ const standsInCard = (context, node, seen) => {
532
+ for (let current = node.parent; current; current = current.parent) {
533
+ if (current.type !== "JSXElement")
534
+ continue;
535
+ const name = grammarName(context, current.openingElement.name);
536
+ if (name?.length === 1 && name[0] === "SurfaceCard")
537
+ return true;
538
+ }
539
+ const component = componentNameOf(node);
540
+ if (component === null || seen.has(component))
541
+ return false;
542
+ seen.add(component);
543
+ const usages = [];
544
+ walk(context.sourceCode, context.sourceCode.ast, (visited) => {
545
+ if (visited.type === "JSXOpeningElement" && visited.name.type === "JSXIdentifier" && visited.name.name === component)
546
+ usages.push(visited);
547
+ });
548
+ return usages.some((usage) => standsInCard(context, usage, seen));
549
+ };
550
+ /** A table is not placed in a SurfaceCard: plain rows are a `SurfaceCard kind="list"` with the note in its footer. */
551
+ export const noTableInCard = defineRule({
552
+ meta: {
553
+ type: "problem",
554
+ docs: { description: "A DataTable is not placed inside a SurfaceCard." },
555
+ schema: [],
556
+ messages: {
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.",
558
+ },
559
+ },
560
+ create(context) {
561
+ return {
562
+ JSXOpeningElement(node) {
563
+ const name = grammarName(context, node.name);
564
+ if (name?.length !== 1 || name[0] !== "DataTable")
565
+ return;
566
+ if (standsInCard(context, node, new Set()))
567
+ context.report({ node, messageId: "table" });
568
+ },
569
+ };
570
+ },
571
+ });
572
+ /** The header slots whose command hugs its label: `PageHeader` `actions` and the `labelEnd` of a card or a list card. */
573
+ const HEADER_SLOTS = new Map([
574
+ ["PageHeader", ["actions"]],
575
+ ["SurfaceCard", ["labelEnd"]],
576
+ ]);
577
+ /** True when the Button's own opening element sets `width="fill"`. */
578
+ const fillsWidth = (context, opening) => opening.attributes.some((entry) => propStrings(context, entry, "width", "variant").some((value) => value.text === "fill"));
579
+ /** The header slot (`PageHeader actions`, `labelEnd`) a node is written in, or null. */
580
+ const headerSlotOf = (context, node) => {
581
+ for (let current = node.parent; current; current = current.parent) {
582
+ if (current.type !== "JSXAttribute" || current.name.type !== "JSXIdentifier")
583
+ continue;
584
+ const owner = current.parent;
585
+ if (owner.type !== "JSXOpeningElement")
586
+ continue;
587
+ const name = grammarName(context, owner.name);
588
+ if (name?.length === 1 && name[0] !== undefined && HEADER_SLOTS.get(name[0])?.includes(current.name.name) === true)
589
+ return `${name[0]} ${current.name.name}`;
590
+ }
591
+ return null;
592
+ };
593
+ /** 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"`. */
594
+ export const noFillCommandInHeader = defineRule({
595
+ meta: {
596
+ type: "problem",
597
+ docs: { description: "A command in a page or card header hugs its label." },
598
+ schema: [],
599
+ messages: {
600
+ fill: "FE_COMMAND_FILLS_HEADER: this Button is `width=\"fill\"` in {{slot}}. A page or pane command hugs its label and sits at the end of its header row; `width=\"fill\"` is for a form submit, an auth panel and a phone action bar (`PinnedActionBar`). Drop `width`.",
601
+ },
602
+ },
603
+ create(context) {
604
+ return {
605
+ JSXOpeningElement(node) {
606
+ const name = grammarName(context, node.name);
607
+ if (name?.length !== 1 || name[0] !== "Button" || !fillsWidth(context, node))
608
+ return;
609
+ const slot = headerSlotOf(context, node);
610
+ if (slot !== null)
611
+ context.report({ node, messageId: "fill", data: { slot } });
612
+ },
613
+ };
614
+ },
615
+ });
616
+ /** The props that made a TagGroup a selectable filter. */
617
+ const TAG_SELECTION_PROPS = new Set(["selectionMode", "selectedIds", "defaultSelectedIds", "onSelectionChange"]);
618
+ /** A TagGroup holds values the user entered (remove, reorder); a filter over a collection is a FilterButtonGroup. */
619
+ export const noSelectableTagGroup = defineRule({
620
+ meta: {
621
+ type: "problem",
622
+ docs: { description: "A TagGroup has no selection: a filter over a collection is a FilterButtonGroup." },
623
+ schema: [],
624
+ messages: {
625
+ selection: "FE_TAG_GROUP_SELECTION: a TagGroup holds values the user entered, to remove or to order, and has no selection. Peer values that filter a collection that stays in place are a `FilterButtonGroup` (`sm` toggle buttons on one row, the rest behind a \"+n\" search list).",
626
+ },
627
+ },
628
+ create(context) {
629
+ return {
630
+ JSXOpeningElement(node) {
631
+ const name = grammarName(context, node.name);
632
+ if (name?.length !== 1 || name[0] !== "TagGroup")
633
+ return;
634
+ for (const entry of node.attributes) {
635
+ if (entry.type === "JSXAttribute" && entry.name.type === "JSXIdentifier" && TAG_SELECTION_PROPS.has(entry.name.name))
636
+ context.report({ node: entry, messageId: "selection" });
637
+ }
638
+ },
639
+ };
640
+ },
641
+ });
642
+ /** The bounded surfaces that carry their own label. */
643
+ const LABELLED_CARDS = new Set(["SurfaceCard"]);
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. */
645
+ const propText = (context, opening, name) => {
646
+ const attribute = opening.attributes.find((entry) => entry.type === "JSXAttribute" && entry.name.type === "JSXIdentifier" && entry.name.name === name);
647
+ if (attribute === undefined || attribute.type !== "JSXAttribute" || attribute.value === null)
648
+ return null;
649
+ const value = attribute.value;
650
+ if (value.type === "Literal")
651
+ return String(value.value);
652
+ if (value.type === "JSXExpressionContainer" && value.expression.type !== "JSXEmptyExpression") {
653
+ const expression = value.expression;
654
+ return expression.type === "Literal" ? String(expression.value) : context.sourceCode.getText(expression);
655
+ }
656
+ return null;
657
+ };
658
+ /** The element children of a JSX parent, whitespace-only text and empty expressions left out. */
659
+ const meaningfulChildren = (parent) => parent.children.filter((child) => !(child.type === "JSXText" && child.value.trim() === "") && !(child.type === "JSXExpressionContainer" && child.expression.type === "JSXEmptyExpression"));
660
+ /** A card has a label by design: a Section holding one card is a hand heading over that card. */
661
+ export const noHeadingAboveCard = defineRule({
662
+ meta: {
663
+ type: "problem",
664
+ docs: { description: "A Section that holds a single card is a hand heading: the card has a label." },
665
+ schema: [],
666
+ messages: {
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.",
669
+ },
670
+ },
671
+ create(context) {
672
+ return {
673
+ JSXElement(element) {
674
+ const section = grammarName(context, element.openingElement.name);
675
+ if (section?.length !== 1 || section[0] !== "Section")
676
+ return;
677
+ if (propText(context, element.openingElement, "kind") === "list")
678
+ return;
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");
689
+ if (title === null)
690
+ return;
691
+ for (const card of cards) {
692
+ if (propText(context, card.openingElement, "ariaLabel") === title)
693
+ context.report({ node: card.openingElement, messageId: "name" });
694
+ }
695
+ },
696
+ };
697
+ },
698
+ });
598
699
  /** The surface laws ship at error in every composing source tree. */
599
- export const rules = { "divider-full-bleed": dividerFullBleed, "nested-control-variant": nestedControlVariant, "no-hand-built-panes": noHandBuiltPanes, "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 };
600
701
  /** No app can lower or omit a surface law. */
601
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.