@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.
- package/README.md +5 -7
- package/dist/cn.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-XE6HC5MJ.js → chunk-JZE7TIQY.js} +9 -2
- package/dist/gates/chunk-JZE7TIQY.js.map +7 -0
- package/dist/gates/{chunk-I7CJLUPM.js → chunk-XAPFC37F.js} +73 -6
- package/dist/gates/chunk-XAPFC37F.js.map +7 -0
- package/dist/gates/chunk-YKZXKVR2.js +39 -0
- package/dist/gates/chunk-YKZXKVR2.js.map +7 -0
- package/dist/gates/cli.js +2 -1
- package/dist/gates/cli.js.map +1 -1
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +2 -1
- package/dist/gates/create-app.js.map +1 -1
- package/dist/gates/hook.d.ts +7 -1
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.js +26 -7
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +6 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/index.js.map +2 -2
- package/dist/registry/{chunk-6OH3TWGF.js → chunk-KMZIMB3G.js} +5 -2
- package/dist/registry/{chunk-6OH3TWGF.js.map → chunk-KMZIMB3G.js.map} +2 -2
- package/dist/registry/cli.js +187 -25
- package/dist/registry/cli.js.map +4 -4
- package/dist/registry/find.d.ts +5 -0
- package/dist/registry/find.d.ts.map +1 -1
- package/dist/registry/index.js +1 -1
- package/dist/registry/siblings.d.ts +21 -0
- package/dist/registry/siblings.d.ts.map +1 -0
- package/dist/registry/skill.d.ts +20 -0
- package/dist/registry/skill.d.ts.map +1 -0
- package/package.json +4 -2
- package/registry.json +1 -1
- package/skill/estiva-ui.md +128 -0
- package/src/ScrollArea.mdx +8 -4
- package/src/ScrollArea.stories.tsx +6 -6
- package/src/cn.ts +1 -0
- package/src/gates/app-checks.ts +15 -2
- package/src/gates/create-app.test.ts +8 -1
- package/src/gates/create-app.ts +5 -0
- package/src/gates/gates.test.ts +46 -0
- package/src/gates/hook.ts +69 -5
- package/src/gates/status.ts +25 -0
- package/src/gates/token-lint.ts +5 -0
- package/src/registry/app.test.ts +58 -1
- package/src/registry/cli.ts +60 -8
- package/src/registry/find.ts +10 -1
- package/src/registry/siblings.test.ts +85 -0
- package/src/registry/siblings.ts +96 -0
- package/src/registry/skill.test.ts +96 -0
- package/src/registry/skill.ts +117 -0
- package/dist/gates/chunk-I7CJLUPM.js.map +0 -7
- package/dist/gates/chunk-XE6HC5MJ.js.map +0 -7
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"find.d.ts","sourceRoot":"","sources":["../../src/registry/find.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAEvD,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,aAAa,CAAA;IACpB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,uDAAuD;IACvD,KAAK,EAAE,MAAM,EAAE,CAAA;IACf;;;;;OAKG;IACH,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB;AAoDD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,KAAS,EAAE,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,EAAE,CAEnH;AAED
|
|
1
|
+
{"version":3,"file":"find.d.ts","sourceRoot":"","sources":["../../src/registry/find.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAEvD,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,aAAa,CAAA;IACpB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,uDAAuD;IACvD,KAAK,EAAE,MAAM,EAAE,CAAA;IACf;;;;;OAKG;IACH,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB;AAoDD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,KAAS,EAAE,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,EAAE,CAEnH;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,QAAQ,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,KAAS,EAAE,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,EAAE,CAuEzH;AAED,yGAAyG;AACzG,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,aAAa,GAAG,MAAM,GAAG,IAAI,CAGhF;AAUD,gGAAgG;AAChG,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,QAAQ,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAgD1G"}
|
package/dist/registry/index.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.30.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
|
|
71
|
+
"ui:find": "node dist/registry/cli.js find",
|
|
70
72
|
"prepublishOnly": "npm run build"
|
|
71
73
|
},
|
|
72
74
|
"peerDependencies": {
|
package/registry.json
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
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 its reason on the line above it. For the gate:
|
|
35
|
+
`// @estiva-escape: <reason>`, never `eslint-disable`. For the token lint:
|
|
36
|
+
`// eslint-disable-next-line <rule> -- @estiva-escape: <reason>`.
|
|
37
|
+
|
|
38
|
+
## Gotchas
|
|
39
|
+
|
|
40
|
+
The mistakes made here more than once, from the record (UIG-20 counted 542
|
|
41
|
+
recorded defects; these 24 cover the 111 that are screen work). Each says what
|
|
42
|
+
to do instead. Apps differ in frame and theme: never carry one app's layout,
|
|
43
|
+
sizes or colours into another; take them from tokens.
|
|
44
|
+
|
|
45
|
+
**Layout**
|
|
46
|
+
|
|
47
|
+
1. **A header row drawn by hand** at the top of a column → `ContainerHeader`.
|
|
48
|
+
Its actions are `IconButton`s with tooltips. A whole list column is
|
|
49
|
+
`ListColumn`, which draws the bar; a field that adds to the list goes in its
|
|
50
|
+
`above`.
|
|
51
|
+
2. **A list with no scroll container**, cut off at the fold (no lint sees a box
|
|
52
|
+
that was never built) → a list column is `ListColumn`, which scrolls its
|
|
53
|
+
rows; anything else that grows goes in a `ScrollArea`. Check with more rows
|
|
54
|
+
than fit.
|
|
55
|
+
3. **`EmptyState` at the wrong level** → `scope="page"` only when the whole
|
|
56
|
+
page is empty, `scope="section"` for one part of it, placed inside the box
|
|
57
|
+
its rows live in. Never wrap it in a box of your own that places it.
|
|
58
|
+
4. **`EditableText` rendering too large**: it has no size of its own and takes
|
|
59
|
+
the size around it → put the text size on your own element around it. The
|
|
60
|
+
same holds for `Person`, `Link`, `Card` and `SectionLabel`.
|
|
61
|
+
5. **Flex rows that move things**: loose children of an `items-start` row float
|
|
62
|
+
at different heights; a scrolling flex column squashes rows → group them in
|
|
63
|
+
one `items-center` row; give your own rows in a scrolling column `shrink-0`.
|
|
64
|
+
6. **A group heading drawn by hand** (a row holding a `SectionLabel`) →
|
|
65
|
+
`SectionHeader` (`hover="none"` when nothing acts on it); a group that folds
|
|
66
|
+
is `CollapsibleSection`.
|
|
67
|
+
7. **A line drawn by hand** (`border-t`, `<hr>`, a gap) → `Divider`; between a
|
|
68
|
+
menu's groups, `MenuSeparator`, which brings its own room.
|
|
69
|
+
|
|
70
|
+
**Parts and props**
|
|
71
|
+
|
|
72
|
+
8. **The browser's tooltip** (`title=`), or an icon button with none →
|
|
73
|
+
`WithTooltip` (`inline` inside text) or `IconButton`'s `tooltip`.
|
|
74
|
+
9. **A row of icon buttons laid out by hand** → `Toolbar` with `ToolbarButton`
|
|
75
|
+
and `ToolbarLink` (they throw outside one). A row inside a `Popover` is a
|
|
76
|
+
`Toolbar` with `surface={false}`.
|
|
77
|
+
10. **Fields sent by a hand-written Enter handler**, with no form → `Form`,
|
|
78
|
+
which owns the keys, `busy` and focus. Clear a field's `error` when it
|
|
79
|
+
changes, or the form will not send.
|
|
80
|
+
11. **A link to an in-app page as a bare `href`**: every click reloads the app
|
|
81
|
+
→ go through the app's router, as `NavItem` does.
|
|
82
|
+
12. **Wrong loading and failure states**: an `EmptyState` while data is still
|
|
83
|
+
coming, a hand-made pulsing box → a skeleton shaped like what is coming
|
|
84
|
+
(`SkeletonList`, `SkeletonRow`, `SkeletonBar`). Say a failure in
|
|
85
|
+
words, never as "empty" and never in red; a failure toast or banner has
|
|
86
|
+
the `error` tone.
|
|
87
|
+
13. **A floating panel mounted only while open** (`{open && <Popover…>}`), or
|
|
88
|
+
capped with a class → keep it mounted and drive `open`; cap a long list with
|
|
89
|
+
`Popover`'s `maxHeight`; never open a panel around an empty list.
|
|
90
|
+
14. **Deleting a part without checking what else it carried** → read everything
|
|
91
|
+
the container holds first, and remove its imports, escapes and stories with it.
|
|
92
|
+
|
|
93
|
+
**Stories and usage pages**
|
|
94
|
+
|
|
95
|
+
15. **A story that shows one branch**: something drawn behind `{prop && …}` that
|
|
96
|
+
no story passes, or data with no fixture → a story for every branch; one
|
|
97
|
+
fixture holding what the backend really sends. `estiva-ui check` refuses
|
|
98
|
+
the first.
|
|
99
|
+
16. **A screen with no story** is where mistakes hide: every gate was green on
|
|
100
|
+
a page with eight → look at the running screen, or give it a story with
|
|
101
|
+
more rows than fit, an empty state and a loading state.
|
|
102
|
+
17. **A story under a new heading, or named after its file** → use a heading
|
|
103
|
+
the Storybook already has, name the entry after its part, give each meta its
|
|
104
|
+
`component` (or the part's page a **Seen in** line).
|
|
105
|
+
18. **`{…}` in a usage page's prose** (MDX reads it as code and the page will
|
|
106
|
+
not compile), or a description over two lines → backticks around braces; a
|
|
107
|
+
part's description is one line.
|
|
108
|
+
|
|
109
|
+
**Working in these repositories**
|
|
110
|
+
|
|
111
|
+
19. **An escape with a false reason** ("no part does this" has been wrong
|
|
112
|
+
twice) → search for the thing first. Write the escape on the line above
|
|
113
|
+
the statement: a gate escape never inside `eslint-disable`, a token-lint
|
|
114
|
+
one inside `eslint-disable-next-line <rule> --`.
|
|
115
|
+
20. **Classes the lint cannot see**: in a const not named `…_CLASSES` or
|
|
116
|
+
`…Styles`, or in an array that is `.join()`ed → keep classes in
|
|
117
|
+
`className` or `cn()`, or in a map with one of those names.
|
|
118
|
+
21. **The editor gate and this skill need a session started at the repository's
|
|
119
|
+
top folder.** Started elsewhere, run the repository's `lint:rules` yourself
|
|
120
|
+
before committing.
|
|
121
|
+
22. **`npm install` on Windows prunes Linux-only entries from the lockfile**, and
|
|
122
|
+
CI dies at `npm ci` → never commit a lockfile Windows rewrote; restore it and
|
|
123
|
+
bump only the package's own entry. Stop every dev server before `npm ci`.
|
|
124
|
+
23. **Storybook shows old code after a package bump** → delete
|
|
125
|
+
`node_modules/.cache/storybook` and restart.
|
|
126
|
+
24. **A local checkout lags `origin/main`, and a conflicting PR shows no CI** →
|
|
127
|
+
`git fetch` and read `origin/main`; when checks are missing, look for a
|
|
128
|
+
conflict first.
|
package/src/ScrollArea.mdx
CHANGED
|
@@ -33,14 +33,18 @@ the content is moving; nothing moves when it appears.
|
|
|
33
33
|
```tsx
|
|
34
34
|
import { ScrollArea } from '@estiva-app/ui'
|
|
35
35
|
|
|
36
|
-
<ScrollArea className="h-[240px]"
|
|
36
|
+
<ScrollArea className="h-[240px]" contentClassName="flex flex-col p-2">
|
|
37
37
|
{rows}
|
|
38
38
|
</ScrollArea>
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
- Three boxes, and each class prop lands on one. `className` is the
|
|
42
|
+
region: its size and placement. `viewportClassName` is the box that
|
|
43
|
+
scrolls: a `max-h-*` cap goes there and nowhere else. `contentClassName`
|
|
44
|
+
is the box the children sit in: the padding, the gap, `flex flex-col`.
|
|
45
|
+
- The region needs a height to have anything to scroll. A cap on the region
|
|
46
|
+
lets the viewport grow to its content and nothing scrolls; cap the
|
|
47
|
+
viewport instead: `viewportClassName="max-h-[240px]"`.
|
|
44
48
|
- **`viewportRef` and `onScroll` hand back the box that scrolls**, for a
|
|
45
49
|
region that reads or drives its own scrolling: a conversation that arrives
|
|
46
50
|
at its newest message, keeps its place when older ones load above it, and
|
|
@@ -18,7 +18,7 @@ const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`)
|
|
|
18
18
|
export const Default: Story = {
|
|
19
19
|
render: () => (
|
|
20
20
|
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
21
|
-
<ScrollArea className="h-full"
|
|
21
|
+
<ScrollArea className="h-full" contentClassName="p-2">
|
|
22
22
|
<ul className="flex flex-col gap-px">
|
|
23
23
|
{rows.map((row) => (
|
|
24
24
|
<li key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -55,7 +55,7 @@ export const StickyHeadings: Story = {
|
|
|
55
55
|
export const Fits: Story = {
|
|
56
56
|
render: () => (
|
|
57
57
|
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
58
|
-
<ScrollArea className="h-full"
|
|
58
|
+
<ScrollArea className="h-full" contentClassName="p-2">
|
|
59
59
|
<ul className="flex flex-col gap-px">
|
|
60
60
|
{rows.slice(0, 4).map((row) => (
|
|
61
61
|
<li key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -72,7 +72,7 @@ export const Fits: Story = {
|
|
|
72
72
|
export const Horizontal: Story = {
|
|
73
73
|
render: () => (
|
|
74
74
|
<div className="w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
75
|
-
<ScrollArea orientation="horizontal"
|
|
75
|
+
<ScrollArea orientation="horizontal" contentClassName="p-2">
|
|
76
76
|
<div className="flex w-max gap-2">
|
|
77
77
|
{rows.slice(0, 12).map((row) => (
|
|
78
78
|
<div key={row} className="w-[120px] shrink-0 rounded-md bg-bg-inset px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -89,7 +89,7 @@ export const Horizontal: Story = {
|
|
|
89
89
|
export const Both: Story = {
|
|
90
90
|
render: () => (
|
|
91
91
|
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
92
|
-
<ScrollArea orientation="both" className="h-full"
|
|
92
|
+
<ScrollArea orientation="both" className="h-full" contentClassName="p-2">
|
|
93
93
|
<div className="flex w-max flex-col gap-px">
|
|
94
94
|
{rows.map((row) => (
|
|
95
95
|
<div key={row} className="w-[480px] rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -106,7 +106,7 @@ export const Both: Story = {
|
|
|
106
106
|
export const SidewaysInsideAPage: Story = {
|
|
107
107
|
render: () => (
|
|
108
108
|
<div className="h-[240px] w-[280px] rounded-lg border border-border-default bg-bg-surface">
|
|
109
|
-
<ScrollArea className="h-full"
|
|
109
|
+
<ScrollArea className="h-full" contentClassName="p-2">
|
|
110
110
|
<div className="flex flex-col gap-px">
|
|
111
111
|
{rows.slice(0, 3).map((row) => (
|
|
112
112
|
<p key={row} className="rounded-md px-2 py-1.5 text-body-2 text-text-primary">
|
|
@@ -114,7 +114,7 @@ export const SidewaysInsideAPage: Story = {
|
|
|
114
114
|
</p>
|
|
115
115
|
))}
|
|
116
116
|
<div className="my-1 rounded-md border border-border-default">
|
|
117
|
-
<ScrollArea orientation="horizontal"
|
|
117
|
+
<ScrollArea orientation="horizontal" contentClassName="p-2">
|
|
118
118
|
<div className="flex w-max gap-2">
|
|
119
119
|
{rows.slice(0, 12).map((row) => (
|
|
120
120
|
<div key={row} className="w-[120px] shrink-0 rounded-md bg-bg-inset px-2 py-1.5 text-body-2 text-text-primary">
|
package/src/cn.ts
CHANGED
package/src/gates/app-checks.ts
CHANGED
|
@@ -199,11 +199,24 @@ export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG
|
|
|
199
199
|
{ what: "CI's job gate runs it", run: () => h.ciJob('gate', 'registry:check') },
|
|
200
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') },
|
|
201
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.
|
|
202
204
|
ticket('UIG-20', [
|
|
203
|
-
{ what: '
|
|
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')) },
|
|
204
210
|
]),
|
|
211
|
+
// What loads into every Claude session (UIG-21): CLAUDE.md is an index, the
|
|
212
|
+
// rest loads where it applies, and a rule a check can hold is a check.
|
|
205
213
|
ticket('UIG-21', [
|
|
206
|
-
{ what: '
|
|
214
|
+
{ what: 'CLAUDE.md with its imports is under 200 lines, and every rule file is scoped', run: () => h.instructions(200) },
|
|
215
|
+
{ what: "CI's job gate runs the token lint", run: () => {
|
|
216
|
+
const tokens = h.ciJob('gate', 'lint:tokens')
|
|
217
|
+
return tokens.result === 'pass' ? tokens : h.ciJob('gate', 'lint')
|
|
218
|
+
} },
|
|
219
|
+
{ what: "a plain tailwind-merge is an error naming the package's cn()", run: tokenProbe("import { twMerge } from 'tailwind-merge'\nexport const merged = twMerge('p-2', 'p-3')\n", 'error', 'cn()', 'src/lib/__gates_probe__.ts') },
|
|
207
220
|
]),
|
|
208
221
|
ticket('UIG-22', [
|
|
209
222
|
{ what: 'a hand-made header row is an error naming the header part', run: all(h, headers.map((name) => probe(component(`<section className="flex flex-col"><div className="flex items-center px-3 py-2"><span>${name === 'SectionHeader' ? 'Issues' : 'Folders'}</span></div><div /></section>`), 'error', name)), 'a hand-made header row is an error naming the header part') },
|
|
@@ -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,13 @@ 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')
|
|
48
|
+
// What loads into every session starts small and imports nothing (UIG-21).
|
|
49
|
+
expect(files['CLAUDE.md'].replace(/\n$/, '').split('\n').length).toBeLessThan(200)
|
|
50
|
+
expect(files['CLAUDE.md']).not.toMatch(/^@\S/m)
|
|
44
51
|
})
|
|
45
52
|
|
|
46
53
|
it('uses the sidebar frame and one theme, set on <html>', () => {
|
package/src/gates/create-app.ts
CHANGED
|
@@ -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
|
|
@@ -292,8 +293,12 @@ export default gateConfig({ quiet: { 'react-hooks': reactHooks } })
|
|
|
292
293
|
hooks: {
|
|
293
294
|
PreToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook' }] }],
|
|
294
295
|
},
|
|
296
|
+
permissions: { allow: SEARCH_RULES },
|
|
295
297
|
}),
|
|
296
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
|
+
|
|
297
302
|
'scripts/gates-checks.mjs': `import { appChecks } from '@estiva-app/ui/gates'
|
|
298
303
|
|
|
299
304
|
/**
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -68,6 +68,15 @@ describe('tokenLint and tokenValues', () => {
|
|
|
68
68
|
expect(messages.filter((m) => m.ruleId?.startsWith('token-'))).toEqual([expect.objectContaining({ ruleId: 'token-spacing/no-restricted-classes', severity: 1 })])
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
it('refuse a plain tailwind-merge and name cn(), for an app and for the package (UIG-21)', async () => {
|
|
72
|
+
const code = "import { twMerge } from 'tailwind-merge'\nexport const merged = twMerge('p-2', 'p-3')\n"
|
|
73
|
+
const app = await lintWith([tokenLint(), tokenValues()], code, 'src/lib/probe.ts')
|
|
74
|
+
expect(app).toEqual([expect.objectContaining({ ruleId: 'no-restricted-imports', severity: 2, message: expect.stringContaining("package's cn()") })])
|
|
75
|
+
const pkg = await lintWith([tokenLint({ audience: 'package' })], code, 'src/probe.ts')
|
|
76
|
+
expect(pkg).toEqual([expect.objectContaining({ ruleId: 'no-restricted-imports', message: expect.stringContaining('src/cn.ts') })])
|
|
77
|
+
expect(await lintWith([tokenLint()], "import { cn } from '@estiva-app/ui'\nexport const merged = cn('p-2', 'p-3')\n", 'src/lib/probe.ts')).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
|
|
71
80
|
it('leave a test file to its test', async () => {
|
|
72
81
|
const messages = await lintWith([tokenLint(), tokenValues()], component('<div style={{ color: "red" }}>x</div>'), 'src/Probe.test.tsx')
|
|
73
82
|
expect(messages.filter((m) => m.severity === 2)).toEqual([])
|
|
@@ -178,6 +187,31 @@ describe('runHook', () => {
|
|
|
178
187
|
}
|
|
179
188
|
})
|
|
180
189
|
|
|
190
|
+
it('stops a new part once with what already exists, then lets it through (UIG-20)', async () => {
|
|
191
|
+
const seen = mkdtempSync(join(scratch, 'seen-'))
|
|
192
|
+
const input = { ...write('src/PanelHeader.tsx', 'export function PanelHeader() {\n return <div>x</div>\n}\n'), session_id: 'one' }
|
|
193
|
+
const first = await runHook({ root: dir, input, seen })
|
|
194
|
+
expect(first.code).toBe(2)
|
|
195
|
+
expect(first.message).toContain('src/PanelHeader.tsx was not written yet: it adds a new part, PanelHeader.')
|
|
196
|
+
expect(first.message).toContain('What already exists for "panel header"')
|
|
197
|
+
expect(first.message).toContain('ContainerHeader')
|
|
198
|
+
expect((await runHook({ root: dir, input, seen })).code).toBe(0)
|
|
199
|
+
// Another session is asked again.
|
|
200
|
+
expect((await runHook({ root: dir, input: { ...input, session_id: 'two' }, seen })).code).toBe(2)
|
|
201
|
+
// The search reads every app beside this checkout: seconds on a machine that holds them all.
|
|
202
|
+
}, 60_000)
|
|
203
|
+
|
|
204
|
+
it('does not stop a file already there, a story, or a file that draws nothing', async () => {
|
|
205
|
+
const seen = mkdtempSync(join(scratch, 'seen-'))
|
|
206
|
+
for (const input of [
|
|
207
|
+
write('src/Page.tsx', 'export function Page() {\n return <div>y</div>\n}\n'),
|
|
208
|
+
write('src/Panel.stories.tsx', 'export const Default = () => <div>x</div>\n'),
|
|
209
|
+
write('src/format.tsx', 'export function formatDate(date: Date) {\n return date.toISOString()\n}\n'),
|
|
210
|
+
]) {
|
|
211
|
+
expect((await runHook({ root: dir, input, seen })).code).toBe(0)
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
181
215
|
it('finds the app in a folder of its own, as Ship keeps it in web/', async () => {
|
|
182
216
|
const top = join(scratch, 'top')
|
|
183
217
|
mkdirSync(top, { recursive: true })
|
|
@@ -203,6 +237,18 @@ describe('gates:status', () => {
|
|
|
203
237
|
expect(h.script('package.json', 'gates:status').result).toBe('pass')
|
|
204
238
|
})
|
|
205
239
|
|
|
240
|
+
it('counts what loads into every session, imports included, and refuses a rule file with no paths (UIG-21)', () => {
|
|
241
|
+
const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i}`).join('\n') + '\n'
|
|
242
|
+
const scoped = '---\npaths:\n - "src/**"\n---\n\nA rule.\n'
|
|
243
|
+
const small = helpers(app('instructions-small', { 'CLAUDE.md': lines(40), '.claude/rules/ui.md': scoped }))
|
|
244
|
+
expect(small.instructions(200)).toMatchObject({ result: 'pass', detail: expect.stringContaining('40 lines') })
|
|
245
|
+
// 150 + 60 = 210: CLAUDE.md alone is under the limit, and its import takes it over.
|
|
246
|
+
const imported = helpers(app('instructions-import', { 'CLAUDE.md': `${lines(149)}@RULES.md\n`, 'RULES.md': lines(60) }))
|
|
247
|
+
expect(imported.instructions(200)).toMatchObject({ result: 'fail', detail: expect.stringContaining('210 lines') })
|
|
248
|
+
const unscoped = helpers(app('instructions-unscoped', { 'CLAUDE.md': lines(10), '.claude/rules/ui.md': scoped, '.claude/rules/all.md': 'Loads every time.\n' }))
|
|
249
|
+
expect(unscoped.instructions(200)).toMatchObject({ result: 'fail', detail: expect.stringContaining('.claude/rules/all.md') })
|
|
250
|
+
})
|
|
251
|
+
|
|
206
252
|
it('prints a repo on its own: its own rows, then its parts', async () => {
|
|
207
253
|
const dir = app('status-run', {
|
|
208
254
|
'scripts/gates-checks.mjs': "export default (h) => ({ repo: 'probe', tickets: [\n { ref: 'UIG-2', title: 'Rails', owner: false, checks: [{ what: 'wired', run: () => h.PASS('yes') }] },\n { ref: 'UIG-9', title: 'Allow-list', owner: false, checks: [{ what: 'a look is refused', run: () => h.FAIL('no') }] },\n] })\n",
|
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 {
|
|
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 {
|
|
29
|
-
import {
|
|
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
|
package/src/gates/status.ts
CHANGED
|
@@ -75,6 +75,12 @@ export interface GateHelpers {
|
|
|
75
75
|
catalogue(appDir: string): Promise<CheckResult>
|
|
76
76
|
/** Every reusable part keeps the usage-page contract (UIG-19): the package's one copy of it. */
|
|
77
77
|
contract(appDir: string): Promise<CheckResult>
|
|
78
|
+
/**
|
|
79
|
+
* What loads into every Claude session stays small (UIG-21): `CLAUDE.md` and
|
|
80
|
+
* every file it imports with `@` come to fewer than `limit` lines, and each
|
|
81
|
+
* `.claude/rules` file has `paths:`, so it loads only where it applies.
|
|
82
|
+
*/
|
|
83
|
+
instructions(limit?: number): CheckResult
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
export interface GateCheck {
|
|
@@ -193,6 +199,25 @@ export function helpers(ROOT: string): GateHelpers {
|
|
|
193
199
|
}
|
|
194
200
|
},
|
|
195
201
|
|
|
202
|
+
instructions(limit = 200) {
|
|
203
|
+
if (!exists('CLAUDE.md')) return FAIL('there is no CLAUDE.md')
|
|
204
|
+
// As `wc -l` counts them: a last line ending in a newline is not one more.
|
|
205
|
+
const lines = (rel: string) => read(rel).replace(/\r?\n$/, '').split(/\r?\n/).length
|
|
206
|
+
const imports = [...read('CLAUDE.md').matchAll(/^@(\S+)\s*$/gm)].map((m) => m[1])
|
|
207
|
+
const missing = imports.filter((rel) => !exists(rel))
|
|
208
|
+
if (missing.length) return FAIL(`CLAUDE.md imports ${missing.join(', ')}, which is not there`)
|
|
209
|
+
const total = imports.reduce((n, rel) => n + lines(rel), lines('CLAUDE.md'))
|
|
210
|
+
const what = imports.length ? `CLAUDE.md with ${imports.join(', ')}` : 'CLAUDE.md'
|
|
211
|
+
if (total >= limit) return FAIL(`${what}: ${total} lines load into every session; the limit is ${limit}`)
|
|
212
|
+
const rules = listFiles('.claude/rules', (n) => n.endsWith('.md'))
|
|
213
|
+
const unscoped = rules.filter((rel) => {
|
|
214
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(read(rel))
|
|
215
|
+
return !front || !/^paths:/m.test(front[1])
|
|
216
|
+
})
|
|
217
|
+
if (unscoped.length) return FAIL(`${unscoped.join(', ')} has no paths:, so it loads into every session`)
|
|
218
|
+
return PASS(`${what}: ${total} lines in every session; ${rules.length ? `${rules.length} rule files, each loading only for its paths` : 'no rule files'}`)
|
|
219
|
+
},
|
|
220
|
+
|
|
196
221
|
file(rel) {
|
|
197
222
|
return exists(rel) ? PASS(`${rel} exists`) : FAIL(`${rel} does not exist`)
|
|
198
223
|
},
|