@techninja/clearstack 0.3.13 → 0.3.15

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/docs/BUILD_LOG.md CHANGED
@@ -168,6 +168,42 @@ These are the significant corrections:
168
168
  - **Actual:** Coordinates calculated from SVG rect, not accounting for pan
169
169
  - **Fix:** Shared `canvasPos()` utility subtracts pan offset from all tools
170
170
 
171
+ ### Enumerable models (`id: true`) break tsc
172
+
173
+ - **Expected:** `@type {import('hybrids').Model<Product>}` works with `id: true`
174
+ - **Actual:** tsc rejects `id: true` — it's not in the Model type definition
175
+ - **Fix:** Type the model as `@type {any}` with a comment explaining the cast
176
+ - **Documented in:** JSDOC_TYPING.md → Enumerable Models Need `@type {any}`
177
+
178
+ ### Empty arrays in store models throw at runtime
179
+
180
+ - **Expected:** `items: []` on a singleton model works as an empty default
181
+ - **Actual:** Hybrids throws `The first item of the 'items' array must be defined`
182
+ - **Why:** Hybrids infers array item type from the first element at model setup
183
+ - **Fix:** Provide a prototype item: `items: [{ sku: '', quantity: 0 }]`
184
+ - **Documented in:** STATE_AND_ROUTING.md → Store Array Properties
185
+
186
+ ### List store descriptors don't match array types in tsc
187
+
188
+ - **Expected:** `store([Model])` assignable to an array-typed host property
189
+ - **Actual:** tsc sees `EnumerableInstance`, not `any[]` — three separate casts needed
190
+ - **Fix:** Cast the descriptor, `store.ready()` call, and the array before `.map()`
191
+ - **Documented in:** JSDOC_TYPING.md → List Store Properties Need Casts
192
+
193
+ ### Organisms importing pages creates circular dependencies
194
+
195
+ - **Expected:** `router.url(PageView)` works from an organism for link generation
196
+ - **Actual:** Page imports organism, organism imports page → circular
197
+ - **Fix:** Use string URLs (`/product/${sku}`) in organisms instead of `router.url()`
198
+ - **Documented in:** FRONTEND_IMPLEMENTATION_RULES.md → Organisms Must Not Import Pages
199
+
200
+ ### `list` connector params need cast for custom filter properties
201
+
202
+ - **Expected:** `list: async ({ category }) => ...` destructures cleanly
203
+ - **Actual:** Param is `ModelIdentifier`, tsc rejects `.category` access
204
+ - **Fix:** Cast params to `any` before accessing custom properties
205
+ - **Documented in:** JSDOC_TYPING.md → `list` Connector Params
206
+
171
207
  ---
172
208
 
173
209
  ## Metrics
@@ -212,6 +212,29 @@ Pages → Templates → Organisms → Molecules → Atoms
212
212
  Store / Utils
213
213
  ```
214
214
 
215
+ #### Organisms Must Not Import Pages
216
+
217
+ Organisms sit below pages in the import hierarchy. If an organism needs
218
+ to generate a URL to a page (e.g. a product grid linking to a product
219
+ detail page), **use a string URL, not `router.url(PageView)`**.
220
+
221
+ `router.url()` requires importing the page component, which creates a
222
+ circular dependency: page → organism → page. Even if the bundler or
223
+ browser resolves it, it makes the dependency graph untraceable.
224
+
225
+ ```javascript
226
+ // ❌ BAD — organism imports a page, circular dependency
227
+ import ProductDetailView from '#pages/product-detail/product-detail-view.js';
228
+ html`<a href="${router.url(ProductDetailView, { sku })}">View</a>`;
229
+
230
+ // ✅ GOOD — string URL, no import needed
231
+ html`<a href="${`/product/${sku}`}">View</a>`;
232
+ ```
233
+
234
+ If the URL pattern changes, update it in one place. For complex URL
235
+ generation, extract a `buildUrl(view, params)` utility in `src/utils/`
236
+ that returns strings — no component imports.
237
+
215
238
  - Atoms import **nothing** from other component tiers.
216
239
  - Molecules import only from **atoms**.
217
240
  - Organisms import from **molecules** and **atoms**.
@@ -76,6 +76,73 @@ function handleClick(host, event) {
76
76
  }
77
77
  ```
78
78
 
79
+ ## Enumerable Models Need `@type {any}`
80
+
81
+ Hybrids' TypeScript definitions don't support `id: true` on `Model<T>` —
82
+ `tsc` rejects it because `id` isn't in the typedef. The fix is to type
83
+ the entire model as `any` with a comment explaining why:
84
+
85
+ ```javascript
86
+ /** @type {any} — hybrids Model with id:true; cast to bypass tsc limitations */
87
+ const Product = {
88
+ id: true,
89
+ name: '',
90
+ // ...
91
+ [store.connect]: { get: ..., list: ... },
92
+ };
93
+ ```
94
+
95
+ Do **not** add `id` to the `@typedef` — it's a hybrids directive, not a
96
+ data field. The `@typedef` should describe the shape of the data.
97
+
98
+ ## List Store Properties Need Casts
99
+
100
+ `store([Model])` returns a descriptor that `tsc` can't reconcile with
101
+ array-typed host properties. Both the descriptor and `store.ready()` calls
102
+ need `any` casts:
103
+
104
+ ```javascript
105
+ /**
106
+ * @typedef {Object} MyGridHost
107
+ * @property {any} items — list store, cast for tsc
108
+ */
109
+
110
+ /** @type {import('hybrids').Component<MyGridHost>} */
111
+ export default define({
112
+ tag: 'my-grid',
113
+ items: /** @type {any} */ (store([Product], { id: () => ({}) })),
114
+ render: {
115
+ value: ({ items }) => html`
116
+ ${
117
+ /** @type {any} */ (store).ready(items)
118
+ ? /** @type {any[]} */ (items).map((i) => html`<span>${i.name}</span>`)
119
+ : html`<p>Loading…</p>`
120
+ }
121
+ `,
122
+ shadow: false,
123
+ },
124
+ });
125
+ ```
126
+
127
+ Three casts are needed:
128
+
129
+ 1. `/** @type {any} */` on the `store([Model])` descriptor assignment
130
+ 2. `/** @type {any} */ (store).ready(items)` — `store.ready()` overloads
131
+ reject array-typed arguments
132
+ 3. `/** @type {any[]} */ (items)` before calling `.map()` / `.filter()`
133
+
134
+ ## `list` Connector Params Are `ModelIdentifier`
135
+
136
+ The `list` connector receives a `ModelIdentifier`, not a plain object.
137
+ Accessing custom filter properties (like `.category`) requires a cast:
138
+
139
+ ```javascript
140
+ list: async (params) => {
141
+ const category = /** @type {any} */ (params)?.category;
142
+ // ...
143
+ },
144
+ ```
145
+
79
146
  ## Rules
80
147
 
81
148
  - Every exported component gets a `@typedef` for its host interface.
package/lib/check.js CHANGED
@@ -42,11 +42,9 @@ export function loadConfig(projectDir) {
42
42
  };
43
43
  }
44
44
 
45
- /**
46
- * Build check commands for the detected package manager.
47
- * @param {string} runner
48
- */
49
- function buildCmds(runner) {
45
+ /** Build check commands for the detected package manager. */
46
+ export function buildCmds(projectDir) {
47
+ const runner = detectRunner(projectDir);
50
48
  const audit = runner === 'pnpm exec' ? 'pnpm audit --prod' : 'npm audit --omit=dev';
51
49
  return {
52
50
  lint: `${runner} eslint --config .configs/eslint.config.js . --fix`,
@@ -58,8 +56,6 @@ function buildCmds(runner) {
58
56
  };
59
57
  }
60
58
 
61
- /** @deprecated Use buildCmds() instead — kept for backward compat. */
62
- export const CMDS = buildCmds('npx');
63
59
 
64
60
  /** @typedef {{ key: string, name: string, parent?: string, run: () => boolean }} Check */
65
61
 
@@ -108,10 +104,10 @@ export function parentKeys(checks) {
108
104
  return [...new Set(checks.filter((c) => c.parent).map((c) => c.parent))];
109
105
  }
110
106
 
111
- /** Run the full spec compliance check (used by clearstack CLI). */
107
+ /** Run the full spec compliance check (used by clearstack CLI and scripts/spec.js). */
112
108
  export async function check(projectDir, scope) {
113
109
  const cfg = loadConfig(projectDir);
114
- const cmds = buildCmds(detectRunner(projectDir));
110
+ const cmds = buildCmds(projectDir);
115
111
  const checks = buildChecks(projectDir, cfg, cmds);
116
112
 
117
113
  if (scope && scope !== 'all') {
@@ -127,7 +123,7 @@ export async function check(projectDir, scope) {
127
123
  return;
128
124
  }
129
125
 
130
- console.log('Running spec compliance check...\n');
126
+ console.log('🔍 Clearstack compliance checking now... 💙\n');
131
127
  const results = checks.map((c) => c.run());
132
128
  const passed = results.filter(Boolean).length;
133
129
  console.log(`\n${'='.repeat(40)}`);
@@ -21,7 +21,7 @@ export async function writePackageJson(dest, vars, existing) {
21
21
  dev: 'node --watch --env-file=.env --env-file=.env.local src/server.js',
22
22
  postinstall: 'node scripts/setup.js',
23
23
  test: 'node scripts/test.js',
24
- spec: 'clearstack',
24
+ spec: 'node scripts/spec.js',
25
25
  };
26
26
 
27
27
  const specDeps = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -24,20 +24,8 @@ jobs:
24
24
  - name: Install Playwright Chromium
25
25
  run: npx playwright install chromium --with-deps
26
26
 
27
- - name: Code line counts (≤150)
28
- run: node scripts/spec.js code
29
-
30
- - name: Doc line counts (≤500)
31
- run: node scripts/spec.js docs
32
-
33
- - name: ESLint
34
- run: npx eslint --config .configs/eslint.config.js .
35
-
36
- - name: Prettier
37
- run: npx prettier --config .configs/.prettierrc --check src scripts server.js tests
38
-
39
- - name: JSDoc types (tsc --checkJs)
40
- run: npx tsc --project .configs/jsconfig.json
27
+ - name: Spec compliance
28
+ run: node scripts/spec.js all
41
29
 
42
30
  - name: Node tests
43
31
  run: node --test tests/*.test.js src/utils/*.test.js src/store/*.test.js
@@ -168,6 +168,42 @@ These are the significant corrections:
168
168
  - **Actual:** Coordinates calculated from SVG rect, not accounting for pan
169
169
  - **Fix:** Shared `canvasPos()` utility subtracts pan offset from all tools
170
170
 
171
+ ### Enumerable models (`id: true`) break tsc
172
+
173
+ - **Expected:** `@type {import('hybrids').Model<Product>}` works with `id: true`
174
+ - **Actual:** tsc rejects `id: true` — it's not in the Model type definition
175
+ - **Fix:** Type the model as `@type {any}` with a comment explaining the cast
176
+ - **Documented in:** JSDOC_TYPING.md → Enumerable Models Need `@type {any}`
177
+
178
+ ### Empty arrays in store models throw at runtime
179
+
180
+ - **Expected:** `items: []` on a singleton model works as an empty default
181
+ - **Actual:** Hybrids throws `The first item of the 'items' array must be defined`
182
+ - **Why:** Hybrids infers array item type from the first element at model setup
183
+ - **Fix:** Provide a prototype item: `items: [{ sku: '', quantity: 0 }]`
184
+ - **Documented in:** STATE_AND_ROUTING.md → Store Array Properties
185
+
186
+ ### List store descriptors don't match array types in tsc
187
+
188
+ - **Expected:** `store([Model])` assignable to an array-typed host property
189
+ - **Actual:** tsc sees `EnumerableInstance`, not `any[]` — three separate casts needed
190
+ - **Fix:** Cast the descriptor, `store.ready()` call, and the array before `.map()`
191
+ - **Documented in:** JSDOC_TYPING.md → List Store Properties Need Casts
192
+
193
+ ### Organisms importing pages creates circular dependencies
194
+
195
+ - **Expected:** `router.url(PageView)` works from an organism for link generation
196
+ - **Actual:** Page imports organism, organism imports page → circular
197
+ - **Fix:** Use string URLs (`/product/${sku}`) in organisms instead of `router.url()`
198
+ - **Documented in:** FRONTEND_IMPLEMENTATION_RULES.md → Organisms Must Not Import Pages
199
+
200
+ ### `list` connector params need cast for custom filter properties
201
+
202
+ - **Expected:** `list: async ({ category }) => ...` destructures cleanly
203
+ - **Actual:** Param is `ModelIdentifier`, tsc rejects `.category` access
204
+ - **Fix:** Cast params to `any` before accessing custom properties
205
+ - **Documented in:** JSDOC_TYPING.md → `list` Connector Params
206
+
171
207
  ---
172
208
 
173
209
  ## Metrics
@@ -212,6 +212,29 @@ Pages → Templates → Organisms → Molecules → Atoms
212
212
  Store / Utils
213
213
  ```
214
214
 
215
+ #### Organisms Must Not Import Pages
216
+
217
+ Organisms sit below pages in the import hierarchy. If an organism needs
218
+ to generate a URL to a page (e.g. a product grid linking to a product
219
+ detail page), **use a string URL, not `router.url(PageView)`**.
220
+
221
+ `router.url()` requires importing the page component, which creates a
222
+ circular dependency: page → organism → page. Even if the bundler or
223
+ browser resolves it, it makes the dependency graph untraceable.
224
+
225
+ ```javascript
226
+ // ❌ BAD — organism imports a page, circular dependency
227
+ import ProductDetailView from '#pages/product-detail/product-detail-view.js';
228
+ html`<a href="${router.url(ProductDetailView, { sku })}">View</a>`;
229
+
230
+ // ✅ GOOD — string URL, no import needed
231
+ html`<a href="${`/product/${sku}`}">View</a>`;
232
+ ```
233
+
234
+ If the URL pattern changes, update it in one place. For complex URL
235
+ generation, extract a `buildUrl(view, params)` utility in `src/utils/`
236
+ that returns strings — no component imports.
237
+
215
238
  - Atoms import **nothing** from other component tiers.
216
239
  - Molecules import only from **atoms**.
217
240
  - Organisms import from **molecules** and **atoms**.
@@ -76,6 +76,73 @@ function handleClick(host, event) {
76
76
  }
77
77
  ```
78
78
 
79
+ ## Enumerable Models Need `@type {any}`
80
+
81
+ Hybrids' TypeScript definitions don't support `id: true` on `Model<T>` —
82
+ `tsc` rejects it because `id` isn't in the typedef. The fix is to type
83
+ the entire model as `any` with a comment explaining why:
84
+
85
+ ```javascript
86
+ /** @type {any} — hybrids Model with id:true; cast to bypass tsc limitations */
87
+ const Product = {
88
+ id: true,
89
+ name: '',
90
+ // ...
91
+ [store.connect]: { get: ..., list: ... },
92
+ };
93
+ ```
94
+
95
+ Do **not** add `id` to the `@typedef` — it's a hybrids directive, not a
96
+ data field. The `@typedef` should describe the shape of the data.
97
+
98
+ ## List Store Properties Need Casts
99
+
100
+ `store([Model])` returns a descriptor that `tsc` can't reconcile with
101
+ array-typed host properties. Both the descriptor and `store.ready()` calls
102
+ need `any` casts:
103
+
104
+ ```javascript
105
+ /**
106
+ * @typedef {Object} MyGridHost
107
+ * @property {any} items — list store, cast for tsc
108
+ */
109
+
110
+ /** @type {import('hybrids').Component<MyGridHost>} */
111
+ export default define({
112
+ tag: 'my-grid',
113
+ items: /** @type {any} */ (store([Product], { id: () => ({}) })),
114
+ render: {
115
+ value: ({ items }) => html`
116
+ ${
117
+ /** @type {any} */ (store).ready(items)
118
+ ? /** @type {any[]} */ (items).map((i) => html`<span>${i.name}</span>`)
119
+ : html`<p>Loading…</p>`
120
+ }
121
+ `,
122
+ shadow: false,
123
+ },
124
+ });
125
+ ```
126
+
127
+ Three casts are needed:
128
+
129
+ 1. `/** @type {any} */` on the `store([Model])` descriptor assignment
130
+ 2. `/** @type {any} */ (store).ready(items)` — `store.ready()` overloads
131
+ reject array-typed arguments
132
+ 3. `/** @type {any[]} */ (items)` before calling `.map()` / `.filter()`
133
+
134
+ ## `list` Connector Params Are `ModelIdentifier`
135
+
136
+ The `list` connector receives a `ModelIdentifier`, not a plain object.
137
+ Accessing custom filter properties (like `.category`) requires a cast:
138
+
139
+ ```javascript
140
+ list: async (params) => {
141
+ const category = /** @type {any} */ (params)?.category;
142
+ // ...
143
+ },
144
+ ```
145
+
79
146
  ## Rules
80
147
 
81
148
  - Every exported component gets a `@typedef` for its host interface.
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Spec enforcement — interactive menu + CLI shortcuts.
5
+ * Delegates all check logic to @techninja/clearstack.
6
+ * @module scripts/spec
7
+ */
8
+
9
+ import {
10
+ loadConfig,
11
+ buildChecks,
12
+ buildCmds,
13
+ resolveChecks,
14
+ parentKeys,
15
+ check,
16
+ } from '@techninja/clearstack/lib/check.js';
17
+
18
+ const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
19
+ const [sub, subsub] = process.argv.slice(2);
20
+ const scope = subsub ? `${sub} ${subsub}` : sub;
21
+
22
+ if (scope) {
23
+ await check(ROOT, scope);
24
+ } else {
25
+ await interactive(ROOT);
26
+ }
27
+
28
+ /** Show interactive menu, then run the selected check. */
29
+ async function interactive(dir) {
30
+ const cfg = loadConfig(dir);
31
+ const checks = buildChecks(dir, cfg, buildCmds(dir));
32
+ try {
33
+ const { select } = await import('@inquirer/prompts');
34
+ const action = await select({
35
+ message: 'Spec checker — what do you want to validate?',
36
+ choices: menuChoices(checks),
37
+ });
38
+ await check(dir, action);
39
+ } catch (e) {
40
+ if (e?.name === 'ExitPromptError') process.exit(0);
41
+ throw e;
42
+ }
43
+ }
44
+
45
+ /** Build interactive menu choices with hierarchy. */
46
+ function menuChoices(checks) {
47
+ const choices = [];
48
+ const seen = new Set();
49
+ for (const c of checks) {
50
+ if (c.parent && !seen.has(c.parent)) {
51
+ seen.add(c.parent);
52
+ const kids = checks.filter((k) => k.parent === c.parent);
53
+ const label = kids.map((k) => k.name).join(' + ');
54
+ choices.push({ name: `${label} [${c.parent}]`, value: c.parent });
55
+ for (const k of kids)
56
+ choices.push({ name: ` ${k.name} [${c.parent} ${k.key}]`, value: `${c.parent} ${k.key}` });
57
+ } else if (!c.parent) {
58
+ choices.push({ name: `${c.name} [${c.key}]`, value: c.key });
59
+ }
60
+ }
61
+ choices.push({ name: 'All (full spec check)', value: 'all' });
62
+ return choices;
63
+ }