@estiva-app/ui 0.21.1 → 0.23.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 (108) hide show
  1. package/README.md +21 -0
  2. package/dist/Avatar.d.ts +3 -0
  3. package/dist/Avatar.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/ChipInput.d.ts +3 -0
  7. package/dist/ChipInput.d.ts.map +1 -1
  8. package/dist/CommandPalette.d.ts +6 -0
  9. package/dist/CommandPalette.d.ts.map +1 -1
  10. package/dist/ContainerHeader.d.ts +25 -0
  11. package/dist/ContainerHeader.d.ts.map +1 -0
  12. package/dist/EmptyState.d.ts +10 -1
  13. package/dist/EmptyState.d.ts.map +1 -1
  14. package/dist/IdentityMenu.d.ts +3 -0
  15. package/dist/IdentityMenu.d.ts.map +1 -1
  16. package/dist/Menu.d.ts +7 -0
  17. package/dist/Menu.d.ts.map +1 -1
  18. package/dist/Skeleton.d.ts +2 -0
  19. package/dist/Skeleton.d.ts.map +1 -1
  20. package/dist/Toast.d.ts +5 -0
  21. package/dist/Toast.d.ts.map +1 -1
  22. package/dist/Toolbar.d.ts +4 -0
  23. package/dist/Toolbar.d.ts.map +1 -1
  24. package/dist/Tooltip.d.ts +4 -0
  25. package/dist/Tooltip.d.ts.map +1 -1
  26. package/dist/gates/app-checks.d.ts.map +1 -1
  27. package/dist/gates/{chunk-ZGJ2J5NU.js → chunk-EA33NP5B.js} +275 -11
  28. package/dist/gates/chunk-EA33NP5B.js.map +7 -0
  29. package/dist/gates/{chunk-AUXD4GCY.js → chunk-GTQZEHPC.js} +15 -1
  30. package/dist/gates/chunk-GTQZEHPC.js.map +7 -0
  31. package/dist/gates/cli.js +1 -1
  32. package/dist/gates/create-app.d.ts +2 -0
  33. package/dist/gates/create-app.d.ts.map +1 -1
  34. package/dist/gates/create-app.js +1 -1
  35. package/dist/gates/index.js +25 -3
  36. package/dist/gates/index.js.map +2 -2
  37. package/dist/gates/status.d.ts +2 -0
  38. package/dist/gates/status.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +69 -48
  42. package/dist/index.js.map +4 -4
  43. package/dist/registry/app-DQI6WLHR.js +10 -0
  44. package/dist/registry/app-DQI6WLHR.js.map +7 -0
  45. package/dist/registry/app.d.ts +21 -0
  46. package/dist/registry/app.d.ts.map +1 -0
  47. package/dist/registry/build-LGFCCOLR.js +27 -0
  48. package/dist/registry/build-LGFCCOLR.js.map +7 -0
  49. package/dist/registry/build.d.ts +95 -0
  50. package/dist/registry/build.d.ts.map +1 -0
  51. package/dist/registry/chunk-E4JNV7PC.js +155 -0
  52. package/dist/registry/chunk-E4JNV7PC.js.map +7 -0
  53. package/dist/registry/chunk-NJN4MQAM.js +397 -0
  54. package/dist/registry/chunk-NJN4MQAM.js.map +7 -0
  55. package/dist/registry/chunk-W2B2G7OE.js +188 -0
  56. package/dist/registry/chunk-W2B2G7OE.js.map +7 -0
  57. package/dist/registry/chunk-WMFF3MPP.js +648 -0
  58. package/dist/registry/chunk-WMFF3MPP.js.map +7 -0
  59. package/dist/registry/cli.d.ts +2 -0
  60. package/dist/registry/cli.d.ts.map +1 -0
  61. package/dist/registry/cli.js +176 -0
  62. package/dist/registry/cli.js.map +7 -0
  63. package/dist/registry/find.d.ts +54 -0
  64. package/dist/registry/find.d.ts.map +1 -0
  65. package/dist/registry/index.d.ts +17 -0
  66. package/dist/registry/index.d.ts.map +1 -0
  67. package/dist/registry/index.js +34 -0
  68. package/dist/registry/index.js.map +7 -0
  69. package/dist/registry/schema.d.ts +224 -0
  70. package/dist/registry/schema.d.ts.map +1 -0
  71. package/package.json +15 -2
  72. package/registry.json +4615 -0
  73. package/src/Avatar.tsx +3 -0
  74. package/src/Banner.mdx +17 -4
  75. package/src/Banner.stories.tsx +22 -0
  76. package/src/Banner.test.tsx +35 -0
  77. package/src/Banner.tsx +34 -9
  78. package/src/ChipInput.tsx +3 -0
  79. package/src/CommandPalette.tsx +6 -0
  80. package/src/ContainerHeader.mdx +56 -0
  81. package/src/ContainerHeader.stories.tsx +62 -0
  82. package/src/ContainerHeader.test.tsx +47 -0
  83. package/src/ContainerHeader.tsx +45 -0
  84. package/src/EmptyState.mdx +12 -0
  85. package/src/EmptyState.stories.tsx +6 -1
  86. package/src/EmptyState.test.tsx +18 -1
  87. package/src/EmptyState.tsx +14 -1
  88. package/src/IdentityMenu.tsx +3 -0
  89. package/src/Menu.tsx +7 -0
  90. package/src/Skeleton.tsx +2 -0
  91. package/src/Toast.tsx +5 -0
  92. package/src/Toolbar.tsx +4 -0
  93. package/src/Tooltip.tsx +4 -0
  94. package/src/gates/app-checks.ts +23 -1
  95. package/src/gates/create-app.test.ts +77 -6
  96. package/src/gates/create-app.ts +288 -15
  97. package/src/gates/status.ts +16 -0
  98. package/src/index.ts +1 -0
  99. package/src/registry/app.test.ts +562 -0
  100. package/src/registry/app.ts +854 -0
  101. package/src/registry/build.ts +679 -0
  102. package/src/registry/cli.ts +204 -0
  103. package/src/registry/find.ts +232 -0
  104. package/src/registry/index.ts +31 -0
  105. package/src/registry/registry.test.ts +448 -0
  106. package/src/registry/schema.ts +419 -0
  107. package/dist/gates/chunk-AUXD4GCY.js.map +0 -7
  108. package/dist/gates/chunk-ZGJ2J5NU.js.map +0 -7
@@ -0,0 +1,204 @@
1
+ /// <reference types="node" />
2
+ /**
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
+ *
6
+ * estiva-ui find <words…> [--also [name=]<folder>]… [--json] [--limit n]
7
+ * estiva-ui build [--out <path>]
8
+ * estiva-ui check
9
+ *
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.
24
+ */
25
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
26
+ import { basename, resolve } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+ import { findInRegistries, formatFindings } from './find'
29
+ import { CLASSES, validateRegistry, type Registry } from './schema'
30
+
31
+ /**
32
+ * The builders, loaded only when they are used.
33
+ *
34
+ * They read TypeScript with TypeScript, and `typescript` is a dev dependency of
35
+ * the app that installs this package — a real one in Peek, Ship and this repo,
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.
39
+ */
40
+ const packageBuilder = () => import('./build')
41
+ const appBuilder = () => import('./app')
42
+
43
+ const [command, ...rest] = process.argv.slice(2)
44
+ const VALUED = ['--limit', '--out', '--file', '--root', '--repo', '--also']
45
+ const flag = (name: string) => rest.includes(`--${name}`)
46
+ const value = (name: string) => {
47
+ const at = rest.indexOf(`--${name}`)
48
+ return at === -1 ? undefined : rest[at + 1]
49
+ }
50
+ const values = (name: string) => rest.flatMap((word, i) => (word === `--${name}` && rest[i + 1] !== undefined ? [rest[i + 1]] : []))
51
+ /** Everything that is not a flag or a flag's value: the words of the question. */
52
+ const plain = () => {
53
+ const out: string[] = []
54
+ for (let i = 0; i < rest.length; i++) {
55
+ if (rest[i].startsWith('--')) {
56
+ if (VALUED.includes(rest[i])) i += 1
57
+ continue
58
+ }
59
+ out.push(rest[i])
60
+ }
61
+ return out
62
+ }
63
+
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 {
71
+ const named = value('file')
72
+ if (named) return resolve(named)
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`.
76
+ return fileURLToPath(new URL('../../registry.json', import.meta.url))
77
+ }
78
+
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`)
82
+ const registry = JSON.parse(readFileSync(path, 'utf8')) as Registry
83
+ const problems = validateRegistry(registry)
84
+ // A malformed catalogue is worse than none: it would answer, wrongly.
85
+ if (problems.length) throw new Error(`${path} does not match the schema:\n ${problems.join('\n ')}`)
86
+ return registry
87
+ }
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
+
105
+ async function main(): Promise<number> {
106
+ switch (command) {
107
+ case 'find': {
108
+ const query = plain().join(' ')
109
+ if (!query.trim()) {
110
+ process.stderr.write('estiva-ui find <words…> — what the thing you need does, in words\n')
111
+ return 1
112
+ }
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
+ }
133
+ const limit = Number(value('limit') ?? 5)
134
+ const findings = findInRegistries(registries, query, { limit: Number.isFinite(limit) && limit > 0 ? limit : 5 })
135
+ if (flag('json')) {
136
+ process.stdout.write(`${JSON.stringify(findings.map((finding) => finding.entry), null, 2)}\n`)
137
+ return 0
138
+ }
139
+ process.stdout.write(`${formatFindings(registries, findings, query)}\n`)
140
+ // Nothing found is not a failure — it is the answer that sends a session
141
+ // to ask rather than to invent.
142
+ return 0
143
+ }
144
+
145
+ case 'build': {
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
158
+ }
159
+ const { serializeRegistry } = await packageBuilder()
160
+ const registry = await buildApp(root, value('repo'))
161
+ writeFileSync(out, serializeRegistry(registry), 'utf8')
162
+ process.stdout.write(`${out}: ${summary(registry)}\n`)
163
+ return 0
164
+ }
165
+
166
+ case 'check': {
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 })
174
+ const problems = validateRegistry(built)
175
+ if (problems.length) {
176
+ process.stderr.write(`the registry does not match its own schema:\n ${problems.join('\n ')}\n`)
177
+ return 1
178
+ }
179
+ if (!existsSync(path)) {
180
+ process.stderr.write(`${path} is missing — run "npm run registry"\n`)
181
+ return 1
182
+ }
183
+ // Byte for byte. The point of the check is that the committed file cannot
184
+ // drift from the code; "near enough" is drift.
185
+ if (readFileSync(path, 'utf8').replace(/\r\n/g, '\n') !== serializeRegistry(built)) {
186
+ process.stderr.write(`${path} is not what the code produces — run "npm run registry" and commit the result\n`)
187
+ return 1
188
+ }
189
+ process.stdout.write(`${path}: current, ${built.entries.length} entries from ${built.builtFrom.exports} exports\n`)
190
+ return 0
191
+ }
192
+
193
+ default:
194
+ process.stderr.write('estiva-ui <find | build | check>\n')
195
+ return 1
196
+ }
197
+ }
198
+
199
+ try {
200
+ process.exitCode = await main()
201
+ } catch (error) {
202
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
203
+ process.exitCode = 1
204
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The catalogue's reader (UIG-12): `ui:find <words>`.
3
+ *
4
+ * It exists to prove the format. A schema nobody consumes is a schema nobody
5
+ * has tested, and the questions it has to answer are regression tests, not the
6
+ * scope: "floating panel" must find `Popover`, "scrolling" `ScrollArea`,
7
+ * "empty" `EmptyState`.
8
+ *
9
+ * It is also the answer to "what do we have for this?", which is the question
10
+ * the wall cannot answer: a lint rule refuses the wrong thing, and this is
11
+ * where the right one is named (UIG-20 makes a session ask it first).
12
+ */
13
+ import type { Registry, RegistryEntry } from './schema'
14
+
15
+ export interface Finding {
16
+ entry: RegistryEntry
17
+ /** How many of the query's words matched anywhere. Ranked on this first. */
18
+ matched: number
19
+ score: number
20
+ /** Which fields matched, for the reader to say why. */
21
+ where: string[]
22
+ /**
23
+ * The props the query named **by name**, so the answer shows those rather
24
+ * than all of them. A prop whose note merely carries one of the words counts
25
+ * towards the score and is not shown: on "floating panel", six of `Popover`'s
26
+ * notes say "panel", and printing all six buries the answer.
27
+ */
28
+ props: string[]
29
+ }
30
+
31
+ /**
32
+ * Words that are in nearly every sentence, and so tell a search nothing.
33
+ *
34
+ * Measured: `ui:find "truncate a long link"` printed `Link`'s `external` prop,
35
+ * because its note says "in **a** new tab" and the one-letter word matched
36
+ * itself exactly. The minimum length in `alike` only guards a *prefix* match;
37
+ * an exact match short-circuits it, which is right for `tab` and useless for `a`.
38
+ */
39
+ const STOP = new Set([
40
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'can', 'do', 'for', 'from', 'how', 'i', 'if', 'in', 'is', 'it', 'its', 'me', 'my', 'no', 'not', 'of',
41
+ 'on', 'or', 'our', 'so', 'that', 'the', 'their', 'them', 'then', 'there', 'they', 'this', 'to', 'up', 'was', 'we', 'what', 'when', 'which', 'with', 'you', 'your',
42
+ ])
43
+
44
+ /**
45
+ * The words of a piece of text, with names taken apart.
46
+ *
47
+ * `CommandPalette` is kept whole *and* split, so "palette" reaches it and
48
+ * "commandpalette" still does. Prose is already words; this costs it nothing.
49
+ */
50
+ function words(text: string): string[] {
51
+ const found: string[] = []
52
+ for (const token of text.match(/[A-Za-z0-9]+/g) ?? []) {
53
+ found.push(token.toLowerCase())
54
+ const parts = token.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)
55
+ if (parts.length > 1) found.push(...parts.map((part) => part.toLowerCase()))
56
+ }
57
+ return found
58
+ }
59
+
60
+ /**
61
+ * Whether a word of the query means a word of the text.
62
+ *
63
+ * Either being a prefix of the other covers "scroll" against "scrolling"; the
64
+ * five-letter key covers "scrolls" against "scrolling", where neither is. Crude
65
+ * on purpose — a stemmer is a dependency and a surprise, and the failure here
66
+ * is an extra row in a list of five, not a wrong answer.
67
+ *
68
+ * **Three letters at least**, or a prefix match is no match at all: `person's`
69
+ * puts the word "s" in the text, and every query on earth starts with some
70
+ * letter. Measured — before this, "scrolling" found `Avatar`.
71
+ */
72
+ function alike(query: string, text: string): boolean {
73
+ if (query === text) return true
74
+ if (Math.min(query.length, text.length) < 3) return false
75
+ if (text.startsWith(query) || query.startsWith(text)) return true
76
+ return query.slice(0, 5) === text.slice(0, 5)
77
+ }
78
+
79
+ const hit = (query: string, text: string) => words(text).some((word) => alike(query, word))
80
+
81
+ /**
82
+ * Search name, purpose, behaviours and variants.
83
+ *
84
+ * A row that answers more of the question comes first, whatever it scored:
85
+ * "floating panel" has to reach `Popover`, whose purpose carries both words,
86
+ * over `MenuPanel`, whose name carries one of them loudly.
87
+ */
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[] {
101
+ const all = words(query)
102
+ // Drop the words that mean nothing. If the question was only those, keep them
103
+ // rather than answer nothing at all.
104
+ const carrying = all.filter((word) => word.length >= 3 && !STOP.has(word))
105
+ const asked = carrying.length ? carrying : all
106
+ if (asked.length === 0) return []
107
+
108
+ const findings: Finding[] = []
109
+ const entries = registries.flatMap((registry) => registry.entries).filter((entry) => entry.app?.class !== 're-export')
110
+ for (const entry of entries) {
111
+ let score = 0
112
+ let matched = 0
113
+ const where = new Set<string>()
114
+ const byName = new Set<string>()
115
+ const byNote = new Set<string>()
116
+ for (const word of asked) {
117
+ let best = 0
118
+ if (entry.name.toLowerCase() === word) {
119
+ best = 100
120
+ where.add('name')
121
+ } else if (hit(word, entry.name)) {
122
+ best = 40
123
+ where.add('name')
124
+ }
125
+ if (hit(word, entry.purpose)) {
126
+ best = Math.max(best, 20)
127
+ where.add('purpose')
128
+ }
129
+ for (const owned of entry.ownsBehaviours) {
130
+ if (hit(word, owned.behaviour) || hit(word, owned.id)) {
131
+ best = Math.max(best, 15)
132
+ where.add('behaviour')
133
+ }
134
+ }
135
+ for (const variant of entry.variants) {
136
+ if (variant.values.some((value) => hit(word, value)) || hit(word, variant.prop)) {
137
+ best = Math.max(best, 8)
138
+ where.add('variant')
139
+ byName.add(variant.prop)
140
+ }
141
+ }
142
+ // What it can do. "does Link already truncate?" is the question this
143
+ // answers, and answering it is what stops the thing being built twice.
144
+ for (const prop of entry.props) {
145
+ // A prop the question named by name is the answer; one whose note
146
+ // happens to carry the word is a hint. Named ones go first, so a short
147
+ // answer shows the right lines.
148
+ if (hit(word, prop.name)) {
149
+ best = Math.max(best, 14)
150
+ where.add('prop')
151
+ byName.add(prop.name)
152
+ } else if (prop.note !== null && hit(word, prop.note)) {
153
+ best = Math.max(best, 10)
154
+ where.add('prop')
155
+ byNote.add(prop.name)
156
+ }
157
+ }
158
+ if (best > 0) matched += 1
159
+ score += best
160
+ }
161
+ if (matched > 0) findings.push({ entry, matched, score, where: [...where], props: [...byName] })
162
+ }
163
+
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))
166
+ return findings.slice(0, limit)
167
+ }
168
+
169
+ /** `http://localhost:6008/?path=/docs/overlays-popover--docs`, or `null` where the entry has no page. */
170
+ export function docsLink(registry: Registry, entry: RegistryEntry): string | null {
171
+ if (!entry.docsId) return null
172
+ return registry.storybook.devUrl + registry.storybook.docsPath.replace('{docsId}', entry.docsId)
173
+ }
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
+
183
+ /** What the command prints: the import line first, because that is what the reader came for. */
184
+ export function formatFindings(searched: Registry | Registry[], findings: Finding[], query: string): string {
185
+ const registries = Array.isArray(searched) ? searched : [searched]
186
+ if (findings.length === 0) {
187
+ return [
188
+ `Nothing in ${registries.map((registry) => registry.builtFrom.repo).join(', ')} matches "${query}".`,
189
+ 'If nothing here does what you need, say so and ask — do not invent a component.',
190
+ ].join('\n')
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
+ }
202
+
203
+ const blocks = findings.map((finding) => {
204
+ const { entry } = finding
205
+ const facts = entry.app
206
+ const lines = [
207
+ `${entry.name} · ${facts ? CLASS_WORDS[facts.class] ?? facts.class : entry.kind} · ${entry.repo} (matched ${finding.where.join(', ')})`,
208
+ ` ${entry.purpose}`,
209
+ facts?.defaultExport ? ` import ${entry.name} from '${entry.importPath}'` : ` import { ${entry.name} } from '${entry.importPath}'`,
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` : ''}`)
214
+ if (entry.ownsBehaviours.length) lines.push(` owns: ${entry.ownsBehaviours.map((owned) => owned.behaviour).join(' · ')}`)
215
+ if (entry.variants.length) lines.push(` ${entry.variants.map((variant) => `${variant.prop}: ${variant.values.join(' | ')}`).join(' ')}`)
216
+ // The props the question named, with their own line — not all of them. A
217
+ // component with twenty props would bury its own answer.
218
+ const shown = finding.props
219
+ .filter((name) => !entry.variants.some((variant) => variant.prop === name))
220
+ .slice(0, 3)
221
+ .map((name) => entry.props.find((prop) => prop.name === name))
222
+ for (const prop of shown) {
223
+ if (prop) lines.push(` ${prop.name}: ${prop.takes}${prop.note ? ` — ${prop.note}` : ''}`)
224
+ }
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)
227
+ if (link) lines.push(` ${link}`)
228
+ return lines.join('\n')
229
+ })
230
+
231
+ return blocks.join('\n\n')
232
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `@estiva-app/ui/registry` — the component catalogue (UIG-12, seam S2).
3
+ *
4
+ * The one copy, here, as every gate piece is (docs/GATES.md §23): the skill
5
+ * (UIG-20), the copy detector (UIG-25) and the merged search (UIG-19) read
6
+ * this, and no repo keeps a parser of its own.
7
+ *
8
+ * The data itself is `registry.json` at the package's root, committed and
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.
12
+ */
13
+ export { buildRegistry, readIndexExports, serializeRegistry, type BuildOptions } from './build'
14
+ export { buildAppRegistry, type AppBuildOptions } from './app'
15
+ export { docsLink, findInRegistries, findInRegistry, formatFindings, type Finding } from './find'
16
+ export {
17
+ CLASSES,
18
+ SCHEMA_VERSION,
19
+ validateRegistry,
20
+ type AppFacts,
21
+ type EntryBehaviour,
22
+ type EntryClass,
23
+ type FileWithoutPart,
24
+ type EntryKind,
25
+ type EntryStatus,
26
+ type EntryVariant,
27
+ type PurposeSource,
28
+ type Registry,
29
+ type RegistryEntry,
30
+ type RegistryExclusion,
31
+ } from './schema'