@estiva-app/ui 0.28.0 → 0.30.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 (55) hide show
  1. package/README.md +5 -7
  2. package/dist/cn.d.ts.map +1 -1
  3. package/dist/gates/app-checks.d.ts.map +1 -1
  4. package/dist/gates/{chunk-XE6HC5MJ.js → chunk-JZE7TIQY.js} +9 -2
  5. package/dist/gates/chunk-JZE7TIQY.js.map +7 -0
  6. package/dist/gates/{chunk-I7CJLUPM.js → chunk-XAPFC37F.js} +73 -6
  7. package/dist/gates/chunk-XAPFC37F.js.map +7 -0
  8. package/dist/gates/chunk-YKZXKVR2.js +39 -0
  9. package/dist/gates/chunk-YKZXKVR2.js.map +7 -0
  10. package/dist/gates/cli.js +2 -1
  11. package/dist/gates/cli.js.map +1 -1
  12. package/dist/gates/create-app.d.ts.map +1 -1
  13. package/dist/gates/create-app.js +2 -1
  14. package/dist/gates/create-app.js.map +1 -1
  15. package/dist/gates/hook.d.ts +7 -1
  16. package/dist/gates/hook.d.ts.map +1 -1
  17. package/dist/gates/index.js +26 -7
  18. package/dist/gates/index.js.map +2 -2
  19. package/dist/gates/status.d.ts +6 -0
  20. package/dist/gates/status.d.ts.map +1 -1
  21. package/dist/gates/token-lint.d.ts.map +1 -1
  22. package/dist/index.js.map +2 -2
  23. package/dist/registry/{chunk-6OH3TWGF.js → chunk-KMZIMB3G.js} +5 -2
  24. package/dist/registry/{chunk-6OH3TWGF.js.map → chunk-KMZIMB3G.js.map} +2 -2
  25. package/dist/registry/cli.js +187 -25
  26. package/dist/registry/cli.js.map +4 -4
  27. package/dist/registry/find.d.ts +5 -0
  28. package/dist/registry/find.d.ts.map +1 -1
  29. package/dist/registry/index.js +1 -1
  30. package/dist/registry/siblings.d.ts +21 -0
  31. package/dist/registry/siblings.d.ts.map +1 -0
  32. package/dist/registry/skill.d.ts +20 -0
  33. package/dist/registry/skill.d.ts.map +1 -0
  34. package/package.json +4 -2
  35. package/registry.json +1 -1
  36. package/skill/estiva-ui.md +128 -0
  37. package/src/ScrollArea.mdx +8 -4
  38. package/src/ScrollArea.stories.tsx +6 -6
  39. package/src/cn.ts +1 -0
  40. package/src/gates/app-checks.ts +15 -2
  41. package/src/gates/create-app.test.ts +8 -1
  42. package/src/gates/create-app.ts +5 -0
  43. package/src/gates/gates.test.ts +46 -0
  44. package/src/gates/hook.ts +69 -5
  45. package/src/gates/status.ts +25 -0
  46. package/src/gates/token-lint.ts +5 -0
  47. package/src/registry/app.test.ts +58 -1
  48. package/src/registry/cli.ts +60 -8
  49. package/src/registry/find.ts +10 -1
  50. package/src/registry/siblings.test.ts +85 -0
  51. package/src/registry/siblings.ts +96 -0
  52. package/src/registry/skill.test.ts +96 -0
  53. package/src/registry/skill.ts +117 -0
  54. package/dist/gates/chunk-I7CJLUPM.js.map +0 -7
  55. package/dist/gates/chunk-XE6HC5MJ.js.map +0 -7
@@ -89,6 +89,7 @@ const WORDS = {
89
89
  type: '$0 is type written by hand. A token from the package sets the size, line height, letter spacing and weight together (text-body-2, text-caption, text-h5...); if none fits, add one to the package, or say why not in an eslint-disable comment.',
90
90
  corner: '$0 is a corner written by hand, and no token has it. Use a corner from the package (rounded-sm 4px, rounded-md 6px, rounded-lg 8px...), or say why not in an eslint-disable comment.',
91
91
  shadow: '$0 is a shadow written by hand. Use a shadow from the package (shadow-sm, shadow-md, shadow-focus-ring, drop-shadow-glow-success...); a shadow with no token is a missing token.',
92
+ merge: "Merge classes with the package's cn(), which the app re-exports. A plain tailwind-merge does not know the type ramp, and drops text-body-2 beside a text colour.",
92
93
  },
93
94
  package: {
94
95
  ramp: "Tailwind's type ramp is not a token. Use the ramp in tailwind-preset.js: text-body-2, text-caption, text-h3, text-btn-default...",
@@ -98,6 +99,7 @@ const WORDS = {
98
99
  type: '$0 is type written by hand. A token from tailwind-preset.js sets the size, line height, letter spacing and weight together (text-body-2, text-caption, text-h5...); if none fits, add one to the preset and to cn.ts, or say why not in an eslint-disable comment.',
99
100
  corner: '$0 is a corner written by hand, and no token has it. Use a corner from tailwind-preset.js (rounded-sm 4px, rounded-md 6px, rounded-lg 8px...), or say why not in an eslint-disable comment.',
100
101
  shadow: '$0 is a shadow written by hand. Use a shadow from tailwind-preset.js (shadow-sm, shadow-md, shadow-focus-ring, drop-shadow-glow-success...); a shadow with no token is a missing token.',
102
+ merge: "Merge classes with this package's cn() (src/cn.ts), the one place tailwind-merge is taught the type ramp. A plain tailwind-merge drops text-body-2 beside a text colour.",
101
103
  },
102
104
  } satisfies Record<TokenAudience, Record<string, string>>
103
105
 
@@ -140,6 +142,9 @@ export function tokenLint({ tailwindConfig = 'tailwind.config.js', audience = 'a
140
142
  ],
141
143
  },
142
144
  ],
145
+ // UIG-21: "use the package's cn(), never a plain twMerge" was a sentence
146
+ // in all three repos' CLAUDE.md, and nothing checked it.
147
+ 'no-restricted-imports': ['error', { paths: [{ name: 'tailwind-merge', message: words.merge }] }],
143
148
  },
144
149
  }
145
150
  }
@@ -526,6 +526,25 @@ describe('one search over the package and the apps', () => {
526
526
  expect(found.indexOf('estiva-ui:EmptyState')).toBeLessThan(found.indexOf('fixture:EmptyState'))
527
527
  })
528
528
 
529
+ it("puts a package part whose name carries a word before an app's part answering as many (UIG-20)", () => {
530
+ // "panel header" put an app's own `Header` above the package's `ContainerHeader`: its whole name is one of the words.
531
+ const header = buildAppRegistry({
532
+ root: app({ 'src/Header.tsx': '/** The top row of a panel: its title and its buttons. */\nexport function Header() {\n return <header />\n}\n' }),
533
+ repo: 'fixture',
534
+ })
535
+ const found = findInRegistries([packageRegistry, header], 'panel header', { limit: 5 }).map((f) => `${f.entry.repo}:${f.entry.name}`)
536
+ expect(found[0]).toBe('estiva-ui:ContainerHeader')
537
+ expect(found).toContain('fixture:Header')
538
+ })
539
+
540
+ it("does not let a package part the words reach only through its notes bury the app's answer", () => {
541
+ const composer = buildAppRegistry({
542
+ root: app({ 'src/Composer.tsx': '/** Where a comment is written and sent. */\nexport function Composer() {\n return <form />\n}\n' }),
543
+ repo: 'fixture',
544
+ })
545
+ expect(findInRegistries([packageRegistry, composer], 'comment composer')[0].entry).toMatchObject({ repo: 'fixture', name: 'Composer' })
546
+ })
547
+
529
548
  it('says which apps hand a package part on, and how a default is imported', () => {
530
549
  const text = formatFindings([packageRegistry, registry], findInRegistries([packageRegistry, registry], 'Button', { limit: 1 }), 'Button')
531
550
  expect(text).toContain('fixture hands it on from @/components/ui/Button')
@@ -554,10 +573,48 @@ describe.skipIf(!existsSync(cli))('estiva-ui in an app', () => {
554
573
  }
555
574
  expect(failed.stdout).toMatch(/^fixture: 17 parts in 15 files \(2 more hold none\)/)
556
575
  expect(failed.stderr).toContain('Timeline (reusable, src/components/Timeline.tsx) has no usage page: write src/components/Timeline.mdx')
557
- const one = app({ 'src/One.tsx': '/** One, drawn once. */\nexport function One() {\n return <i />\n}\n' })
576
+ const one = app({ 'src/One.tsx': '/** One, drawn once. */\nexport function One() {\n return <i />\n}\n', 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
577
+ run(['skill'], one)
558
578
  expect(run(['check'], one)).toContain('usage pages: all 0 reusable parts keep the contract')
559
579
  })
560
580
 
581
+ it('skill writes a loader that reads the installed package, and check fails when it is missing or differs (UIG-20)', () => {
582
+ const two = app({ 'src/Two.tsx': '/** Two, drawn once. */\nexport function Two() {\n return <i />\n}\n', 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
583
+ const failure = (cwd: string) => {
584
+ try {
585
+ run(['check'], cwd)
586
+ } catch (error) {
587
+ return (error as { stderr?: string }).stderr ?? ''
588
+ }
589
+ return ''
590
+ }
591
+ expect(failure(two)).toContain('.claude/skills/estiva-ui/SKILL.md is missing: run "estiva-ui skill" and commit it')
592
+ run(['skill'], two)
593
+ const loader = readFileSync(join(two, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), 'utf8')
594
+ expect(loader).toMatch(/^---\nname: estiva-ui\ndescription: "Use before building/)
595
+ expect(loader).toContain('!`cat "${CLAUDE_PROJECT_DIR}/node_modules/@estiva-app/ui/skill/estiva-ui.md"`')
596
+ expect(JSON.parse(readFileSync(join(two, '.claude', 'settings.json'), 'utf8')).permissions.allow).toContain('Bash(npm run ui:find *)')
597
+ // Its text is the package's, never a copy.
598
+ expect(loader).not.toContain('# The skill')
599
+ expect(run(['check'], two)).toContain('claude skill: the loader reads the package’s text')
600
+ writeFileSync(join(two, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), `${loader}\nA line of our own.\n`)
601
+ expect(failure(two)).toContain('SKILL.md is not what the package writes')
602
+ })
603
+
604
+ it('skill --out writes a loader for a folder that holds the repositories, reading an app inside it', () => {
605
+ const holder = mkdtempSync(join(tmpdir(), 'uig20-holder-'))
606
+ made.push(holder)
607
+ mkdirSync(join(holder, 'one', 'node_modules', '@estiva-app', 'ui', 'skill'), { recursive: true })
608
+ writeFileSync(join(holder, 'one', 'package.json'), JSON.stringify({ name: 'one' }))
609
+ writeFileSync(join(holder, 'one', 'node_modules', '@estiva-app', 'ui', 'skill', 'estiva-ui.md'), '# The skill\n')
610
+ run(['skill', '--out', '..'], join(holder, 'one'))
611
+ const loader = readFileSync(join(holder, '.claude', 'skills', 'estiva-ui', 'SKILL.md'), 'utf8')
612
+ expect(loader).toContain('!`cat "${CLAUDE_PROJECT_DIR}/one/node_modules/@estiva-app/ui/skill/estiva-ui.md"`')
613
+ // Claude Code refuses a path that climbs out with `..`, so a folder that does not hold the app is refused here.
614
+ const elsewhere = app({ 'node_modules/@estiva-app/ui/skill/estiva-ui.md': '# The skill\n' })
615
+ expect(() => run(['skill', '--out', holder], elsewhere)).toThrow(/does not hold/)
616
+ })
617
+
561
618
  it("find searches the app and the package, and never takes an app's own registry.json for the package's", () => {
562
619
  // What `estiva-ui build` leaves behind in an app, and git ignores.
563
620
  run(['build'], root)
@@ -3,9 +3,10 @@
3
3
  * `estiva-ui` — the catalogue as a command (UIG-12, UIG-13; docs/GATES.md §23
4
4
  * for why it ships in the package rather than as a script pasted into each repo).
5
5
  *
6
- * estiva-ui find <words…> [--also [name=]<folder>]… [--json] [--limit n]
6
+ * estiva-ui find <words…> [--here | --also [name=]<folder>…] [--json] [--limit n]
7
7
  * estiva-ui build [--out <path>]
8
8
  * estiva-ui check
9
+ * estiva-ui skill [--out <folder>]
9
10
  *
10
11
  * Every command takes `--root <folder>` (the library it reads; default: here)
11
12
  * and `--repo <name>` (what an app's entries are called; default: its package name).
@@ -21,15 +22,23 @@
21
22
  *
22
23
  * `find` searches the package's catalogue — the one shipped inside the installed
23
24
  * package, or this repo's own in the package — plus, in an app, the app's own,
24
- * built fresh, plus each `--also` app that sits beside it.
25
+ * built fresh, plus every app beside it (UIG-20, `./siblings`): found, not
26
+ * named, so one command answers the same from any repository, or from the
27
+ * folder that holds them. `--here` searches no neighbour; `--also` names them.
28
+ *
29
+ * `skill` writes the repository's Claude skill loader (UIG-20, `./skill`), or
30
+ * the one for `--out <folder>`; `check` fails when the repository's is missing
31
+ * or differs.
25
32
  */
26
33
  import { spawnSync } from 'node:child_process'
27
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
34
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
28
35
  import { tmpdir } from 'node:os'
29
- import { basename, join, resolve } from 'node:path'
36
+ import { basename, dirname, join, relative, resolve } from 'node:path'
30
37
  import { fileURLToPath } from 'node:url'
31
38
  import { CONTRACT_KINDS, contractProblems, linkProblems } from './contract'
32
39
  import { findInRegistries, formatFindings } from './find'
40
+ import { findSiblings, workspaceOf } from './siblings'
41
+ import { loaderPath, loaderProblem, loaderText, repositoryOf, settingsPath, settingsWithSearch } from './skill'
33
42
  import { CLASSES, validateRegistry, type Registry } from './schema'
34
43
 
35
44
  /**
@@ -91,6 +100,20 @@ function readPackageRegistry(): Registry {
91
100
  return registry
92
101
  }
93
102
 
103
+ /** The installed package whose skill text a loader reads: this repo in the package, else the app's install. */
104
+ const packageRoot = () => (isPackage ? root : resolve(root, 'node_modules', '@estiva-app', 'ui'))
105
+
106
+ /** The Claude skill loader (UIG-20): reports, and says whether it passed. */
107
+ function checkLoader(): boolean {
108
+ const problem = loaderProblem(repositoryOf(root), packageRoot())
109
+ if (problem) {
110
+ process.stderr.write(`claude skill: ${problem}\n`)
111
+ return false
112
+ }
113
+ process.stdout.write('claude skill: the loader reads the package’s text\n')
114
+ return true
115
+ }
116
+
94
117
  async function buildApp(folder: string, repo: string | undefined): Promise<Registry> {
95
118
  const { buildAppRegistry } = await appBuilder()
96
119
  const registry = buildAppRegistry({ root: folder, repo })
@@ -179,7 +202,13 @@ async function main(): Promise<number> {
179
202
  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`)
180
203
  }
181
204
  }
182
- if (!isPackage) await add(root, value('repo'))
205
+ // The folder it runs in is searched when it is an app; the folder that
206
+ // holds the repositories is not one, and only its neighbours answer.
207
+ const here = !isPackage && existsSync(resolve(root, 'package.json')) ? (manifestAt(root).name ?? basename(root)) : null
208
+ if (here) await add(root, value('repo'))
209
+ if (!values('also').length && !flag('here')) {
210
+ for (const sibling of findSiblings(workspaceOf(root), { skip: here ? [here] : [] })) await add(sibling.folder, sibling.name)
211
+ }
183
212
  for (const also of values('also')) {
184
213
  const [name, folder] = also.includes('=') ? [also.slice(0, also.indexOf('=')), also.slice(also.indexOf('=') + 1)] : [undefined, also]
185
214
  const at = resolve(root, folder)
@@ -236,7 +265,8 @@ async function main(): Promise<number> {
236
265
  }
237
266
  const linked = checkLinks(registry, root)
238
267
  const shown = await checkGated(registry, root)
239
- return broken.length || !linked || !shown ? 1 : 0
268
+ const loaded = checkLoader()
269
+ return broken.length || !linked || !shown || !loaded ? 1 : 0
240
270
  }
241
271
  const { buildRegistry, serializeRegistry } = await packageBuilder()
242
272
  const path = packageRegistryPath()
@@ -259,11 +289,33 @@ async function main(): Promise<number> {
259
289
  process.stdout.write(`${path}: current, ${built.entries.length} entries from ${built.builtFrom.exports} exports\n`)
260
290
  const linked = checkLinks(built, root)
261
291
  const shown = await checkGated(built, root, ['component'])
262
- return linked && shown ? 0 : 1
292
+ const loaded = checkLoader()
293
+ return linked && shown && loaded ? 0 : 1
294
+ }
295
+
296
+ case 'skill': {
297
+ const out = value('out')
298
+ const folder = out ? resolve(out) : repositoryOf(root)
299
+ // The loader's path starts where the session does and cannot climb out:
300
+ // Claude Code refuses a `..` in it.
301
+ if (relative(folder, packageRoot()).startsWith('..')) {
302
+ process.stderr.write(`${folder} does not hold ${packageRoot()}: a loader there could not reach the skill's text\n`)
303
+ return 1
304
+ }
305
+ const path = loaderPath(folder)
306
+ mkdirSync(dirname(path), { recursive: true })
307
+ writeFileSync(path, loaderText(folder, packageRoot()), 'utf8')
308
+ process.stdout.write(`${path}: the Claude skill, read from ${packageRoot()}\n`)
309
+ const settings = settingsWithSearch(folder)
310
+ if (settings) {
311
+ writeFileSync(settingsPath(folder), settings, 'utf8')
312
+ process.stdout.write(`${settingsPath(folder)}: the search allowed without a prompt\n`)
313
+ }
314
+ return 0
263
315
  }
264
316
 
265
317
  default:
266
- process.stderr.write('estiva-ui <find | build | check>\n')
318
+ process.stderr.write('estiva-ui <find | build | check | skill>\n')
267
319
  return 1
268
320
  }
269
321
  }
@@ -96,6 +96,11 @@ export function findInRegistry(registry: Registry, query: string, { limit = 5 }:
96
96
  * An app's pass-on is not a result of its own — it is the package's part, and
97
97
  * the package's entry answers for it, saying which apps hand it on. At the
98
98
  * same strength the package's part comes first: it is the one to reach for.
99
+ * And a package part whose name carries one of the words comes before an app's
100
+ * part answering as many words, whatever each scored: "panel header" put an
101
+ * app's own `Header`, whose whole name is one of the words, above the package's
102
+ * `ContainerHeader` (UIG-20). Only by name: a package part the words reach
103
+ * through its notes alone would bury the app part that is the answer.
99
104
  */
100
105
  export function findInRegistries(registries: Registry[], query: string, { limit = 5 }: { limit?: number } = {}): Finding[] {
101
106
  const all = words(query)
@@ -162,7 +167,11 @@ export function findInRegistries(registries: Registry[], query: string, { limit
162
167
  }
163
168
 
164
169
  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))
170
+ const namedInPackage = (finding: Finding) => (finding.entry.app === null && finding.where.includes('name') ? 0 : 1)
171
+ findings.sort(
172
+ (a, b) =>
173
+ b.matched - a.matched || namedInPackage(a) - namedInPackage(b) || b.score - a.score || fromPackage(a) - fromPackage(b) || a.entry.name.localeCompare(b.entry.name),
174
+ )
166
175
  return findings.slice(0, limit)
167
176
  }
168
177
 
@@ -0,0 +1,85 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * UIG-20: the apps beside this one, found rather than named. The folder below
4
+ * is the shape of the real one: two apps (one inside a `web/` folder), a
5
+ * service that does not install the package, a worktree and somebody's copy of
6
+ * an app, and the package itself.
7
+ */
8
+ import { execFileSync } from 'node:child_process'
9
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { afterAll, describe, expect, it } from 'vitest'
14
+ import { appName, findSiblings, workspaceOf } from './siblings'
15
+
16
+ const workspace = mkdtempSync(join(tmpdir(), 'uig20-'))
17
+ afterAll(() => rmSync(workspace, { recursive: true, force: true }))
18
+
19
+ const UI = { '@estiva-app/ui': '^0.28.0' }
20
+ function put(path: string, text: string) {
21
+ mkdirSync(dirname(join(workspace, path)), { recursive: true })
22
+ writeFileSync(join(workspace, path), text)
23
+ }
24
+ function appAt(folder: string, name: string, deps: Record<string, string> = UI, part = 'Part') {
25
+ put(`${folder}/package.json`, JSON.stringify({ name, dependencies: deps }))
26
+ put(`${folder}/src/${part}.tsx`, `/** The ${part.toLowerCase()} of ${name}. */\nexport function ${part}() {\n return <i />\n}\n`)
27
+ }
28
+ // A repository is a folder with `.git`: a directory, or a worktree's file.
29
+ appAt('alpha', 'alpha-app', UI, 'Tabletop')
30
+ put('alpha/.git/HEAD', 'ref: refs/heads/main\n')
31
+ put('beta/package.json', JSON.stringify({ name: 'beta' }))
32
+ put('beta/.git/HEAD', 'ref: refs/heads/main\n')
33
+ appAt('beta/web', 'beta-web', { ...UI }, 'Workbench')
34
+ appAt('alpha-copy', 'alpha-app')
35
+ put('alpha-copy/.git', 'gitdir: ../alpha/.git/worktrees/alpha-copy\n')
36
+ appAt('beta-notes/web', 'beta-web')
37
+ appAt('service', 'service', { react: '^19.0.0' })
38
+ put('ui/package.json', JSON.stringify({ name: '@estiva-app/ui' }))
39
+ mkdirSync(join(workspace, 'ui', 'src'), { recursive: true })
40
+
41
+ describe('the apps beside this one', () => {
42
+ it('is an app only when it installs the package and has a src', () => {
43
+ expect(appName(join(workspace, 'alpha'))).toBe('alpha-app')
44
+ expect(appName(join(workspace, 'service'))).toBeNull()
45
+ expect(appName(join(workspace, 'ui'))).toBeNull()
46
+ expect(appName(join(workspace, 'beta'))).toBeNull()
47
+ })
48
+
49
+ it('finds the folder that holds the repositories, from inside one or from that folder itself', () => {
50
+ expect(workspaceOf(join(workspace, 'beta', 'web', 'src'))).toBe(workspace)
51
+ expect(workspaceOf(join(workspace, 'alpha-copy'))).toBe(workspace)
52
+ })
53
+
54
+ it('finds each app once, inside a web folder too, and never a second checkout of the same app', () => {
55
+ expect(findSiblings(workspace).map((s) => [s.name, s.packageName])).toEqual([
56
+ ['alpha', 'alpha-app'],
57
+ ['beta', 'beta-web'],
58
+ ])
59
+ })
60
+
61
+ it('leaves out the app the search runs in, so a worktree of it is not searched twice', () => {
62
+ expect(findSiblings(workspace, { skip: ['alpha-app'] }).map((s) => s.name)).toEqual(['beta'])
63
+ })
64
+ })
65
+
66
+ // The command, as a session runs it: `dist/` is what `npm test` builds first.
67
+ const cli = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist', 'registry', 'cli.js')
68
+ describe.skipIf(!existsSync(cli))('estiva-ui find, with nothing else typed', () => {
69
+ const find = (cwd: string, ...words: string[]) =>
70
+ execFileSync(process.execPath, [cli, 'find', ...words], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
71
+
72
+ it('searches every app beside the one it runs in', () => {
73
+ const out = find(join(workspace, 'alpha-copy'), 'workbench')
74
+ expect(out).toMatch(/^Workbench {2}· .* · {2}beta/m)
75
+ })
76
+
77
+ it('answers the same from the folder that holds them', () => {
78
+ expect(find(workspace, 'tabletop')).toMatch(/^Tabletop {2}· .* · {2}alpha/m)
79
+ expect(find(workspace, 'zzqqx')).toContain('Nothing in estiva-ui, alpha, beta matches')
80
+ })
81
+
82
+ it('searches no neighbour when told --here', () => {
83
+ expect(find(join(workspace, 'alpha'), 'workbench', '--here')).not.toContain('Workbench')
84
+ })
85
+ })
@@ -0,0 +1,96 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * The apps beside this one, found rather than named (UIG-20).
4
+ *
5
+ * `estiva-ui find` with nothing else typed searches the package, the app it is
6
+ * run in, and every app that sits beside it. Katerina's condition for having no
7
+ * shared Storybook is that one search, run unasked from any repository, covers
8
+ * every catalogue; a list of names written into each repository's script would
9
+ * be the drifting copy the gates exist to remove, and would miss the next app.
10
+ *
11
+ * An app is a folder, or one folder inside it, whose `package.json` depends on
12
+ * `@estiva-app/ui` and which has a `src` to read. A service that does not
13
+ * install the package is not an app of this catalogue until it does.
14
+ *
15
+ * Each app is searched once. A second checkout of the same app (a worktree, or
16
+ * somebody's copy) carries the same package name; the app the search runs in
17
+ * wins, then the one whose top folder has the shortest name.
18
+ */
19
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
20
+ import { basename, dirname, join, resolve } from 'node:path'
21
+
22
+ export const PACKAGE_NAME = '@estiva-app/ui'
23
+
24
+ export interface Sibling {
25
+ /** What its entries are called: its top folder's name. */
26
+ name: string
27
+ /** The folder the catalogue is built from. */
28
+ folder: string
29
+ /** Its `package.json` name, which says two checkouts are one app. */
30
+ packageName: string
31
+ }
32
+
33
+ function manifest(folder: string): { name?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> } | null {
34
+ const file = join(folder, 'package.json')
35
+ if (!existsSync(file)) return null
36
+ try {
37
+ return JSON.parse(readFileSync(file, 'utf8'))
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /** The name of the app in `folder`, or null when the folder is not one. */
44
+ export function appName(folder: string): string | null {
45
+ const m = manifest(folder)
46
+ if (!m || m.name === PACKAGE_NAME) return null
47
+ const uses = Boolean(m.dependencies?.[PACKAGE_NAME] ?? m.devDependencies?.[PACKAGE_NAME])
48
+ return uses && existsSync(join(folder, 'src')) ? (m.name ?? basename(folder)) : null
49
+ }
50
+
51
+ const isDir = (path: string) => {
52
+ try {
53
+ return statSync(path).isDirectory()
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The folder that holds the repositories: the parent of the repository `from`
61
+ * is in, or `from` itself when it is in none (a folder that only holds them).
62
+ */
63
+ export function workspaceOf(from: string): string {
64
+ let at = resolve(from)
65
+ for (;;) {
66
+ if (existsSync(join(at, '.git'))) return dirname(at)
67
+ const up = dirname(at)
68
+ if (up === at) return resolve(from)
69
+ at = up
70
+ }
71
+ }
72
+
73
+ /** Every app in `workspace`, one per package name, except those named in `skip`. */
74
+ export function findSiblings(workspace: string, { skip = [] }: { skip?: string[] } = {}): Sibling[] {
75
+ let tops: string[]
76
+ try {
77
+ tops = readdirSync(workspace).filter((name) => !name.startsWith('.') && name !== 'node_modules' && isDir(join(workspace, name)))
78
+ } catch {
79
+ return []
80
+ }
81
+ const found: Sibling[] = []
82
+ for (const top of tops) {
83
+ const folder = join(workspace, top)
84
+ const inside = readdirSync(folder).filter((name) => !name.startsWith('.') && name !== 'node_modules' && isDir(join(folder, name)))
85
+ for (const candidate of [folder, ...inside.map((name) => join(folder, name))]) {
86
+ const packageName = appName(candidate)
87
+ if (packageName) found.push({ name: top, folder: candidate, packageName })
88
+ }
89
+ }
90
+ const byPackage = new Map<string, Sibling>()
91
+ for (const sibling of found.sort((a, b) => a.name.length - b.name.length || a.name.localeCompare(b.name))) {
92
+ if (skip.includes(sibling.packageName) || byPackage.has(sibling.packageName)) continue
93
+ byPackage.set(sibling.packageName, sibling)
94
+ }
95
+ return [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name))
96
+ }
@@ -0,0 +1,96 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * UIG-20: the skill's text, held to what it points at.
4
+ *
5
+ * The skill names the package's parts and props. Renaming one would leave the
6
+ * skill sending sessions to something that is not there, so every part it names
7
+ * is checked against the catalogue. Its Gotchas stand for recorded defects,
8
+ * reconciled in `docs/GATES-SKILL.md`; that table has to keep adding up.
9
+ */
10
+ import { readFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { describe, expect, it } from 'vitest'
14
+ import type { Registry } from './schema'
15
+ import { loaderText, SKILL_DESCRIPTION } from './skill'
16
+
17
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
18
+ const text = readFileSync(join(repoRoot, 'skill', 'estiva-ui.md'), 'utf8').replace(/\r\n/g, '\n')
19
+ const registry = JSON.parse(readFileSync(join(repoRoot, 'registry.json'), 'utf8')) as Registry
20
+ const byName = new Map(registry.entries.map((entry) => [entry.name, entry]))
21
+ const record = readFileSync(join(repoRoot, 'docs', 'GATES-SKILL.md'), 'utf8').replace(/\r\n/g, '\n')
22
+
23
+ describe('the skill', () => {
24
+ it('has the four parts, stays short, and fits Claude Code’s description limit', () => {
25
+ expect(text).toMatch(/^# /)
26
+ for (const heading of ['## When this applies', '## Steps, in order', '## Gotchas']) expect(text).toContain(heading)
27
+ expect(text.split('\n').length).toBeLessThan(200)
28
+ expect(SKILL_DESCRIPTION.length).toBeLessThanOrEqual(1536)
29
+ expect(text).toMatch(/1\. \*\*Search before creating anything\.\*\* Run\n {3}`npm run ui:find/)
30
+ })
31
+
32
+ it('names only parts the package has', () => {
33
+ const named = [...new Set([...text.matchAll(/`([A-Z][A-Za-z]+)`/g)].map((m) => m[1]))]
34
+ expect(named.length).toBeGreaterThan(20)
35
+ expect(named.filter((name) => !byName.has(name))).toEqual([])
36
+ })
37
+
38
+ it('names only props those parts have', () => {
39
+ const props: [string, string][] = [
40
+ ['EmptyState', 'scope'],
41
+ ['ListColumn', 'above'],
42
+ ['IconButton', 'tooltip'],
43
+ ['SectionHeader', 'hover'],
44
+ ['WithTooltip', 'inline'],
45
+ ['Toolbar', 'surface'],
46
+ ['Form', 'busy'],
47
+ ['Popover', 'maxHeight'],
48
+ ['Popover', 'open'],
49
+ ]
50
+ for (const [part, prop] of props) expect(byName.get(part)?.props.map((p) => p.name), `${part}.${prop}`).toContain(prop)
51
+ })
52
+
53
+ it('carries the four regressions the ticket names', () => {
54
+ expect(text).toMatch(/header row drawn by hand[\s\S]*?`ContainerHeader`/)
55
+ expect(text).toMatch(/no scroll container[\s\S]*?`ListColumn`/)
56
+ expect(text).toMatch(/`EditableText` rendering too large/)
57
+ expect(text).toMatch(/`EmptyState` at the wrong level[\s\S]*?scope="page"/)
58
+ })
59
+
60
+ it('assumes no app: no app is named, so it holds in every one', () => {
61
+ expect(`${text}\n${SKILL_DESCRIPTION}`).not.toMatch(/\b(Peek|Ship|Leaf)\b/)
62
+ })
63
+
64
+ it('is read in by the loader, never copied into it', () => {
65
+ const loader = loaderText(repoRoot, repoRoot)
66
+ expect(loader).not.toContain('## Gotchas')
67
+ expect(loader).toContain('!`cat "${CLAUDE_PROJECT_DIR}/skill/estiva-ui.md"`')
68
+ })
69
+ })
70
+
71
+ describe('the Gotchas, against the record', () => {
72
+ const rows = [...record.matchAll(/^\| (\d+) \|.*\| (G\d+|EXCL)\b[^|]*\|$/gm)].map((m) => ({ n: Number(m[1]), verdict: m[2] }))
73
+ const gotchas = [...text.matchAll(/^(\d+)\. \*\*/gm)].map((m) => Number(m[1]))
74
+
75
+ it('gives every recorded defect a row, numbered without a gap', () => {
76
+ expect(rows.length).toBeGreaterThan(500)
77
+ expect(rows.map((row) => row.n)).toEqual(rows.map((_, i) => i + 1))
78
+ })
79
+
80
+ it('backs every Gotcha with at least two recorded defects, and points at none the skill lacks', () => {
81
+ const numbered = gotchas.filter((n, i) => i >= 5 || n > 5) // the five steps are numbered too
82
+ const count = (g: number) => rows.filter((row) => row.verdict === `G${g}`).length
83
+ for (let g = 1; g <= 24; g++) expect(count(g), `G${g}`).toBeGreaterThanOrEqual(2)
84
+ expect(rows.filter((row) => row.verdict.startsWith('G') && Number(row.verdict.slice(1)) > 24)).toEqual([])
85
+ expect(numbered.filter((n) => n >= 1 && n <= 24).length).toBeGreaterThanOrEqual(19)
86
+ expect(text).toMatch(/^24\. \*\*/m)
87
+ })
88
+
89
+ it('states counts that add up', () => {
90
+ const into = rows.filter((row) => row.verdict !== 'EXCL').length
91
+ const out = rows.filter((row) => row.verdict === 'EXCL').length
92
+ expect(record).toContain(`**${rows.length}** | **${into}** | **${out}**`)
93
+ expect(text).toContain(`counted ${rows.length}`)
94
+ expect(text).toContain(`cover the ${into}`)
95
+ })
96
+ })
@@ -0,0 +1,117 @@
1
+ /// <reference types="node" />
2
+ /**
3
+ * The Claude skill (UIG-20): search the catalogue before building anything.
4
+ *
5
+ * Its text lives once, in this package, as `skill/estiva-ui.md` (Katerina,
6
+ * 17 September 2026: the same gate text pasted into two repositories is a
7
+ * defect). Claude Code only loads a skill from a `SKILL.md` under a folder's
8
+ * `.claude/skills/`, and no plugin source can point into `node_modules`, so each
9
+ * repository commits a loader: the trigger below, and one line that reads the
10
+ * installed package's text into the skill when it opens (`` !`…` `` runs before
11
+ * Claude sees the skill). `estiva-ui skill` writes it; `estiva-ui check` fails
12
+ * when a repository's loader is missing or is not what this writes.
13
+ *
14
+ * Measured with fresh sessions of Claude Code 2.1.215 (UIG-20), three things
15
+ * shape that line and the settings beside it:
16
+ *
17
+ * - An injected command runs without a prompt only when Claude Code can read it
18
+ * statically as read-only: a plain `cat` of a plain path. A path with `..`
19
+ * ("may follow a symlink outside the working directory"), a `$(…)`, or a
20
+ * `node -e` are all refused, and a refused command stops the skill opening.
21
+ * So the path starts at `${CLAUDE_PROJECT_DIR}`, the folder the session
22
+ * started in, as the editor gate's hook does: a session started above or
23
+ * below the repository gets neither.
24
+ * - An `allowed-tools` line makes opening the skill itself ask for permission
25
+ * ("Execute skill"). So the search is allowed in the repository's committed
26
+ * `.claude/settings.json` instead, and the skill carries no `allowed-tools`.
27
+ * - Claude reads a skill's description before it opens the skill, so the
28
+ * trigger is written into the loader; it is the one part that cannot be
29
+ * pulled in later.
30
+ */
31
+ import { existsSync, readFileSync } from 'node:fs'
32
+ import { dirname, join, relative, resolve, sep } from 'node:path'
33
+
34
+ export const SKILL_NAME = 'estiva-ui'
35
+
36
+ /** When Claude opens the skill by itself. Kept under Claude Code's 1,536 characters. */
37
+ export const SKILL_DESCRIPTION =
38
+ 'Use before building, adding or changing anything on screen in an app that uses @estiva-app/ui, or in @estiva-app/ui itself: ' +
39
+ 'a screen, panel, list, row, header, dialog, menu, empty state, a new component, or how something looks. ' +
40
+ 'Also when someone asks for a component in plain words ("I need a…", "add a…", "is there a…"). ' +
41
+ "It searches every catalogue at once, the package's and each app's Storybook, for a part that already does the job, " +
42
+ 'and lists the mistakes this codebase keeps making.'
43
+
44
+ /** The search, allowed without a prompt in the folder's committed `.claude/settings.json`. */
45
+ export const SEARCH_RULES = ['Bash(npm run ui:find *)', 'Bash(npm --prefix * run ui:find *)', 'PowerShell(npm run ui:find *)', 'PowerShell(npm --prefix * run ui:find *)']
46
+
47
+ /** The skill's text, inside a package folder. */
48
+ export const skillTextPath = (packageRoot: string) => join(packageRoot, 'skill', `${SKILL_NAME}.md`)
49
+
50
+ /** Where a folder keeps its loader. */
51
+ export const loaderPath = (folder: string) => join(folder, '.claude', 'skills', SKILL_NAME, 'SKILL.md')
52
+
53
+ /** Where a folder keeps the settings that allow the search. */
54
+ export const settingsPath = (folder: string) => join(folder, '.claude', 'settings.json')
55
+
56
+ /** The loader for `folder`, reading the text from `packageRoot`, from where a session in `folder` starts. */
57
+ export function loaderText(folder: string, packageRoot: string): string {
58
+ const fromTop = relative(folder, skillTextPath(packageRoot)).split(sep).join('/')
59
+ return [
60
+ '---',
61
+ `name: ${SKILL_NAME}`,
62
+ `description: ${JSON.stringify(SKILL_DESCRIPTION)}`,
63
+ '---',
64
+ '',
65
+ '<!-- Written by `estiva-ui skill` from @estiva-app/ui; `estiva-ui check` fails when it differs. The text is the installed package’s, read in below. -->',
66
+ '',
67
+ `!\`cat "\${CLAUDE_PROJECT_DIR}/${fromTop}"\``,
68
+ '',
69
+ ].join('\n')
70
+ }
71
+
72
+ type Settings = { permissions?: { allow?: string[] } & Record<string, unknown> } & Record<string, unknown>
73
+
74
+ const readSettings = (folder: string): Settings | null => {
75
+ const path = settingsPath(folder)
76
+ if (!existsSync(path)) return {}
77
+ try {
78
+ return JSON.parse(readFileSync(path, 'utf8')) as Settings
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ /** `folder`'s settings with the search allowed, everything else as it was; null when they cannot be read. */
85
+ export function settingsWithSearch(folder: string): string | null {
86
+ const settings = readSettings(folder)
87
+ if (!settings) return null
88
+ const allow = settings.permissions?.allow ?? []
89
+ const missing = SEARCH_RULES.filter((rule) => !allow.includes(rule))
90
+ if (!missing.length && existsSync(settingsPath(folder))) return null
91
+ settings.permissions = { ...settings.permissions, allow: [...allow, ...missing] }
92
+ return `${JSON.stringify(settings, null, 2)}\n`
93
+ }
94
+
95
+ /** The repository `from` is in: the nearest folder with `.git`, or `from` when there is none. */
96
+ export function repositoryOf(from: string): string {
97
+ let at = resolve(from)
98
+ for (;;) {
99
+ if (existsSync(join(at, '.git'))) return at
100
+ const up = dirname(at)
101
+ if (up === at) return resolve(from)
102
+ at = up
103
+ }
104
+ }
105
+
106
+ /** What is wrong with `folder`'s loader, or null when it is what `loaderText` writes. */
107
+ export function loaderProblem(folder: string, packageRoot: string): string | null {
108
+ const path = loaderPath(folder)
109
+ const shown = relative(process.cwd(), path).split(sep).join('/') || path
110
+ if (!existsSync(path)) return `${shown} is missing: run "estiva-ui skill" and commit it`
111
+ if (readFileSync(path, 'utf8').replace(/\r\n/g, '\n') !== loaderText(folder, packageRoot)) return `${shown} is not what the package writes: run "estiva-ui skill" and commit it`
112
+ if (!existsSync(skillTextPath(packageRoot))) return `${shown} reads ${skillTextPath(packageRoot)}, which is not there`
113
+ const allow = readSettings(folder)?.permissions?.allow ?? []
114
+ const missing = SEARCH_RULES.filter((rule) => !allow.includes(rule))
115
+ if (missing.length) return `.claude/settings.json does not allow the search (${missing.join(', ')}): run "estiva-ui skill" and commit it`
116
+ return null
117
+ }