@starci/eslint-canon-fe 9.8.4 → 9.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.8.5 - 2026-11-01
4
+
5
+ - 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.
6
+ - Added: `no-selectable-tag-group` (R353, `FE_TAG_GROUP_SELECTION`): a selection prop on a Grammar `TagGroup` is refused; a filter is a `FilterButtonGroup`.
7
+ - Added: `no-fill-command-in-header` (R352, `FE_COMMAND_FILLS_HEADER`): a Grammar `Button` with `width="fill"` in the `actions` of a `PageHeader` or the `labelEnd` of a `SurfaceCard` or `SurfaceListCard` is refused (a page or pane command hugs its label).
8
+ - Added: `no-table-in-card` (R351, `FE_TABLE_IN_CARD`): a Grammar `DataTable` enclosed by a `SurfaceCard`, or rendered by a component of the same file that a `SurfaceCard` encloses, is refused; plain rows are a `SurfaceListCard` with the note in its `footer`. It is a slot-free surface law at error, with no inline exemption.
9
+ - Changed: the bundled canon pins name @starci/grammar 0.20.2, eslint-grammar-core and eslint-grammar-offset-pop 0.20.2, eslint-canon-be 3.11.6, eslint-canon-fe 9.8.5, stylelint-canon 2.4.0 and hfs 4.16.5 (RELEASE_VERSION_NOT_BUMPED: the previous versions are on npm). One rule law changes (above).
10
+
3
11
  ## 9.8.4 - 2026-11-01
4
12
 
5
13
  - Changed: the bundled canon pins name @starci/grammar 0.20.1, eslint-grammar-core and eslint-grammar-offset-pop 0.20.1, eslint-canon-be 3.11.5, eslint-canon-fe 9.8.4, stylelint-canon 2.4.0 and hfs 4.16.4 (RELEASE_VERSION_NOT_BUMPED: the previous versions are on npm). No rule changes.
@@ -29,7 +37,7 @@
29
37
 
30
38
  ## 9.6.7 - 2026-10-31
31
39
 
32
- - Changed: the bundled runtime copies follow @starci/hfs 4.14.0 (R334 one style, `ruleParams.be.sqlPassThrough`) and the canon pins name the cohort hfs 4.14.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.6.7, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6, with @starci/ai-router 0.5.0; no rule changed.
40
+ - Changed: the bundled runtime copies follow @starci/hfs 4.14.0 (R334 one style, `ruleParams.be.sqlPassThrough`) and the canon pins name the cohort hfs 4.14.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.6.7, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6, with @starci/ai-router 0.5.0; the status rule changes (below).
33
41
 
34
42
  ## 9.6.6 - 2026-10-31
35
43
 
package/README.md CHANGED
@@ -41,9 +41,10 @@ family that publishes the shared HeroUI surface and field API: `nested-control-v
41
41
  variants on layer-aware fields and the pill (`variant="secondary"`) on Tabs, which are the underline bar (`FE_NESTED_VARIANT`),
42
42
  `no-app-surface-paint` keeps surface paint in Grammar (`FE_GRAMMAR_LAYOUT_ONLY`) and
43
43
  `no-list-panel-padding` refuses horizontal padding wrappers around Grammar lists and section captions
44
- within the nearest painted surface, a `Pane` or a `PaneSection` included (`FE_LIST_PANEL_PADDING`), and
44
+ within the nearest painted surface, a `Pane` or a `PaneSection` included (`FE_LIST_PANEL_PADDING`),
45
45
  `no-hand-built-panes` refuses a multi-pane frame drawn by hand, a vertical divider as a column seam or
46
- columns inside a joined card, where `PaneGrid` owns both (`FE_HAND_BUILT_PANES`). The R65 budget law's
46
+ 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
47
48
  `no-app-relative-clock` leaves live relative time and countdowns to Grammar's shared clock.
48
49
 
49
50
  The spacing law (R350) leaves every distance to Grammar: `no-spacing-utility` refuses a padding, margin, gap
@@ -104,7 +104,7 @@ 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-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
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
108
  "button-intent-required", "button-no-variant", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
109
109
  ]);
110
110
  /** The source of a plain front-end tree: its `src/`. */
package/dist/lib/why.d.ts CHANGED
@@ -42,6 +42,26 @@ export declare const why: {
42
42
  en: string;
43
43
  fix: string;
44
44
  };
45
+ "no-fill-command-in-header": {
46
+ code: string;
47
+ en: string;
48
+ fix: string;
49
+ };
50
+ "no-selectable-tag-group": {
51
+ code: string;
52
+ en: string;
53
+ fix: string;
54
+ };
55
+ "no-heading-above-card": {
56
+ code: string;
57
+ en: string;
58
+ fix: string;
59
+ };
60
+ "no-table-in-card": {
61
+ code: string;
62
+ en: string;
63
+ fix: string;
64
+ };
45
65
  "button-intent-required": {
46
66
  code: string;
47
67
  en: string;
package/dist/lib/why.js CHANGED
@@ -42,6 +42,26 @@ export const why = {
42
42
  en: "`<file>` draws a multi-pane frame by hand: a vertical Divider between columns of a flex row, or a flex row of columns inside a joined SurfaceCard.",
43
43
  fix: "Compose the columns as Panes of a PaneGrid (areas per breakpoint, collapse=\"drawer\" for a side pane), mounted as Page chrome=\"panes\" on a tabbed app page.",
44
44
  },
45
+ "no-fill-command-in-header": {
46
+ code: "FE_COMMAND_FILLS_HEADER",
47
+ en: "`<file>` stretches a command in a page or card header (`PageHeader actions`, `labelEnd`) with `width=\"fill\"`.",
48
+ fix: "A page or pane command hugs its label and sits at the end of its header row: drop `width`. `width=\"fill\"` is for a form submit, an auth panel and a phone action bar.",
49
+ },
50
+ "no-selectable-tag-group": {
51
+ code: "FE_TAG_GROUP_SELECTION",
52
+ en: "`<file>` gives a TagGroup a selection (`selectionMode`, `selectedIds`, `onSelectionChange`) to use it as a filter.",
53
+ fix: "A TagGroup holds values the user entered; peer values that filter a collection are a FilterButtonGroup (sm toggle buttons on one row, the rest behind a +n search list).",
54
+ },
55
+ "no-heading-above-card": {
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.",
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
+ },
60
+ "no-table-in-card": {
61
+ code: "FE_TABLE_IN_CARD",
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.",
64
+ },
45
65
  "button-intent-required": {
46
66
  code: "FE_BUTTON_INTENT_REQUIRED",
47
67
  en: "`<file>` renders a Button or IconButton without the command's intent.",
@@ -9,11 +9,23 @@ export declare const noListPanelPadding: import("./lib/rule.js").Rule<"padding",
9
9
  export declare const dividerFullBleed: import("./lib/rule.js").Rule<"padding", []>;
10
10
  /** 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
11
  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. */
13
+ export declare const noTableInCard: import("./lib/rule.js").Rule<"table", []>;
14
+ /** 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
+ export declare const noFillCommandInHeader: import("./lib/rule.js").Rule<"fill", []>;
16
+ /** A TagGroup holds values the user entered (remove, reorder); a filter over a collection is a FilterButtonGroup. */
17
+ 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. */
19
+ export declare const noHeadingAboveCard: import("./lib/rule.js").Rule<"name" | "header", []>;
12
20
  /** The surface laws ship at error in every composing source tree. */
13
21
  export declare const rules: {
14
22
  "divider-full-bleed": import("./lib/rule.js").Rule<"padding", []>;
15
23
  "nested-control-variant": import("./lib/rule.js").Rule<"variant" | "tabs", []>;
16
24
  "no-hand-built-panes": import("./lib/rule.js").Rule<"seam" | "joined", []>;
25
+ "no-fill-command-in-header": import("./lib/rule.js").Rule<"fill", []>;
26
+ "no-selectable-tag-group": import("./lib/rule.js").Rule<"selection", []>;
27
+ "no-heading-above-card": import("./lib/rule.js").Rule<"name" | "header", []>;
28
+ "no-table-in-card": import("./lib/rule.js").Rule<"table", []>;
17
29
  "no-list-panel-padding": import("./lib/rule.js").Rule<"padding", []>;
18
30
  "no-app-surface-paint": import("./lib/rule.js").Rule<"surface", []>;
19
31
  };
@@ -595,7 +595,201 @@ export const noHandBuiltPanes = defineRule({
595
595
  };
596
596
  },
597
597
  });
598
+ /** The capitalised same-file component a node renders inside (a declaration, a variable's function, or a wrapped one), or null. */
599
+ const componentNameOf = (node) => {
600
+ for (let current = node.parent; current; current = current.parent) {
601
+ if (current.type === "FunctionDeclaration") {
602
+ if (current.id !== null && /^[A-Z]/.test(current.id.name))
603
+ return current.id.name;
604
+ continue;
605
+ }
606
+ if (current.type !== "ArrowFunctionExpression" && current.type !== "FunctionExpression")
607
+ continue;
608
+ const holder = current.parent.type === "CallExpression" ? current.parent.parent : current.parent;
609
+ if (holder.type === "VariableDeclarator" && holder.id.type === "Identifier" && /^[A-Z]/.test(holder.id.name))
610
+ return holder.id.name;
611
+ }
612
+ return null;
613
+ };
614
+ /** True when a SurfaceCard element encloses the node, or a component of this file that renders the node is itself rendered inside one. */
615
+ const standsInCard = (context, node, seen) => {
616
+ for (let current = node.parent; current; current = current.parent) {
617
+ if (current.type !== "JSXElement")
618
+ continue;
619
+ const name = grammarName(context, current.openingElement.name);
620
+ if (name?.length === 1 && name[0] === "SurfaceCard")
621
+ return true;
622
+ }
623
+ const component = componentNameOf(node);
624
+ if (component === null || seen.has(component))
625
+ return false;
626
+ seen.add(component);
627
+ const usages = [];
628
+ walk(context.sourceCode, context.sourceCode.ast, (visited) => {
629
+ if (visited.type === "JSXOpeningElement" && visited.name.type === "JSXIdentifier" && visited.name.name === component)
630
+ usages.push(visited);
631
+ });
632
+ return usages.some((usage) => standsInCard(context, usage, seen));
633
+ };
634
+ /** A table is not placed in a SurfaceCard: plain rows are a SurfaceListCard with the note in its footer. */
635
+ export const noTableInCard = defineRule({
636
+ meta: {
637
+ type: "problem",
638
+ docs: { description: "A DataTable is not placed inside a SurfaceCard." },
639
+ schema: [],
640
+ 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.",
642
+ },
643
+ },
644
+ create(context) {
645
+ return {
646
+ JSXOpeningElement(node) {
647
+ const name = grammarName(context, node.name);
648
+ if (name?.length !== 1 || name[0] !== "DataTable")
649
+ return;
650
+ if (standsInCard(context, node, new Set()))
651
+ context.report({ node, messageId: "table" });
652
+ },
653
+ };
654
+ },
655
+ });
656
+ /** The header slots whose command hugs its label: `PageHeader` `actions` and the `labelEnd` of a card or a list card. */
657
+ const HEADER_SLOTS = new Map([
658
+ ["PageHeader", ["actions"]],
659
+ ["SurfaceCard", ["labelEnd"]],
660
+ ["SurfaceListCard", ["labelEnd"]],
661
+ ]);
662
+ /** True when the Button's own opening element sets `width="fill"`. */
663
+ const fillsWidth = (context, opening) => opening.attributes.some((entry) => propStrings(context, entry, "width", "variant").some((value) => value.text === "fill"));
664
+ /** The header slot (`PageHeader actions`, `labelEnd`) a node is written in, or null. */
665
+ const headerSlotOf = (context, node) => {
666
+ for (let current = node.parent; current; current = current.parent) {
667
+ if (current.type !== "JSXAttribute" || current.name.type !== "JSXIdentifier")
668
+ continue;
669
+ const owner = current.parent;
670
+ if (owner.type !== "JSXOpeningElement")
671
+ continue;
672
+ const name = grammarName(context, owner.name);
673
+ if (name?.length === 1 && name[0] !== undefined && HEADER_SLOTS.get(name[0])?.includes(current.name.name) === true)
674
+ return `${name[0]} ${current.name.name}`;
675
+ }
676
+ return null;
677
+ };
678
+ /** 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"`. */
679
+ export const noFillCommandInHeader = defineRule({
680
+ meta: {
681
+ type: "problem",
682
+ docs: { description: "A command in a page or card header hugs its label." },
683
+ schema: [],
684
+ messages: {
685
+ 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`.",
686
+ },
687
+ },
688
+ create(context) {
689
+ return {
690
+ JSXOpeningElement(node) {
691
+ const name = grammarName(context, node.name);
692
+ if (name?.length !== 1 || name[0] !== "Button" || !fillsWidth(context, node))
693
+ return;
694
+ const slot = headerSlotOf(context, node);
695
+ if (slot !== null)
696
+ context.report({ node, messageId: "fill", data: { slot } });
697
+ },
698
+ };
699
+ },
700
+ });
701
+ /** The props that made a TagGroup a selectable filter. */
702
+ const TAG_SELECTION_PROPS = new Set(["selectionMode", "selectedIds", "defaultSelectedIds", "onSelectionChange"]);
703
+ /** A TagGroup holds values the user entered (remove, reorder); a filter over a collection is a FilterButtonGroup. */
704
+ export const noSelectableTagGroup = defineRule({
705
+ meta: {
706
+ type: "problem",
707
+ docs: { description: "A TagGroup has no selection: a filter over a collection is a FilterButtonGroup." },
708
+ schema: [],
709
+ messages: {
710
+ 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).",
711
+ },
712
+ },
713
+ create(context) {
714
+ return {
715
+ JSXOpeningElement(node) {
716
+ const name = grammarName(context, node.name);
717
+ if (name?.length !== 1 || name[0] !== "TagGroup")
718
+ return;
719
+ for (const entry of node.attributes) {
720
+ if (entry.type === "JSXAttribute" && entry.name.type === "JSXIdentifier" && TAG_SELECTION_PROPS.has(entry.name.name))
721
+ context.report({ node: entry, messageId: "selection" });
722
+ }
723
+ },
724
+ };
725
+ },
726
+ });
727
+ /** The bounded surfaces that carry their own label. */
728
+ const LABELLED_CARDS = new Set(["SurfaceCard", "SurfaceListCard", "SurfaceAccordionCard"]);
729
+ /** 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
+ const propText = (context, opening, name) => {
731
+ const attribute = opening.attributes.find((entry) => entry.type === "JSXAttribute" && entry.name.type === "JSXIdentifier" && entry.name.name === name);
732
+ if (attribute === undefined || attribute.type !== "JSXAttribute" || attribute.value === null)
733
+ return null;
734
+ const value = attribute.value;
735
+ if (value.type === "Literal")
736
+ return String(value.value);
737
+ if (value.type === "JSXExpressionContainer" && value.expression.type !== "JSXEmptyExpression") {
738
+ const expression = value.expression;
739
+ return expression.type === "Literal" ? String(expression.value) : context.sourceCode.getText(expression);
740
+ }
741
+ return null;
742
+ };
743
+ /** The element children of a JSX parent, whitespace-only text and empty expressions left out. */
744
+ 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. */
746
+ export const noHeadingAboveCard = defineRule({
747
+ meta: {
748
+ type: "problem",
749
+ docs: { description: "A SectionHeader directly above a single card is a hand heading: the card has a label." },
750
+ schema: [],
751
+ 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.",
754
+ },
755
+ },
756
+ create(context) {
757
+ const check = (parent) => {
758
+ const children = meaningfulChildren(parent);
759
+ children.forEach((child, index) => {
760
+ if (child.type !== "JSXElement")
761
+ return;
762
+ const header = grammarName(context, child.openingElement.name);
763
+ if (header?.length !== 1 || header[0] !== "SectionHeader")
764
+ 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");
778
+ if (title === null)
779
+ return;
780
+ for (const card of cards) {
781
+ if (propText(context, card.openingElement, "ariaLabel") === title)
782
+ context.report({ node: card.openingElement, messageId: "name" });
783
+ }
784
+ });
785
+ };
786
+ return {
787
+ JSXElement: check,
788
+ JSXFragment: check,
789
+ };
790
+ },
791
+ });
598
792
  /** 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 };
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 };
600
794
  /** No app can lower or omit a surface law. */
601
795
  export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
@@ -74,3 +74,91 @@ const Frame = () => (
74
74
  </Page>
75
75
  )
76
76
  ```
77
+
78
+ ## `starci-fe/no-table-in-card`
79
+
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.
81
+
82
+ Invalid:
83
+
84
+ ```tsx
85
+ import { SurfaceCard, DataTable } from "@starci/grammar/core"
86
+ const Rules = () => (
87
+ <SurfaceCard label="Rules" headingLevel={2}>
88
+ <Text>{description}</Text>
89
+ <DataTable label="Rules" columns={columns} rows={rows} renderCell={renderCell} emptyContent={null} />
90
+ <Text>{footnote}</Text>
91
+ </SurfaceCard>
92
+ )
93
+ ```
94
+
95
+ Valid:
96
+
97
+ ```tsx
98
+ import { SurfaceListCard, ListRow } from "@starci/grammar/core"
99
+ const Rules = () => (
100
+ <SurfaceListCard label="Rules" footer={footnote}>
101
+ {rules.map((rule) => <ListRow key={rule.id} title={rule.title} description={rule.summary} />)}
102
+ </SurfaceListCard>
103
+ )
104
+ ```
105
+
106
+ ## `starci-fe/no-fill-command-in-header`
107
+
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.
109
+
110
+ Invalid:
111
+
112
+ ```tsx
113
+ import { PageHeader, Button } from "@starci/grammar/core"
114
+ const Head = () => <PageHeader title="Agents" actions={<Button intent="commit" width="fill">Add agent</Button>} />
115
+ ```
116
+
117
+ Valid:
118
+
119
+ ```tsx
120
+ import { PageHeader, Button } from "@starci/grammar/core"
121
+ const Head = () => <PageHeader title="Agents" actions={<Button intent="commit">Add agent</Button>} />
122
+ ```
123
+
124
+ ## `starci-fe/no-selectable-tag-group`
125
+
126
+ Finding code: `FE_TAG_GROUP_SELECTION` (R353). A Grammar `TagGroup` holds values the user entered, to remove or to order, and has no selection: `selectionMode`, `selectedIds`, `defaultSelectedIds` and `onSelectionChange` on it are refused. Peer values that filter a collection that stays in place are a `FilterButtonGroup`.
127
+
128
+ Invalid:
129
+
130
+ ```tsx
131
+ import { TagGroup } from "@starci/grammar/core"
132
+ const Filter = () => <TagGroup label="Team" items={items} selectionMode="single" selectedIds={[team]} onSelectionChange={pick} />
133
+ ```
134
+
135
+ Valid:
136
+
137
+ ```tsx
138
+ import { FilterButtonGroup } from "@starci/grammar/core"
139
+ const Filter = () => <FilterButtonGroup label="Team" options={options} value={team} onValueChange={pick} />
140
+ ```
141
+
142
+ ## `starci-fe/no-heading-above-card`
143
+
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`.
145
+
146
+ Invalid:
147
+
148
+ ```tsx
149
+ import { SectionHeader, SurfaceListCard } from "@starci/grammar/core"
150
+ const Rules = () => (
151
+ <section>
152
+ <SectionHeader title="Rules" count={3} action={add} />
153
+ <SurfaceListCard ariaLabel="Rules">{rows}</SurfaceListCard>
154
+ </section>
155
+ )
156
+ ```
157
+
158
+ Valid:
159
+
160
+ ```tsx
161
+ import { SurfaceListCard } from "@starci/grammar/core"
162
+ const Rules = () => <SurfaceListCard label="Rules" fact="3" labelEnd={add} footer={note}>{rows}</SurfaceListCard>
163
+ ```
164
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.8.4",
3
+ "version": "9.8.5",
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",
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.20.1
34
+ version: 0.20.2
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.11.5
41
+ version: 3.11.6
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.8.4
48
+ version: 9.8.5
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.20.1
55
+ version: 0.20.2
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.20.1
62
+ version: 0.20.2
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.16.4
109
+ version: 4.16.5
110
110
  group: starci
111
111
  install: registry
112
112
  side: both