@estiva-app/ui 0.22.0 → 0.24.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 (173) hide show
  1. package/README.md +21 -0
  2. package/dist/AppShell.d.ts.map +1 -1
  3. package/dist/AttachmentCard.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +20 -2
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/ContainerHeader.d.ts +25 -0
  7. package/dist/ContainerHeader.d.ts.map +1 -0
  8. package/dist/EmptyState.d.ts +10 -1
  9. package/dist/EmptyState.d.ts.map +1 -1
  10. package/dist/Form.d.ts.map +1 -1
  11. package/dist/IconButton.d.ts +9 -1
  12. package/dist/IconButton.d.ts.map +1 -1
  13. package/dist/PreviewCard.d.ts +9 -2
  14. package/dist/PreviewCard.d.ts.map +1 -1
  15. package/dist/SectionHeader.d.ts +7 -1
  16. package/dist/SectionHeader.d.ts.map +1 -1
  17. package/dist/Select.d.ts +11 -3
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/Tooltip.d.ts +13 -1
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/eslint/index.js +2 -1
  22. package/dist/eslint/index.js.map +2 -2
  23. package/dist/eslint/no-restyled-part.d.ts.map +1 -1
  24. package/dist/gates/app-checks.d.ts.map +1 -1
  25. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  26. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  27. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  28. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  29. package/dist/gates/cli.js +1 -1
  30. package/dist/gates/create-app.d.ts +2 -0
  31. package/dist/gates/create-app.d.ts.map +1 -1
  32. package/dist/gates/create-app.js +1 -1
  33. package/dist/gates/index.js +25 -3
  34. package/dist/gates/index.js.map +2 -2
  35. package/dist/gates/status.d.ts +2 -0
  36. package/dist/gates/status.d.ts.map +1 -1
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +196 -156
  40. package/dist/index.js.map +4 -4
  41. package/dist/registry/app-DQI6WLHR.js +10 -0
  42. package/dist/registry/app.d.ts +21 -0
  43. package/dist/registry/app.d.ts.map +1 -0
  44. package/dist/registry/build-LGFCCOLR.js +27 -0
  45. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  46. package/dist/registry/build.d.ts +68 -2
  47. package/dist/registry/build.d.ts.map +1 -1
  48. package/dist/registry/{chunk-MRSBS5OP.js → chunk-E4JNV7PC.js} +68 -12
  49. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  50. package/dist/registry/{chunk-QDYGB3QN.js → chunk-NJN4MQAM.js} +60 -15
  51. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  52. package/dist/registry/{chunk-IJNCYVH4.js → chunk-W2B2G7OE.js} +34 -9
  53. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  54. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  55. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  56. package/dist/registry/cli.js +81 -26
  57. package/dist/registry/cli.js.map +3 -3
  58. package/dist/registry/find.d.ts +12 -1
  59. package/dist/registry/find.d.ts.map +1 -1
  60. package/dist/registry/index.d.ts +5 -2
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js +11 -3
  63. package/dist/registry/schema.d.ts +83 -6
  64. package/dist/registry/schema.d.ts.map +1 -1
  65. package/package.json +2 -2
  66. package/registry.json +283 -95
  67. package/src/AppShell.mdx +10 -0
  68. package/src/AppShell.test.tsx +16 -0
  69. package/src/AppShell.tsx +4 -1
  70. package/src/AttachmentCard.mdx +9 -0
  71. package/src/AttachmentCard.test.tsx +10 -0
  72. package/src/AttachmentCard.tsx +8 -5
  73. package/src/Avatar.mdx +13 -3
  74. package/src/AvatarGroup.mdx +4 -0
  75. package/src/Banner.mdx +30 -7
  76. package/src/Banner.stories.tsx +22 -0
  77. package/src/Banner.test.tsx +35 -0
  78. package/src/Banner.tsx +34 -9
  79. package/src/Breadcrumb.mdx +10 -0
  80. package/src/Button.mdx +14 -3
  81. package/src/Card.mdx +9 -0
  82. package/src/Checkbox.mdx +10 -0
  83. package/src/Chip.mdx +4 -0
  84. package/src/ChipInput.mdx +12 -0
  85. package/src/CollapsibleSection.mdx +9 -0
  86. package/src/CommandPalette.mdx +14 -2
  87. package/src/ConfirmDialog.mdx +11 -0
  88. package/src/ContainerHeader.mdx +60 -0
  89. package/src/ContainerHeader.stories.tsx +62 -0
  90. package/src/ContainerHeader.test.tsx +47 -0
  91. package/src/ContainerHeader.tsx +45 -0
  92. package/src/DialogShell.mdx +14 -0
  93. package/src/Divider.mdx +9 -1
  94. package/src/EditableText.mdx +11 -0
  95. package/src/EmptyState.mdx +27 -4
  96. package/src/EmptyState.stories.tsx +6 -1
  97. package/src/EmptyState.test.tsx +18 -1
  98. package/src/EmptyState.tsx +14 -1
  99. package/src/Field.mdx +10 -1
  100. package/src/FieldLine.mdx +9 -1
  101. package/src/FilePicker.mdx +8 -0
  102. package/src/Form.mdx +10 -0
  103. package/src/Form.test.tsx +26 -0
  104. package/src/Form.tsx +7 -0
  105. package/src/IconButton.mdx +16 -2
  106. package/src/IconButton.stories.tsx +3 -0
  107. package/src/IconButton.test.tsx +25 -0
  108. package/src/IconButton.tsx +61 -46
  109. package/src/IdentityMenu.mdx +9 -0
  110. package/src/InlineChip.mdx +8 -0
  111. package/src/Kbd.mdx +4 -0
  112. package/src/Link.mdx +10 -0
  113. package/src/Menu.mdx +16 -1
  114. package/src/MenuItem.mdx +12 -2
  115. package/src/NavItem.mdx +8 -0
  116. package/src/Person.mdx +4 -0
  117. package/src/PersonTrigger.mdx +9 -0
  118. package/src/Popover.mdx +21 -0
  119. package/src/PreviewCard.mdx +19 -4
  120. package/src/PreviewCard.tsx +11 -4
  121. package/src/ProgressBar.mdx +8 -0
  122. package/src/Property.mdx +4 -0
  123. package/src/Rail.mdx +9 -1
  124. package/src/RailItem.mdx +8 -0
  125. package/src/Reaction.mdx +9 -0
  126. package/src/ReactionPicker.mdx +8 -0
  127. package/src/ScrollArea.mdx +13 -2
  128. package/src/SearchInput.mdx +9 -0
  129. package/src/SectionHeader.mdx +13 -0
  130. package/src/SectionHeader.stories.tsx +9 -0
  131. package/src/SectionHeader.test.tsx +9 -0
  132. package/src/SectionHeader.tsx +8 -2
  133. package/src/SectionLabel.mdx +4 -0
  134. package/src/Select.mdx +21 -2
  135. package/src/Select.stories.tsx +4 -1
  136. package/src/Select.test.tsx +17 -0
  137. package/src/Select.tsx +34 -13
  138. package/src/Sidebar.mdx +8 -0
  139. package/src/Skeleton.mdx +4 -0
  140. package/src/Tabs.mdx +12 -1
  141. package/src/TextInput.mdx +8 -0
  142. package/src/Textarea.mdx +8 -0
  143. package/src/Toast.mdx +12 -1
  144. package/src/Toolbar.mdx +11 -0
  145. package/src/Tooltip.mdx +26 -2
  146. package/src/Tooltip.stories.tsx +26 -0
  147. package/src/Tooltip.test.tsx +29 -0
  148. package/src/Tooltip.tsx +17 -4
  149. package/src/TopBar.mdx +4 -0
  150. package/src/eslint/no-rebuilt-behaviour.ts +1 -1
  151. package/src/eslint/no-restyled-part.ts +1 -0
  152. package/src/gates/app-checks.ts +23 -1
  153. package/src/gates/create-app.test.ts +77 -6
  154. package/src/gates/create-app.ts +288 -15
  155. package/src/gates/status.ts +16 -0
  156. package/src/index.ts +1 -0
  157. package/src/pages.test.ts +142 -0
  158. package/src/registry/app.test.ts +562 -0
  159. package/src/registry/app.ts +854 -0
  160. package/src/registry/build.ts +114 -25
  161. package/src/registry/cli.ts +105 -42
  162. package/src/registry/find.ts +45 -8
  163. package/src/registry/index.ts +8 -1
  164. package/src/registry/registry.test.ts +5 -5
  165. package/src/registry/schema.ts +163 -12
  166. package/tailwind-preset.js +22 -1
  167. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  168. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
  169. package/dist/registry/build-GOVLABI6.js +0 -13
  170. package/dist/registry/chunk-IJNCYVH4.js.map +0 -7
  171. package/dist/registry/chunk-MRSBS5OP.js.map +0 -7
  172. package/dist/registry/chunk-QDYGB3QN.js.map +0 -7
  173. /package/dist/registry/{build-GOVLABI6.js.map → app-DQI6WLHR.js.map} +0 -0
@@ -24,7 +24,7 @@ import { SCHEMA_VERSION, type EntryBehaviour, type EntryKind, type EntryProp, ty
24
24
  export interface BuildOptions {
25
25
  /** The repository's top folder. */
26
26
  root?: string
27
- /** The name this registry's entries are recorded under. One repo per registry until UIG-13. */
27
+ /** The name this registry's entries are recorded under. */
28
28
  repo?: string
29
29
  }
30
30
 
@@ -36,7 +36,7 @@ interface RawExport {
36
36
  isType: boolean
37
37
  }
38
38
 
39
- /** What the parser found above and about one exported declaration. */
39
+ /** What the parser found above and about one top-level declaration. */
40
40
  interface Declared {
41
41
  /** The doc comment, cleaned, or `''`. */
42
42
  doc: string
@@ -49,7 +49,7 @@ interface Declared {
49
49
  propsType?: ts.TypeNode
50
50
  }
51
51
 
52
- const PACKAGE_IMPORT = '@estiva-app/ui'
52
+ export const PACKAGE_IMPORT = '@estiva-app/ui'
53
53
 
54
54
  /**
55
55
  * Storybook's own `sanitize`, which is what turns a title and a story's export
@@ -58,7 +58,7 @@ const PACKAGE_IMPORT = '@estiva-app/ui'
58
58
  * builder, which apps run. A test holds this to the ids a real Storybook build
59
59
  * produces.
60
60
  */
61
- function sanitize(name: string): string {
61
+ export function sanitize(name: string): string {
62
62
  return name
63
63
  .toLowerCase()
64
64
  .replace(/[ ’–—―′¿'`~!@#$%^&*()_|+\-=?;:'",.<>{}[\]\\/]/g, '-')
@@ -87,7 +87,7 @@ function storyNameFromExport(key: string): string {
87
87
  .trim()
88
88
  }
89
89
 
90
- const toId = (title: string, name: string) => `${sanitize(title)}--${sanitize(storyNameFromExport(name))}`
90
+ export const toId = (title: string, name: string) => `${sanitize(title)}--${sanitize(storyNameFromExport(name))}`
91
91
 
92
92
  /** Read `src/index.ts`. Its `export { … } from './Sibling'` statements are the target set. */
93
93
  export function readIndexExports(source: string): RawExport[] {
@@ -122,9 +122,11 @@ function docAbove(source: string, node: ts.Node, headerPos: number | null = null
122
122
  if (!jsdoc) return ''
123
123
  if (headerPos !== null && jsdoc.pos === headerPos) return ''
124
124
  // Only a comment that sits against the declaration describes it: anything
125
- // with a blank line or another statement between them is about something else.
125
+ // with another statement between them is about something else. A `//` line
126
+ // between is still its own — the written note an escape needs sits there, and
127
+ // TypeScript attaches the doc comment through it too.
126
128
  const between = source.slice(jsdoc.end, node.getStart())
127
- if (between.trim() !== '') return ''
129
+ if (between.replace(/\/\/[^\n]*/g, '').trim() !== '') return ''
128
130
  return source
129
131
  .slice(jsdoc.pos, jsdoc.end)
130
132
  .split(/\r?\n/)
@@ -141,7 +143,7 @@ function docAbove(source: string, node: ts.Node, headerPos: number | null = null
141
143
  * "A list of actions from a trigger." is a purpose; "One row, two parts." is
142
144
  * not, on its own.
143
145
  */
144
- function firstSentence(prose: string): string {
146
+ export function firstSentence(prose: string): string {
145
147
  const flat = prose
146
148
  .split(/\n\s*\n/)[0]
147
149
  .split('\n')
@@ -157,7 +159,7 @@ function firstSentence(prose: string): string {
157
159
  }
158
160
 
159
161
  /** A page's opening paragraph: everything between its `# Heading` and the first blank line. */
160
- function pageOpening(mdx: string): string {
162
+ export function pageOpening(mdx: string): string {
161
163
  const lines = mdx.split(/\r?\n/)
162
164
  const heading = lines.findIndex((line) => /^#\s+\S/.test(line))
163
165
  if (heading < 0) return ''
@@ -214,17 +216,31 @@ function literalUnion(type: ts.TypeNode | undefined, aliases: Map<string, ts.Typ
214
216
  return values.length > 1 ? values : null
215
217
  }
216
218
 
217
- /** Everything one sibling file says about the names it exports. */
218
219
  /** A type’s finished props and word-choices, read by the module that declares them. */
219
- interface Resolved {
220
+ export interface Resolved {
220
221
  props: EntryProp[]
221
222
  variants: EntryVariant[]
222
223
  }
223
224
 
224
- /** Ask another file of this package for a type it declares, already resolved. */
225
- type Sibling = (module: string, typeName: string) => Resolved | undefined
225
+ /**
226
+ * Ask another file for a type it declares, already resolved. `specifier` is the
227
+ * import as written (`./IconButton`, `@/lib/types`); the caller decides what it
228
+ * points at, and answers `undefined` for anything outside the library it reads.
229
+ */
230
+ export type Sibling = (specifier: string, typeName: string) => Resolved | undefined
226
231
 
227
- function readModule(source: string, sibling: Sibling = () => undefined) {
232
+ export interface ReadOptions {
233
+ /**
234
+ * Whether a file with no comment of its own at the top lends the comment on
235
+ * its first declaration to the file. The package: yes — `Skeleton.tsx`'s
236
+ * family paragraph sits there, and taking it as `SkeletonBar`'s was wrong. An
237
+ * app: no — a comment directly above a part is that part's, which is what a
238
+ * person writing one expects; a file's own goes at the very top.
239
+ */
240
+ lendFirstComment?: boolean
241
+ }
242
+
243
+ export function readModule(source: string, sibling: Sibling = () => undefined, { lendFirstComment = true }: ReadOptions = {}) {
228
244
  const file = ts.createSourceFile('module.tsx', source, ts.ScriptTarget.Latest, true)
229
245
  const declarations = new Map<string, Declared>()
230
246
  const aliases = new Map<string, ts.TypeNode>()
@@ -232,7 +248,7 @@ function readModule(source: string, sibling: Sibling = () => undefined) {
232
248
  /** What each interface extends, as written — `Omit<IdentityMenuProps, 'compact'>` and all. */
233
249
  const bases = new Map<string, ts.ExpressionWithTypeArguments[]>()
234
250
  /** Which module each imported name came from. */
235
- const importedFrom = new Map<string, string>()
251
+ const importedFrom = new Map<string, { specifier: string; imported: string }>()
236
252
 
237
253
  const propsTypeOf = (parameters: readonly ts.ParameterDeclaration[]): ts.TypeNode | undefined => parameters[0]?.type
238
254
 
@@ -257,18 +273,42 @@ function readModule(source: string, sibling: Sibling = () => undefined) {
257
273
  return undefined
258
274
  }
259
275
 
276
+ /**
277
+ * `const Row: FC<RowProps> = ({ … }) => …` leaves the parameter bare: the
278
+ * props are the annotation's type argument — `FC`, `FunctionComponent`,
279
+ * `React.FC`, `React.FunctionComponent`.
280
+ */
281
+ const propsOfAnnotation = (type: ts.TypeNode | undefined): ts.TypeNode | undefined => {
282
+ if (!type || !ts.isTypeReferenceNode(type)) return undefined
283
+ const named = ts.isIdentifier(type.typeName) ? type.typeName.text : type.typeName.right.text
284
+ return named === 'FC' || named === 'FunctionComponent' ? type.typeArguments?.[0] : undefined
285
+ }
286
+
260
287
  // The file's header comment: the first `/**` above the first thing that is
261
288
  // not an import. It belongs to the file, whatever export happens to follow it.
262
289
  const opening = file.statements.find((statement) => !ts.isImportDeclaration(statement))
263
290
  const headerPos = opening ? ((ts.getLeadingCommentRanges(source, opening.getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**')?.pos ?? null) : null
291
+ // A file that opens with its own comment, above its imports — Peek's and Ship's
292
+ // way — has its header there, so the comment on its first declaration is that
293
+ // declaration's own. Only a file with no such comment lends the first one it has.
294
+ const topPos = file.statements[0] ? ((ts.getLeadingCommentRanges(source, file.statements[0].getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**')?.pos ?? null) : null
295
+ // An app lends no comment to the file but one above its imports: in a file
296
+ // with no imports the first comment sits on the first part, and is the part's.
297
+ const aboveImports = !!file.statements[0] && ts.isImportDeclaration(file.statements[0])
298
+ const refused = lendFirstComment ? (topPos ?? headerPos) : aboveImports ? topPos : null
264
299
 
265
300
  for (const statement of file.statements) {
266
301
  if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) {
267
302
  const bindings = statement.importClause?.namedBindings
268
303
  if (bindings && ts.isNamedImports(bindings)) {
269
- for (const element of bindings.elements) importedFrom.set(element.name.text, statement.moduleSpecifier.text)
304
+ for (const element of bindings.elements) importedFrom.set(element.name.text, { specifier: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text })
270
305
  }
271
306
  }
307
+ // `export type { TopicRowProps } from './TopicRow'` hands a type on without
308
+ // declaring it: a file that asks this one for it is sent on to the next.
309
+ if (ts.isExportDeclaration(statement) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
310
+ for (const element of statement.exportClause.elements) importedFrom.set(element.name.text, { specifier: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text })
311
+ }
272
312
  if (ts.isTypeAliasDeclaration(statement)) aliases.set(statement.name.text, statement.type)
273
313
  if (ts.isInterfaceDeclaration(statement)) {
274
314
  shapes.set(statement.name.text, [...statement.members])
@@ -280,19 +320,49 @@ function readModule(source: string, sibling: Sibling = () => undefined) {
280
320
  }
281
321
  if (ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type)) shapes.set(statement.name.text, [...statement.type.members])
282
322
 
283
- const exported = ts.canHaveModifiers(statement) && ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
284
- if (!exported) continue
285
- const doc = docAbove(source, statement, headerPos)
323
+ // Every top-level function, class and constant, exported or not. The package
324
+ // exports at the declaration, so for it the two are the same; an app also
325
+ // writes `function Row() {…}` and `export { Row }` or `export default Row`
326
+ // further down, and the name is still read here, with its comment and props.
327
+ if (ts.isClassDeclaration(statement)) {
328
+ // `class ErrorBoundary extends Component<Props, State>`: its props are the
329
+ // first type argument of what it extends. `export default class extends …`
330
+ // has no name of its own, and is kept under `default`.
331
+ const base = (statement.heritageClauses ?? []).find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]
332
+ const name = statement.name?.text ?? (ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ? 'default' : null)
333
+ if (name) declarations.set(name, { doc: docAbove(source, statement, refused), deprecated: false, propsType: base?.typeArguments?.[0] })
334
+ continue
335
+ }
336
+ // `export default memo(() => …)` and `export default function () {…}`: a part
337
+ // an app writes as its file's default, with no name of its own. Kept under
338
+ // `default`, with its comment and its props.
339
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals && !ts.isIdentifier(statement.expression)) {
340
+ declarations.set('default', { doc: docAbove(source, statement, refused), deprecated: false, propsType: propsOfInitializer(statement.expression) })
341
+ continue
342
+ }
343
+ if (ts.isFunctionDeclaration(statement) && !statement.name && ts.getModifiers(statement)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) {
344
+ declarations.set('default', { doc: docAbove(source, statement, refused), deprecated: false, propsType: propsTypeOf(statement.parameters) })
345
+ continue
346
+ }
347
+ if (!ts.isFunctionDeclaration(statement) && !ts.isVariableStatement(statement)) continue
348
+ const doc = docAbove(source, statement, refused)
286
349
  const deprecated = /(^|\n)@deprecated\b/.test(doc) || /\*\s*@deprecated\b/.test(source.slice(Math.max(0, statement.getFullStart()), statement.getStart()))
287
350
 
288
351
  if (ts.isFunctionDeclaration(statement) && statement.name) {
352
+ // Overloads: the first signature carries the comment and the props a caller
353
+ // sees; a later one, or the body, only fills in a comment the first lacks.
354
+ const held = declarations.get(statement.name.text)
355
+ if (held) {
356
+ if (!held.doc && doc) held.doc = doc
357
+ continue
358
+ }
289
359
  declarations.set(statement.name.text, { doc, deprecated, propsType: propsTypeOf(statement.parameters) })
290
360
  continue
291
361
  }
292
362
  if (ts.isVariableStatement(statement)) {
293
363
  for (const declaration of statement.declarationList.declarations) {
294
364
  if (!ts.isIdentifier(declaration.name)) continue
295
- declarations.set(declaration.name.text, { doc, deprecated, propsType: propsOfInitializer(declaration.initializer) })
365
+ declarations.set(declaration.name.text, { doc, deprecated, propsType: propsOfInitializer(declaration.initializer) ?? propsOfAnnotation(declaration.type) })
296
366
  }
297
367
  }
298
368
  }
@@ -427,19 +497,34 @@ function readModule(source: string, sibling: Sibling = () => undefined) {
427
497
  // `type ToolbarInputProps = TextInputProps` — an alias, not an interface.
428
498
  const alias = aliases.get(name)
429
499
  if (alias) return resolveNode(alias, seen)
430
- // Not declared here at all. Follow the import, but only into this package.
500
+ // Not declared here at all. Follow the import; the caller says where it may go.
431
501
  const from = importedFrom.get(name)
432
- return (from?.startsWith('./') ? sibling(from.slice(2), name) : undefined) ?? EMPTY
502
+ return (from ? sibling(from.specifier, from.imported) : undefined) ?? EMPTY
433
503
  }
434
504
 
435
505
  /** What one declaration's first parameter takes. */
436
506
  const resolve = (propsType: ts.TypeNode | undefined): Resolved => resolveNode(propsType, new Set())
437
507
 
438
- return { declarations, resolve, resolveName: (name: string) => resolveName(name, new Set()) }
508
+ // The file's own description, for an app's file of one part: the first `/**`
509
+ // at the very top, above the imports — where Peek and Ship write it — or else
510
+ // the one directly above the first thing that is not an import.
511
+ const jsdocAt = (node: ts.Node | undefined) => (node ? (ts.getLeadingCommentRanges(source, node.getFullStart()) ?? []).find((range) => source.slice(range.pos, range.pos + 3) === '/**') : undefined)
512
+ const headerRange = jsdocAt(file.statements[0]) ?? jsdocAt(opening)
513
+ return { declarations, headerDoc: headerRange ? headerComment(source, headerRange) : '', resolve, resolveName: (name: string) => resolveName(name, new Set()) }
514
+ }
515
+
516
+ /** The file's own header comment, cleaned the way `docAbove` cleans a declaration's. */
517
+ function headerComment(source: string, range: ts.CommentRange): string {
518
+ return source
519
+ .slice(range.pos, range.end)
520
+ .split(/\r?\n/)
521
+ .map((line) => line.trim().replace(/^\/\*\*/, '').replace(/\*\/$/, '').replace(/^\*/, '').trim())
522
+ .join('\n')
523
+ .trim()
439
524
  }
440
525
 
441
526
  /** The `title` a stories file gives Storybook, and the stories it exports. */
442
- function readStories(source: string): { title: string | null; stories: string[] } {
527
+ export function readStories(source: string): { title: string | null; stories: string[] } {
443
528
  const file = ts.createSourceFile('x.stories.tsx', source, ts.ScriptTarget.Latest, true)
444
529
  let title: string | null = null
445
530
  const stories: string[] = []
@@ -502,7 +587,7 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
502
587
  // The callback lets one file's props type reach a type declared in another
503
588
  // — `ToolbarButtonProps extends IconButtonProps`. It is only called later,
504
589
  // by which time this module is in the cache, so the recursion terminates.
505
- const read = { ...readModule(readFileSync(join(src, file), 'utf8'), (module, typeName) => moduleOf(module).resolveName(typeName)), file: `src/${file}` }
590
+ const read = { ...readModule(readFileSync(join(src, file), 'utf8'), (specifier, typeName) => (specifier.startsWith('./') ? moduleOf(specifier.slice(2)).resolveName(typeName) : undefined)), file: `src/${file}` }
506
591
  modules.set(name, read)
507
592
  return read
508
593
  }
@@ -555,6 +640,7 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
555
640
  docsId: title ? `${sanitize(title)}--docs` : null,
556
641
  storyId: title && story ? toId(title, story) : null,
557
642
  docPage: pageFile,
643
+ app: null,
558
644
  })
559
645
  }
560
646
 
@@ -565,11 +651,13 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
565
651
  return {
566
652
  schemaVersion: SCHEMA_VERSION,
567
653
  builtFrom: {
654
+ kind: 'package',
568
655
  repo,
569
656
  package: manifest.name,
570
657
  packageVersion: manifest.version,
571
658
  exports: values.length,
572
659
  typeExports: exported.length - values.length,
660
+ files: null,
573
661
  },
574
662
  storybook: {
575
663
  docsPath: '/?path=/docs/{docsId}',
@@ -581,6 +669,7 @@ export function buildRegistry({ root = process.cwd(), repo = 'estiva-ui' }: Buil
581
669
  // hook included, each marked by `kind`. The field stays because the count
582
670
  // has to reconcile out loud — see validateRegistry.
583
671
  excluded: [],
672
+ filesWithoutParts: [],
584
673
  }
585
674
  }
586
675
 
@@ -1,47 +1,59 @@
1
1
  /// <reference types="node" />
2
2
  /**
3
- * `estiva-ui` — the catalogue as a command (UIG-12; docs/GATES.md §23 for why
4
- * it ships in the package rather than as a script pasted into each repo).
3
+ * `estiva-ui` — the catalogue as a command (UIG-12, UIG-13; docs/GATES.md §23
4
+ * for why it ships in the package rather than as a script pasted into each repo).
5
5
  *
6
- * estiva-ui find <words…> [--json] [--limit n] what do we have for this?
7
- * estiva-ui build [--out <path>] write registry.json
8
- * estiva-ui check rebuild and compare; CI runs this
6
+ * estiva-ui find <words…> [--also [name=]<folder>]… [--json] [--limit n]
7
+ * estiva-ui build [--out <path>]
8
+ * estiva-ui check
9
9
  *
10
- * `find` reads the committed `registry.json`: the one in the folder it is run
11
- * from, else the one shipped inside the installed package. So an app can ask
12
- * the question without a checkout of this repo. `build` and `check` read the
13
- * source, so they only mean anything inside the library they describe.
10
+ * Every command takes `--root <folder>` (the library it reads; default: here)
11
+ * and `--repo <name>` (what an app's entries are called; default: its package name).
12
+ *
13
+ * **In the package** (`@estiva-app/ui` itself): `build` writes the committed
14
+ * `registry.json`, and `check` rebuilds it and fails on a single byte of drift.
15
+ *
16
+ * **In an app**: nothing is committed (Katerina, 18 September 2026 — the
17
+ * package is public, the apps are private). `check` builds the app's catalogue
18
+ * and fails when a part has no one-line description or cannot be sorted; CI's
19
+ * `gate` job runs it. `build` writes the catalogue to a file only when asked.
20
+ *
21
+ * `find` searches the package's catalogue — the one shipped inside the installed
22
+ * package, or this repo's own in the package — plus, in an app, the app's own,
23
+ * built fresh, plus each `--also` app that sits beside it.
14
24
  */
15
25
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
16
- import { resolve } from 'node:path'
26
+ import { basename, resolve } from 'node:path'
17
27
  import { fileURLToPath } from 'node:url'
18
- import { findInRegistry, formatFindings } from './find'
19
- import { validateRegistry, type Registry } from './schema'
28
+ import { findInRegistries, formatFindings } from './find'
29
+ import { CLASSES, validateRegistry, type Registry } from './schema'
20
30
 
21
31
  /**
22
- * The builder, loaded only when it is used.
32
+ * The builders, loaded only when they are used.
23
33
  *
24
- * It reads TypeScript with TypeScript, and `typescript` is a dev dependency of
34
+ * They read TypeScript with TypeScript, and `typescript` is a dev dependency of
25
35
  * the app that installs this package — a real one in Peek, Ship and this repo,
26
- * and possibly absent elsewhere. `find` needs none of it: it reads a JSON file.
27
- * A static import made the bundler put the whole builder in the same chunk as
28
- * `find`, so `npx estiva-ui find …` died with ERR_MODULE_NOT_FOUND before
29
- * opening the file it needed.
36
+ * and possibly absent elsewhere. A static import made the bundler put the whole
37
+ * builder in the same chunk as `find`, so `npx estiva-ui find …` died with
38
+ * ERR_MODULE_NOT_FOUND before opening the file it needed.
30
39
  */
31
- const builder = () => import('./build')
40
+ const packageBuilder = () => import('./build')
41
+ const appBuilder = () => import('./app')
32
42
 
33
43
  const [command, ...rest] = process.argv.slice(2)
44
+ const VALUED = ['--limit', '--out', '--file', '--root', '--repo', '--also']
34
45
  const flag = (name: string) => rest.includes(`--${name}`)
35
46
  const value = (name: string) => {
36
47
  const at = rest.indexOf(`--${name}`)
37
48
  return at === -1 ? undefined : rest[at + 1]
38
49
  }
50
+ const values = (name: string) => rest.flatMap((word, i) => (word === `--${name}` && rest[i + 1] !== undefined ? [rest[i + 1]] : []))
39
51
  /** Everything that is not a flag or a flag's value: the words of the question. */
40
52
  const plain = () => {
41
53
  const out: string[] = []
42
54
  for (let i = 0; i < rest.length; i++) {
43
55
  if (rest[i].startsWith('--')) {
44
- if (rest[i] === '--limit' || rest[i] === '--out' || rest[i] === '--file') i += 1
56
+ if (VALUED.includes(rest[i])) i += 1
45
57
  continue
46
58
  }
47
59
  out.push(rest[i])
@@ -49,19 +61,24 @@ const plain = () => {
49
61
  return out
50
62
  }
51
63
 
52
- /** The committed file: where it is run, else the copy inside the installed package. */
53
- function registryPath(): string {
64
+ const root = resolve(value('root') ?? '.')
65
+ const manifestAt = (folder: string) => JSON.parse(readFileSync(resolve(folder, 'package.json'), 'utf8')) as { name?: string }
66
+ /** The package itself, or an app that installs it. */
67
+ const isPackage = existsSync(resolve(root, 'package.json')) && manifestAt(root).name === '@estiva-app/ui'
68
+
69
+ /** The package's committed catalogue: `--file`, this repo's own in the package, else the copy inside the installed package. */
70
+ function packageRegistryPath(): string {
54
71
  const named = value('file')
55
72
  if (named) return resolve(named)
56
- const here = resolve(process.cwd(), 'registry.json')
57
- if (existsSync(here)) return here
58
- // dist/registry/cli.js the package's own root.
73
+ if (isPackage) return resolve(root, 'registry.json')
74
+ // dist/registry/cli.js the package's own root. Never an app's own folder:
75
+ // a registry.json there is the app's, written by `estiva-ui build`.
59
76
  return fileURLToPath(new URL('../../registry.json', import.meta.url))
60
77
  }
61
78
 
62
- function readRegistry(): Registry {
63
- const path = registryPath()
64
- if (!existsSync(path)) throw new Error(`no registry.json at ${path} — run "estiva-ui build" in the library first`)
79
+ function readPackageRegistry(): Registry {
80
+ const path = packageRegistryPath()
81
+ if (!existsSync(path)) throw new Error(`no registry.json at ${path} — run "estiva-ui build" in the package first`)
65
82
  const registry = JSON.parse(readFileSync(path, 'utf8')) as Registry
66
83
  const problems = validateRegistry(registry)
67
84
  // A malformed catalogue is worse than none: it would answer, wrongly.
@@ -69,6 +86,22 @@ function readRegistry(): Registry {
69
86
  return registry
70
87
  }
71
88
 
89
+ async function buildApp(folder: string, repo: string | undefined): Promise<Registry> {
90
+ const { buildAppRegistry } = await appBuilder()
91
+ const registry = buildAppRegistry({ root: folder, repo })
92
+ const problems = validateRegistry(registry)
93
+ if (problems.length) throw new Error(`the catalogue of ${registry.builtFrom.repo} does not match its own schema:\n ${problems.join('\n ')}`)
94
+ return registry
95
+ }
96
+
97
+ /** One line per kind, in a fixed order, so two runs read the same. */
98
+ function summary(registry: Registry): string {
99
+ const count = (cls: string) => registry.entries.filter((entry) => entry.app?.class === cls).length
100
+ const kinds = CLASSES.map((cls) => `${count(cls)} ${cls}`).join(', ')
101
+ const files = registry.builtFrom.files ?? 0
102
+ return `${registry.builtFrom.repo}: ${registry.entries.length} parts in ${files - registry.filesWithoutParts.length} files (${registry.filesWithoutParts.length} more hold none) — ${kinds}. Every part is described and sorted.`
103
+ }
104
+
72
105
  async function main(): Promise<number> {
73
106
  switch (command) {
74
107
  case 'find': {
@@ -77,37 +110,67 @@ async function main(): Promise<number> {
77
110
  process.stderr.write('estiva-ui find <words…> — what the thing you need does, in words\n')
78
111
  return 1
79
112
  }
80
- const registry = readRegistry()
113
+ const registries: Registry[] = [readPackageRegistry()]
114
+ // An app that cannot be read is said out loud, and the rest still answer.
115
+ const add = async (folder: string, repo: string | undefined) => {
116
+ try {
117
+ registries.push(await buildApp(folder, repo))
118
+ } catch (error) {
119
+ const [first, ...more] = String(error instanceof Error ? error.message : error).split('\n')
120
+ process.stderr.write(`not searched: ${first}${more.length ? `\n${more.slice(0, 3).join('\n')}${more.length > 3 ? `\n …and ${more.length - 3} more — run "estiva-ui check" there` : ''}` : ''}\n`)
121
+ }
122
+ }
123
+ if (!isPackage) await add(root, value('repo'))
124
+ for (const also of values('also')) {
125
+ const [name, folder] = also.includes('=') ? [also.slice(0, also.indexOf('=')), also.slice(also.indexOf('=') + 1)] : [undefined, also]
126
+ const at = resolve(root, folder)
127
+ if (!existsSync(resolve(at, 'package.json'))) {
128
+ process.stderr.write(`not searched: ${name ?? basename(at)} — nothing at ${at}\n`)
129
+ continue
130
+ }
131
+ await add(at, name)
132
+ }
81
133
  const limit = Number(value('limit') ?? 5)
82
- const findings = findInRegistry(registry, query, { limit: Number.isFinite(limit) && limit > 0 ? limit : 5 })
134
+ const findings = findInRegistries(registries, query, { limit: Number.isFinite(limit) && limit > 0 ? limit : 5 })
83
135
  if (flag('json')) {
84
136
  process.stdout.write(`${JSON.stringify(findings.map((finding) => finding.entry), null, 2)}\n`)
85
137
  return 0
86
138
  }
87
- process.stdout.write(`${formatFindings(registry, findings, query)}\n`)
139
+ process.stdout.write(`${formatFindings(registries, findings, query)}\n`)
88
140
  // Nothing found is not a failure — it is the answer that sends a session
89
141
  // to ask rather than to invent.
90
142
  return 0
91
143
  }
92
144
 
93
145
  case 'build': {
94
- const { buildRegistry, serializeRegistry } = await builder()
95
- const out = resolve(value('out') ?? 'registry.json')
96
- const registry = buildRegistry()
97
- const problems = validateRegistry(registry)
98
- if (problems.length) {
99
- process.stderr.write(`the registry this build produced does not match its own schema:\n ${problems.join('\n ')}\n`)
100
- return 1
146
+ const out = resolve(root, value('out') ?? 'registry.json')
147
+ if (isPackage) {
148
+ const { buildRegistry, serializeRegistry } = await packageBuilder()
149
+ const registry = buildRegistry({ root })
150
+ const problems = validateRegistry(registry)
151
+ if (problems.length) {
152
+ process.stderr.write(`the registry this build produced does not match its own schema:\n ${problems.join('\n ')}\n`)
153
+ return 1
154
+ }
155
+ writeFileSync(out, serializeRegistry(registry), 'utf8')
156
+ process.stdout.write(`${out}: ${registry.entries.length} entries from ${registry.builtFrom.exports} exports\n`)
157
+ return 0
101
158
  }
159
+ const { serializeRegistry } = await packageBuilder()
160
+ const registry = await buildApp(root, value('repo'))
102
161
  writeFileSync(out, serializeRegistry(registry), 'utf8')
103
- process.stdout.write(`${out}: ${registry.entries.length} entries from ${registry.builtFrom.exports} exports\n`)
162
+ process.stdout.write(`${out}: ${summary(registry)}\n`)
104
163
  return 0
105
164
  }
106
165
 
107
166
  case 'check': {
108
- const { buildRegistry, serializeRegistry } = await builder()
109
- const path = registryPath()
110
- const built = buildRegistry()
167
+ if (!isPackage) {
168
+ process.stdout.write(`${summary(await buildApp(root, value('repo')))}\n`)
169
+ return 0
170
+ }
171
+ const { buildRegistry, serializeRegistry } = await packageBuilder()
172
+ const path = packageRegistryPath()
173
+ const built = buildRegistry({ root })
111
174
  const problems = validateRegistry(built)
112
175
  if (problems.length) {
113
176
  process.stderr.write(`the registry does not match its own schema:\n ${problems.join('\n ')}\n`)
@@ -86,6 +86,18 @@ const hit = (query: string, text: string) => words(text).some((word) => alike(qu
86
86
  * over `MenuPanel`, whose name carries one of them loudly.
87
87
  */
88
88
  export function findInRegistry(registry: Registry, query: string, { limit = 5 }: { limit?: number } = {}): Finding[] {
89
+ return findInRegistries([registry], query, { limit })
90
+ }
91
+
92
+ /**
93
+ * Search several catalogues as one: the package's, the app's own, and any app
94
+ * beside it (UIG-13).
95
+ *
96
+ * An app's pass-on is not a result of its own — it is the package's part, and
97
+ * the package's entry answers for it, saying which apps hand it on. At the
98
+ * same strength the package's part comes first: it is the one to reach for.
99
+ */
100
+ export function findInRegistries(registries: Registry[], query: string, { limit = 5 }: { limit?: number } = {}): Finding[] {
89
101
  const all = words(query)
90
102
  // Drop the words that mean nothing. If the question was only those, keep them
91
103
  // rather than answer nothing at all.
@@ -94,7 +106,8 @@ export function findInRegistry(registry: Registry, query: string, { limit = 5 }:
94
106
  if (asked.length === 0) return []
95
107
 
96
108
  const findings: Finding[] = []
97
- for (const entry of registry.entries) {
109
+ const entries = registries.flatMap((registry) => registry.entries).filter((entry) => entry.app?.class !== 're-export')
110
+ for (const entry of entries) {
98
111
  let score = 0
99
112
  let matched = 0
100
113
  const where = new Set<string>()
@@ -148,7 +161,8 @@ export function findInRegistry(registry: Registry, query: string, { limit = 5 }:
148
161
  if (matched > 0) findings.push({ entry, matched, score, where: [...where], props: [...byName] })
149
162
  }
150
163
 
151
- findings.sort((a, b) => b.matched - a.matched || b.score - a.score || a.entry.name.localeCompare(b.entry.name))
164
+ const fromPackage = (finding: Finding) => (finding.entry.app === null ? 0 : 1)
165
+ findings.sort((a, b) => b.matched - a.matched || b.score - a.score || fromPackage(a) - fromPackage(b) || a.entry.name.localeCompare(b.entry.name))
152
166
  return findings.slice(0, limit)
153
167
  }
154
168
 
@@ -158,22 +172,45 @@ export function docsLink(registry: Registry, entry: RegistryEntry): string | nul
158
172
  return registry.storybook.devUrl + registry.storybook.docsPath.replace('{docsId}', entry.docsId)
159
173
  }
160
174
 
175
+ /** What an app's part is, in the words the search prints. */
176
+ const CLASS_WORDS: Record<string, string> = {
177
+ 'one-off': 'used in one place',
178
+ reusable: 'used in several places',
179
+ 'promote-candidate': 'used in several places; could move into the package',
180
+ unused: 'used nowhere in the app',
181
+ }
182
+
161
183
  /** What the command prints: the import line first, because that is what the reader came for. */
162
- export function formatFindings(registry: Registry, findings: Finding[], query: string): string {
184
+ export function formatFindings(searched: Registry | Registry[], findings: Finding[], query: string): string {
185
+ const registries = Array.isArray(searched) ? searched : [searched]
163
186
  if (findings.length === 0) {
164
187
  return [
165
- `Nothing in ${registry.builtFrom.repo} matches "${query}".`,
188
+ `Nothing in ${registries.map((registry) => registry.builtFrom.repo).join(', ')} matches "${query}".`,
166
189
  'If nothing here does what you need, say so and ask — do not invent a component.',
167
190
  ].join('\n')
168
191
  }
192
+ const registryOf = (entry: RegistryEntry) => registries.find((registry) => registry.builtFrom.repo === entry.repo) ?? registries[0]
193
+ // Which apps hand a package part on, and from where: in Peek, `Button` is
194
+ // imported from `@/components/ui/Button`, and the answer should say so.
195
+ const handedOn = new Map<string, string[]>()
196
+ for (const registry of registries) {
197
+ for (const entry of registry.entries) {
198
+ if (entry.app?.class !== 're-export' || entry.app.handsOn === null) continue
199
+ handedOn.set(entry.app.handsOn, [...(handedOn.get(entry.app.handsOn) ?? []), `${entry.repo} hands it on${entry.name === entry.app.handsOn ? '' : ` as ${entry.name}`} from ${entry.importPath}`])
200
+ }
201
+ }
169
202
 
170
203
  const blocks = findings.map((finding) => {
171
204
  const { entry } = finding
205
+ const facts = entry.app
172
206
  const lines = [
173
- `${entry.name} · ${entry.kind} · ${entry.repo} (matched ${finding.where.join(', ')})`,
207
+ `${entry.name} · ${facts ? CLASS_WORDS[facts.class] ?? facts.class : entry.kind} · ${entry.repo} (matched ${finding.where.join(', ')})`,
174
208
  ` ${entry.purpose}`,
175
- ` import { ${entry.name} } from '${entry.importPath}'`,
209
+ facts?.defaultExport ? ` import ${entry.name} from '${entry.importPath}'` : ` import { ${entry.name} } from '${entry.importPath}'`,
176
210
  ]
211
+ if (!facts) for (const said of handedOn.get(entry.name) ?? []) lines.push(` ${said}`)
212
+ if (facts?.packageNamesake) lines.push(` the package has a ${facts.packageNamesake} too: check it first`)
213
+ if (facts && facts.usedIn.length) lines.push(` used in ${facts.usedIn.slice(0, 2).join(', ')}${facts.usedIn.length > 2 ? ` and ${facts.usedIn.length - 2} more` : ''}`)
177
214
  if (entry.ownsBehaviours.length) lines.push(` owns: ${entry.ownsBehaviours.map((owned) => owned.behaviour).join(' · ')}`)
178
215
  if (entry.variants.length) lines.push(` ${entry.variants.map((variant) => `${variant.prop}: ${variant.values.join(' | ')}`).join(' ')}`)
179
216
  // The props the question named, with their own line — not all of them. A
@@ -185,8 +222,8 @@ export function formatFindings(registry: Registry, findings: Finding[], query: s
185
222
  for (const prop of shown) {
186
223
  if (prop) lines.push(` ${prop.name}: ${prop.takes}${prop.note ? ` — ${prop.note}` : ''}`)
187
224
  }
188
- if (entry.props.length) lines.push(` ${entry.props.length} props in all; see the page for the rest`)
189
- const link = docsLink(registry, entry)
225
+ if (entry.props.length) lines.push(` ${entry.props.length} props in all; see ${entry.docsId ? 'the page' : entry.sourceFile} for the rest`)
226
+ const link = docsLink(registryOf(entry), entry)
190
227
  if (link) lines.push(` ${link}`)
191
228
  return lines.join('\n')
192
229
  })
@@ -7,13 +7,20 @@
7
7
  *
8
8
  * The data itself is `registry.json` at the package's root, committed and
9
9
  * shipped. Read it with `estiva-ui find`, or import these to read it yourself.
10
+ * An app's own catalogue (UIG-13) is built from its code with
11
+ * `buildAppRegistry` each time it is read, and never committed.
10
12
  */
11
13
  export { buildRegistry, readIndexExports, serializeRegistry, type BuildOptions } from './build'
12
- export { docsLink, findInRegistry, formatFindings, type Finding } from './find'
14
+ export { buildAppRegistry, type AppBuildOptions } from './app'
15
+ export { docsLink, findInRegistries, findInRegistry, formatFindings, type Finding } from './find'
13
16
  export {
17
+ CLASSES,
14
18
  SCHEMA_VERSION,
15
19
  validateRegistry,
20
+ type AppFacts,
16
21
  type EntryBehaviour,
22
+ type EntryClass,
23
+ type FileWithoutPart,
17
24
  type EntryKind,
18
25
  type EntryStatus,
19
26
  type EntryVariant,