@estiva-app/ui 0.35.2 → 0.36.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 (123) hide show
  1. package/dist/eslint/escape.d.ts +23 -6
  2. package/dist/eslint/escape.d.ts.map +1 -1
  3. package/dist/eslint/index.js +70 -9
  4. package/dist/eslint/index.js.map +2 -2
  5. package/dist/eslint/no-raw-element.d.ts +7 -0
  6. package/dist/eslint/no-raw-element.d.ts.map +1 -1
  7. package/dist/eslint/no-restyled-part.d.ts +8 -0
  8. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  9. package/dist/gates/app-checks.d.ts.map +1 -1
  10. package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
  11. package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
  12. package/dist/gates/chunk-SYVKGOWM.js +1131 -0
  13. package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
  14. package/dist/gates/ci.d.ts +18 -0
  15. package/dist/gates/ci.d.ts.map +1 -0
  16. package/dist/gates/cli.js +20 -3
  17. package/dist/gates/cli.js.map +2 -2
  18. package/dist/gates/create-app.d.ts.map +1 -1
  19. package/dist/gates/create-app.js +1 -1
  20. package/dist/gates/escapes.d.ts +23 -0
  21. package/dist/gates/escapes.d.ts.map +1 -0
  22. package/dist/gates/gate-config.d.ts.map +1 -1
  23. package/dist/gates/hook.d.ts +2 -0
  24. package/dist/gates/hook.d.ts.map +1 -1
  25. package/dist/gates/index.d.ts +5 -1
  26. package/dist/gates/index.d.ts.map +1 -1
  27. package/dist/gates/index.js +37 -183
  28. package/dist/gates/index.js.map +4 -4
  29. package/dist/gates/name-escapes.d.ts +14 -0
  30. package/dist/gates/name-escapes.d.ts.map +1 -0
  31. package/dist/gates/status.d.ts +3 -1
  32. package/dist/gates/status.d.ts.map +1 -1
  33. package/dist/gates/token-lint.d.ts.map +1 -1
  34. package/dist/gates/token-style.d.ts +21 -0
  35. package/dist/gates/token-style.d.ts.map +1 -0
  36. package/dist/index.js +13 -13
  37. package/dist/index.js.map +1 -1
  38. package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
  39. package/dist/registry/app.d.ts.map +1 -1
  40. package/dist/registry/behaviours.d.ts +71 -0
  41. package/dist/registry/behaviours.d.ts.map +1 -0
  42. package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
  43. package/dist/registry/build.d.ts.map +1 -1
  44. package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
  45. package/dist/registry/chunk-EVJW26UR.js.map +7 -0
  46. package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
  47. package/dist/registry/chunk-SIB44CES.js.map +7 -0
  48. package/dist/registry/{chunk-5K2TRR4X.js → chunk-XK747STJ.js} +18 -3
  49. package/dist/registry/chunk-XK747STJ.js.map +7 -0
  50. package/dist/registry/cli.js +7 -6
  51. package/dist/registry/cli.js.map +2 -2
  52. package/dist/registry/contract.d.ts +13 -1
  53. package/dist/registry/contract.d.ts.map +1 -1
  54. package/dist/registry/index.d.ts +1 -1
  55. package/dist/registry/index.d.ts.map +1 -1
  56. package/dist/registry/index.js +11 -5
  57. package/package.json +4 -1
  58. package/registry.json +293 -17
  59. package/skill/estiva-ui.md +2 -1
  60. package/src/AppShell.mdx +2 -1
  61. package/src/AttachmentCard.tsx +7 -7
  62. package/src/Button.tsx +1 -1
  63. package/src/Card.tsx +2 -2
  64. package/src/ChipInput.mdx +2 -0
  65. package/src/ChipInput.tsx +1 -1
  66. package/src/CommandPalette.tsx +3 -3
  67. package/src/DialogShell.tsx +1 -1
  68. package/src/EmptyState.mdx +4 -3
  69. package/src/Lightbox.tsx +1 -1
  70. package/src/NavItem.tsx +1 -1
  71. package/src/PersonTrigger.tsx +1 -1
  72. package/src/Reaction.tsx +1 -1
  73. package/src/ScrollArea.mdx +4 -0
  74. package/src/Select.tsx +1 -1
  75. package/src/TopBar.mdx +1 -1
  76. package/src/eslint/escape.ts +43 -14
  77. package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
  78. package/src/eslint/index.test.ts +2 -2
  79. package/src/eslint/no-copied-look.test.ts +1 -1
  80. package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
  81. package/src/eslint/no-handmade-empty-state.test.ts +2 -2
  82. package/src/eslint/no-handmade-header.test.ts +2 -2
  83. package/src/eslint/no-native-title.test.ts +2 -2
  84. package/src/eslint/no-raw-element.test.ts +47 -15
  85. package/src/eslint/no-raw-element.ts +53 -3
  86. package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
  87. package/src/eslint/no-restyled-part.test.ts +21 -2
  88. package/src/eslint/no-restyled-part.ts +30 -0
  89. package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
  90. package/src/gates/app-checks.ts +4 -3
  91. package/src/gates/ci.ts +152 -0
  92. package/src/gates/cli.ts +19 -3
  93. package/src/gates/count.ts +1 -1
  94. package/src/gates/create-app.test.ts +2 -2
  95. package/src/gates/create-app.ts +22 -16
  96. package/src/gates/escapes.ts +111 -0
  97. package/src/gates/gate-config.ts +2 -0
  98. package/src/gates/gates.test.ts +86 -1
  99. package/src/gates/hook.ts +38 -4
  100. package/src/gates/index.ts +5 -1
  101. package/src/gates/name-escapes.ts +77 -0
  102. package/src/gates/status.ts +45 -2
  103. package/src/gates/token-lint.ts +11 -4
  104. package/src/gates/token-style.ts +79 -0
  105. package/src/how.test.ts +282 -0
  106. package/src/pages.test.ts +6 -0
  107. package/src/registry/app.test.ts +34 -0
  108. package/src/registry/app.ts +52 -2
  109. package/src/registry/behaviours.ts +223 -0
  110. package/src/registry/build.ts +36 -0
  111. package/src/registry/cli.ts +5 -3
  112. package/src/registry/contract.test.ts +27 -2
  113. package/src/registry/contract.ts +28 -3
  114. package/src/registry/index.ts +1 -1
  115. package/src/registry/registry.test.ts +14 -0
  116. package/dist/gates/chunk-DBMUQAR6.js +0 -607
  117. package/dist/gates/chunk-DBMUQAR6.js.map +0 -7
  118. package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
  119. package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
  120. package/dist/registry/chunk-5K2TRR4X.js.map +0 -7
  121. package/dist/registry/chunk-XVII7P6G.js.map +0 -7
  122. /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
  123. /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * What a part owns, worked out from what it draws (Katerina's ruling B9,
3
+ * 25 September).
4
+ *
5
+ * The plan's Gate 0 asked for "the behaviours it owns — derived from which Base
6
+ * UI parts it imports". Until 0.36.0 only "is built on Base UI" was; the rest
7
+ * was UIG-8's hand list of owners, which is right for the part that does the
8
+ * work and silent for every part built from it: ConfirmDialog draws DialogShell,
9
+ * whose page says it keeps focus inside, and the catalogue listed no focus for
10
+ * ConfirmDialog. Every app entry listed nothing at all.
11
+ *
12
+ * Now a part owns:
13
+ *
14
+ * - what the Base UI parts it draws do, from UIG-8's enumeration (`baseUi` on
15
+ * each behaviour, read from Base UI's source): all of it for a whole part
16
+ * (`Menu.Root`), what it is for a piece (`Menu.Item`);
17
+ * - what the whole of the part it **is** does (floating, focus, keys, scroll):
18
+ * the part it hands back at its root, followed through parts inside parts, in
19
+ * the package and in an app. ConfirmDialog returns a DialogShell, so it keeps
20
+ * focus inside; a page with a menu somewhere in it does not float, and a
21
+ * tooltip's floating stays the tooltip's;
22
+ * - and, in the package, what UIG-8's list says (the floor: a part like `Link`
23
+ * is reachable with Tab without any Base UI in it).
24
+ *
25
+ * Read from the code: a JSX tag inside the part's own declaration, or inside a
26
+ * function of the same file it draws. A root on one branch of a condition
27
+ * still counts, so the list says what a part can be, not what one screen shows.
28
+ */
29
+ import ts from 'typescript'
30
+ import { OWNED_BEHAVIOURS } from '../eslint/index'
31
+ import type { EntryBehaviour } from './schema'
32
+
33
+ /** `@base-ui/react`, its sub-paths, and the old `@base-ui-components/react` spelling. */
34
+ export const isBaseUi = (specifier: string) => /^@base-ui(?:-components)?\/react(?:\/|$)/.test(specifier)
35
+
36
+ /**
37
+ * What drawing a Base UI part gives, by behaviour id. The whole part (`Menu.Root`,
38
+ * or a part of one piece: `<Separator>`, `<Toggle>`) does all UIG-8 lists for it;
39
+ * a piece of one (`Menu.Item`, `Toolbar.Button`) only says what it is.
40
+ */
41
+ export function baseUiBehaviours(part: string, piece?: string): string[] {
42
+ const whole = !piece || piece === 'Root' || piece === 'Provider'
43
+ return OWNED_BEHAVIOURS.filter((owned) => owned.id === 'base-ui' || (owned.baseUi.includes(part) && (whole || owned.id === 'role'))).map((owned) => owned.id)
44
+ }
45
+
46
+ /**
47
+ * What a part hands on to the parts drawn around it: what the whole thing does.
48
+ * Being built on Base UI, a role and a Tab stop stay the inner part's: a banner
49
+ * with a button in it is not itself reachable with Tab.
50
+ */
51
+ export const HANDED_ON = new Set(['portal', 'press-outside', 'page-keys', 'focus', 'scroll-lock', 'follow', 'scroll'])
52
+
53
+ /** A hint drawn beside a part: its floating is its own, never the part's. */
54
+ export const HINTS = new Set(['Tooltip', 'WithTooltip', 'TooltipProvider', 'PreviewCard'])
55
+
56
+ /** What a part drawn inside another hands on to it. */
57
+ export const handedOn = (name: string, owned: Iterable<string>) => (HINTS.has(name) ? [] : [...owned].filter((id) => HANDED_ON.has(id)))
58
+
59
+ /** Behaviour ids as the catalogue writes them: in UIG-8's order, each once. */
60
+ export function asBehaviours(ids: Iterable<string>): EntryBehaviour[] {
61
+ const held = new Set(ids)
62
+ return OWNED_BEHAVIOURS.filter((owned) => held.has(owned.id)).map((owned) => ({ id: owned.id, behaviour: owned.behaviour }))
63
+ }
64
+
65
+ /** A tag as written: `Dialog.Popup`, `Button`. */
66
+ function tagText(tag: ts.JsxTagNameExpression): string | null {
67
+ if (ts.isIdentifier(tag)) return tag.text
68
+ if (ts.isPropertyAccessExpression(tag)) {
69
+ const inner = tagText(tag.expression as ts.JsxTagNameExpression)
70
+ return inner && `${inner}.${tag.name.text}`
71
+ }
72
+ return null
73
+ }
74
+
75
+ /** A tag's name and its piece: `['Dialog', 'Popup']`, `['Button', undefined]`. */
76
+ export const splitTag = (tag: string): [string, string | undefined] => {
77
+ const [root, piece] = tag.split('.')
78
+ return [root, piece]
79
+ }
80
+
81
+ /** Every tag drawn anywhere under `node`, as written. */
82
+ function tagsUnder(node: ts.Node): Set<string> {
83
+ const found = new Set<string>()
84
+ const visit = (n: ts.Node) => {
85
+ if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) {
86
+ const tag = tagText(n.tagName)
87
+ if (tag && /^[A-Z]/.test(tag)) found.add(tag)
88
+ }
89
+ ts.forEachChild(n, visit)
90
+ }
91
+ visit(node)
92
+ return found
93
+ }
94
+
95
+ /**
96
+ * For each name a file declares at its top (a function, a `const`, a class):
97
+ * the tags drawn inside it, with the file's own functions it draws followed, so
98
+ * what is left are names the file imports or declares without a body.
99
+ */
100
+ export function drawnIn(sf: ts.SourceFile): Map<string, Set<string>> {
101
+ const direct = new Map<string, Set<string>>()
102
+ for (const statement of sf.statements) {
103
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) direct.set(statement.name.text, tagsUnder(statement))
104
+ else if (ts.isVariableStatement(statement)) {
105
+ for (const d of statement.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer) direct.set(d.name.text, tagsUnder(d.initializer))
106
+ }
107
+ }
108
+ const closed = new Map<string, Set<string>>()
109
+ for (const name of direct.keys()) {
110
+ const out = new Set<string>()
111
+ const seen = new Set([name])
112
+ const stack = [name]
113
+ while (stack.length) {
114
+ for (const tag of direct.get(stack.pop() as string) ?? []) {
115
+ out.add(tag)
116
+ if (direct.has(tag) && !seen.has(tag)) {
117
+ seen.add(tag)
118
+ stack.push(tag)
119
+ }
120
+ }
121
+ }
122
+ closed.set(name, out)
123
+ }
124
+ return closed
125
+ }
126
+
127
+ /** The tags an expression hands back at its top: both sides of a condition, never a fragment's children. */
128
+ function rootsOfExpression(e: ts.Node | undefined, out: Set<string>): void {
129
+ if (!e) return
130
+ if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isNonNullExpression(e)) return rootsOfExpression(e.expression, out)
131
+ if (ts.isConditionalExpression(e)) {
132
+ rootsOfExpression(e.whenTrue, out)
133
+ rootsOfExpression(e.whenFalse, out)
134
+ return
135
+ }
136
+ if (ts.isBinaryExpression(e)) {
137
+ if (e.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) rootsOfExpression(e.left, out)
138
+ rootsOfExpression(e.right, out)
139
+ return
140
+ }
141
+ // A fragment is several things side by side (a page and the dialog it opens):
142
+ // the part is none of them alone.
143
+ if (ts.isJsxFragment(e)) return
144
+ const tag = ts.isJsxElement(e) ? tagText(e.openingElement.tagName) : ts.isJsxSelfClosingElement(e) ? tagText(e.tagName) : null
145
+ if (tag && /^[A-Z]/.test(tag)) out.add(tag)
146
+ }
147
+
148
+ /** The tags a function hands back: every `return`, or an arrow's body, never a nested function's. */
149
+ function rootsOfFunction(fn: ts.Node): Set<string> {
150
+ const out = new Set<string>()
151
+ if (ts.isArrowFunction(fn) && !ts.isBlock(fn.body)) rootsOfExpression(fn.body, out)
152
+ const visit = (n: ts.Node) => {
153
+ if (n !== fn && (ts.isFunctionLike(n) || ts.isClassLike(n))) return
154
+ if (ts.isReturnStatement(n)) rootsOfExpression(n.expression, out)
155
+ ts.forEachChild(n, visit)
156
+ }
157
+ visit(fn)
158
+ return out
159
+ }
160
+
161
+ /** The function a declaration's value is: itself, or the one inside `forwardRef(…)`, `memo(…)`. */
162
+ function functionOf(node: ts.Node | undefined): ts.Node | undefined {
163
+ if (!node) return undefined
164
+ if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) return node
165
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) return functionOf(node.expression)
166
+ if (ts.isCallExpression(node)) for (const argument of node.arguments) {
167
+ const fn = functionOf(argument)
168
+ if (fn) return fn
169
+ }
170
+ return undefined
171
+ }
172
+
173
+ /**
174
+ * For each function a file declares at its top: the parts it **is** — the tags it
175
+ * hands back at its root, following the file's own functions it hands back.
176
+ * `ConfirmDialog` returns a `DialogShell`, so it is one; a page with a menu
177
+ * somewhere in it is not a menu.
178
+ */
179
+ export function rootsIn(sf: ts.SourceFile): Map<string, Set<string>> {
180
+ const direct = new Map<string, Set<string>>()
181
+ for (const statement of sf.statements) {
182
+ if (ts.isFunctionDeclaration(statement) && statement.name) direct.set(statement.name.text, rootsOfFunction(statement))
183
+ else if (ts.isVariableStatement(statement)) {
184
+ for (const d of statement.declarationList.declarations) {
185
+ const fn = functionOf(d.initializer)
186
+ if (ts.isIdentifier(d.name) && fn) direct.set(d.name.text, rootsOfFunction(fn))
187
+ }
188
+ }
189
+ }
190
+ const closed = new Map<string, Set<string>>()
191
+ for (const name of direct.keys()) {
192
+ const out = new Set<string>()
193
+ const seen = new Set([name])
194
+ const stack = [name]
195
+ while (stack.length) {
196
+ for (const tag of direct.get(stack.pop() as string) ?? []) {
197
+ if (direct.has(tag)) {
198
+ if (!seen.has(tag)) {
199
+ seen.add(tag)
200
+ stack.push(tag)
201
+ }
202
+ } else out.add(tag)
203
+ }
204
+ }
205
+ closed.set(name, out)
206
+ }
207
+ return closed
208
+ }
209
+
210
+ /** Each imported value name → where it came from. */
211
+ export function importsOf(sf: ts.SourceFile): Map<string, { specifier: string; imported: string }> {
212
+ const out = new Map<string, { specifier: string; imported: string }>()
213
+ for (const statement of sf.statements) {
214
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue
215
+ const clause = statement.importClause
216
+ if (!clause || clause.isTypeOnly) continue
217
+ const specifier = statement.moduleSpecifier.text
218
+ if (clause.name) out.set(clause.name.text, { specifier, imported: 'default' })
219
+ const bindings = clause.namedBindings
220
+ if (bindings && ts.isNamedImports(bindings)) for (const e of bindings.elements) if (!e.isTypeOnly) out.set(e.name.text, { specifier, imported: (e.propertyName ?? e.name).text })
221
+ }
222
+ return out
223
+ }
@@ -19,6 +19,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
19
19
  import { join } from 'node:path'
20
20
  import ts from 'typescript'
21
21
  import { OWNED_BEHAVIOURS } from '../eslint/index'
22
+ import { asBehaviours, baseUiBehaviours, drawnIn, handedOn, importsOf, isBaseUi, rootsIn, splitTag } from './behaviours'
22
23
  import { looksOf, parseForLooks } from '../eslint/looks-of'
23
24
  import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, type EntryVariant, type Registry, type RegistryEntry } from './schema'
24
25
 
@@ -654,6 +655,41 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
654
655
 
655
656
  if (problems.length) throw new Error(`the registry cannot be built:\n ${problems.join('\n ')}`)
656
657
 
658
+ // What each part owns, from what it draws (B9): the Base UI parts in it, and
659
+ // every part of the package inside it, followed; UIG-8's list is the floor.
660
+ const moduleByName = new Map(values.map((v) => [v.name, v.module]))
661
+ const drawing = new Map<string, { drawn: Map<string, Set<string>>; roots: Map<string, Set<string>>; imports: ReturnType<typeof importsOf> }>()
662
+ const drawingOf = (moduleName: string) => {
663
+ if (!drawing.has(moduleName)) {
664
+ const file = moduleOf(moduleName).file
665
+ const sf = ts.createSourceFile(file, readFileSync(join(root, file), 'utf8'), ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
666
+ drawing.set(moduleName, { drawn: drawnIn(sf), roots: rootsIn(sf), imports: importsOf(sf) })
667
+ }
668
+ return drawing.get(moduleName)!
669
+ }
670
+ const owns = (name: string, seen: Set<string>): Set<string> => {
671
+ const out = new Set(behavioursOf(name).map((b) => b.id))
672
+ const moduleName = moduleByName.get(name)
673
+ if (!moduleName || seen.has(name)) return out
674
+ seen.add(name)
675
+ const { drawn, roots, imports } = drawingOf(moduleName)
676
+ // The Base UI parts it draws, anywhere in it.
677
+ for (const tag of drawn.get(name) ?? []) {
678
+ const [local, piece] = splitTag(tag)
679
+ const from = imports.get(local)
680
+ if (from && isBaseUi(from.specifier)) for (const id of baseUiBehaviours(from.imported, piece)) out.add(id)
681
+ }
682
+ // The parts it is: what it hands back at its root.
683
+ for (const tag of roots.get(name) ?? []) {
684
+ const [local] = splitTag(tag)
685
+ const from = imports.get(local)
686
+ const part = from?.specifier.startsWith('./') ? from.imported : !from && local !== name ? local : undefined
687
+ if (part && moduleByName.has(part)) for (const id of handedOn(part, owns(part, seen))) out.add(id)
688
+ }
689
+ return out
690
+ }
691
+ for (const entry of entries) if (entry.kind === 'component') entry.ownsBehaviours = asBehaviours(owns(entry.name, new Set()))
692
+
657
693
  entries.sort((a, b) => a.name.localeCompare(b.name))
658
694
 
659
695
  return {
@@ -278,7 +278,10 @@ async function main(): Promise<number> {
278
278
  const registry = await buildApp(root, value('repo'))
279
279
  process.stdout.write(`${summary(registry)}\n`)
280
280
  // The usage-page contract and the story links (UIG-19): the one copy, run by every app's `gate` job.
281
- const broken = contractProblems(registry, root)
281
+ // A When not may name a part of the package or of this app (B8).
282
+ const pkg = readPackageRegistry()
283
+ const known = new Set([...pkg.entries, ...registry.entries].map((e) => e.name))
284
+ const broken = contractProblems(registry, root, known)
282
285
  if (broken.length) {
283
286
  process.stderr.write(`${broken.length === 1 ? 'a part breaks' : `${broken.length} parts break`} the usage-page contract:\n ${broken.join('\n ')}\n`)
284
287
  } else {
@@ -291,9 +294,8 @@ async function main(): Promise<number> {
291
294
  const mapped = checkStoryMap(root, registry, linked.headings)
292
295
  // What the pages name must still be there (R14): parts under When and When not,
293
296
  // and the stories a Seen in line sends a reader to, here or in the package.
294
- const pkg = readPackageRegistry()
295
297
  const stale = nameProblems(registry, root, {
296
- known: new Set([...pkg.entries, ...registry.entries].map((e) => e.name)),
298
+ known,
297
299
  ...(linked.titles ? { titles: linked.titles } : {}),
298
300
  packageIds: pkg.entries.flatMap((e) => [e.storyId, e.docsId].filter((id): id is string => Boolean(id))),
299
301
  })
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os'
18
18
  import { dirname, join } from 'node:path'
19
19
  import { afterAll, describe, expect, it } from 'vitest'
20
20
  import { buildAppRegistry } from './app'
21
- import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem } from './contract'
21
+ import { contractProblems, hasSeenIn, linkProblems, nameProblems, pageProblem, whenNotProblem } from './contract'
22
22
 
23
23
  const made: string[] = []
24
24
  afterAll(() => {
@@ -53,6 +53,8 @@ const PAGE = [
53
53
  '',
54
54
  'A word.',
55
55
  '',
56
+ 'No alternative: a count has only this badge.',
57
+ '',
56
58
  '## How',
57
59
  '',
58
60
  '```tsx',
@@ -78,7 +80,7 @@ describe('a usage page', () => {
78
80
  })
79
81
 
80
82
  it('fails with its sections out of order', () => {
81
- const swapped = PAGE.replace('## When not\n\nA word.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
83
+ const swapped = PAGE.replace('## When not\n\nA word.\n\nNo alternative: a count has only this badge.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
82
84
  expect(pageProblem(swapped)).toMatch(/^sections out of order/)
83
85
  })
84
86
 
@@ -93,6 +95,18 @@ describe('a usage page', () => {
93
95
  expect(pageProblem(later)).toBe('no code under "How"')
94
96
  })
95
97
 
98
+ // B8: When not names the part to use instead, or says there is none.
99
+ it('asks When not to name another real part, or to say there is none', () => {
100
+ const known = new Set(['Badge', 'Once', 'Chip'])
101
+ const whenNot = (body: string) => PAGE.replace('A word.\n\nNo alternative: a count has only this badge.', body)
102
+ expect(whenNotProblem(PAGE, ['Badge'], known)).toBeNull()
103
+ expect(whenNotProblem(whenNot('A word. That is a **Chip**.'), ['Badge'], known)).toBeNull()
104
+ expect(whenNotProblem(whenNot('- A word.\n\nNo alternative: a count has only this.'), ['Badge'], known)).toBeNull()
105
+ expect(whenNotProblem(whenNot('A word.'), ['Badge'], known)).toMatch(/^"When not" names no other part/)
106
+ // Its own name, a name nothing has, and "No alternative:" mid-line do not count.
107
+ expect(whenNotProblem(whenNot('Not **Badge** twice, nor a `Widget`. See: No alternative: here.'), ['Badge'], known)).not.toBeNull()
108
+ })
109
+
96
110
  it('reads a Windows checkout', () => {
97
111
  expect(pageProblem(PAGE.replace(/\n/g, '\r\n'))).toBeNull()
98
112
  })
@@ -136,6 +150,17 @@ describe('an app against the contract', () => {
136
150
  expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
137
151
  })
138
152
 
153
+ it("links a part with no story to its page by the page's title, and names one that links to nothing (B10)", () => {
154
+ const seen = PAGE.replace('A small count beside a name.\n', 'A small count beside a name.\n\n**Seen in** — *Parts/Card*, beside the title.\n')
155
+ const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': seen })
156
+ const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
157
+ expect(registry.entries.find((e) => e.name === 'Badge')).toMatchObject({ docsId: 'parts-badge--docs', storyId: null })
158
+ writeFileSync(join(dir, 'src/Badge.mdx'), seen.replace("<Meta title='Parts/Badge' />", '<Meta />'))
159
+ expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
160
+ 'Badge (reusable, src/Badge.tsx) links to nothing in Storybook: give src/Badge.mdx a `<Meta title="…" />`, or give the part a story',
161
+ ])
162
+ })
163
+
139
164
  it("counts a story file named after the part that draws its view, from the part's own file", () => {
140
165
  const dir = app({
141
166
  'src/Panel.tsx': `${part('Panel')}\n/** The half that only draws. */\nexport function PanelView() {\n return <div>view</div>\n}\n`,
@@ -87,7 +87,7 @@ export function linkProblems(registry: Registry, ids: ReadonlySet<string>): stri
87
87
  *
88
88
  * `root` is the app's own folder: the one its catalogue's paths are relative to.
89
89
  */
90
- export function contractProblems(registry: Registry, root: string): string[] {
90
+ export function contractProblems(registry: Registry, root: string, known: ReadonlySet<string> = new Set(registry.entries.map((e) => e.name))): string[] {
91
91
  const problems: string[] = []
92
92
  for (const entry of registry.entries) {
93
93
  const cls = entry.app?.class
@@ -103,9 +103,16 @@ export function contractProblems(registry: Registry, root: string): string[] {
103
103
  const text = readFileSync(join(root, page), 'utf8')
104
104
  const problem = pageProblem(text)
105
105
  if (problem) problems.push(`${where}: ${page} has ${problem}`)
106
+ const whenNot = whenNotProblem(text, [entry.name], known)
107
+ if (whenNot) problems.push(`${where}: ${page}: ${whenNot}`)
106
108
  if (entry.storyId === null && !hasSeenIn(text)) {
107
109
  problems.push(`${where} is drawn nowhere: give it a story, or write a "**Seen in**" line on ${page} naming the stories that draw it, or why none can`)
108
110
  }
111
+ // A reusable part links to its page or a story (Katerina's ruling B10, 25 September):
112
+ // the catalogue reads the page's `<Meta title>`, so a page without one leaves it unlinked.
113
+ if (entry.storyId === null && entry.docsId === null) {
114
+ problems.push(`${where} links to nothing in Storybook: give ${page} a \`<Meta title="…" />\`, or give the part a story`)
115
+ }
109
116
  }
110
117
  return problems
111
118
  }
@@ -118,8 +125,26 @@ const toStoryId = (title: string) =>
118
125
  .replace(/^-+|-+$/g, '')
119
126
 
120
127
  /** The names a page's section writes as a part: bold or in backticks, starting with a capital. */
121
- function namedParts(section: string): string[] {
122
- return [...section.matchAll(/\*\*([A-Z][A-Za-z0-9]+)\*\*|`([A-Z][A-Za-z0-9]+)`/g)].map((m) => m[1] ?? m[2])
128
+ export function namedParts(section: string): string[] {
129
+ return [...section.matchAll(/\*\*`?([A-Z][A-Za-z0-9]+)`?\*\*|`([A-Z][A-Za-z0-9]+)`/g)].map((m) => m[1] ?? m[2])
130
+ }
131
+
132
+ /** The line a "When not" starts where no part stands in (Katerina's ruling B8). */
133
+ export const NO_ALTERNATIVE = /^No alternative:/m
134
+
135
+ /**
136
+ * Why a page's `When not` breaks the ruling B8 (25 September), or null. The plan
137
+ * asked for "When not — naming the alternative"; CI checked only that the
138
+ * heading was there, and 29 pages across the three repos named nothing. Now the
139
+ * section names a real part other than the page's own (`own`), from `known`, or
140
+ * a line of it starts "No alternative:" and says why there is none.
141
+ */
142
+ export function whenNotProblem(source: string, own: readonly string[], known: ReadonlySet<string>): string | null {
143
+ const section = source.replace(/\r\n/g, '\n').split(/^##\s+When not\s*$/m)[1]?.split(/^##\s/m)[0]
144
+ // A missing section is pageProblem's to report.
145
+ if (section === undefined || NO_ALTERNATIVE.test(section)) return null
146
+ if (namedParts(section).some((name) => known.has(name) && !own.includes(name))) return null
147
+ return '"When not" names no other part: name the part to use instead, as **Name** or `Name`, or start a line "No alternative:" that says why there is none'
123
148
  }
124
149
 
125
150
  /**
@@ -12,7 +12,7 @@
12
12
  */
13
13
  export { buildRegistry, readIndexExports, serializeRegistry, type BuildOptions } from './build'
14
14
  export { buildAppRegistry, type AppBuildOptions } from './app'
15
- export { CONTRACT_KINDS, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
15
+ export { CONTRACT_KINDS, NO_ALTERNATIVE, PAGE_SECTIONS, contractProblems, hasSeenIn, linkProblems, namedParts, pageProblem, whenNotProblem } from './contract'
16
16
  export { docsLink, findInRegistries, findInRegistry, formatFindings, type Finding } from './find'
17
17
  export {
18
18
  CLASSES,
@@ -80,6 +80,20 @@ describe('the registry builds', () => {
80
80
  expect(entry('Card').ownsBehaviours).toEqual([])
81
81
  })
82
82
 
83
+ // B9: worked out from what a part draws and what it is, not only UIG-8's owners.
84
+ it('gives a part what the part it is owns, and a Base UI piece only what it is', () => {
85
+ const ids = (name: string) => entry(name).ownsBehaviours.map((owned) => owned.id)
86
+ // ConfirmDialog returns a DialogShell: it keeps focus inside and stops the page scrolling.
87
+ expect(ids('ConfirmDialog')).toEqual(expect.arrayContaining(['portal', 'focus', 'scroll-lock', 'press-outside']))
88
+ // MenuItem draws Menu.Item: a menu item, not a floating menu.
89
+ expect(ids('MenuItem')).toEqual(expect.arrayContaining(['base-ui', 'role']))
90
+ expect(ids('MenuItem')).not.toContain('portal')
91
+ // A tooltip's floating stays the tooltip's.
92
+ expect(ids('NavItem')).not.toContain('portal')
93
+ // Being built on Base UI, a role and a Tab stop are not handed on.
94
+ expect(ids('EmptyState')).not.toContain('tab-stop')
95
+ })
96
+
83
97
  it('reads the variants from the props type, following a local alias', () => {
84
98
  // ButtonVariant and ButtonSize are aliases; the props type points at them.
85
99
  expect(entry('Button').variants).toEqual([