@starci/eslint-canon-fe 8.4.39 → 8.4.41
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 +11 -0
- package/README.md +10 -0
- package/app-shell.mjs +161 -0
- package/callback-shape.mjs +1 -13
- package/docs/app-shell.md +50 -0
- package/docs/grammar-boundary.md +25 -1
- package/docs/surface-rules.md +41 -0
- package/grammar-boundary.mjs +112 -0
- package/hooks-folder.mjs +9 -3
- package/icon.mjs +31 -1
- package/index.mjs +6 -0
- package/lib/app-shell-cluster.mjs +66 -0
- package/lib/app-shell-create.mjs +33 -0
- package/lib/app-shell-owner-source.mjs +124 -0
- package/lib/app-shell-owner.mjs +65 -0
- package/lib/app-shell-scope.mjs +79 -0
- package/lib/app-shell-visible.mjs +92 -0
- package/lib/app-shell.mjs +148 -0
- package/lib/bindings.mjs +15 -0
- package/lib/config.mjs +1 -0
- package/lib/fe-stack.mjs +8 -4
- package/lib/scope.mjs +2 -2
- package/lib/typed-class-values.mjs +72 -0
- package/lib/why.mjs +42 -2
- package/lint-escape-hatch.mjs +2 -2
- package/menu-entries.mjs +133 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/slots.yaml +113 -53
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/goal/source-phrases.yaml +95 -0
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
- package/runtime/scripts/hfs/slots.mjs +2 -1
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/secret-patterns.mjs +58 -9
- package/runtime/scripts/lib/source-phrases.mjs +34 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/size-and-state-budget.mjs +3 -3
- package/surface-rules.mjs +230 -0
- package/swr-family.mjs +136 -7
- package/transport.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 8.4.41 - 2026-10-26
|
|
4
|
+
|
|
5
|
+
- Enforce the app-shell canon through fixed WorkspaceShell frames, drawer navigation, ordered navbar controls, control sizing and page-owned creation, with full and lite regression coverage.
|
|
6
|
+
- Fixed: `operations-only-in-swr-hooks` (FE_OP_OUTSIDE_SWR_HOOK) no longer reports named imports that all resolve, through relative re-exports, to a generated `__generated__/input-bounds` module (`INPUT_BOUNDS`, `inputBoundsIssues`): they are contract data, not API operations. Mixed, default and namespace imports of the barrel still report.
|
|
7
|
+
- Added: the stream reader of server-sent events (`fe.transport.stream`, `modules/api/sse/clients/`) is part of the API layer, so it may call `fetch` (FE_TRANSPORT_OWNER); a stream operation (`sse/<area>/stream-<channel>.ts`) must import it.
|
|
8
|
+
- Added: `useSWRSubscription` (`swr/subscription`) is an SWR request hook of the family (FE_SWR_OUTSIDE_FAMILY, FE_OP_OUTSIDE_SWR_HOOK, FE_HOOKS_ARE_HOOKS): a stream operation is consumed only by `hooks/swr/api/sse/subscriptions/useSubscribe<X>Swr.ts`.
|
|
9
|
+
- Added: `nested-control-variant` (`FE_NESTED_VARIANT`) refuses explicit primary fields in Grammar surface subtrees and app-authored `bg-surface` paint. `no-list-panel-padding` (`FE_LIST_PANEL_PADDING`) refuses stacked horizontal wrapper padding around Grammar lists and section captions.
|
|
10
|
+
|
|
11
|
+
## 8.4.40 - 2026-10-25
|
|
12
|
+
- Changed: the bundled canon pins select @starci/stylelint-canon 2.0.11, @starci/hfs 4.7.33 and @starci/cli 1.7.33.
|
|
13
|
+
|
|
3
14
|
## 8.4.39 - 2026-10-25
|
|
4
15
|
- Changed: the bundled canon pins select @starci/grammar 0.11.0, @starci/hfs 4.7.32 and @starci/cli 1.7.32.
|
|
5
16
|
|
package/README.md
CHANGED
|
@@ -32,6 +32,10 @@ exported; `why[rule]` is `{ code, vi, fixVi }`, the finding code and the Vietnam
|
|
|
32
32
|
|
|
33
33
|
## Rules
|
|
34
34
|
|
|
35
|
+
The [app-shell](docs/app-shell.md) law holds the fixed `WorkspaceShell` frame, mobile drawer navigation,
|
|
36
|
+
navbar action kinds and order, shared bar control size, and page ownership of create actions. Its
|
|
37
|
+
rules are gathered into the normal FE lint and application gate.
|
|
38
|
+
|
|
35
39
|
Each law has a page under [`docs/`](docs) with, for every rule, an invalid and a valid example and
|
|
36
40
|
its Vietnamese why. New in 5.0: [env-owner](docs/env-owner.md), [transport](docs/transport.md),
|
|
37
41
|
[client-boundary](docs/client-boundary.md), [hooks-folder](docs/hooks-folder.md),
|
|
@@ -76,6 +80,12 @@ The one locale pattern everywhere is `next-intl`, a `[locale]` segment, default
|
|
|
76
80
|
Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
|
|
77
81
|
typed React props and children.
|
|
78
82
|
|
|
83
|
+
The [surface composition law](docs/surface-rules.md) holds ANATOMY-2 and PADDING-0:
|
|
84
|
+
`nested-control-variant` refuses explicit primary fields inside Grammar surfaces and raw app
|
|
85
|
+
`bg-surface` paint (`FE_NESTED_VARIANT`); `no-list-panel-padding` refuses horizontal padding
|
|
86
|
+
wrappers around Grammar lists and section captions (`FE_LIST_PANEL_PADDING`). Grammar supplies
|
|
87
|
+
the nested secondary default and owns the list's panel inset.
|
|
88
|
+
|
|
79
89
|
## Requirements
|
|
80
90
|
|
|
81
91
|
ESLint 9+ (flat config), `eslint-plugin-react-hooks` 7+, and Node.js 20.9+.
|
package/app-shell.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** FE-APP-SHELL-1..5: static app-frame and visible navbar composition from the UI canon. */
|
|
2
|
+
import { isProductSource } from "./lib/scope.mjs"
|
|
3
|
+
import { grammarName, isBarControl, propOf, resolveLocal, semanticText, textProp } from "./lib/app-shell.mjs"
|
|
4
|
+
import { appBarOpenings, isAppBar, isAppShell, isNavigationDrawer, ownsAppFrame } from "./lib/app-shell-scope.mjs"
|
|
5
|
+
import { visitVisible } from "./lib/app-shell-visible.mjs"
|
|
6
|
+
import { clusterAlternatives } from "./lib/app-shell-cluster.mjs"
|
|
7
|
+
import { isCreateAction, menuHasCreate } from "./lib/app-shell-create.mjs"
|
|
8
|
+
import { provesImportedBar } from "./lib/app-shell-owner.mjs"
|
|
9
|
+
import { variableOf } from "./lib/bindings.mjs"
|
|
10
|
+
|
|
11
|
+
const CANON = "knowledge/ui/composition/app-shell.yaml"
|
|
12
|
+
const BOTTOM_TABS = /^(?:BottomNav|BottomNavigation|BottomTabBar|MobileBottomNav|MobileTabBar)$/
|
|
13
|
+
const EXPECTED_CLUSTER = ["language:IconButton", "theme:Switch", "notifications:IconButton", "account:AvatarButton"]
|
|
14
|
+
|
|
15
|
+
const meta = (code, description, messages) => ({
|
|
16
|
+
type: "problem",
|
|
17
|
+
docs: { description: `${code}: ${description}`, url: CANON },
|
|
18
|
+
schema: [],
|
|
19
|
+
messages,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** The app frame is the fixed-viewport Grammar shell; hand-written frame landmarks are rejected. */
|
|
23
|
+
export const appShellFrame = {
|
|
24
|
+
meta: meta("FE_APP_SHELL_FRAME", "FE-APP-SHELL-1 uses WorkspaceShell viewport=fixed.", {
|
|
25
|
+
frame: "FE_APP_SHELL_FRAME: compose the app frame with WorkspaceShell viewport=\"fixed\"; Grammar owns its header, navigation and independent scroll regions.",
|
|
26
|
+
}),
|
|
27
|
+
create(context) {
|
|
28
|
+
if (!isProductSource(context)) return {}
|
|
29
|
+
return { JSXOpeningElement(node) {
|
|
30
|
+
const wrongViewport = isAppShell(context, node) && textProp(context, node, "viewport") !== "fixed"
|
|
31
|
+
const handFrame = ownsAppFrame(context, node) && (node.name.type === "JSXIdentifier" && ["header", "nav", "main"].includes(node.name.name) || isAppBar(context, node) && !appBarOpenings(context).has(node))
|
|
32
|
+
if (wrongViewport || handFrame) {
|
|
33
|
+
context.report({ node, messageId: "frame" })
|
|
34
|
+
}
|
|
35
|
+
} }
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Bottom navigation is not an alternative compact app-shell frame. */
|
|
40
|
+
export const noShellBottomTabs = {
|
|
41
|
+
meta: meta("FE_APP_SHELL_BOTTOM_TABS", "FE-APP-SHELL-4 keeps mobile navigation in the sidebar drawer.", {
|
|
42
|
+
bottom: "FE_APP_SHELL_BOTTOM_TABS: the app shell uses a mobile subnav opener and Sidebar drawer. Remove bottom tab navigation and WorkspaceShell compactNavigation.",
|
|
43
|
+
}),
|
|
44
|
+
create(context) {
|
|
45
|
+
if (!isProductSource(context)) return {}
|
|
46
|
+
return { JSXOpeningElement(node) {
|
|
47
|
+
const name = grammarName(context, node)
|
|
48
|
+
const compact = name === "WorkspaceShell" && propOf(context, node, "compactNavigation")
|
|
49
|
+
const value = compact && resolveLocal(context, compact.value)
|
|
50
|
+
const absent = value?.type === "Identifier" && value.name === "undefined" && !variableOf(context, value)?.defs.length
|
|
51
|
+
if (BOTTOM_TABS.test(name ?? "") || compact && !absent && (!compact.known || compact.value !== null || compact.present)) context.report({ node, messageId: "bottom" })
|
|
52
|
+
} }
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const visitBar = (context, node, visit) => {
|
|
57
|
+
const name = grammarName(context, node)
|
|
58
|
+
if (name === "WorkspaceShell" && isAppShell(context, node)) {
|
|
59
|
+
for (const slot of ["header", "compactHeader"]) visitVisible(context, propOf(context, node, slot).value, visit)
|
|
60
|
+
} else if (isAppBar(context, node)) {
|
|
61
|
+
visitVisible(context, node.parent, visit)
|
|
62
|
+
} else if (isNavigationDrawer(context, node)) {
|
|
63
|
+
visitVisible(context, propOf(context, node, "trigger").value, visit)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const delegatesImportedBar = (context, expression) => {
|
|
68
|
+
let imported = false
|
|
69
|
+
let controls = false
|
|
70
|
+
let unknown = false
|
|
71
|
+
visitVisible(context, expression, (opening, name) => {
|
|
72
|
+
if (isBarControl(context, opening, name)) controls = true
|
|
73
|
+
if (name !== null) return
|
|
74
|
+
const proof = provesImportedBar(context, opening)
|
|
75
|
+
if (proof === true) imported = true
|
|
76
|
+
if (proof === false) unknown = true
|
|
77
|
+
})
|
|
78
|
+
return imported && !controls && !unknown
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every visible control in a navbar band uses the shared control size. */
|
|
82
|
+
export const navbarControlSize = {
|
|
83
|
+
meta: meta("FE_NAVBAR_CONTROL_SIZE", "FE-APP-SHELL-2 requires size=control on bar controls.", {
|
|
84
|
+
control: "FE_NAVBAR_CONTROL_SIZE: {{name}} in the navbar band needs size=\"control\" so every bar control shares Grammar's control height.",
|
|
85
|
+
}),
|
|
86
|
+
create(context) {
|
|
87
|
+
if (!isProductSource(context)) return {}
|
|
88
|
+
const reported = new Set()
|
|
89
|
+
return { JSXOpeningElement(node) {
|
|
90
|
+
visitBar(context, node, (control, name) => {
|
|
91
|
+
if (!isBarControl(context, control, name) || textProp(context, control, "size") === "control" || reported.has(control)) return
|
|
92
|
+
reported.add(control)
|
|
93
|
+
context.report({ node: control, messageId: "control", data: { name } })
|
|
94
|
+
})
|
|
95
|
+
} }
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The right cluster is checked at the bar's actions composition, in visual source order. */
|
|
100
|
+
export const navbarCluster = {
|
|
101
|
+
meta: meta("FE_NAVBAR_CLUSTER", "FE-APP-SHELL-3 fixes navbar action kinds and order.", {
|
|
102
|
+
cluster: "FE_NAVBAR_CLUSTER: navbar actions must be a tertiary Language IconButton in DropdownMenu, theme Switch, notification IconButton in Popover, account AvatarButton in DropdownMenu, in that order. Mobile retains notifications then account and moves language/theme into the drawer.",
|
|
103
|
+
}),
|
|
104
|
+
create(context) {
|
|
105
|
+
if (!isProductSource(context)) return {}
|
|
106
|
+
return { JSXOpeningElement(node) {
|
|
107
|
+
const name = grammarName(context, node)
|
|
108
|
+
if (!isAppShell(context, node) && !isAppBar(context, node)) return
|
|
109
|
+
const shellHeader = name === "WorkspaceShell"
|
|
110
|
+
const actions = propOf(context, node, shellHeader ? "header" : "actions")
|
|
111
|
+
const { alternatives, ownsBar } = clusterAlternatives(context, actions.value, shellHeader)
|
|
112
|
+
if (ownsBar) return
|
|
113
|
+
if (shellHeader && delegatesImportedBar(context, actions.value)) return
|
|
114
|
+
const desktop = EXPECTED_CLUSTER.join("|")
|
|
115
|
+
const mobile = EXPECTED_CLUSTER.slice(2).join("|")
|
|
116
|
+
const sequences = alternatives.map((sequence) => sequence.join("|"))
|
|
117
|
+
if (!sequences.includes(desktop) || sequences.some((sequence) => sequence !== desktop && sequence !== mobile)) context.report({ node, messageId: "cluster" })
|
|
118
|
+
} }
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Creation belongs to its page, never to the global app bar. */
|
|
123
|
+
export const noNavbarCreate = {
|
|
124
|
+
meta: meta("FE_NAVBAR_CREATE", "FE-APP-SHELL-5 keeps create actions on pages.", {
|
|
125
|
+
create: "FE_NAVBAR_CREATE: move this create action from the navbar to its page.",
|
|
126
|
+
}),
|
|
127
|
+
create(context) {
|
|
128
|
+
if (!isProductSource(context)) return {}
|
|
129
|
+
const reported = new Set()
|
|
130
|
+
const report = (node) => {
|
|
131
|
+
if (reported.has(node)) return
|
|
132
|
+
reported.add(node)
|
|
133
|
+
context.report({ node, messageId: "create" })
|
|
134
|
+
}
|
|
135
|
+
const inspect = (control, name) => {
|
|
136
|
+
if (name === "DropdownMenu" || name === "Popover") {
|
|
137
|
+
if (["entries", "items", "sections"].some((slot) => menuHasCreate(context, propOf(context, control, slot).value))) report(control)
|
|
138
|
+
visitVisible(context, propOf(context, control, "content").value, inspect)
|
|
139
|
+
for (const child of control.parent.children) visitVisible(context, child, inspect)
|
|
140
|
+
}
|
|
141
|
+
if (!isBarControl(context, control, name) || reported.has(control)) return
|
|
142
|
+
const text = `${semanticText(context, control)} ${control.parent.children.map((child) => context.sourceCode.getText(child)).join(" ")}`
|
|
143
|
+
if (isCreateAction(text)) report(control)
|
|
144
|
+
}
|
|
145
|
+
return { JSXOpeningElement(node) {
|
|
146
|
+
visitBar(context, node, inspect)
|
|
147
|
+
} }
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Every app-shell enforcer is gathered into the ordinary FE lint and gate path. */
|
|
152
|
+
export const rules = {
|
|
153
|
+
"app-shell-frame": appShellFrame,
|
|
154
|
+
"no-shell-bottom-tabs": noShellBottomTabs,
|
|
155
|
+
"navbar-control-size": navbarControlSize,
|
|
156
|
+
"navbar-cluster": navbarCluster,
|
|
157
|
+
"no-navbar-create": noNavbarCreate,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every rule is mandatory. */
|
|
161
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
package/callback-shape.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* This syntax check follows lexical bindings and local aliases rather than trusting a method's spelling.
|
|
8
8
|
*/
|
|
9
9
|
import { stringOf, walk } from "./lib/ast.mjs"
|
|
10
|
+
import { patternPath } from "./lib/bindings.mjs"
|
|
10
11
|
import { normalizePath } from "./lib/path.mjs"
|
|
11
12
|
import { ownerExportKind } from "./lib/pure-owner-helper.mjs"
|
|
12
13
|
import { classifyImport, inSlot, resolveImport } from "./lib/scope.mjs"
|
|
@@ -32,19 +33,6 @@ const bindingOf = (sourceCode, node) => {
|
|
|
32
33
|
/** Whether an identifier refers to the actual unshadowed platform global. */
|
|
33
34
|
const isGlobal = (sourceCode, node, name) => node?.type === "Identifier" && node.name === name && !(bindingOf(sourceCode, node)?.defs.length)
|
|
34
35
|
|
|
35
|
-
/** The property path that a destructured binding selects from its initializer. */
|
|
36
|
-
const patternPath = (pattern, name) => {
|
|
37
|
-
if (pattern.type === "Identifier") return pattern.name === name ? [] : null
|
|
38
|
-
if (pattern.type === "AssignmentPattern") return patternPath(pattern.left, name)
|
|
39
|
-
if (pattern.type !== "ObjectPattern") return null
|
|
40
|
-
for (const property of pattern.properties) {
|
|
41
|
-
if (property.type !== "Property") continue
|
|
42
|
-
const nested = patternPath(property.value, name)
|
|
43
|
-
if (nested !== null) return [property.computed ? stringOf(property.key) : property.key.name ?? property.key.value, ...nested]
|
|
44
|
-
}
|
|
45
|
-
return null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
36
|
/** Route configuration is inert; a second HTTP door or an exported helper is not. */
|
|
49
37
|
const CONFIG_EXPORTS = new Set(["runtime", "dynamic", "revalidate", "fetchCache", "preferredRegion", "maxDuration"])
|
|
50
38
|
const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"])
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# App shell
|
|
2
|
+
|
|
3
|
+
Owner: knowledge/ui/composition/app-shell.yaml
|
|
4
|
+
|
|
5
|
+
The application composes `WorkspaceShell viewport="fixed"`. The `app-shell-frame` rule identifies
|
|
6
|
+
workspace chrome from a fixed shell, sidebar/navigation slot, workspace identity, HFS layout owner,
|
|
7
|
+
or a shell owner combining a bar and sidebar. It rejects hand-written app-frame landmarks and
|
|
8
|
+
workspace shells without the fixed viewport. Generic flow-based site chrome stays in its own scope.
|
|
9
|
+
`no-shell-bottom-tabs` rejects bottom navigation components and the shell's `compactNavigation` slot.
|
|
10
|
+
|
|
11
|
+
The `navbar-cluster` rule reads the visible `TopBar` or `NavigationFeatureNav` actions in source order:
|
|
12
|
+
tertiary Language `IconButton` triggering `DropdownMenu`, theme `Switch`, notification `IconButton`
|
|
13
|
+
triggering `Popover`, account `AvatarButton` triggering `DropdownMenu`. It follows
|
|
14
|
+
local JSX constants and components, constant object members, imported Grammar aliases, and namespace imports. Translated
|
|
15
|
+
labels may be dynamic; icon sources identify language and notifications. Menu contents and popover
|
|
16
|
+
content belong to their overlays and are outside the bar's control sequence. Conditional render
|
|
17
|
+
branches are checked separately. Grammar `TopBar` and `NavigationFeatureNav` compositions are app
|
|
18
|
+
bars by default. A proven `WorkspaceShell viewport="flow"` with no workspace/sidebar evidence owns
|
|
19
|
+
generic site chrome. A shell hosting an imported navbar delegates only after its owned public
|
|
20
|
+
export and rendered Base chain prove a `TopBar` or `NavigationFeatureNav` in every alternative.
|
|
21
|
+
Workspace exports resolve back to source. Raw or unknown imported headers are red; the actual
|
|
22
|
+
bar's own actions are checked where composed. Each lint source snapshot has fresh dependency proofs.
|
|
23
|
+
|
|
24
|
+
`navbar-control-size` requires `size="control"` on each bar control. Unknown trailing prop spreads
|
|
25
|
+
cannot prove a size; put the explicit size after a spread. A left `Drawer` containing
|
|
26
|
+
`Sidebar presentation="drawer"` also identifies a mobile bar opener when composed in a separate
|
|
27
|
+
client owner. Its trigger is checked; drawer bodies and footers retain their normal field sizes.
|
|
28
|
+
`no-navbar-create` places creation on its
|
|
29
|
+
own page and rejects create controls in the navbar and its attached menus or popovers. Static
|
|
30
|
+
evidence includes literal labels, translated keys with create semantics, and conventional create
|
|
31
|
+
handlers. Runtime-generated labels and arbitrary handler behavior need semantic review; the rule
|
|
32
|
+
does not execute application code.
|
|
33
|
+
|
|
34
|
+
| Rule | Invalid composition | Valid composition |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `app-shell-frame` | An app `Shell` returns a hand-written `<header>` and `<nav>`. | `WorkspaceShell viewport="fixed"` owns the app frame. |
|
|
37
|
+
| `no-shell-bottom-tabs` | The shell supplies `compactNavigation={<BottomNav />}`. | The shell supplies `navigationDrawer` with a drawer `Sidebar`. |
|
|
38
|
+
| `navbar-control-size` | A navbar `IconButton` uses `size="md"`. | The same control uses `size="control"`. |
|
|
39
|
+
| `navbar-cluster` | Account precedes notifications, or a language trigger uses a regular `Button`. | Language menu, theme switch, notification popover, account menu use their stated Grammar trigger kinds in that order. |
|
|
40
|
+
| `no-navbar-create` | A navbar button or attached menu item creates a record. | The page owns its create action. |
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
// Invalid: the navbar action creates a page record and has a different height.
|
|
44
|
+
<TopBar actions={<Button onPress={on.create}>Create</Button>} />
|
|
45
|
+
|
|
46
|
+
// Valid: a page action belongs to page content.
|
|
47
|
+
<SurfaceCard><Button onPress={on.create}>Create</Button></SurfaceCard>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
All five rules are errors in the gathered FE canon and run through the ordinary application gate.
|
package/docs/grammar-boundary.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Grammar boundary
|
|
2
2
|
|
|
3
|
-
Law module: `grammar-boundary.mjs`. Catalogue: R62 FE_NATIVE_FORM_CONTROL (the grammar owns the element).
|
|
3
|
+
Law module: `grammar-boundary.mjs`. Catalogue: R62 FE_NATIVE_FORM_CONTROL (the grammar owns the element), R65 FE_SIZE_AND_STATE_BUDGET (timer ownership).
|
|
4
4
|
|
|
5
5
|
Page structure and text are composed from `@starci/grammar` (or the repository's `<family>-ui` package), not drawn as raw HTML.
|
|
6
6
|
|
|
@@ -14,6 +14,8 @@ Applies to app source that composes the grammar (slots `fe.feature`, `fe.compone
|
|
|
14
14
|
|
|
15
15
|
The refused tags are exactly those `@starci/grammar` has a component for (the twin test reads the package source): `h1-h6` (`Heading`), `p` and `span` (`Text`), `hr` (`Divider`), `label` (`Label`), `form` (`Form`), `fieldset` (`Fieldset`), `dl` `dt` `dd` (`DescriptionList`), `table` `thead` `tbody` `tr` `td` `th` (`DataTable`), `ul` `ol` `li` (`List` and `ListItem` for a plain semantic list, `SurfaceListCard`, `StaticStateRow`), `header` (`SectionHeader`, `TopBar`), `footer` (`Footer`), `nav` (`NavLandmark` for a named row of links, `Subnav`, `Breadcrumbs`, `NavigationFeatureNav`), `section` (`Region` for a named non-card region, `SurfaceCard`), `main` (`WorkspaceShell`), `figure` and `figcaption` (`MediaFrame`). `div`, `br`, `aside` and `article` are not refused: the grammar has no component for them.
|
|
16
16
|
|
|
17
|
+
Native `<time>` remains available for an absolute timestamp. Live relative time and countdown labels use `RelativeTime`.
|
|
18
|
+
|
|
17
19
|
**Invalid** (`src/features/pages/home/component.tsx`)
|
|
18
20
|
|
|
19
21
|
```ts
|
|
@@ -31,3 +33,25 @@ export const Home = () => <SurfaceCard><Text>{t("intro")}</Text></SurfaceCard>
|
|
|
31
33
|
**Why:** Raw `<tag>` in `<file>`. Page structure and text are composed from grammar components, not written as raw HTML.
|
|
32
34
|
|
|
33
35
|
**Fix:** Replace it with the matching grammar component (`Heading`, `Text`, `SurfaceCard`, ...); if the grammar lacks one, add it to the grammar or the ui package instead of drawing it in place.
|
|
36
|
+
|
|
37
|
+
## `starci-fe/no-app-relative-clock`
|
|
38
|
+
|
|
39
|
+
Grammar owns the shared clock for live relative time and countdowns. App components, hooks, modules and shared packages, including a product UI package, pass `dateTime`, `locale` and `mode` to `RelativeTime`.
|
|
40
|
+
|
|
41
|
+
The rule rejects a recurring `setInterval` or self-scheduling `setTimeout` whose callback or same-file helper reads `Date.now()`, `Date()`, zero-argument `new Date()` or `performance.now()`. It follows local aliases and destructured platform methods. Static reads, one-shot delays and timers that do not read the clock remain available. Indirect external clock helpers and counters that only decrement state need semantic review.
|
|
42
|
+
|
|
43
|
+
**Invalid** (`src/hooks/time/useNow.ts`)
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const timer = setInterval(() => update(Date.now()), 1000)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Valid** (`src/components/leaves/UpdatedAt/index.tsx`)
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
const UpdatedAt = () => <RelativeTime dateTime={instant} locale={locale} mode="relative" />
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Finding code:** `FE_SIZE_AND_STATE_BUDGET`
|
|
56
|
+
|
|
57
|
+
**Fix:** Render the Grammar primitive and pass the instant; its single shared ticker owns updates and its deterministic fallback owns the server render.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Grammar surface composition
|
|
2
|
+
|
|
3
|
+
Law module: `surface-rules.mjs`. Canon: ANATOMY-2 and PADDING-0.
|
|
4
|
+
|
|
5
|
+
## `starci-fe/nested-control-variant`
|
|
6
|
+
|
|
7
|
+
Finding code: `FE_NESTED_VARIANT`. Grammar surfaces supply the secondary field default through their nesting context. An explicit primary field inside a surface subtree in the same component is refused. Named import aliases and namespace imports preserve Grammar ownership. A different component owns a separate static subtree; overlay triggers belong outside the overlay content.
|
|
8
|
+
|
|
9
|
+
App `className` values carry layout. A raw `bg-surface` utility paints a surface and is refused, including conditional classes, class builders, responsive variants, important modifiers and opacity suffixes. Static props spreads keep the same obligations. Typed app lint follows imported colocated class declarations as well as local constants.
|
|
10
|
+
|
|
11
|
+
Invalid:
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import { SurfaceCard, SearchField } from "@starci/grammar/core"
|
|
15
|
+
const Panel = () => <SurfaceCard><SearchField variant="primary" /></SurfaceCard>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Valid:
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { SurfaceCard, SearchField } from "@starci/grammar/core"
|
|
22
|
+
const Panel = () => <SurfaceCard><SearchField /></SurfaceCard>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## `starci-fe/no-list-panel-padding`
|
|
26
|
+
|
|
27
|
+
Finding code: `FE_LIST_PANEL_PADDING`. A wrapper around Grammar `ListBox` or its section caption cannot add `px-*`, `ps-*` or `pe-*`. These insets add to the list's own padding. Grammar's panel inset aligns rows, section captions and the surface header to one content edge.
|
|
28
|
+
|
|
29
|
+
Invalid:
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { SurfaceCard, ListBox } from "@starci/grammar/core"
|
|
33
|
+
const Panel = () => <SurfaceCard><div className="px-2"><ListBox label="Members" items={members} /></div></SurfaceCard>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Valid:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { SurfaceCard, ListBox } from "@starci/grammar/core"
|
|
40
|
+
const Panel = () => <SurfaceCard><ListBox inset="panel" label="Members" items={members} /></SurfaceCard>
|
|
41
|
+
```
|
package/grammar-boundary.mjs
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* rule refuses to guess a scope.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { walk } from "./lib/ast.mjs"
|
|
21
|
+
import { functionOf, isFn, unwrap, variableOf } from "./lib/bindings.mjs"
|
|
20
22
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
23
|
+
import { isProductSource } from "./lib/scope.mjs"
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* The intrinsic elements of page structure and text, each with the grammar components that render it.
|
|
@@ -82,9 +85,118 @@ export const noRawStructuralElement = {
|
|
|
82
85
|
},
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/** The static property name of an access or destructured property. */
|
|
89
|
+
const propertyName = (node) => {
|
|
90
|
+
const key = node.property ?? node.key
|
|
91
|
+
if (!node.computed && key?.type === "Identifier") return key.name
|
|
92
|
+
return key?.type === "Literal" && typeof key.value === "string" ? key.value : null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The platform member a local destructured binding selects. */
|
|
96
|
+
const destructuredPath = (context, declarator, local, seen) => {
|
|
97
|
+
if (declarator.id.type !== "ObjectPattern") return null
|
|
98
|
+
const property = declarator.id.properties.find((entry) => entry.type === "Property" && entry.value.type === "Identifier" && entry.value.name === local)
|
|
99
|
+
const name = property && propertyName(property)
|
|
100
|
+
const object = platformPath(context, declarator.init, seen)
|
|
101
|
+
return object && name ? [...object, name] : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A platform path, following local aliases and destructuring while refusing shadowed globals. */
|
|
105
|
+
const platformPath = (context, node, seen = new Set()) => {
|
|
106
|
+
const inner = unwrap(node)
|
|
107
|
+
if (!inner || seen.has(inner)) return null
|
|
108
|
+
seen.add(inner)
|
|
109
|
+
if (inner.type === "MemberExpression") {
|
|
110
|
+
const object = platformPath(context, inner.object, seen)
|
|
111
|
+
const name = propertyName(inner)
|
|
112
|
+
return object && name ? [...object, name] : null
|
|
113
|
+
}
|
|
114
|
+
if (inner.type !== "Identifier") return null
|
|
115
|
+
const variable = variableOf(context, inner)
|
|
116
|
+
if (!variable || variable.defs.length === 0) return [inner.name]
|
|
117
|
+
for (const definition of variable.defs) {
|
|
118
|
+
if (definition.type !== "Variable" || !definition.node.init) continue
|
|
119
|
+
const declarator = definition.node
|
|
120
|
+
if (declarator.id.type === "Identifier") return platformPath(context, declarator.init, seen)
|
|
121
|
+
return destructuredPath(context, declarator, inner.name, seen)
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A platform API spelling with the optional global-object prefix removed. */
|
|
127
|
+
const platformName = (context, node) => {
|
|
128
|
+
const parts = platformPath(context, node)
|
|
129
|
+
if (!parts) return null
|
|
130
|
+
if (["window", "globalThis", "self"].includes(parts[0])) parts.shift()
|
|
131
|
+
return parts.join(".")
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A wall-clock read: Date.now(), Date(), zero-argument new Date(), or performance.now(). */
|
|
135
|
+
const readsClock = (context, root, seen = new Set()) => {
|
|
136
|
+
if (!root || seen.has(root)) return false
|
|
137
|
+
seen.add(root)
|
|
138
|
+
let found = false
|
|
139
|
+
walk(context.sourceCode, root, (node) => {
|
|
140
|
+
if (found) return false
|
|
141
|
+
if (node.type === "NewExpression" && node.arguments.length === 0 && platformName(context, node.callee) === "Date") found = true
|
|
142
|
+
if (node.type !== "CallExpression") return undefined
|
|
143
|
+
if (["Date", "Date.now", "performance.now"].includes(platformName(context, node.callee))) found = true
|
|
144
|
+
const helper = functionOf(context, node.callee)
|
|
145
|
+
if (helper && readsClock(context, helper.body, seen)) found = true
|
|
146
|
+
return undefined
|
|
147
|
+
})
|
|
148
|
+
return found
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether a timer callback runs its enclosing function again, directly or through a same-file helper. */
|
|
152
|
+
const callsFunction = (context, root, target, seen = new Set()) => {
|
|
153
|
+
if (!root || seen.has(root)) return false
|
|
154
|
+
if (root === target) return true
|
|
155
|
+
seen.add(root)
|
|
156
|
+
let found = false
|
|
157
|
+
walk(context.sourceCode, root.body ?? root, (node) => {
|
|
158
|
+
if (found) return false
|
|
159
|
+
if (node.type !== "CallExpression") return undefined
|
|
160
|
+
const called = functionOf(context, node.callee)
|
|
161
|
+
if (called === target || called && callsFunction(context, called, target, seen)) found = true
|
|
162
|
+
return undefined
|
|
163
|
+
})
|
|
164
|
+
return found
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Grammar owns the shared clock for live time labels; product components and hooks only pass instants. */
|
|
168
|
+
export const noAppRelativeClock = {
|
|
169
|
+
meta: {
|
|
170
|
+
type: "problem",
|
|
171
|
+
docs: { description: "Live relative time and countdowns use Grammar's shared clock rather than a product-owned ticker." },
|
|
172
|
+
schema: [],
|
|
173
|
+
messages: {
|
|
174
|
+
clock: "This recurring timer reads the clock. Render RelativeTime with dateTime, locale and mode; Grammar owns its single shared ticker and its server-safe first render. Product components and hooks pass the target instant and never start another clock for time labels.",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
create(context) {
|
|
178
|
+
if (!isProductSource(context)) return {}
|
|
179
|
+
return {
|
|
180
|
+
CallExpression(node) {
|
|
181
|
+
const timer = platformName(context, node.callee)
|
|
182
|
+
if (timer !== "setInterval" && timer !== "setTimeout") return
|
|
183
|
+
const callback = functionOf(context, node.arguments[0])
|
|
184
|
+
if (!callback) return
|
|
185
|
+
let owner = node.parent
|
|
186
|
+
while (owner && !isFn(owner)) owner = owner.parent
|
|
187
|
+
const repeats = timer === "setInterval" || owner && callsFunction(context, callback, owner)
|
|
188
|
+
if (repeats && (readsClock(context, callback.body) || timer === "setTimeout" && readsClock(context, owner.body))) {
|
|
189
|
+
context.report({ node, messageId: "clock" })
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
85
196
|
/** The rules this law contributes to the plugin. */
|
|
86
197
|
export const rules = {
|
|
87
198
|
"no-raw-structural-element": noRawStructuralElement,
|
|
199
|
+
"no-app-relative-clock": noAppRelativeClock,
|
|
88
200
|
}
|
|
89
201
|
|
|
90
202
|
/** Every rule is an error. */
|
package/hooks-folder.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* saying where the non-hook code goes. It now does, per family of the front-end stack (each its own slot):
|
|
8
8
|
*
|
|
9
9
|
* hooks/swr/api/{graphql,rest}/{queries,mutations}/use{Query,Mutate,Get,Post,Put,Patch,Delete}<X>Swr.ts
|
|
10
|
+
* hooks/swr/api/sse/subscriptions/useSubscribe<X>Swr.ts
|
|
10
11
|
* one SWR hook per modules/api operation (fe.hooks.swr), plus SwrSideEffects.tsx
|
|
11
12
|
* hooks/rhf/use<X>Form.ts one form (fe.hooks.rhf)
|
|
12
13
|
* hooks/zustand/<store>/store.ts one local store and its use*.ts selectors (fe.hooks.zustand)
|
|
@@ -37,7 +38,7 @@ import { rules as swrFamilyRules } from "./swr-family.mjs"
|
|
|
37
38
|
const isHookName = (name) => /^use[A-Z0-9]/.test(name)
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
* The SWR key of a hook (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`, fe-stack data): the one constant a hook file exports beside its
|
|
41
|
+
* The SWR key of a hook (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`, `SUBSCRIBE_<X>_SWR_KEY`, fe-stack data): the one constant a hook file exports beside its
|
|
41
42
|
* hook, so a mutation or a socket message revalidates exactly that cache entry.
|
|
42
43
|
*/
|
|
43
44
|
const isSwrKeyName = (name) => /^[A-Z][A-Z0-9_]*_SWR_KEY$/.test(name)
|
|
@@ -85,8 +86,9 @@ const SWR_HOOKS = new Map([
|
|
|
85
86
|
["swr/immutable", new Set(["useSWRImmutable"])],
|
|
86
87
|
["swr/infinite", new Set(["useSWRInfinite"])],
|
|
87
88
|
["swr/mutation", new Set(["useSWRMutation"])],
|
|
89
|
+
["swr/subscription", new Set(["useSWRSubscription"])],
|
|
88
90
|
])
|
|
89
|
-
const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"]])
|
|
91
|
+
const SWR_DEFAULTS = new Map([["swr", "useSWR"], ["swr/immutable", "useSWRImmutable"], ["swr/infinite", "useSWRInfinite"], ["swr/mutation", "useSWRMutation"], ["swr/subscription", "useSWRSubscription"]])
|
|
90
92
|
/**
|
|
91
93
|
* The library hooks a composite hook may stand on (an explicit list, owner ruling 2026-10-16): next-intl, next/navigation,
|
|
92
94
|
* react-hook-form and zustand's React entry points. A name outside the list is not a hook by its spelling.
|
|
@@ -205,7 +207,8 @@ const hookFileOf = (context) => {
|
|
|
205
207
|
const stray = Boolean(verdict) && !verdict.allowed
|
|
206
208
|
const allows = (verdict?.allows ?? hfs.slot(found.slot)?.allows ?? []).join("`, `")
|
|
207
209
|
// A role the slot names (`entry`, `effects`, `store`) or a non-hook file it allows (sockets.ts, enums/, types/, barrels) is no hook file.
|
|
208
|
-
|
|
210
|
+
const subscriptions = ["/api/sse/subscriptions/", "/api/graphql/subscriptions/"].some((folder) => file.split(String.fromCharCode(92)).join("/").includes(folder))
|
|
211
|
+
return { stray, directlyInHooks: false, hookFile: !stray && !found.role && isHookFileName(name), sharedFile: false, family: found.slot, allows, subscriptions }
|
|
209
212
|
}
|
|
210
213
|
if (!inSlot(context, "fe.hooks")) return null
|
|
211
214
|
// A file directly in `hooks/` is its own root: the slot binds the file name as the `domain`.
|
|
@@ -232,6 +235,8 @@ export const hooksFolderHoldsHooksOnly = {
|
|
|
232
235
|
"`{{name}}` is in `hooks/` but is not a React hook. Hooks are `use<Name>.ts`; the domain's non-hook helpers go in `<domain>.shared.ts`; a call to the back end is an operation of `modules/api`, wrapped by an SWR hook in `hooks/swr/`.",
|
|
233
236
|
family:
|
|
234
237
|
"`{{name}}` is not a file `{{family}}` names. This family holds `{{allows}}`: an SWR hook is `use{Query,Mutate}<X>Swr.ts` (GraphQL) or `use{Get,Post,Put,Patch,Delete}<X>Swr.ts` (REST) in its `api/<protocol>/<queries|mutations>/` folder, a form is `use<X>Form.ts`. A name outside the list is a hook nobody finds by its family, or code that is not a hook.",
|
|
238
|
+
subscription:
|
|
239
|
+
"`{{name}}` is not a file `{{family}}` names in a subscriptions folder. A stream or GraphQL subscription hook is `useSubscribe<X>Swr.ts` in `api/sse/subscriptions/` or `api/graphql/subscriptions/`, one hook per file, exporting at most its `SUBSCRIBE_<X>_SWR_KEY`.",
|
|
235
240
|
export:
|
|
236
241
|
"`{{name}}` is exported from a hook file but is not a hook. A hook file exports the hook (a function named `use...`) and at most its SWR key (`QUERY_<X>_SWR_KEY`, `MUTATE_<X>_SWR_KEY`); a helper belongs in `<domain>.shared.ts`.",
|
|
237
242
|
many:
|
|
@@ -256,6 +261,7 @@ export const hooksFolderHoldsHooksOnly = {
|
|
|
256
261
|
Program(program) {
|
|
257
262
|
if (judged.directlyInHooks) return context.report({ node: program, messageId: "domain" })
|
|
258
263
|
if (!judged.stray) return
|
|
264
|
+
if (judged.family && judged.subscriptions) return context.report({ node: program, messageId: "subscription", data: { name, family: judged.family } })
|
|
259
265
|
if (judged.family) context.report({ node: program, messageId: "family", data: { name, family: judged.family, allows: judged.allows } })
|
|
260
266
|
else context.report({ node: program, messageId: "notHook", data: { name } })
|
|
261
267
|
},
|
package/icon.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* generalises on its own, and the gap between them is exactly where a third step gets invented.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { isClassAttribute, staticText } from "./lib/ast.mjs"
|
|
15
|
+
import { attribute, isClassAttribute, staticText, tagName } from "./lib/ast.mjs"
|
|
16
16
|
import { canonicalPackages } from "./lib/fe-stack.mjs"
|
|
17
17
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
18
18
|
import { fileOf, inSlot, isComponentFile, isProductSource, kindOfFile, roleOfFile } from "./lib/scope.mjs"
|
|
@@ -171,6 +171,35 @@ export const noOffScaleGlyphSize = {
|
|
|
171
171
|
},
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// -- ICON-1 (call site) ----------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/** A size utility on the class of a glyph: `size-5`, `w-4`, `h-6`, `min-w-4`. */
|
|
177
|
+
const SIZE_UTILITY = /(?:^|\s)(?:size|w|h|min-w|min-h|max-w|max-h)-\S+/
|
|
178
|
+
|
|
179
|
+
/** The icon's size is its usage's: the call site passes the glyph and never a size class. */
|
|
180
|
+
export const noIconSizeClass = {
|
|
181
|
+
meta: {
|
|
182
|
+
type: "problem",
|
|
183
|
+
docs: { description: "A glyph component takes its size from its usage (the 20px leading step is size-5), never from a size class at the call site." },
|
|
184
|
+
schema: [],
|
|
185
|
+
messages: {
|
|
186
|
+
size:
|
|
187
|
+
"`{{cls}}` sizes a glyph at the call site. The size of a glyph is its usage's (`Icon` and the Grammar components that draw one: 24px heading, 20px leading = size-5, 16px chip), so the app passes the glyph and no size class; a size written here is a third step nobody applies the same way twice.",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
create(context) {
|
|
191
|
+
if (!isProductSource(context) || isIconLeafFile(context)) return {}
|
|
192
|
+
return {
|
|
193
|
+
JSXOpeningElement(node) {
|
|
194
|
+
const name = tagName(node)
|
|
195
|
+
if (name === null || !/Icon$/.test(name)) return
|
|
196
|
+
const hit = SIZE_UTILITY.exec(staticText(attribute(node, "className")?.value) ?? "")
|
|
197
|
+
if (hit) context.report({ node, messageId: "size", data: { cls: hit[0].trim() } })
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
174
203
|
// -- ICON-10 ---------------------------------------------------------------------------------------
|
|
175
204
|
|
|
176
205
|
/** A repeated metric cell does not invent a feature glyph absent from the reference. */
|
|
@@ -266,6 +295,7 @@ export const rules = {
|
|
|
266
295
|
"no-vendor-icon-outside-icon-leaf": noVendorIconOutsideIconLeaf,
|
|
267
296
|
"heroicons-is-the-glyph-vendor": heroiconsIsTheGlyphVendor,
|
|
268
297
|
"no-off-scale-glyph-size": noOffScaleGlyphSize,
|
|
298
|
+
"no-icon-size-class": noIconSizeClass,
|
|
269
299
|
"no-decorative-icon-in-metric-cell": noDecorativeIconInMetricCell,
|
|
270
300
|
"no-inline-svg-outside-brand-mark-leaf": noInlineSvgOutsideBrandMarkLeaf,
|
|
271
301
|
}
|