@starci/eslint-canon-fe 8.4.43 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +36 -84
  3. package/brand-values.mjs +1 -1
  4. package/docs/hooks-folder.md +5 -3
  5. package/docs/native-controls.md +1 -5
  6. package/docs/next-conventions.md +8 -8
  7. package/docs/size-and-state-budget.md +22 -0
  8. package/docs/surface-rules.md +11 -9
  9. package/drawing.mjs +4 -66
  10. package/hooks-folder.mjs +5 -3
  11. package/index.mjs +7 -25
  12. package/landmark.mjs +0 -3
  13. package/lib/class-text.mjs +29 -0
  14. package/lib/config.mjs +4 -6
  15. package/lib/drawing-scope.mjs +15 -0
  16. package/lib/typed-class-values.mjs +10 -1
  17. package/lib/why.mjs +10 -40
  18. package/next-conventions.mjs +33 -16
  19. package/package.json +4 -3
  20. package/runtime/knowledge/grammars/index.yaml +102 -0
  21. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  22. package/runtime/knowledge/hfs/fe-stack.yaml +7 -0
  23. package/runtime/knowledge/hfs/slots.yaml +3 -3
  24. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  25. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  26. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  27. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  28. package/runtime/scripts/hfs/slots.mjs +1 -0
  29. package/runtime/scripts/hfs/view.mjs +1 -0
  30. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  31. package/size-and-state-budget.mjs +116 -4
  32. package/surface-rules.mjs +159 -26
  33. package/tokens.mjs +5 -217
  34. package/app-shell.mjs +0 -161
  35. package/docs/app-shell.md +0 -50
  36. package/docs/grammar-boundary.md +0 -57
  37. package/docs/status-colors.md +0 -32
  38. package/grammar-boundary.mjs +0 -203
  39. package/icon.mjs +0 -310
  40. package/lib/app-shell-cluster.mjs +0 -66
  41. package/lib/app-shell-create.mjs +0 -33
  42. package/lib/app-shell-owner-source.mjs +0 -124
  43. package/lib/app-shell-owner.mjs +0 -65
  44. package/lib/app-shell-scope.mjs +0 -79
  45. package/lib/app-shell-visible.mjs +0 -92
  46. package/lib/app-shell.mjs +0 -148
  47. package/lib/status-tones.generated.mjs +0 -9
  48. package/menu-entries.mjs +0 -133
  49. package/runtime/modules/goal/source-phrases.yaml +0 -95
  50. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  51. package/status-colors.mjs +0 -137
  52. package/typography.mjs +0 -78
  53. package/vendor-boundary.mjs +0 -26
package/landmark.mjs CHANGED
@@ -5,9 +5,6 @@
5
5
  * application may pass children through layouts and pages normally.
6
6
  */
7
7
 
8
- /** Named branch owners that may provide landmark semantics. */
9
- export const LANDMARK_BRANCHES = new Set(["Main"])
10
-
11
8
  /** No custom landmark AST rule is needed; semantic ownership is enforced by the component code. */
12
9
  export const rules = {}
13
10
 
@@ -0,0 +1,29 @@
1
+ /** Shared static class-string readers for token discipline and Grammar scale checks. */
2
+ import { isClassAttribute, staticText } from "./ast.mjs"
3
+ import { isProductSource } from "./scope.mjs"
4
+
5
+ /** Static strings in JSX, constants and arrays, preserving the authored class order. */
6
+ const classText = (value) => {
7
+ if (value?.type === "JSXExpressionContainer") return classText(value.expression)
8
+ if (value?.type === "ArrayExpression") {
9
+ const members = value.elements.map((element) => classText(element)).filter(Boolean)
10
+ return members.length > 0 ? members.join(" ") : null
11
+ }
12
+ return staticText(value)
13
+ }
14
+
15
+ /** Visit class strings in product JSX, declarations and named class arrays. */
16
+ export const classTextVisitors = (context, report) => {
17
+ if (!isProductSource(context)) return {}
18
+ return {
19
+ JSXAttribute(node) {
20
+ if (isClassAttribute(node)) report(node, classText(node.value))
21
+ },
22
+ VariableDeclarator(node) {
23
+ report(node, classText(node.init))
24
+ },
25
+ Property(node) {
26
+ if (!node.computed && node.key.type === "Identifier" && node.key.name === "classes") report(node, classText(node.value))
27
+ },
28
+ }
29
+ }
package/lib/config.mjs CHANGED
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * The one flat config of a StarCi front end.
3
3
  *
4
- * A repository's `eslint.config.mjs` is a managed file (R05/R16/R17), rendered by `starci app sync` as exactly:
5
- *
6
- * import { loadHfs, starciFeConfig } from "@starci/eslint-canon-fe"
7
- * export default starciFeConfig({ hfs: loadHfs(import.meta.url) })
4
+ * A repository's `eslint.config.mjs` is managed by `starci app sync` (R05/R16/R17). Its template composes this universal
5
+ * factory with the explicitly selected Grammar family's factory, passing the same HFS view to both.
8
6
  *
9
7
  * The factory owns everything else: which files are linted (the source of every app and every workspace package), typed linting on the source (`parserOptions.projectService`, so a rule asks what a node IS rather than what
10
8
  * it is called), the ignores, the linter options that make a disable comment impossible, every canon rule at `error`,
@@ -97,10 +95,10 @@ export const SLOT_FREE_RULES = Object.freeze([
97
95
  "client-no-server-import", "no-server-action", "no-dangerous-html", "response-cookie-attributes", "require-export-jsdoc", "no-vietnamese-in-source",
98
96
  "no-hardcoded-endpoint-fallback", "no-runtime-namespace", "list-item-has-key", "no-index-key", "no-inline-literal-prop-in-list", "prefer-arrow-export",
99
97
  "handler-on-prefix", "no-native-form-control", "no-native-img", "image-has-size", "no-second-i18n-stack", "html-lang-from-locale", "no-null-suspense-fallback",
100
- "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "status-text-uses-soft-foreground", "no-copy-in-call", "no-alert", "no-failure-collapse",
98
+ "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "no-copy-in-call", "no-alert", "no-failure-collapse",
101
99
  "outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
102
100
  "prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
103
- "nested-control-variant", "no-list-panel-padding",
101
+ "nested-control-variant", "no-list-panel-padding", "no-app-surface-paint",
104
102
  ])
105
103
 
106
104
  /** The source of a plain front-end tree: its `src/`. */
@@ -0,0 +1,15 @@
1
+ /** Shared HFS scope of connected/presentational component pairs. */
2
+ import { hfsOf } from "./hfs.mjs"
3
+ import { fileOf, inSlot, kindOfFile, roleOfFile } from "./scope.mjs"
4
+
5
+ const SPLIT_KINDS = new Set(["blocks", "pages", "layouts", "overlays"])
6
+
7
+ /** The owning surface folder of the linted entry or presentation file. */
8
+ const surfaceName = (context) => hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0]
9
+
10
+ /** Whether the file belongs to an HFS owner with a connected/presentational pair. */
11
+ export const inSplitTier = (context) => inSlot(context, "fe.components", "fe.feature") && SPLIT_KINDS.has(kindOfFile(context))
12
+
13
+ /** The paired presentation file's component kind, or null outside that scope. */
14
+ export const pureHalf = (context) =>
15
+ inSplitTier(context) && roleOfFile(context) === "drawing" && /^[A-Z][A-Za-z0-9]*$/.test(surfaceName(context)) ? kindOfFile(context) : null
@@ -5,7 +5,7 @@ import ts from "typescript"
5
5
  const fragments = (checker, node, seen, mode) => {
6
6
  if (!node || seen.has(node)) return []
7
7
  seen.add(node)
8
- if (ts.isStringLiteralLike(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) return [node.text]
8
+ if (ts.isStringLiteralLike(node) || ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) return mode === "style-keys" ? [] : [node.text]
9
9
  if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) return fragments(checker, node.expression, seen, mode)
10
10
  if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
11
11
  const symbol = ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)
@@ -13,6 +13,15 @@ const fragments = (checker, node, seen, mode) => {
13
13
  : checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node)
14
14
  return symbolFragments(checker, symbol, seen, mode)
15
15
  }
16
+ if (mode === "style-keys" && ts.isObjectLiteralExpression(node)) {
17
+ return node.properties.flatMap((property) => {
18
+ if (ts.isSpreadAssignment(property)) return fragments(checker, property.expression, seen, mode)
19
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return []
20
+ return ts.isComputedPropertyName(property.name)
21
+ ? fragments(checker, property.name.expression, seen, "variant")
22
+ : [property.name.text]
23
+ })
24
+ }
16
25
  return fragmentChildren(node, mode).flatMap((child) => fragments(checker, child, seen, mode))
17
26
  }
18
27
 
package/lib/why.mjs CHANGED
@@ -21,39 +21,19 @@
21
21
  export const why = {
22
22
  "nested-control-variant": {
23
23
  code: "FE_NESTED_VARIANT",
24
- en: "`<file>` forces a primary field inside a Grammar surface, or paints a raw bg-surface in app CSS.",
25
- fix: "Compose a Grammar surface and omit the nested field variant so its surface context supplies secondary.",
24
+ en: "`<file>` sets a static Grammar field variant, or forces primary Tabs inside a painted surface.",
25
+ fix: "Omit the field or nested Tabs variant so Grammar supplies the default from its surface context.",
26
+ },
27
+ "no-app-surface-paint": {
28
+ code: "FE_GRAMMAR_LAYOUT_ONLY",
29
+ en: "`<file>` sets paint or typography in app CSS instead of composing Grammar's published components and props.",
30
+ fix: "Keep app CSS layout-only and compose a painted Grammar surface such as SurfaceCard.",
26
31
  },
27
32
  "no-list-panel-padding": {
28
33
  code: "FE_LIST_PANEL_PADDING",
29
34
  en: "`<file>` adds horizontal wrapper padding to a Grammar list or its section caption, stacking the content inset.",
30
35
  fix: "Remove the wrapper padding and use ListBox inset=\"panel\" so headers, captions and rows share the panel content edge.",
31
36
  },
32
- "app-shell-frame": {
33
- code: "FE_APP_SHELL_FRAME",
34
- en: "The app frame must use Grammar WorkspaceShell with its fixed viewport.",
35
- fix: "Compose WorkspaceShell viewport=\"fixed\" and supply its header, navigation and primary slots.",
36
- },
37
- "no-shell-bottom-tabs": {
38
- code: "FE_APP_SHELL_BOTTOM_TABS",
39
- en: "Bottom tab navigation replaces the required mobile sidebar drawer.",
40
- fix: "Use WorkspaceShell navigationDrawer and Sidebar presentation=\"drawer\".",
41
- },
42
- "navbar-control-size": {
43
- code: "FE_NAVBAR_CONTROL_SIZE",
44
- en: "A navbar control does not use Grammar's shared control height.",
45
- fix: "Set size=\"control\" on every visible bar control.",
46
- },
47
- "navbar-cluster": {
48
- code: "FE_NAVBAR_CLUSTER",
49
- en: "Navbar action component kinds or order differ from the app-shell canon.",
50
- fix: "Compose Language IconButton, theme Switch, notification IconButton and account AvatarButton in that order.",
51
- },
52
- "no-navbar-create": {
53
- code: "FE_NAVBAR_CREATE",
54
- en: "A create action is composed into the global navbar.",
55
- fix: "Move the create action to the page that owns it.",
56
- },
57
37
  "no-env-outside-config": {
58
38
  code: "FE_ENV_OWNER",
59
39
  en: "`<file>` reads an environment variable. Only `modules/config` may read env.",
@@ -134,11 +114,6 @@ export const why = {
134
114
  en: "`<file>` uses a raw colour/length value (`<what>`). Colour and spacing come only from grammar tokens; brand colour lives only in `brand.css`.",
135
115
  fix: "Use a grammar token class or variable; a colour value is declared only in `modules/brand/brand.css`.",
136
116
  },
137
- "status-text-uses-soft-foreground": {
138
- code: "FE_STYLE_TOKEN_ONLY",
139
- en: "`<file>` paints text or an icon with a solid status tone (`<what>`, e.g. `text-success`). A solid tone is a background colour; used as a text colour it reads below 4.5:1 on the page background.",
140
- fix: "Switch to the soft pair: `text-<tone>-soft-foreground` (with `bg-<tone>-soft` when it sits on a light surface); a solid tone is used only with `bg-<tone>` and `text-<tone>-foreground`.",
141
- },
142
117
  "no-native-form-control": {
143
118
  code: "FE_NATIVE_FORM_CONTROL",
144
119
  en: "Raw `<tag>` in `<file>`. Use the grammar renderer.",
@@ -232,12 +207,12 @@ export const why = {
232
207
  "navigation-from-intl": {
233
208
  code: "FE_I18N_NAVIGATION",
234
209
  en: "`<file>` imports `Link`, `useRouter`, `usePathname` or `redirect` from Next, which does not know the locale: the path loses the `[locale]` prefix.",
235
- fix: "Import from `modules/i18n/navigation`, built by next-intl from `routing.ts`.",
210
+ fix: "Import from the locale-aware public routes entry or shared i18n package, built from the one routing configuration.",
236
211
  },
237
212
  "no-native-anchor": {
238
213
  code: "FE_I18N_NAVIGATION",
239
214
  en: "`<a>` in `<file>` points at an internal route (reloads the whole page, loses the locale, loses prefetch) or opens a new tab without `rel`.",
240
- fix: "Use `Link` from `modules/i18n/navigation` with an href built by `modules/routes`; a `_blank` link adds `rel=\"noopener noreferrer\"`.",
215
+ fix: "Use the locale-aware Link with an href built by modules/routes; a _blank link adds rel=\"noopener noreferrer\".",
241
216
  },
242
217
  "use-intl-formatter": {
243
218
  code: "FE_I18N_FORMATTER",
@@ -307,12 +282,7 @@ export const why = {
307
282
  "i18n-stack-in-one-module": {
308
283
  code: "FE_I18N_PLACEMENT",
309
284
  en: "`<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack is written once for the whole repo.",
310
- fix: "Call the `createAppI18n` factory of the shared i18n package inside `modules/i18n/index.ts` and import the result; a single-app repo writes the stack only in that app's `modules/i18n`.",
311
- },
312
- "no-raw-structural-element": {
313
- code: "FE_NATIVE_FORM_CONTROL",
314
- en: "Raw `<tag>` in `<file>`. Page structure and text are composed from grammar components, not hand-written HTML.",
315
- fix: "Replace it with the matching grammar component (`Heading`, `Text`, `SurfaceCard`, ...); if grammar lacks it, add it to grammar or the ui package instead of drawing it in place.",
285
+ fix: "Use the shared i18n factory; a single app keeps routing/request config in modules/i18n and navigation in modules/routes. The proxy imports routing without navigation.",
316
286
  },
317
287
  "no-app-relative-clock": {
318
288
  code: "FE_SIZE_AND_STATE_BUDGET",
@@ -15,7 +15,7 @@ import { attribute, attributeValue, calleeName, leadingTextOf, stringOf } from "
15
15
  import { variableOf } from "./lib/bindings.mjs"
16
16
  import { forbiddingConcern } from "./lib/fe-stack.mjs"
17
17
  import { hfsOf } from "./lib/hfs.mjs"
18
- import { baseName, classOf, fileOf, inSlot, roleOfFile, slotOfFile } from "./lib/scope.mjs"
18
+ import { baseName, classOf, classifyImport, fileOf, inSlot, roleOfFile, slotOfFile } from "./lib/scope.mjs"
19
19
 
20
20
  /** Another i18n stack: a second place that decides which language a reader sees (one the fe-stack catalog forbids by name is fe-stack-import's finding). */
21
21
  const OTHER_I18N = /^(?:react-i18next|i18next|next-i18next|react-intl|next-translate|typesafe-i18n|rosetta)(?:\/.*)?$|^@lingui\/.+$/
@@ -214,26 +214,27 @@ export const noNullSuspenseFallback = {
214
214
 
215
215
  // -- FE-NEXT-7 -------------------------------------------------------------------------------------
216
216
 
217
- /** Files that legitimately sit outside the locale provider or own the navigation module: the i18n module, the root error boundary (role `global-error`), the proxy (role `proxy`; `instrumentation` files are not exempt). */
217
+ /** The locale-aware navigation owner and framework boundaries outside its provider. */
218
218
  const isNavigationOwner = (context) =>
219
- inSlot(context, "fe.modules.i18n") ||
219
+ inSlot(context, "fe.package.i18n") ||
220
+ (inSlot(context, "fe.modules.routes") && hfsOf(context).apps.length === 1) ||
220
221
  (inSlot(context, "fe.source-root-pinned") && roleOfFile(context) === "proxy") ||
221
222
  (inSlot(context, "fe.route") && roleOfFile(context) === "global-error")
222
223
 
223
224
  /** The router helpers that are locale-blind in `next/navigation`. */
224
225
  const LOCALE_BLIND = new Set(["useRouter", "usePathname", "redirect", "permanentRedirect"])
225
226
 
226
- /** Navigation helpers come from `modules/i18n/navigation`, which knows the locale. */
227
+ /** Navigation helpers come from the locale-aware public routes or shared i18n entry. */
227
228
  export const navigationFromIntl = {
228
229
  meta: {
229
230
  type: "problem",
230
- docs: { description: "`Link`, `useRouter`, `usePathname` and `redirect` come from `modules/i18n/navigation`, not from Next." },
231
+ docs: { description: "`Link`, `useRouter`, `usePathname` and `redirect` come from the locale-aware routes or shared i18n public entry, not from Next." },
231
232
  schema: [],
232
233
  messages: {
233
234
  link:
234
- "`next/link` does not know the locale: a link built with it drops the `[locale]` prefix, so a Vietnamese reader lands on the default language of the next page (or on a 404 under `localePrefix: \"as-needed\"`). Import `Link` from `modules/i18n/navigation`, which `next-intl` builds from `routing.ts`.",
235
+ "`next/link` does not know the locale: a link built with it drops the `[locale]` prefix. Import Link from the locale-aware public routes entry or shared i18n package, built from the one routing configuration.",
235
236
  helper:
236
- "`{{name}}` from `next/navigation` does not know the locale: it navigates to a path without the `[locale]` prefix. Import it from `modules/i18n/navigation`, which `next-intl` builds from `routing.ts`.",
237
+ "`{{name}}` from `next/navigation` does not know the locale. Import it from the locale-aware public routes entry or shared i18n package, built from the one routing configuration.",
237
238
  },
238
239
  },
239
240
  create(context) {
@@ -263,11 +264,11 @@ const isExternalHref = (text) => /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(text)
263
264
  export const noNativeAnchor = {
264
265
  meta: {
265
266
  type: "problem",
266
- docs: { description: "An internal link is `Link` from `modules/i18n/navigation`; an external `_blank` link carries `rel`." },
267
+ docs: { description: "An internal link uses the locale-aware public navigation entry; an external `_blank` link carries `rel`." },
267
268
  schema: [],
268
269
  messages: {
269
270
  internal:
270
- "`<a href=\"{{href}}\">` for a route of this app: it reloads the whole page, skips prefetch and drops the locale prefix. Use `Link` from `modules/i18n/navigation` with an href built by `modules/routes`.",
271
+ "`<a href=\"{{href}}\">` for a route of this app reloads the page, skips prefetch and drops the locale prefix. Use the locale-aware Link with an href built by modules/routes.",
271
272
  rel:
272
273
  "`target=\"_blank\"` with no `rel`. The opened page can reach back through `window.opener`. Add `rel=\"noopener noreferrer\"`.",
273
274
  },
@@ -376,9 +377,9 @@ const stackFactoryOf = (context, callee) => {
376
377
  *
377
378
  * `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
378
379
  * `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
379
- * member counts), are called only in a file of the i18n package (slot `fe.package.i18n`, `createAppI18n`), or in the `fe.modules.i18n`
380
- * slot of a repository that declares exactly one app. In a multi-app repository an app's `modules/i18n` imports the package
381
- * factory; three byte-identical `request.ts` files, one per app, are three stacks that drift.
380
+ * member counts), have one owner. A shared i18n package owns its stack. In a one-app repository, routing and request
381
+ * config belong to modules/i18n while createNavigation belongs to modules/routes. The proxy imports routing without
382
+ * evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
382
383
  *
383
384
  * It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
384
385
  * next-intl's own stack. Different evidence, different remedy.
@@ -386,26 +387,42 @@ const stackFactoryOf = (context, callee) => {
386
387
  export const i18nStackInOneModule = {
387
388
  meta: {
388
389
  type: "problem",
389
- docs: { description: "next-intl's routing, navigation, request config and middleware factories are called only in the i18n package (or the only app's `modules/i18n`)." },
390
+ docs: { description: "A shared i18n package owns the stack; one app keeps routing/request config in i18n and navigation in routes, with a routing-only proxy entry." },
390
391
  schema: [],
391
392
  messages: {
392
393
  stack:
393
- "`{{name}}` builds a layer of the next-intl stack here. The stack is written once per repository, {{where}}; a copy in an app is a second stack that drifts from the first (three apps once carried byte-identical `request.ts` files). Import what the shared factory returns instead of calling `{{name}}`.",
394
+ "`{{name}}` builds the next-intl stack outside its owner. Keep {{where}} and import the owned result through its public entry.",
395
+ proxyNavigation:
396
+ "The proxy imports navigation runtime through {{source}}. Import routing from the i18n public entry, or use the shared package's routing-only proxy export; the proxy must not evaluate the routes/navigation barrel.",
394
397
  },
395
398
  },
396
399
  create(context) {
397
400
  const hfs = hfsOf(context)
398
401
  const slot = slotOfFile(context)
399
402
  const single = hfs.apps.length === 1
400
- if (slot === "fe.package.i18n" || (slot === "fe.modules.i18n" && single)) return {}
401
403
  const proxy = single && slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy"
402
- const where = single ? "in the app's `modules/i18n`" : "in the i18n package (`packages/<family>-i18n`, exported as `createAppI18n`) that every app's `modules/i18n` calls"
404
+ const proxyFile = slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy"
405
+ const where = single ? "routing/request config in modules/i18n and navigation in modules/routes" : "the stack in the shared i18n package that every app calls"
406
+ const safeProxyImport = (node) => {
407
+ if (!proxyFile || !node.source || node.importKind === "type" || node.exportKind === "type") return
408
+ if (node.specifiers?.length && node.specifiers.every((entry) => entry.importKind === "type" || entry.exportKind === "type")) return
409
+ const source = String(node.source.value)
410
+ if (source === "next-intl/navigation" || classifyImport(context, source)?.slot === "fe.modules.routes") {
411
+ context.report({ node, messageId: "proxyNavigation", data: { source } })
412
+ }
413
+ }
403
414
  return {
415
+ ImportDeclaration: safeProxyImport,
416
+ ExportNamedDeclaration: safeProxyImport,
417
+ ExportAllDeclaration: safeProxyImport,
404
418
  CallExpression(node) {
405
419
  const factory = stackFactoryOf(context, node.callee)
406
420
  // In a one-app repository the proxy (role `proxy`) mounts the locale middleware built from the one routing; a shared repository
407
421
  // builds it in the i18n package and the proxy re-exports it.
408
422
  if (factory?.name === "createMiddleware" && proxy) return
423
+ if (slot === "fe.package.i18n") return
424
+ if (single && factory?.name === "createNavigation" && slot === "fe.modules.routes") return
425
+ if (single && factory?.name !== "createNavigation" && slot === "fe.modules.i18n") return
409
426
  if (factory) context.report({ node, messageId: "stack", data: { name: factory.name, where } })
410
427
  },
411
428
  }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "8.4.43",
4
- "description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin and attached by one factory.",
3
+ "version": "9.0.0",
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",
7
7
  "private": false,
8
8
  "exports": {
9
- ".": "./index.mjs"
9
+ ".": "./index.mjs",
10
+ "./lib/*": "./lib/*"
10
11
  },
11
12
  "main": "./index.mjs",
12
13
  "files": [
@@ -0,0 +1,102 @@
1
+ schema: starci/knowledge-source@1
2
+ id: grammars
3
+ title: Grammar families
4
+ purpose: |
5
+ Catalog of authored UI Grammar families. Inventory only — not a default-family rule.
6
+ The selected design specification names the applicable installed family. The app binds that choice
7
+ explicitly in sides.fe.grammar; the catalog resolves its lint package and component contract.
8
+ A missing or ambiguous family never silently resolves to a family. DNA snapshots describe the
9
+ observed implementation; they do not install packages or prove current rendered behavior.
10
+ appliesTo:
11
+ - grammar
12
+ provenance:
13
+ limitations: |
14
+ Component names, tokens and gap tables describe the reference family at observation time.
15
+ Verify actual package identity/version, exported types, CSS and renderer source in the selected
16
+ repository before using snapshot APIs or gaps. Record current source references in the selected
17
+ .starciwork node; an old source census is not current UAT evidence. The current public consumer
18
+ entry observed for FE work is @starci/grammar/common — confirm installed exports before import.
19
+ The `common` entry is a DNA snapshot of that semantic entry and nothing more: it declares no palette,
20
+ no family boundary and no composition playbook, because the entry itself ships none of them.
21
+ guidance:
22
+ - id: catalog-not-default
23
+ requirement: |
24
+ Do not treat this catalog as selecting a default Grammar family. Family choice belongs to the
25
+ current design specification and its explicit sides.fe.grammar declaration.
26
+ rationale: |
27
+ Silent fallback to StarCi would invent product direction and override an absent/ambiguous design.
28
+ - id: verify-before-use
29
+ requirement: |
30
+ Before applying snapshot APIs or recorded gaps, inspect the selected repository's package
31
+ identity/version, exported types, CSS and renderer source.
32
+ rationale: |
33
+ Snapshots are historical; installed exports may differ. Inventing missing APIs is forbidden.
34
+ - id: ui-owns-universal
35
+ requirement: |
36
+ Common semantics, renderer anatomy, state and accessibility remain owned by knowledge/ui.
37
+ A family owns its scoped visual realization and promoted idioms; the product owns its facts,
38
+ content, routes and approved composition.
39
+ Apply FE-RULE-OWNERSHIP in knowledge/patterns/fe/index.yaml when placing requirements and checks.
40
+ rationale: |
41
+ Keeps universal UI law separate from family-scoped DNA and product composition.
42
+ topics:
43
+ - id: common
44
+ path: common/DNA.yaml
45
+ title: Common Grammar — DNA
46
+ summary: |
47
+ Generated DNA snapshot of the `@starci/grammar/common` public entry: custom properties,
48
+ the 110 renderers of its registry, their classes, stamped rule ids and recorded gaps. The DNA owns the observed package
49
+ version and source inventory. Common supplies anatomy, not colour. The snapshot is drift-checked.
50
+ - id: starci
51
+ path: starci/index.yaml
52
+ title: StarCi Core Grammar
53
+ summary: |
54
+ Historical snapshot of the StarCi Core family — DNA, idioms, playbook (including composing the
55
+ 0.5.0 forms, overlays and navigation chrome) and family boundaries.
56
+ - id: offset-pop
57
+ path: offset-pop/index.yaml
58
+ title: Offset Pop Grammar
59
+ summary: |
60
+ Snapshot of the product-neutral Offset Pop family — cream canvas, ink outline plus hard offset
61
+ shadow, one pink decision accent with a text-safe accent for text — with DNA, idioms, a
62
+ mobile-first playbook for playful learning or consumer apps, and family boundaries including two
63
+ decisions flagged for the family owner.
64
+ families:
65
+ - id: common
66
+ title: common
67
+ componentContract:
68
+ package: '@starci/grammar'
69
+ entry: '@starci/grammar/common'
70
+ styleEntry: '@starci/grammar/common.css'
71
+ peers: [react, '@heroui/react', '@xyflow/react']
72
+ packageSnapshot: common/DNA.yaml
73
+ scope: |
74
+ DNA snapshot only. This catalog carries no Common-side family boundary, idiom set or composition
75
+ playbook: `@starci/grammar/common` is the semantic/props entry every visual family is defined over
76
+ (`defineGrammarFamily`), not a family that ships a palette, so `authority`, `reusableStyle`,
77
+ `productComposition` and `index` have nothing to name here. Universal semantics, renderer anatomy,
78
+ state and accessibility remain owned by knowledge/ui.
79
+ - id: starci
80
+ title: starci
81
+ contractRef: common
82
+ lint:
83
+ package: '@starci/eslint-grammar-starci'
84
+ factory: starciGrammarConfig
85
+ prefix: starci-grammar
86
+ authority: starci/family.yaml
87
+ packageSnapshot: starci/DNA.yaml
88
+ reusableStyle: starci/idioms.yaml
89
+ productComposition: starci/playbook.yaml
90
+ index: starci/index.yaml
91
+ - id: offset-pop
92
+ title: offset-pop
93
+ contractRef: common
94
+ lint:
95
+ package: '@starci/eslint-grammar-offset-pop'
96
+ factory: offsetPopGrammarConfig
97
+ prefix: starci-grammar
98
+ authority: offset-pop/family.yaml
99
+ packageSnapshot: offset-pop/DNA.yaml
100
+ reusableStyle: offset-pop/idioms.yaml
101
+ productComposition: offset-pop/playbook.yaml
102
+ index: offset-pop/index.yaml
@@ -21,30 +21,44 @@ pins:
21
21
  install: registry
22
22
  side: fe
23
23
  source: packages/heroicons/package.json
24
- why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
24
+ why: 'StarCi custom Heroicons-compatible cuts; eslint-grammar-starci (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
25
25
  '@starci/grammar':
26
- version: 0.12.1
26
+ version: 0.12.3
27
27
  group: starci
28
28
  install: registry
29
29
  side: fe
30
30
  source: packages/grammar/package.json
31
31
  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.'
32
32
  '@starci/eslint-canon-be':
33
- version: 3.4.43
33
+ version: 3.4.44
34
34
  group: starci
35
35
  install: registry
36
36
  side: be
37
37
  source: packages/eslint/be/package.json
38
38
  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.'
39
39
  '@starci/eslint-canon-fe':
40
- version: 8.4.43
40
+ version: 9.0.0
41
41
  group: starci
42
42
  install: registry
43
43
  side: fe
44
44
  source: packages/eslint/fe/package.json
45
45
  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.'
46
+ '@starci/eslint-grammar-starci':
47
+ version: 0.12.3
48
+ group: starci
49
+ install: registry
50
+ side: fe
51
+ source: packages/eslint/grammar-starci/package.json
52
+ why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
53
+ '@starci/eslint-grammar-offset-pop':
54
+ version: 0.12.3
55
+ group: starci
56
+ install: registry
57
+ side: fe
58
+ source: packages/eslint/grammar-offset-pop/package.json
59
+ why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
46
60
  '@starci/stylelint-canon':
47
- version: 2.0.11
61
+ version: 2.0.12
48
62
  group: starci
49
63
  install: registry
50
64
  side: fe
@@ -77,14 +91,14 @@ pins:
77
91
  source: packages/test-world/package.json
78
92
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
79
93
  '@starci/cli':
80
- version: 1.7.34
94
+ version: 1.7.35
81
95
  group: starci
82
96
  install: registry
83
97
  side: both
84
98
  source: packages/cli/package.json
85
99
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
86
100
  '@starci/hfs':
87
- version: 4.7.34
101
+ version: 4.7.35
88
102
  group: starci
89
103
  install: registry
90
104
  side: both
@@ -380,7 +394,7 @@ pins:
380
394
  version: 2.2.0
381
395
  group: ui
382
396
  side: fe
383
- why: 'the base of the one application icon set (fe-stack icons, with @starci/heroicons for the glyphs it lacks); eslint-canon-fe (icon.mjs) admits @heroicons/react/24/outline and /16/solid.'
397
+ why: 'the base of the one application icon set (fe-stack icons, with @starci/heroicons for the glyphs it lacks); eslint-grammar-starci (icon.mjs) admits @heroicons/react/24/outline and /16/solid.'
384
398
  '@dicebear/core':
385
399
  version: 10.4.0
386
400
  group: ui
@@ -237,6 +237,13 @@ concerns:
237
237
  pattern: FE-LINT
238
238
  forbids: []
239
239
  why: the canon lint and format configuration.
240
+ - id: grammar-lint
241
+ tier: opt
242
+ packages: ['@starci/eslint-grammar-starci', '@starci/eslint-grammar-offset-pop']
243
+ home: [fe.source-root-pinned]
244
+ pattern: FE-RULE-OWNERSHIP
245
+ forbids: []
246
+ why: The selected Grammar family's lint package runs beside the universal front-end canon; the family catalog declares the package and factory.
240
247
  - id: auth-supabase
241
248
  tier: opt
242
249
  packages: ['@supabase/supabase-js', '@supabase/ssr']
@@ -2396,9 +2396,9 @@ slots:
2396
2396
  presence: required
2397
2397
  tracked: tracked
2398
2398
  tier: foundation
2399
- # The app's i18n module: its catalogs and the one call of the repository's i18n factory. The next-intl stack (routing,
2400
- # navigation, request config) is written once per repository: in the i18n package (fe.package.i18n, `createAppI18n`)
2401
- # when the repository has one, else in the only app's module.
2399
+ # The app's routing, request config and catalogs, or its adapter to the shared i18n factory. A single app
2400
+ # owns createNavigation in fe.modules.routes, keeping this public entry safe for its proxy. Shared stacks
2401
+ # belong to fe.package.i18n and expose a routing-only proxy entry separately from navigation.
2402
2402
  requires: [index.ts]
2403
2403
  tests: none
2404
2404
  rules: [FE_I18N_PLACEMENT, FE_I18N_CATALOG]
@@ -8,6 +8,22 @@
8
8
  # a word to scan for).
9
9
  kinds: [config-key, bias-field, verb, flag, code, vocabulary, file]
10
10
  removed:
11
+ - id: fe-landmark-branch-inventory
12
+ kind: vocabulary
13
+ name: LANDMARK_BRANCHES
14
+ use: 'the selected Grammar lint package owns its component vocabulary; the universal FE canon exposes no component inventory'
15
+ since: 1.0.0-alpha.31
16
+ - id: fe-grammar-rule-namespace
17
+ kind: vocabulary
18
+ name: starci-fe Grammar component rules
19
+ match: 'starci-fe/(?:app-shell-frame|no-shell-bottom-tabs|navbar-control-size|navbar-cluster|no-navbar-create|no-raw-structural-element|no-heading-tag-outside-heading-component|status-text-uses-soft-foreground|vendor-primitive-has-named-owner|no-vendor-icon-outside-icon-leaf|heroicons-is-the-glyph-vendor|no-off-scale-glyph-size|no-icon-size-class|destructive-menu-entry-needs-danger-tone|no-decorative-icon-in-metric-cell|no-inline-svg-outside-brand-mark-leaf|no-fractional-step|no-hand-rolled-heading|no-unresolved-token-class|slot-status-through-slotview)\b'
20
+ use: 'the same rule id under starci-grammar/ from the selected Grammar lint package; FE-RULE-OWNERSHIP governs placement'
21
+ since: 1.0.0-alpha.31
22
+ - id: offset-pop-toaster-content-guidance
23
+ kind: vocabulary
24
+ name: toaster-clear-of-the-dock
25
+ use: 'toaster-clear-of-content in the Offset Pop playbook'
26
+ since: 1.0.0-alpha.31
11
27
  - id: cut-seam-command
12
28
  kind: flag
13
29
  name: cut-seam --command
@@ -401,3 +417,16 @@ removed:
401
417
  use: 'the release tag is always <release.tagPrefix><package.json version>; delete the key'
402
418
  since: 1.0.0-alpha.31
403
419
  match: '\btagMatchesVersion\b'
420
+
421
+ - id: grammar-surfacecard-manual-layer
422
+ kind: vocabulary
423
+ name: SurfaceCard manual depth and unpainted frame
424
+ use: 'SurfaceCard derives its depth from the painted parent surface; use an unpainted layout container for grouping and a bounded SurfaceCard for a painted face'
425
+ since: 1.0.0-alpha.31
426
+ match: 'SurfaceCard[^\r\n>]*\s(?:depth=|frame="frameless")'
427
+ - id: grammar-dropdown-negative-tone
428
+ kind: vocabulary
429
+ name: DropdownMenuItem negative tone
430
+ use: 'DropdownMenuItem.tone uses neutral or danger; destructive commands use danger'
431
+ since: 1.0.0-alpha.31
432
+ match: 'DropdownMenuItem(?:Tone|\.tone)[^\r\n]*["'']negative["'']'