@starci/eslint-canon-fe 1.3.2 → 1.4.1

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/props.ts CHANGED
@@ -61,13 +61,13 @@ export type LeafProps<D extends ComponentData, A extends ComponentActions = Comp
61
61
  /**
62
62
  * A COMPOSITE's props. The same three slots a leaf has, and the absence of a fourth is the point.
63
63
  *
64
- * A composite is a CLOSED shape: it assembles leaves into an arrangement a caller can use but
65
- * cannot rearrange. That boundary with `branch` is held by this type rather than by a convention -
66
- * there is no `render` here, so an author who wanted a slot cannot express one and has to decide
67
- * which tier they are actually writing. A boundary the compiler holds does not drift.
68
- * CLOSED DOES NOT MEAN FREEHAND. A composite still assembles through a typed `Tree` contract;
69
- * raw structural markup would create an unnamed layout outside the registry. If a caller may
70
- * supply the content or recipe, the component is a branch rather than a composite.
64
+ * A composite is a CLOSED shape: it assembles leaves into an arrangement a caller can use but
65
+ * cannot rearrange. That boundary with `branch` is held by this type rather than by a convention -
66
+ * there is no `render` here, so an author who wanted a slot cannot express one and has to decide
67
+ * which tier they are actually writing. A boundary the compiler holds does not drift.
68
+ * CLOSED DOES NOT MEAN FREEHAND. A composite still assembles through a typed `Tree` contract;
69
+ * raw structural markup would create an unnamed layout outside the registry. If a caller may
70
+ * supply the content or recipe, the component is a branch rather than a composite.
71
71
  *
72
72
  * It is structurally identical to {@link LeafProps} and deliberately NOT an alias of it: the two
73
73
  * tiers answer different questions, and a shared name would let a reader conclude they are the same
@@ -85,18 +85,18 @@ export type CompositeProps<D extends ComponentData, A extends ComponentActions =
85
85
  *
86
86
  * THIS IS THE SLOT THAT USED TO BE `children`, AND THE CHANGE IS NOT COSMETIC. `children` is markup
87
87
  * that has already been built: it can be a `.map`, a ternary, a fragment, or a subtree nobody named,
88
- * so nothing above it can say what is inside a node and no rule can check. `render` is a contract
89
- * value in one of two lanes. This alias is the bound lane: Tree and ordinary branches receive
90
- * named slots or an explicit projection. A data-driven surface uses {@link ContractRenderBranchProps}
91
- * to receive a stable component type and pass changing runtime `props` into it.
88
+ * so nothing above it can say what is inside a node and no rule can check. `render` is a contract
89
+ * value in one of two lanes. This alias is the bound lane: Tree and ordinary branches receive
90
+ * named slots or an explicit projection. A data-driven surface uses {@link ContractRenderBranchProps}
91
+ * to receive a stable component type and pass changing runtime `props` into it.
92
92
  *
93
93
  * `contract` and `render` are one decision written twice, and `ContractComponent<K>` ties them together:
94
94
  * the key fixes which slots exist and what may fill each, so a card cannot say one thing and hold
95
95
  * another. A wrong leaf, wrong leaf props, a node where a leaf was asked for, a missing slot and a
96
96
  * slot nobody declared are five compile errors rather than five things a reviewer has to spot.
97
97
  *
98
- * A bare callback or JSX value may not arrive: neither carries the exact key metadata that makes
99
- * `contract` and `render` one checked decision.
98
+ * A bare callback or JSX value may not arrive: neither carries the exact key metadata that makes
99
+ * `contract` and `render` one checked decision.
100
100
  *
101
101
  * THERE IS NO LIST VARIANT, and there does not need to be. A slot that holds many says so in the
102
102
  * table with `repeats: true`; that slot becomes an array. `restingCount` separately fixes how many
@@ -110,26 +110,26 @@ export type BranchProps<
110
110
  readonly props: D
111
111
  readonly on?: A
112
112
  readonly contract: K
113
- readonly render: ContractComponent<NoInfer<K>>
114
- readonly isLoading?: boolean
115
- }
116
-
117
- /**
118
- * A data-driven contract host's props. Runtime data stays in `props`; `render` is a stable real
119
- * component type branded for the exact key. `SurfaceListCard` uses this lane because a repeated
120
- * domain collection must not be closed into a new callback descriptor at every host call site.
121
- */
122
- export type ContractRenderBranchProps<
123
- D extends ComponentData,
124
- K extends ContractKey,
125
- A extends ComponentActions = ComponentActions,
126
- > = {
127
- readonly props: D
128
- readonly on?: A
129
- readonly contract: K
130
- readonly render: ContractComponent<NoInfer<K>, LeafProps<D, A>>
131
- readonly isLoading?: boolean
132
- }
113
+ readonly render: ContractComponent<NoInfer<K>>
114
+ readonly isLoading?: boolean
115
+ }
116
+
117
+ /**
118
+ * A data-driven contract host's props. Runtime data stays in `props`; `render` is a stable real
119
+ * component type branded for the exact key. `SurfaceListCard` uses this lane because a repeated
120
+ * domain collection must not be closed into a new callback descriptor at every host call site.
121
+ */
122
+ export type ContractRenderBranchProps<
123
+ D extends ComponentData,
124
+ K extends ContractKey,
125
+ A extends ComponentActions = ComponentActions,
126
+ > = {
127
+ readonly props: D
128
+ readonly on?: A
129
+ readonly contract: K
130
+ readonly render: ContractComponent<NoInfer<K>, LeafProps<D, A>>
131
+ readonly isLoading?: boolean
132
+ }
133
133
 
134
134
  /**
135
135
  * A BLOCK's presentational half. Two slots.
package/served-locale.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Rules for the SERVED LOCALE law - `patterns/fe/served-locale/INDEX.md`.
2
+ * Rules for the SERVED LOCALE law - `fe/canon/patterns/served-locale.md`.
3
3
  *
4
4
  * WHY THESE EXIST. An application obeyed the translation law completely - every label resolved in a
5
5
  * connected half, no literal below a block, both catalogues in step - and served a Vietnamese reader
package/the-split.mjs CHANGED
@@ -66,7 +66,7 @@ export const presentationalPurity = {
66
66
  export const connectedBlockHasPresentationalTwin = {
67
67
  meta: {
68
68
  type: "problem",
69
- docs: { description: "A connected block index renders only its exact `XBase` twin from component.tsx." },
69
+ docs: { description: "A connected block index renders only its exact `_X` twin from component.tsx." },
70
70
  schema: [],
71
71
  messages: {
72
72
  missing:
@@ -81,7 +81,7 @@ export const connectedBlockHasPresentationalTwin = {
81
81
  const block = connectedBlock(context.filename || context.getFilename())
82
82
  if (!block) return {}
83
83
 
84
- const twin = `${block}Base`
84
+ const twin = `_${block}`
85
85
  const worldBindings = new Set()
86
86
  const rendered = []
87
87
  let importsTwin = false
package/typography.mjs CHANGED
@@ -1,83 +1,83 @@
1
- /**
2
- * The rule that holds `typography.md`.
3
- *
4
- * MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
5
- * written by hand. The level union stops a fifth step being asked for; nothing in the type system
6
- * notices an `<h2>` in a file that never mentions the heading component at all.
7
- *
8
- * The rule's own twin lives in `tokens.mjs`: that one catches a heading assembled out of a large
9
- * size and a heavy weight on some other element. Between them they cover both ways a heading gets
10
- * hand-rolled - with the right tag and the wrong look, or the right look and no tag.
11
- */
12
-
13
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
14
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
15
-
16
- /** Product source lives under `src/`; tooling and config are out of scope. */
17
- const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
18
-
19
- /** A twin test may build heading markup by hand to assert against it. */
20
- const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
21
-
22
- /** The one component that owns the tag and the size together, and therefore may write the tag. */
23
- const isHeadingComponent = (filename) => normalizePath(filename).includes("/src/components/leaves/Heading/")
24
-
25
- /** The tags a document outline is built from. */
26
- const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
27
-
28
- /** The deepest level the component draws; below this a page has nested too far. */
29
- const DEEPEST_LEVEL = 4
30
-
31
- /** Intrinsic (lowercase) tag name, or null for a component. */
32
- const tagName = (opening) => {
33
- const name = opening && opening.name
34
- if (!name || name.type !== "JSXIdentifier") return null
35
- return name.name === name.name.toLowerCase() ? name.name : null
36
- }
37
-
38
- // -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
39
-
40
- /** A heading comes from the component that owns its tag and its size as one decision. */
41
- export const noHeadingTagOutsideHeadingComponent = {
42
- meta: {
43
- type: "problem",
44
- docs: { description: "Heading tags are written by the heading component, from a level." },
45
- schema: [],
46
- messages: {
47
- tag:
48
- "`<{{tag}}>` written here throws away the two facts the heading component exists to keep together: the tag a screen reader builds the document outline from, and the size a reader sees. Set separately they drift, and a screen's third-largest text becomes its first heading. Render the heading component with a level of {{level}}; one prop decides both, so they cannot disagree.",
49
- tooDeep:
50
- "`<{{tag}}>` is deeper than the scale goes: levels stop at {{deepest}}, because a fifth means the page has nested further than a reader can hold. That is a structure problem wearing a styling problem's clothes - flatten the section, then render the title with a level the scale has.",
51
- },
52
- },
53
- create(context) {
54
- const file = normalizePath(context.filename || context.getFilename())
55
- if (!isSourceFile(file) || isTestFile(file) || isHeadingComponent(file)) return {}
56
- return {
57
- JSXOpeningElement(node) {
58
- const tag = tagName(node)
59
- if (!tag || !HEADING_TAGS.has(tag)) return
60
- const level = Number(tag.slice(1))
61
- if (level > DEEPEST_LEVEL) {
62
- context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } })
63
- return
64
- }
65
- context.report({ node, messageId: "tag", data: { tag, level } })
66
- },
67
- }
68
- },
69
- }
70
-
71
- /** The rules this law contributes to the plugin. */
72
- export const rules = {
73
- "no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
74
- }
75
-
76
- /**
77
- * The level this law asks for, as the plugin's own opinion.
78
- *
79
- * Exact: a tag name in a file that is not the component that owns it. A repository adopting this
80
- * with history should expect the deeper levels to report a structure problem rather than a styling
81
- * one, and those take longer to fix than the message suggests.
82
- */
83
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
1
+ /**
2
+ * The rule that holds `typography.md`.
3
+ *
4
+ * MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
5
+ * written by hand. The level union stops a fifth step being asked for; nothing in the type system
6
+ * notices an `<h2>` in a file that never mentions the heading component at all.
7
+ *
8
+ * The rule's own twin lives in `tokens.mjs`: that one catches a heading assembled out of a large
9
+ * size and a heavy weight on some other element. Between them they cover both ways a heading gets
10
+ * hand-rolled - with the right tag and the wrong look, or the right look and no tag.
11
+ */
12
+
13
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
14
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
15
+
16
+ /** Product source lives under `src/`; tooling and config are out of scope. */
17
+ const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
18
+
19
+ /** A twin test may build heading markup by hand to assert against it. */
20
+ const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
21
+
22
+ /** The one component that owns the tag and the size together, and therefore may write the tag. */
23
+ const isHeadingComponent = (filename) => normalizePath(filename).includes("/src/components/leaves/Heading/")
24
+
25
+ /** The tags a document outline is built from. */
26
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
27
+
28
+ /** The deepest level the component draws; below this a page has nested too far. */
29
+ const DEEPEST_LEVEL = 4
30
+
31
+ /** Intrinsic (lowercase) tag name, or null for a component. */
32
+ const tagName = (opening) => {
33
+ const name = opening && opening.name
34
+ if (!name || name.type !== "JSXIdentifier") return null
35
+ return name.name === name.name.toLowerCase() ? name.name : null
36
+ }
37
+
38
+ // -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
39
+
40
+ /** A heading comes from the component that owns its tag and its size as one decision. */
41
+ export const noHeadingTagOutsideHeadingComponent = {
42
+ meta: {
43
+ type: "problem",
44
+ docs: { description: "Heading tags are written by the heading component, from a level." },
45
+ schema: [],
46
+ messages: {
47
+ tag:
48
+ "`<{{tag}}>` written here throws away the two facts the heading component exists to keep together: the tag a screen reader builds the document outline from, and the size a reader sees. Set separately they drift, and a screen's third-largest text becomes its first heading. Render the heading component with a level of {{level}}; one prop decides both, so they cannot disagree.",
49
+ tooDeep:
50
+ "`<{{tag}}>` is deeper than the scale goes: levels stop at {{deepest}}, because a fifth means the page has nested further than a reader can hold. That is a structure problem wearing a styling problem's clothes - flatten the section, then render the title with a level the scale has.",
51
+ },
52
+ },
53
+ create(context) {
54
+ const file = normalizePath(context.filename || context.getFilename())
55
+ if (!isSourceFile(file) || isTestFile(file) || isHeadingComponent(file)) return {}
56
+ return {
57
+ JSXOpeningElement(node) {
58
+ const tag = tagName(node)
59
+ if (!tag || !HEADING_TAGS.has(tag)) return
60
+ const level = Number(tag.slice(1))
61
+ if (level > DEEPEST_LEVEL) {
62
+ context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } })
63
+ return
64
+ }
65
+ context.report({ node, messageId: "tag", data: { tag, level } })
66
+ },
67
+ }
68
+ },
69
+ }
70
+
71
+ /** The rules this law contributes to the plugin. */
72
+ export const rules = {
73
+ "no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
74
+ }
75
+
76
+ /**
77
+ * The level this law asks for, as the plugin's own opinion.
78
+ *
79
+ * Exact: a tag name in a file that is not the component that owns it. A repository adopting this
80
+ * with history should expect the deeper levels to report a structure problem rather than a styling
81
+ * one, and those take longer to fix than the message suggests.
82
+ */
83
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -27,15 +27,24 @@ const memberName = (node) => {
27
27
  /** The component library this boundary owns. Its glyph counterpart belongs to `icon.mjs`. */
28
28
  const VENDOR_PACKAGE_PREFIX = "@heroui/"
29
29
 
30
- /** Named mechanics branches own the closed vendor interaction machines. */
31
- const MECHANICS_BRANCH = /\/src\/components\/branches\/(?:ModalBranch|DrawerBranch|DropdownBranch)\//
32
- const LEGACY_SHELL_DIR = "/src/components/shells/"
30
+ /**
31
+ * The shell folder is closed, and it is closed to FOUR now.
32
+ *
33
+ * Three own a vendor interaction machine and ignore their interior. `RouteShell` owns a framework
34
+ * one: a Next segment layout is handed its page as `children`, no tier below a shell may take one,
35
+ * and a server component cannot convert it because a function does not serialise across that
36
+ * boundary. It imports no vendor at all, which is why this rule can admit it without widening what
37
+ * a vendor import means.
38
+ */
39
+ const SHELL_DIR = "/src/components/shells/"
40
+ const FRAMEWORK_SHELL = /\/src\/components\/shells\/RouteShell\//
41
+ const SHELL_FILE = /\/src\/components\/shells\/(?:ModalShell|DrawerShell|DropdownShell|RouteShell)\//
33
42
 
34
43
  /** Named surface branches may own the vendor wrapper they project a content contract into. */
35
44
  const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
36
45
 
37
46
  const isAllowedVendorOwner = (file) =>
38
- file.includes("/src/components/leaves/") || MECHANICS_BRANCH.test(file) || SURFACE_BRANCH.test(file)
47
+ file.includes("/src/components/leaves/") || SHELL_FILE.test(file) || SURFACE_BRANCH.test(file)
39
48
 
40
49
  /** True for a file inside the component tree, where the boundary applies. */
41
50
  const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
@@ -56,15 +65,15 @@ const jsxMemberName = (node) => {
56
65
  export const vendorBoundary = {
57
66
  meta: {
58
67
  type: "problem",
59
- docs: { description: "Vendor imports belong to leaves and named mechanics or surface branches." },
68
+ docs: { description: "Vendor imports belong to leaves, the two covering shells, and named surface branches." },
60
69
  schema: [],
61
70
  messages: {
62
71
  outside:
63
- "This component does not own a vendor primitive. Vendor imports belong to leaves, ModalBranch/DrawerBranch/DropdownBranch, and the named SurfaceCard family branches that project contracts into vendor bodies.",
64
- emptyBranch:
65
- "ModalBranch/DrawerBranch/DropdownBranch must wrap their vendor interaction primitive; otherwise this is an ordinary branch in a privileged mechanics path.",
66
- legacyShell:
67
- "`components/shells` is a deleted structural tier. Move vendor mechanics into the named Branch owner; framework route content closes outside the component tier into a named contract projection.",
72
+ "This component does not own a vendor primitive. Vendor imports belong to leaves, ModalShell/DrawerShell, and the named SurfaceCard family branches that project contracts into vendor bodies.",
73
+ emptyShell:
74
+ "ModalShell/DrawerShell must wrap their vendor covering primitive; otherwise this is an ordinary branch in the wrong tier.",
75
+ unknownShell:
76
+ "Only ModalShell, DrawerShell, DropdownShell and RouteShell are shells. This component must use contract + render as a branch; needing an arbitrary vendor children slot does not create another one - and the list disagreeing with canon is a gate bug, which is how this message came to name two when the tree said three.",
68
77
  },
69
78
  },
70
79
  create(context) {
@@ -72,8 +81,8 @@ export const vendorBoundary = {
72
81
  // Outside the component tree, a provider standing the library up for the whole application is a
73
82
  // different thing from a component pulling in a widget.
74
83
  if (!isComponentFile(file)) return {}
75
- const isLegacyShell = file.includes(LEGACY_SHELL_DIR)
76
- const isMechanicsBranch = MECHANICS_BRANCH.test(file)
84
+ const isShellDirectory = file.includes(SHELL_DIR)
85
+ const isShell = SHELL_FILE.test(file)
77
86
  const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
78
87
  const isAllowed = isAllowedVendorOwner(file)
79
88
  let importsVendor = false
@@ -86,8 +95,14 @@ export const vendorBoundary = {
86
95
  },
87
96
  "Program:exit"(node) {
88
97
  if (isTestFile) return
89
- if (isLegacyShell) context.report({ node, messageId: "legacyShell" })
90
- else if (isMechanicsBranch && !importsVendor) context.report({ node, messageId: "emptyBranch" })
98
+ if (isShellDirectory && !isShell) context.report({ node, messageId: "unknownShell" })
99
+ /*
100
+ * A shell with no vendor import is empty - except the one whose mechanic is the FRAMEWORK's.
101
+ * `RouteShell` converts the children a Next layout is handed into the component every tier
102
+ * below expects, and it imports nothing to do it. Demanding a vendor primitive there would
103
+ * force an import that means nothing, which is how a rule teaches somebody to add noise.
104
+ */
105
+ else if (isShell && !importsVendor && !FRAMEWORK_SHELL.test(file)) context.report({ node, messageId: "emptyShell" })
91
106
  },
92
107
  }
93
108
  },
@@ -95,22 +110,22 @@ export const vendorBoundary = {
95
110
 
96
111
  // -- VENDOR-6 --------------------------------------------------------------------------------------
97
112
 
98
- /** The modal mechanics branch owns one zero-inset vendor body as the scroll region. */
99
- export const modalBranchOwnsScrollBody = {
113
+ /** A content-agnostic modal shell owns one zero-inset vendor body as the scroll region. */
114
+ export const modalShellOwnsScrollBody = {
100
115
  meta: {
101
116
  type: "problem",
102
- docs: { description: "ModalBranch keeps one zero-inset Modal.Body as its scroll region." },
117
+ docs: { description: "ModalShell keeps one zero-inset Modal.Body as its scroll region." },
103
118
  schema: [],
104
119
  messages: {
105
120
  missing:
106
- "ModalBranch must keep one `Modal.Body` around its named content projection: that vendor body is the dialog's scroll region, not a second content surface.",
121
+ "ModalShell must keep one `Modal.Body` around its uninterpreted children: that vendor body is the dialog's scroll region, not a second content surface.",
107
122
  inset:
108
- "ModalBranch's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
123
+ "ModalShell's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
109
124
  },
110
125
  },
111
126
  create(context) {
112
127
  const file = normalizePath(context.filename || context.getFilename())
113
- if (!/\/src\/components\/branches\/ModalBranch\/index\.tsx$/.test(file)) return {}
128
+ if (!/\/src\/components\/shells\/ModalShell\/index\.tsx$/.test(file)) return {}
114
129
  let hasBody = false
115
130
  return {
116
131
  JSXOpeningElement(node) {
@@ -280,24 +295,24 @@ export const textLinkUsesHeroLink = {
280
295
 
281
296
  // -- VENDOR-11 -------------------------------------------------------------------------------------
282
297
 
283
- /** The account block owns the product choices; DropdownBranch alone owns HeroUI mechanics. */
298
+ /** The account block owns the product choices; DropdownShell alone owns HeroUI mechanics. */
284
299
  export const accountControlOwnsDropdown = {
285
300
  meta: {
286
301
  type: "problem",
287
- docs: { description: "AccountMenu is a block over DropdownBranch; only the branch imports HeroUI Dropdown." },
302
+ docs: { description: "AccountMenu is a block over DropdownShell; only the shell imports HeroUI Dropdown." },
288
303
  schema: [],
289
304
  messages: {
290
- dropdown: "DropdownBranch must import HeroUI `Dropdown`; vendor mechanics belong to the branch.",
291
- branch: "AccountMenu's pure block half must compose DropdownBranch instead of importing HeroUI directly.",
292
- vendor: "AccountMenu is a product block. Import DropdownBranch; do not own HeroUI Dropdown here.",
305
+ dropdown: "DropdownShell must import HeroUI `Dropdown`; vendor mechanics belong to the shell.",
306
+ shell: "AccountMenu's pure block half must compose DropdownShell instead of importing HeroUI directly.",
307
+ vendor: "AccountMenu is a product block. Import DropdownShell; do not own HeroUI Dropdown here.",
293
308
  menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
294
309
  direct: "The account IconButton may not carry an action in ShellNav. Guests open AccountMenu; its Sign in or Sign up choice opens the modal.",
295
- pieces: "AccountMenu passes typed section data to DropdownBranch; it must not import Branch Section or Item pieces and rebuild vendor anatomy itself.",
310
+ pieces: "AccountMenu passes typed section data to DropdownShell; it must not import shell Section or Item pieces and rebuild vendor anatomy itself.",
296
311
  },
297
312
  },
298
313
  create(context) {
299
314
  const file = normalizePath(context.filename || context.getFilename())
300
- const isDropdown = /\/src\/components\/branches\/DropdownBranch\/index\.tsx$/.test(file)
315
+ const isDropdown = /\/src\/components\/shells\/DropdownShell\/index\.tsx$/.test(file)
301
316
  const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
302
317
  const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
303
318
  if (!isDropdown && !isMenu && !isNav) return {}
@@ -306,7 +321,7 @@ export const accountControlOwnsDropdown = {
306
321
  ExportNamedDeclaration(node) {
307
322
  if (!isDropdown) return
308
323
  const source = context.sourceCode || context.getSourceCode()
309
- if (/\bDropdownBranch(?:Item|Section)\b/.test(source.getText(node))) {
324
+ if (/\bDropdownShell(?:Item|Section)\b/.test(source.getText(node))) {
310
325
  context.report({ node, messageId: "pieces" })
311
326
  }
312
327
  },
@@ -315,10 +330,10 @@ export const accountControlOwnsDropdown = {
315
330
  hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
316
331
  }
317
332
  if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
318
- if (isMenu && /\/components\/branches\/DropdownBranch$/.test(normalizePath(node.source?.value))) {
333
+ if (isMenu && /\/components\/shells\/DropdownShell$/.test(normalizePath(node.source?.value))) {
319
334
  hasOwner = true
320
335
  if ((node.specifiers || []).some((specifier) =>
321
- /^(?:DropdownBranchSection|DropdownBranchItem)/.test(specifier.imported?.name || ""))) {
336
+ /^(?:DropdownShellSection|DropdownShellItem)/.test(specifier.imported?.name || ""))) {
322
337
  context.report({ node, messageId: "pieces" })
323
338
  }
324
339
  }
@@ -335,7 +350,7 @@ export const accountControlOwnsDropdown = {
335
350
  },
336
351
  "Program:exit"(node) {
337
352
  if (hasOwner) return
338
- context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "branch" : "menu" })
353
+ context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "shell" : "menu" })
339
354
  },
340
355
  }
341
356
  },
@@ -440,12 +455,12 @@ export const noInternalStarciHref = {
440
455
  export const authOverlayOwnsSingleContentHost = {
441
456
  meta: {
442
457
  type: "problem",
443
- docs: { description: "Authentication has one zero-inset content host inside ModalBranch or DrawerBranch." },
458
+ docs: { description: "Authentication has one zero-inset content host inside ModalShell." },
444
459
  schema: [],
445
460
  messages: {
446
- duplicate: "SignInOverlay must project through one host only: do not open a second `Tree`/`ContractContent` inside a branch that already receives `contract`/`render` directly, and do not open `Tree` by hand instead of importing `ContractContent`.",
447
- missing: "SignInOverlay must host its content either by importing `ContractContent` for its already-hosted projection, or by passing `contract` and `render` props directly to `ModalBranch`/`DrawerBranch`.",
448
- inset: "`centred-page-column` must not add py/pt/pb. ModalBranch is zero-inset and the auth content touches that scroll region without a second vertical padding band.",
461
+ duplicate: "SignInOverlay must project with `ContractContent`, not open another `Tree` around a panel that already owns the same host.",
462
+ missing: "SignInOverlay must import `ContractContent` for its already-hosted projection.",
463
+ inset: "`centred-page-column` must not add py/pt/pb. ModalShell is zero-inset and the auth content touches that scroll region without a second vertical padding band.",
449
464
  },
450
465
  },
451
466
  create(context) {
@@ -454,63 +469,24 @@ export const authOverlayOwnsSingleContentHost = {
454
469
  const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
455
470
  if (!isOverlay && !isContracts) return {}
456
471
  let hasContent = false
457
-
458
- /*
459
- * Two call shapes satisfy the same invariant - one contract-driven content host, zero extra
460
- * vertical inset - and both are recognised here.
461
- *
462
- * (a) THE OLD SHAPE: the overlay imports `ContractContent` and mounts it itself as the branch's
463
- * children. `ContractContent` IS the checked host, so importing it is enough to credit the
464
- * overlay with owning one.
465
- *
466
- * (b) THE NEW SHAPE: `ModalBranch`/`DrawerBranch` were migrated to take `{contract, render}`
467
- * directly and build `<Tree contract render />` internally (SLOTS-4). An overlay that hands both
468
- * props straight to the branch has delegated the same host to the branch itself; there is
469
- * nothing left for the overlay to import.
470
- *
471
- * Matched by JSX ATTRIBUTE NAME, not by where `ModalBranch`/`DrawerBranch` resolve from - same
472
- * reasoning VENDOR-12 already applies to `ContractContent`/`Tree` below: canon vocabulary means
473
- * the same thing wherever it is imported from.
474
- */
475
- const MECHANICS_BRANCH_NAMES = new Set(["ModalBranch", "DrawerBranch"])
476
- const hasJsxAttribute = (attributes, attrName) =>
477
- (attributes || []).some((attribute) =>
478
- attribute.type === "JSXAttribute"
479
- && attribute.name?.type === "JSXIdentifier"
480
- && attribute.name.name === attrName)
481
- const jsxChildName = (child) => {
482
- if (child?.type !== "JSXElement") return null
483
- const name = child.openingElement?.name
484
- return name?.type === "JSXIdentifier" ? name.name : null
485
- }
486
-
487
472
  return {
488
473
  ImportDeclaration(node) {
489
474
  if (!isOverlay) return
490
475
  /*
491
- * Matched by the NAME imported, not by where it came from - see the comment above.
476
+ * Matched by the NAME imported, not by where it came from.
477
+ *
478
+ * The path test this replaced only recognised `components/branches/Tree`,
479
+ * which is the single-app layout. A workspace that publishes the same
480
+ * branches from a package satisfied the law and failed the rule, with
481
+ * the only available remedy being an import of a module that does not
482
+ * exist there. `ContractContent` and `Tree` are canon vocabulary: a file
483
+ * importing either means the same thing wherever it resolves from.
492
484
  */
493
485
  for (const specifier of node.specifiers || []) {
494
486
  if (specifier.imported?.name === "ContractContent") hasContent = true
495
- // Opening `Tree` by hand is the one thing neither shape ever does: both shapes close the
496
- // host either inside ContractContent or inside the mechanics branch itself.
497
487
  if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
498
488
  }
499
489
  },
500
- JSXElement(node) {
501
- if (!isOverlay) return
502
- const name = node.openingElement?.name
503
- if (name?.type !== "JSXIdentifier" || !MECHANICS_BRANCH_NAMES.has(name.name)) return
504
- const attributes = node.openingElement.attributes
505
- if (!hasJsxAttribute(attributes, "contract") || !hasJsxAttribute(attributes, "render")) return
506
- // The branch itself now owns the host: shape (b).
507
- hasContent = true
508
- // A second content host nested as children, when the branch already receives contract/render
509
- // directly, duplicates the host the branch already opened.
510
- const nestedHost = (node.children || []).some((child) =>
511
- jsxChildName(child) === "Tree" || jsxChildName(child) === "ContractContent")
512
- if (nestedHost) context.report({ node, messageId: "duplicate" })
513
- },
514
490
  Property(node) {
515
491
  if (!isContracts) return
516
492
  const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
@@ -528,7 +504,7 @@ export const authOverlayOwnsSingleContentHost = {
528
504
  /** The rules this law contributes to the plugin. */
529
505
  export const rules = {
530
506
  "vendor-boundary": vendorBoundary,
531
- "modal-branch-owns-scroll-body": modalBranchOwnsScrollBody,
507
+ "modal-shell-owns-scroll-body": modalShellOwnsScrollBody,
532
508
  "field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
533
509
  "field-label-is-text-only": fieldLabelIsTextOnly,
534
510
  "no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
@@ -543,7 +519,8 @@ export const rules = {
543
519
  * The level this law asks for, as the plugin's own opinion.
544
520
  *
545
521
  * A repository adopting this with history should expect the outward half to report and the inward
546
- * half to be silent until named mechanics branches exist. Creating those owners is the migration;
547
- * no anonymous wrapper tier is introduced.
522
+ * half to be silent, because `shells/` will not exist yet. Creating it is the migration: every
523
+ * vendor container currently wrapped as something else moves there, and each move is what makes the
524
+ * inward check meaningful afterwards.
548
525
  */
549
526
  export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))