@estiva-app/ui 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/gates/app-checks.d.ts.map +1 -1
  2. package/dist/gates/{chunk-EA33NP5B.js → chunk-JZE7TIQY.js} +16 -13
  3. package/dist/gates/chunk-JZE7TIQY.js.map +7 -0
  4. package/dist/gates/{chunk-I7CJLUPM.js → chunk-S75XIY3Z.js} +56 -6
  5. package/dist/gates/chunk-S75XIY3Z.js.map +7 -0
  6. package/dist/gates/chunk-YKZXKVR2.js +39 -0
  7. package/dist/gates/chunk-YKZXKVR2.js.map +7 -0
  8. package/dist/gates/cli.js +2 -1
  9. package/dist/gates/cli.js.map +1 -1
  10. package/dist/gates/create-app.d.ts.map +1 -1
  11. package/dist/gates/create-app.js +2 -1
  12. package/dist/gates/create-app.js.map +1 -1
  13. package/dist/gates/gate-config.d.ts +1 -1
  14. package/dist/gates/gate-config.d.ts.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.d.ts +2 -1
  18. package/dist/gates/index.d.ts.map +1 -1
  19. package/dist/gates/index.js +38 -5
  20. package/dist/gates/index.js.map +2 -2
  21. package/dist/gates/token-lint.d.ts +27 -1
  22. package/dist/gates/token-lint.d.ts.map +1 -1
  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 +126 -0
  37. package/src/gates/app-checks.ts +20 -1
  38. package/src/gates/create-app.test.ts +5 -1
  39. package/src/gates/create-app.ts +12 -11
  40. package/src/gates/gate-config.ts +3 -2
  41. package/src/gates/gates.test.ts +60 -1
  42. package/src/gates/hook.ts +69 -5
  43. package/src/gates/index.ts +2 -1
  44. package/src/gates/token-lint.ts +47 -2
  45. package/src/registry/app.test.ts +58 -1
  46. package/src/registry/cli.ts +60 -8
  47. package/src/registry/find.ts +10 -1
  48. package/src/registry/siblings.test.ts +85 -0
  49. package/src/registry/siblings.ts +96 -0
  50. package/src/registry/skill.test.ts +96 -0
  51. package/src/registry/skill.ts +117 -0
  52. package/dist/gates/chunk-EA33NP5B.js.map +0 -7
  53. package/dist/gates/chunk-I7CJLUPM.js.map +0 -7
@@ -0,0 +1,21 @@
1
+ export declare const PACKAGE_NAME = "@estiva-app/ui";
2
+ export interface Sibling {
3
+ /** What its entries are called: its top folder's name. */
4
+ name: string;
5
+ /** The folder the catalogue is built from. */
6
+ folder: string;
7
+ /** Its `package.json` name, which says two checkouts are one app. */
8
+ packageName: string;
9
+ }
10
+ /** The name of the app in `folder`, or null when the folder is not one. */
11
+ export declare function appName(folder: string): string | null;
12
+ /**
13
+ * The folder that holds the repositories: the parent of the repository `from`
14
+ * is in, or `from` itself when it is in none (a folder that only holds them).
15
+ */
16
+ export declare function workspaceOf(from: string): string;
17
+ /** Every app in `workspace`, one per package name, except those named in `skip`. */
18
+ export declare function findSiblings(workspace: string, { skip }?: {
19
+ skip?: string[];
20
+ }): Sibling[];
21
+ //# sourceMappingURL=siblings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"siblings.d.ts","sourceRoot":"","sources":["../../src/registry/siblings.ts"],"names":[],"mappings":"AAqBA,eAAO,MAAM,YAAY,mBAAmB,CAAA;AAE5C,MAAM,WAAW,OAAO;IACtB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAA;IACZ,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,WAAW,EAAE,MAAM,CAAA;CACpB;AAYD,2EAA2E;AAC3E,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAKrD;AAUD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,IAAS,EAAE,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;CAAO,GAAG,OAAO,EAAE,CAsBlG"}
@@ -0,0 +1,20 @@
1
+ export declare const SKILL_NAME = "estiva-ui";
2
+ /** When Claude opens the skill by itself. Kept under Claude Code's 1,536 characters. */
3
+ export declare const SKILL_DESCRIPTION: string;
4
+ /** The search, allowed without a prompt in the folder's committed `.claude/settings.json`. */
5
+ export declare const SEARCH_RULES: string[];
6
+ /** The skill's text, inside a package folder. */
7
+ export declare const skillTextPath: (packageRoot: string) => string;
8
+ /** Where a folder keeps its loader. */
9
+ export declare const loaderPath: (folder: string) => string;
10
+ /** Where a folder keeps the settings that allow the search. */
11
+ export declare const settingsPath: (folder: string) => string;
12
+ /** The loader for `folder`, reading the text from `packageRoot`, from where a session in `folder` starts. */
13
+ export declare function loaderText(folder: string, packageRoot: string): string;
14
+ /** `folder`'s settings with the search allowed, everything else as it was; null when they cannot be read. */
15
+ export declare function settingsWithSearch(folder: string): string | null;
16
+ /** The repository `from` is in: the nearest folder with `.git`, or `from` when there is none. */
17
+ export declare function repositoryOf(from: string): string;
18
+ /** What is wrong with `folder`'s loader, or null when it is what `loaderText` writes. */
19
+ export declare function loaderProblem(folder: string, packageRoot: string): string | null;
20
+ //# sourceMappingURL=skill.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill.d.ts","sourceRoot":"","sources":["../../src/registry/skill.ts"],"names":[],"mappings":"AAiCA,eAAO,MAAM,UAAU,cAAc,CAAA;AAErC,wFAAwF;AACxF,eAAO,MAAM,iBAAiB,QAKwB,CAAA;AAEtD,8FAA8F;AAC9F,eAAO,MAAM,YAAY,UAAiJ,CAAA;AAE1K,iDAAiD;AACjD,eAAO,MAAM,aAAa,GAAI,aAAa,MAAM,WAAmD,CAAA;AAEpG,uCAAuC;AACvC,eAAO,MAAM,UAAU,GAAI,QAAQ,MAAM,WAA8D,CAAA;AAEvG,+DAA+D;AAC/D,eAAO,MAAM,YAAY,GAAI,QAAQ,MAAM,WAA6C,CAAA;AAExF,6GAA6G;AAC7G,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAatE;AAcD,6GAA6G;AAC7G,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQhE;AAED,iGAAiG;AACjG,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQjD;AAED,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,6 +9,7 @@
9
9
  "src",
10
10
  "stories",
11
11
  "registry.json",
12
+ "skill",
12
13
  "tailwind-preset.js",
13
14
  "tokens.css",
14
15
  "base.css",
@@ -65,8 +66,9 @@
65
66
  "registry": "node dist/registry/cli.js build",
66
67
  "preregistry:check": "node build.mjs",
67
68
  "registry:check": "node dist/registry/cli.js check",
69
+ "skill:check": "vitest run --project unit src/registry/skill.test.ts",
68
70
  "preui:find": "node build.mjs",
69
- "ui:find": "node dist/registry/cli.js find --also peek=../peek --also ship=../ship/web",
71
+ "ui:find": "node dist/registry/cli.js find",
70
72
  "prepublishOnly": "npm run build"
71
73
  },
72
74
  "peerDependencies": {
package/registry.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "kind": "package",
5
5
  "repo": "estiva-ui",
6
6
  "package": "@estiva-app/ui",
7
- "packageVersion": "0.27.0",
7
+ "packageVersion": "0.29.0",
8
8
  "exports": 87,
9
9
  "typeExports": 96,
10
10
  "files": null
@@ -0,0 +1,126 @@
1
+ # Search before you build
2
+
3
+ Every screen here is built from parts: the package `@estiva-app/ui`, and each
4
+ app's own. Every reusable part has a Storybook page that says when to use it
5
+ and when not to. Most of what gets asked for already exists somewhere.
6
+
7
+ ## When this applies
8
+
9
+ - Building or changing a screen, panel, list, row, header, dialog or menu.
10
+ - Adding a component, or a new file that draws one.
11
+ - Changing how something looks: its size, spacing, colour or type.
12
+
13
+ ## Steps, in order
14
+
15
+ 1. **Search before creating anything.** Run
16
+ `npm run ui:find <what it does, in plain words>` in the repository you are
17
+ in. Do it yourself; never ask the person to. With nothing else typed it
18
+ searches the package and every app beside it, and prints a Storybook link
19
+ for each match. Show the person what it found, with the links.
20
+ - From a folder that holds several repositories, run it inside any one of
21
+ them (`npm --prefix <repo> run ui:find <words>`): each searches them all.
22
+ - Search again in other words before deciding nothing fits: "list column",
23
+ then "scrolling list"; "panel header", then "title row".
24
+ 2. **If something fits, read its page before using it:** the link, or the
25
+ `.mdx` beside its file. Read its opening line, **When** and **When not**.
26
+ *When not* names the part to use instead.
27
+ 3. **If nothing fits, say so and ask.** Do not invent a component. Do not copy
28
+ one from another app either: say which app has it, and ask whether it
29
+ should move into the package.
30
+ 4. **Never hand-roll what the package owns.** Its parts own their behaviour:
31
+ keys, focus, floating, closing, scrolling, truncation. The editor gate
32
+ refuses a raw element, a rebuilt behaviour or a restyled part, and its error
33
+ names the part to use.
34
+ 5. **An exception** carries `// @estiva-escape: <reason>` on the line above
35
+ it. Never `eslint-disable`.
36
+
37
+ ## Gotchas
38
+
39
+ The mistakes made here more than once, from the record (UIG-20 counted 542
40
+ recorded defects; these 24 cover the 111 that are screen work). Each says what
41
+ to do instead. Apps differ in frame and theme: never carry one app's layout,
42
+ sizes or colours into another; take them from tokens.
43
+
44
+ **Layout**
45
+
46
+ 1. **A header row drawn by hand** at the top of a column → `ContainerHeader`.
47
+ Its actions are `IconButton`s with tooltips. A whole list column is
48
+ `ListColumn`, which draws the bar; a field that adds to the list goes in its
49
+ `above`.
50
+ 2. **A list with no scroll container**, cut off at the fold (no lint sees a box
51
+ that was never built) → a list column is `ListColumn`, which scrolls its
52
+ rows; anything else that grows goes in a `ScrollArea`. Check with more rows
53
+ than fit.
54
+ 3. **`EmptyState` at the wrong level** → `scope="page"` only when the whole
55
+ page is empty, `scope="section"` for one part of it, placed inside the box
56
+ its rows live in. Never wrap it in a box of your own that places it.
57
+ 4. **`EditableText` rendering too large**: it has no size of its own and takes
58
+ the size around it → put the text size on your own element around it. The
59
+ same holds for `Person`, `Link`, `Card` and `SectionLabel`.
60
+ 5. **Flex rows that move things**: loose children of an `items-start` row float
61
+ at different heights; a scrolling flex column squashes rows → group them in
62
+ one `items-center` row; give your own rows in a scrolling column `shrink-0`.
63
+ 6. **A group heading drawn by hand** (a row holding a `SectionLabel`) →
64
+ `SectionHeader` (`hover="none"` when nothing acts on it); a group that folds
65
+ is `CollapsibleSection`.
66
+ 7. **A line drawn by hand** (`border-t`, `<hr>`, a gap) → `Divider`; between a
67
+ menu's groups, `MenuSeparator`, which brings its own room.
68
+
69
+ **Parts and props**
70
+
71
+ 8. **The browser's tooltip** (`title=`), or an icon button with none →
72
+ `WithTooltip` (`inline` inside text) or `IconButton`'s `tooltip`.
73
+ 9. **A row of icon buttons laid out by hand** → `Toolbar` with `ToolbarButton`
74
+ and `ToolbarLink` (they throw outside one). A row inside a `Popover` is a
75
+ `Toolbar` with `surface={false}`.
76
+ 10. **Fields sent by a hand-written Enter handler**, with no form → `Form`,
77
+ which owns the keys, `busy` and focus. Clear a field's `error` when it
78
+ changes, or the form will not send.
79
+ 11. **A link to an in-app page as a bare `href`**: every click reloads the app
80
+ → go through the app's router, as `NavItem` does.
81
+ 12. **Wrong loading and failure states**: an `EmptyState` while data is still
82
+ coming, a hand-made pulsing box → a skeleton shaped like what is coming
83
+ (`SkeletonList`, `SkeletonRow`, `SkeletonBar`). Say a failure in
84
+ words, never as "empty" and never in red; a failure toast or banner has
85
+ the `error` tone.
86
+ 13. **A floating panel mounted only while open** (`{open && <Popover…>}`), or
87
+ capped with a class → keep it mounted and drive `open`; cap a long list with
88
+ `Popover`'s `maxHeight`; never open a panel around an empty list.
89
+ 14. **Deleting a part without checking what else it carried** → read everything
90
+ the container holds first, and remove its imports, escapes and stories with it.
91
+
92
+ **Stories and usage pages**
93
+
94
+ 15. **A story that shows one branch**: something drawn behind `{prop && …}` that
95
+ no story passes, or data with no fixture → a story for every branch; one
96
+ fixture holding what the backend really sends. `estiva-ui check` refuses
97
+ the first.
98
+ 16. **A screen with no story** is where mistakes hide: every gate was green on
99
+ a page with eight → look at the running screen, or give it a story with
100
+ more rows than fit, an empty state and a loading state.
101
+ 17. **A story under a new heading, or named after its file** → use a heading
102
+ the Storybook already has, name the entry after its part, give each meta its
103
+ `component` (or the part's page a **Seen in** line).
104
+ 18. **`{…}` in a usage page's prose** (MDX reads it as code and the page will
105
+ not compile), or a description over two lines → backticks around braces; a
106
+ part's description is one line.
107
+
108
+ **Working in these repositories**
109
+
110
+ 19. **An escape with a false reason** ("no part does this" has been wrong
111
+ twice) → search for the thing first. Write the escape on the line above
112
+ the statement, never inside `eslint-disable`.
113
+ 20. **Classes the lint cannot see**: in a const not named `…_CLASSES` or
114
+ `…Styles`, or in an array that is `.join()`ed → keep classes in
115
+ `className` or `cn()`, or in a map with one of those names.
116
+ 21. **The editor gate and this skill need a session started at the repository's
117
+ top folder.** Started elsewhere, run the repository's `lint:rules` yourself
118
+ before committing.
119
+ 22. **`npm install` on Windows prunes Linux-only entries from the lockfile**, and
120
+ CI dies at `npm ci` → never commit a lockfile Windows rewrote; restore it and
121
+ bump only the package's own entry. Stop every dev server before `npm ci`.
122
+ 23. **Storybook shows old code after a package bump** → delete
123
+ `node_modules/.cache/storybook` and restart.
124
+ 24. **A local checkout lags `origin/main`, and a conflicting PR shows no CI** →
125
+ `git fetch` and read `origin/main`; when checks are missing, look for a
126
+ conflict first.
@@ -56,6 +56,7 @@ export const APP_TICKET_TITLES: Record<string, string> = {
56
56
  'UIG-28': 'Close the two holes in the token contract — arbitrary values, and inline style',
57
57
  'UIG-30': 'RichText — one component that draws a message\'s text, for both apps',
58
58
  'UIG-32': 'Peek and Ship take their gate pieces from the package',
59
+ 'UIG-37': 'The gates refuse a TypeScript eslint-disable comment — know the rule names, rules off',
59
60
  }
60
61
 
61
62
  /** Run several checks as one: the first that does not pass is the answer. */
@@ -81,6 +82,8 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
81
82
  const installed = at('node_modules/@estiva-app/ui/dist/index.d.ts')
82
83
  const realPage = (): Promise<CheckResult> => h.lint({ ...(cwd ? { cwd } : {}), config: GATES, file: page, code: h.read(at(page)), expect: 'none' })
83
84
  const button = component('<button type="button">x</button>')
85
+ /** A line TypeScript's own lint writes; ESLint refuses it where TypeScript's plugin is unknown (UIG-37). */
86
+ const typescriptDirective = '// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const probe: any = 1\n'
84
87
  const ticket = (ref: string, checks: GateCheck[], owner = false): GateTicket => ({ ref, title: APP_TICKET_TITLES[ref], owner, checks })
85
88
 
86
89
  const tokenProbe = (code: string, expect: 'error' | 'warning' | 'none', mentions?: string, file = PROBE) =>
@@ -196,8 +199,14 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
196
199
  { what: "CI's job gate runs it", run: () => h.ciJob('gate', 'registry:check') },
197
200
  { what: 'the app keeps no copy of it in its own gates-checks.mjs', run: () => h.exists(at('scripts/gates-checks.mjs')) ? h.lacks(at('scripts/gates-checks.mjs'), '"When not"', 'the app checks no page sections itself') : h.PASS('the app has no gates-checks.mjs') },
198
201
  ]),
202
+ // The Claude skill (UIG-20): its text is the installed package's, read in by a
203
+ // loader `estiva-ui skill` writes; the app commits the loader and the settings.
199
204
  ticket('UIG-20', [
200
- { what: 'a committed skill runs ui:find', run: () => (h.listFiles('.claude/skills', (n) => n === 'SKILL.md').some((f) => h.read(f).includes('ui:find')) ? h.PASS('a skill runs ui:find') : h.FAIL('no skill in .claude/skills runs ui:find')) },
205
+ { what: "the skill's loader reads the installed package's text", run: () => h.contains('.claude/skills/estiva-ui/SKILL.md', `!\`cat "\${CLAUDE_PROJECT_DIR}/${at('node_modules/@estiva-app/ui/skill/estiva-ui.md')}"\``, 'the loader reads the package’s skill') },
206
+ { what: 'the search runs without a prompt', run: () => h.contains('.claude/settings.json', 'Bash(npm run ui:find *)', '.claude/settings.json allows npm run ui:find') },
207
+ { what: 'ui:find searches every app beside this one: it names none', run: () => h.lacks(at('package.json'), /"ui:find":[^\n]*--also/, 'ui:find names no neighbour, so it finds them all') },
208
+ ...(WEB ? [{ what: 'ui:find runs from the top folder too', run: () => h.contains('package.json', /"ui:find":/, 'the top folder has ui:find') }] : []),
209
+ { what: "CI's job gate refuses a loader that differs", run: () => (h.exists(at('node_modules/@estiva-app/ui/dist/registry/cli.js')) && /claude skill:/.test(h.read(at('node_modules/@estiva-app/ui/dist/registry/cli.js'))) ? h.ciJob('gate', 'registry:check') : h.FAIL('the installed package does not check the loader: take a version that does')) },
201
210
  ]),
202
211
  ticket('UIG-21', [
203
212
  { what: 'path-scoped instructions exist', run: () => (h.listFiles('.claude/rules', (n) => n.endsWith('.md')).some((f) => /^paths:/m.test(h.read(f))) ? h.PASS('.claude/rules has paths: instructions') : h.FAIL('no .claude/rules file with paths:')) },
@@ -248,5 +257,15 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
248
257
  () => (h.exists('scripts/gates-status.mjs') ? h.FAIL('scripts/gates-status.mjs is a copy of the status engine: run estiva-gates status instead') : h.PASS('no copy of the status engine')),
249
258
  ], 'the gate config, the hook and gates:status are the package\'s; no copy of the engine') },
250
259
  ]),
260
+ ticket('UIG-37', [
261
+ { what: 'a TypeScript eslint-disable comment breaks neither the gate nor the token lint', run: all(h, [
262
+ probe(typescriptDirective, 'none'),
263
+ tokenProbe(typescriptDirective, 'none'),
264
+ ], `${[GATES, ...tokenConfigs].join(', ')}: a TypeScript directive is no error`) },
265
+ { what: 'a token lint on its own is the package\'s tokenConfig, not built by hand', run: () =>
266
+ (h.exists(at('eslint.tokens.config.js'))
267
+ ? h.contains(at('eslint.tokens.config.js'), /\btokenConfig\(/, 'eslint.tokens.config.js runs tokenConfig from @estiva-app/ui/gates')
268
+ : h.PASS('no token lint on its own')) },
269
+ ]),
251
270
  ]
252
271
  }
@@ -30,7 +30,7 @@ describe('create-estiva-app', () => {
30
30
  it('makes the frame, the checks and the gates, each as a file that imports the package', () => {
31
31
  expect(Object.keys(files).sort()).toEqual(
32
32
  [
33
- '.claude/settings.json', '.env.example', '.gates-count.json', '.github/workflows/deploy.yml', '.gitignore', '.storybook/main.ts', '.storybook/preview.tsx',
33
+ '.claude/settings.json', '.claude/skills/estiva-ui/SKILL.md', '.env.example', '.gates-count.json', '.github/workflows/deploy.yml', '.gitignore', '.storybook/main.ts', '.storybook/preview.tsx',
34
34
  'CLAUDE.md', 'README.md', 'docs/GATES-DEBT.md', 'eslint.config.js', 'eslint.gates.config.js', 'eslint.tokens.config.js', 'index.html', 'package.json',
35
35
  'postcss.config.js', 'scripts/gates-checks.mjs', 'src/App.test.tsx', 'src/App.tsx', 'src/auth/AuthShell.tsx', 'src/auth/boot.ts', 'src/auth/estivaId.ts',
36
36
  'src/config.ts', 'src/index.css', 'src/main.tsx', 'src/pages/HomePage.stories.tsx', 'src/pages/HomePage.tsx',
@@ -41,6 +41,10 @@ describe('create-estiva-app', () => {
41
41
  expect(files['eslint.gates.config.js']).toContain("from '@estiva-app/ui/gates'")
42
42
  expect(files['scripts/gates-checks.mjs']).toContain("appChecks(h, { page: 'src/pages/HomePage.tsx' })")
43
43
  expect(JSON.parse(files['.claude/settings.json']).hooks.PreToolUse[0].hooks[0].command).toBe('node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook')
44
+ // The skill loads from the package's one copy: the app holds its trigger and the line that reads it (UIG-20).
45
+ expect(JSON.parse(files['.claude/settings.json']).permissions.allow).toContain('Bash(npm run ui:find *)')
46
+ expect(files['.claude/skills/estiva-ui/SKILL.md']).toContain('!`cat "${CLAUDE_PROJECT_DIR}/node_modules/@estiva-app/ui/skill/estiva-ui.md"`')
47
+ expect(files['.claude/skills/estiva-ui/SKILL.md']).not.toContain('## Steps')
44
48
  })
45
49
 
46
50
  it('uses the sidebar frame and one theme, set on <html>', () => {
@@ -35,6 +35,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
35
35
  import { dirname, join, resolve } from 'node:path'
36
36
  import { fileURLToPath } from 'node:url'
37
37
  import { APP_RULE_IDS } from '../eslint/index'
38
+ import { loaderText, SEARCH_RULES } from '../registry/skill'
38
39
 
39
40
  interface PackageJson {
40
41
  version: string
@@ -264,18 +265,13 @@ export default defineConfig([
264
265
  gateLint(),
265
266
  ])
266
267
  `,
267
- 'eslint.tokens.config.js': `import { TOKEN_LINT_IGNORES, tokenLint, tokenValues } from '@estiva-app/ui/gates'
268
+ 'eslint.tokens.config.js': `import { tokenConfig } from '@estiva-app/ui/gates'
268
269
  import reactHooks from 'eslint-plugin-react-hooks'
269
- import { defineConfig, globalIgnores } from 'eslint/config'
270
270
 
271
- // The token contract on its own (\`npm run lint:tokens\`). React's hooks plugin is
272
- // registered with its rules off, so its directives in the source do not break it.
273
- export default defineConfig([
274
- globalIgnores(TOKEN_LINT_IGNORES),
275
- { plugins: { 'react-hooks': reactHooks }, linterOptions: { reportUnusedDisableDirectives: 'off' } },
276
- tokenLint(),
277
- tokenValues(),
278
- ])
271
+ // The token contract on its own (\`npm run lint:tokens\`), the package's piece.
272
+ // React's hooks plugin is named so its directives do not break it; TypeScript's
273
+ // the package names itself.
274
+ export default tokenConfig({ quiet: { 'react-hooks': reactHooks } })
279
275
  `,
280
276
  'eslint.gates.config.js': `import { gateConfig } from '@estiva-app/ui/gates'
281
277
  import reactHooks from 'eslint-plugin-react-hooks'
@@ -284,7 +280,8 @@ import reactHooks from 'eslint-plugin-react-hooks'
284
280
  * The UI Guardrails' rules on their own — \`npm run lint:rules\`, CI's job \`gate\`
285
281
  * (GitHub requires it on main: never rename it), and what the editor hook in
286
282
  * \`.claude/settings.json\` lints a proposed write with. The package's gate,
287
- * imported. React's hooks plugin is named so its directives do not break it.
283
+ * imported. React's hooks plugin is named so its directives do not break it;
284
+ * TypeScript's the package names itself.
288
285
  *
289
286
  * A place that keeps something the gate refuses says why, on the line above:
290
287
  * \`// @estiva-escape: <reason>\`. Never \`eslint-disable\`: the count refuses it.
@@ -296,8 +293,12 @@ export default gateConfig({ quiet: { 'react-hooks': reactHooks } })
296
293
  hooks: {
297
294
  PreToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook' }] }],
298
295
  },
296
+ permissions: { allow: SEARCH_RULES },
299
297
  }),
300
298
 
299
+ // The Claude skill (UIG-20): its trigger, and a line reading the installed package's text in.
300
+ '.claude/skills/estiva-ui/SKILL.md': loaderText(resolve('/app'), resolve('/app/node_modules/@estiva-app/ui')),
301
+
301
302
  'scripts/gates-checks.mjs': `import { appChecks } from '@estiva-app/ui/gates'
302
303
 
303
304
  /**
@@ -20,7 +20,7 @@ import type { ESLint, Linter } from 'eslint'
20
20
  import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
21
21
  import { parser as typescriptParser } from 'typescript-eslint'
22
22
  import estiva from '../eslint/index'
23
- import { TOKEN_LINT_IGNORES } from './token-lint'
23
+ import { KNOWN_PLUGINS, TOKEN_LINT_IGNORES } from './token-lint'
24
24
 
25
25
  export interface GateConfigOptions {
26
26
  /** `app` (the default) runs `configs.recommended`; `package` runs this package's inward set. */
@@ -29,7 +29,7 @@ export interface GateConfigOptions {
29
29
  * Plugins whose `eslint-disable` directives appear in the source, registered
30
30
  * here with every rule off. ESLint refuses a directive for a rule it does not
31
31
  * know, so without them the gate would fail on another lint's comments.
32
- * The token lint's names are registered already.
32
+ * The token lint's names and TypeScript's are registered already (UIG-37).
33
33
  */
34
34
  quiet?: Record<string, ESLint.Plugin>
35
35
  /** Folders the gate never reads, beside build output. */
@@ -63,6 +63,7 @@ export function gateConfig({ audience = 'app', quiet = {}, ignores = [] }: GateC
63
63
  { ignores: [...TOKEN_LINT_IGNORES, ...ignores] },
64
64
  {
65
65
  plugins: {
66
+ ...KNOWN_PLUGINS,
66
67
  ...quiet,
67
68
  'better-tailwindcss': betterTailwindcss,
68
69
  'token-values': betterTailwindcss,
@@ -7,7 +7,7 @@ import { writeGateCount } from './count'
7
7
  import { gateConfig, gateLint } from './gate-config'
8
8
  import { runHook } from './hook'
9
9
  import { helpers, runStatus } from './status'
10
- import { tokenLint, tokenValues } from './token-lint'
10
+ import { tokenConfig, tokenLint, tokenValues } from './token-lint'
11
11
 
12
12
  /**
13
13
  * The gate pieces, as an app and this package use them (UIG-10, docs/GATES.md
@@ -44,6 +44,8 @@ async function lintWith(config: Linter.Config[], code: string, filePath: string)
44
44
  }
45
45
 
46
46
  const component = (body: string) => `export function Probe() {\n return ${body}\n}\n`
47
+ /** A line TypeScript's own lint writes: Peek PR #324 failed the token lint on it (UIG-37). */
48
+ const typescriptDirective = '// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const probe: any = 1\n'
47
49
 
48
50
  describe('tokenLint and tokenValues', () => {
49
51
  it('refuse a hand-written text size and name the token, in the words for an app', async () => {
@@ -98,6 +100,38 @@ describe('gateConfig', () => {
98
100
  const directive = `// eslint-disable-next-line house/some-rule\n${component('<div>x</div>')}`
99
101
  expect(await lintWith(gateConfig({ quiet: { house: quiet } }), directive, 'src/Probe.tsx')).toEqual([])
100
102
  })
103
+
104
+ it("knows TypeScript's rule names, so a TypeScript directive does not break the gate (UIG-37)", async () => {
105
+ expect(await lintWith(gateConfig(), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
106
+ // An app that names the plugin itself, from its own copy, still loads: its copy wins.
107
+ const own = { rules: { 'no-explicit-any': { create: () => ({}) } } }
108
+ expect(await lintWith(gateConfig({ quiet: { '@typescript-eslint': own } }), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
109
+ })
110
+ })
111
+
112
+ describe('tokenConfig', () => {
113
+ it('runs the token contract on its own: the same messages as the two pieces, for an app and for the package', async () => {
114
+ const code = component('<div className="bg-gray-100 rounded-[3px]">x</div>')
115
+ const app = await lintWith(tokenConfig(), code, 'src/Probe.tsx')
116
+ expect(app.length).toBeGreaterThan(0)
117
+ expect(app).toEqual(await lintWith([tokenLint(), tokenValues()], code, 'src/Probe.tsx'))
118
+ const pkg = await lintWith(tokenConfig({ audience: 'package' }), code, 'src/Probe.tsx')
119
+ expect(pkg).toEqual(await lintWith([tokenLint({ audience: 'package' }), tokenValues({ audience: 'package' })], code, 'src/Probe.tsx'))
120
+ })
121
+
122
+ it("knows TypeScript's rule names and the quiet plugins, so their directives do not break it (UIG-37)", async () => {
123
+ expect(await lintWith(tokenConfig(), typescriptDirective, 'src/lib/probe.ts')).toEqual([])
124
+ const quiet = { rules: { 'some-rule': { create: () => ({}) } } }
125
+ const directive = `// eslint-disable-next-line house/some-rule\n${component('<div>x</div>')}`
126
+ expect(await lintWith(tokenConfig({ quiet: { house: quiet } }), directive, 'src/Probe.tsx')).toEqual([])
127
+ })
128
+
129
+ it('never reads build output, nor a folder the app names', async () => {
130
+ const hand = component('<div className="text-[14px]">x</div>')
131
+ const ignored = (messages: Linter.LintMessage[]) => messages.every((m) => !m.ruleId)
132
+ expect(ignored(await lintWith(tokenConfig(), hand, 'dist/Probe.tsx'))).toBe(true)
133
+ expect(ignored(await lintWith(tokenConfig({ ignores: ['demo'] }), hand, 'demo/Probe.tsx'))).toBe(true)
134
+ })
101
135
  })
102
136
 
103
137
  describe('writeGateCount', () => {
@@ -144,6 +178,31 @@ describe('runHook', () => {
144
178
  }
145
179
  })
146
180
 
181
+ it('stops a new part once with what already exists, then lets it through (UIG-20)', async () => {
182
+ const seen = mkdtempSync(join(scratch, 'seen-'))
183
+ const input = { ...write('src/PanelHeader.tsx', 'export function PanelHeader() {\n return <div>x</div>\n}\n'), session_id: 'one' }
184
+ const first = await runHook({ root: dir, input, seen })
185
+ expect(first.code).toBe(2)
186
+ expect(first.message).toContain('src/PanelHeader.tsx was not written yet: it adds a new part, PanelHeader.')
187
+ expect(first.message).toContain('What already exists for "panel header"')
188
+ expect(first.message).toContain('ContainerHeader')
189
+ expect((await runHook({ root: dir, input, seen })).code).toBe(0)
190
+ // Another session is asked again.
191
+ expect((await runHook({ root: dir, input: { ...input, session_id: 'two' }, seen })).code).toBe(2)
192
+ // The search reads every app beside this checkout: seconds on a machine that holds them all.
193
+ }, 60_000)
194
+
195
+ it('does not stop a file already there, a story, or a file that draws nothing', async () => {
196
+ const seen = mkdtempSync(join(scratch, 'seen-'))
197
+ for (const input of [
198
+ write('src/Page.tsx', 'export function Page() {\n return <div>y</div>\n}\n'),
199
+ write('src/Panel.stories.tsx', 'export const Default = () => <div>x</div>\n'),
200
+ write('src/format.tsx', 'export function formatDate(date: Date) {\n return date.toISOString()\n}\n'),
201
+ ]) {
202
+ expect((await runHook({ root: dir, input, seen })).code).toBe(0)
203
+ }
204
+ })
205
+
147
206
  it('finds the app in a folder of its own, as Ship keeps it in web/', async () => {
148
207
  const top = join(scratch, 'top')
149
208
  mkdirSync(top, { recursive: true })
package/src/gates/hook.ts CHANGED
@@ -22,13 +22,24 @@
22
22
  * `replace_all` for Edit. An Edit carries no whole file, so the edit is applied
23
23
  * to the file on disk here. Anything it cannot read passes, and the tool itself
24
24
  * reports its own failure.
25
+ *
26
+ * **Search first** (UIG-20). A Write that would create a new file drawing a new
27
+ * part, and that the gate lets through, is stopped once: the hook runs
28
+ * `estiva-ui find` on the part's name and hands the matches back, so a session
29
+ * sees what already exists before it adds another. The same file written again
30
+ * in the same session goes through. The skill asks for the search; this makes
31
+ * sure it happened. A search that cannot run stops nothing.
25
32
  */
26
- import { existsSync, readFileSync } from 'node:fs'
33
+ import { spawnSync } from 'node:child_process'
34
+ import { createHash } from 'node:crypto'
35
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
27
36
  import { createRequire } from 'node:module'
28
- import { isAbsolute, join, relative, resolve, sep } from 'node:path'
29
- import { pathToFileURL } from 'node:url'
37
+ import { tmpdir } from 'node:os'
38
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
39
+ import { fileURLToPath, pathToFileURL } from 'node:url'
30
40
  import type { ESLint as ESLintClass } from 'eslint'
31
41
  import { countGates } from '../eslint/index'
42
+ import { repositoryOf } from '../registry/skill'
32
43
 
33
44
  export interface HookOptions {
34
45
  /** The repository's top folder. Defaults to `$CLAUDE_PROJECT_DIR`, then the tool call's `cwd`. */
@@ -39,6 +50,8 @@ export interface HookOptions {
39
50
  audience?: 'app' | 'package'
40
51
  /** The hook's input, when not read from stdin (tests). */
41
52
  input?: unknown
53
+ /** Where a session's searches are remembered. Defaults to a folder in the system's temp. */
54
+ seen?: string
42
55
  }
43
56
 
44
57
  export interface HookResult {
@@ -50,13 +63,14 @@ export interface HookResult {
50
63
 
51
64
  interface ToolCall {
52
65
  tool_name?: string
66
+ session_id?: string
53
67
  cwd?: string
54
68
  tool_input?: { file_path?: unknown; content?: unknown; old_string?: unknown; new_string?: unknown; replace_all?: unknown }
55
69
  }
56
70
 
57
71
  const PASS: HookResult = { code: 0, message: '' }
58
72
 
59
- export async function runHook({ root, app = '.', audience = 'app', input }: HookOptions = {}): Promise<HookResult> {
73
+ export async function runHook({ root, app = '.', audience = 'app', input, seen }: HookOptions = {}): Promise<HookResult> {
60
74
  let call: ToolCall
61
75
  try {
62
76
  call = (input ?? JSON.parse(readFileSync(0, 'utf8'))) as ToolCall
@@ -86,7 +100,7 @@ export async function runHook({ root, app = '.', audience = 'app', input }: Hook
86
100
  // the typecheck's to judge; the gate looks again at the edit that completes it.
87
101
  const errors = results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId))
88
102
  const { disabled } = countGates(results)
89
- if (errors.length === 0 && disabled.length === 0) return PASS
103
+ if (errors.length === 0 && disabled.length === 0) return searchFirst(call, file, text, appDir, top, seen) ?? PASS
90
104
 
91
105
  const shown = (p: string) => relative(top, p).split(sep).join('/')
92
106
  const lines = [`${shown(file)} was not written: the UI Guardrails refuse it (${shown(config)}).`]
@@ -96,6 +110,56 @@ export async function runHook({ root, app = '.', audience = 'app', input }: Hook
96
110
  return { code: 2, message: `${lines.join('\n')}\n` }
97
111
  }
98
112
 
113
+ /** The part a Write would add in a new file, or null: a capitalised export, in a file that draws. */
114
+ export function newPart(tool: string | undefined, file: string, text: string): string | null {
115
+ if (tool !== 'Write' || existsSync(file) || !file.endsWith('.tsx') || /\.stories\.tsx$/.test(file)) return null
116
+ if (!/<\/[A-Za-z]|\/>/.test(text)) return null
117
+ const named = /export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class)\s+([A-Z][A-Za-z0-9]*)/.exec(text)?.[1]
118
+ if (named) return named
119
+ return /export\s+default\s+function\s*\(/.test(text) ? basename(file, '.tsx') : null
120
+ }
121
+
122
+ /** `PanelHeader` → `panel header`: the words the search is asked. */
123
+ export const wordsOf = (name: string) => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z])([A-Z][a-z])/g, '$1 $2').toLowerCase()
124
+
125
+ /** The catalogue command: beside this file once built, or the built one from source (tests). */
126
+ function findCommand(): string | null {
127
+ for (const at of ['../registry/cli.js', '../../dist/registry/cli.js']) {
128
+ const path = fileURLToPath(new URL(at, import.meta.url))
129
+ if (existsSync(path)) return path
130
+ }
131
+ return null
132
+ }
133
+
134
+ /** Stop a new part's first Write with what already exists; null lets it through. */
135
+ function searchFirst(call: ToolCall, file: string, text: string, appDir: string, top: string, seen?: string): HookResult | null {
136
+ const part = newPart(call.tool_name, file, text)
137
+ const command = findCommand()
138
+ if (!part || !command) return null
139
+ const folder = seen ?? join(tmpdir(), 'estiva-ui-search-first')
140
+ const mark = join(folder, createHash('sha1').update(`${call.session_id ?? ''}\n${resolve(file)}`).digest('hex'))
141
+ if (existsSync(mark)) return null
142
+ const words = wordsOf(part)
143
+ const repo = basename(repositoryOf(appDir))
144
+ const run = spawnSync(process.execPath, [command, 'find', ...words.split(' '), '--root', appDir, '--repo', repo, '--limit', '5'], { encoding: 'utf8', timeout: 60_000 })
145
+ if (run.status !== 0 || !run.stdout.trim()) return null
146
+ try {
147
+ mkdirSync(folder, { recursive: true })
148
+ writeFileSync(mark, '')
149
+ } catch {
150
+ return null
151
+ }
152
+ const shown = relative(top, file).split(sep).join('/')
153
+ return {
154
+ code: 2,
155
+ message:
156
+ `${shown} was not written yet: it adds a new part, ${part}. Search before building (UIG-20). What already exists for "${words}":\n\n` +
157
+ `${run.stdout.trim()}\n\n` +
158
+ `If one of these does the job, use it, and read its page first. If the words were wrong, run \`npm run ui:find <other words>\`. ` +
159
+ `If nothing fits, tell the person, then write the file again: it goes through.\n`,
160
+ }
161
+ }
162
+
99
163
  /** The file as it will be after this tool call, or null when that cannot be known. */
100
164
  function proposed(tool: string | undefined, args: NonNullable<ToolCall['tool_input']>, file: string): string | null {
101
165
  if (tool === 'Write') return typeof args.content === 'string' ? args.content : null
@@ -8,6 +8,7 @@
8
8
  * here, and an app imports them:
9
9
  *
10
10
  * - `tokenLint`, `tokenValues` — the token contract's lint settings (UIG-28).
11
+ * - `tokenConfig` — the token lint on its own, as `lint:tokens` runs it (UIG-37).
11
12
  * - `gateLint`, `gateConfig` — where the rules apply, and the gate that runs them alone.
12
13
  * - `writeGateCount` — `.gates-count.json` (seam S3).
13
14
  * - `runHook` — the editor gate a `PreToolUse` hook runs.
@@ -21,7 +22,7 @@
21
22
  * Built by build.mjs into `dist/gates/`, for Node. Nothing here reaches the
22
23
  * components' browser bundle.
23
24
  */
24
- export { TOKEN_LINT_IGNORES, tokenLint, tokenValues, type TokenAudience, type TokenLintOptions } from './token-lint'
25
+ export { TOKEN_LINT_IGNORES, tokenConfig, tokenLint, tokenValues, type TokenAudience, type TokenConfigOptions, type TokenLintOptions } from './token-lint'
25
26
  export { gateConfig, gateLint, type GateConfigOptions } from './gate-config'
26
27
  export { writeGateCount, type CountOptions, type CountResult } from './count'
27
28
  export { runHook, type HookOptions, type HookResult } from './hook'