@techninja/clearstack 0.3.16 → 0.3.19

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.
@@ -13,8 +13,8 @@
13
13
  TypeScript's `tsc` compiler validates JSDoc annotations via `--checkJs`,
14
14
  giving us compile-time type checking without a build step.
15
15
 
16
- A `jsconfig.json` at the project root enables `checkJs: true`. Running
17
- `npm run typecheck` invokes `tsc --project jsconfig.json` which:
16
+ A `jsconfig.json` at `.configs/jsconfig.json` enables `checkJs: true` for
17
+ frontend code. Running `npm run spec types` invokes `tsc` against it.
18
18
 
19
19
  1. Reads all `.js` files in `src/` and `scripts/`
20
20
  2. Parses JSDoc annotations as type information
@@ -24,6 +24,27 @@ A `jsconfig.json` at the project root enables `checkJs: true`. Running
24
24
  This means `@typedef`, `@type`, `@param`, and `@returns` are not just
25
25
  documentation — they are **enforced types**.
26
26
 
27
+ ### Multiple Type Configs
28
+
29
+ The spec checker auto-discovers `jsconfig.json` files in subdirectories.
30
+ If your project has a separate backend (e.g. `api/`) with its own
31
+ dependencies and module resolution, add `api/jsconfig.json`:
32
+
33
+ ```
34
+ your-project/
35
+ ├── .configs/jsconfig.json ← frontend (src/, scripts/)
36
+ └── api/jsconfig.json ← backend (api/, auto-discovered)
37
+ ```
38
+
39
+ ```bash
40
+ npm run spec types # runs all discovered jsconfigs
41
+ npm run spec types frontend # just .configs/jsconfig.json
42
+ npm run spec types api # just api/jsconfig.json
43
+ ```
44
+
45
+ The child key matches the directory name. Both must pass for
46
+ `npm run spec all` to succeed.
47
+
27
48
  ---
28
49
 
29
50
  ## Component Properties
@@ -151,4 +172,4 @@ list: async (params) => {
151
172
  - Keep JSDoc blocks to 3–5 lines. No novels.
152
173
  - Use `/** @type {any} */ (expr)` for framework type limitations (e.g.
153
174
  `store.pending()` on array results) — document why with a comment.
154
- - Run `npm run typecheck` before committing. Zero errors required.
175
+ - Run `npm run spec types` before committing. Zero errors required.
@@ -137,7 +137,7 @@ npm run spec lint # ESLint + Stylelint + Markdown
137
137
  npm run spec lint es # ESLint only
138
138
  npm run spec format # Prettier
139
139
  npm run spec imports # import map aliases
140
- npm run spec types # JSDoc type validation
140
+ npm run spec types # JSDoc types (all jsconfigs)
141
141
  npm run spec audit # security audit
142
142
  npm test # Node + browser tests
143
143
  ```
@@ -172,7 +172,7 @@ spec all → lint + format + code + docs + imports + types + audit
172
172
  | Install dependencies | `npm install` |
173
173
  | Start dev server | `npm run dev` |
174
174
  | Lint + format | `npm run spec lint && npm run spec format` |
175
- | Type check | `npm run typecheck` |
175
+ | Type check | `npm run spec types` |
176
176
  | Run tests | `npm test` |
177
177
  | Full spec check | `npm run spec` |
178
178
  | Update spec + configs | `npm run spec update` |
@@ -253,6 +253,43 @@ This applies to any property with `value: []` and an async `connect`.
253
253
  The `connect` callback sets the value and calls `invalidate()`, but the
254
254
  first render happens before that resolves.
255
255
 
256
+ #### Async Init with Child Component Props
257
+
258
+ When a parent loads data during `connect` and passes it to a child via
259
+ template props, the child may never see the change. Hybrids' change
260
+ detection requires observing old → new. If the prop is set before the
261
+ child mounts, there's no "old" value to compare against.
262
+
263
+ ```javascript
264
+ // ❌ BAD — resultCount set during connect, before child mounts
265
+ _init: {
266
+ value: false,
267
+ connect: (host, _key, invalidate) => {
268
+ loadData(host).then(() => {
269
+ host.resultCount = 14;
270
+ invalidate(); // child mounts with 14, never sees a change
271
+ });
272
+ },
273
+ },
274
+ ```
275
+
276
+ The fix: defer the prop update to after the first render frame so the
277
+ child is mounted and can observe the transition:
278
+
279
+ ```javascript
280
+ // ✅ GOOD — child mounts with default (0), then sees 0 → 14
281
+ connect: (host, _key, invalidate) => {
282
+ loadData(host).then(() => {
283
+ invalidate();
284
+ requestAnimationFrame(() => { host.resultCount = loadedCount; });
285
+ });
286
+ },
287
+ ```
288
+
289
+ Pre-populate external caches during `loadData` so the child has data
290
+ ready when the deferred update triggers. The 0 → 14 transition happens
291
+ within a single frame — no flicker.
292
+
256
293
  ---
257
294
 
258
295
  ## Routing
@@ -410,37 +447,16 @@ For live data updates, the backend pushes events via **Server-Sent Events
410
447
 
411
448
  ### Frontend: SSE Listener
412
449
 
413
- Lives in `src/utils/realtimeSync.js`:
414
-
415
- ```javascript
416
- import { store } from 'hybrids';
417
-
418
- export function connectRealtime(url, modelMap) {
419
- const source = new EventSource(url);
420
-
421
- source.addEventListener('update', (event) => {
422
- const { type } = JSON.parse(event.data);
423
- const Model = modelMap[type];
424
- if (Model) store.clear(Model); // full clear triggers re-fetch
425
- });
426
-
427
- source.addEventListener('error', () => {
428
- source.close();
429
- setTimeout(() => connectRealtime(url, modelMap), 5000);
430
- });
431
-
432
- return () => source.close();
433
- }
434
- ```
450
+ `src/utils/realtimeSync.js` connects to the SSE endpoint, listens for
451
+ `update` events, and calls `store.clear(Model)` to invalidate the cache.
452
+ Any component bound via `store()` re-fetches automatically.
435
453
 
436
- `store.clear(Model)` fully invalidates the cache for that model type.
437
- Any component bound to the model via `store()` will automatically re-fetch
438
- from the API. This is the mechanism that makes multi-user realtime work —
439
- when user A creates a task, user B's task list updates automatically.
454
+ The listener reconnects on error (5s delay) and returns a disconnect
455
+ function for cleanup in the router shell's `connect` descriptor.
440
456
 
441
- For the local user, form submit handlers also call `store.clear(Model)`
442
- immediately after a successful save. The SSE event that follows is
443
- redundant for the local user but ensures other connected clients update.
457
+ `store.clear(Model)` is also called by form submit handlers after a
458
+ successful save. The SSE event that follows is redundant for the local
459
+ user but ensures other connected clients update.
444
460
 
445
461
  ### Backend: SSE Endpoint
446
462
 
@@ -449,31 +465,20 @@ contract.
449
465
 
450
466
  ### Wiring It Up
451
467
 
452
- In the router shell's `connect` descriptor:
468
+ In the router shell, use a `connect` descriptor to start SSE and return
469
+ the disconnect function. Map entity types to store models:
453
470
 
454
471
  ```javascript
455
- import { connectRealtime } from '../utils/realtimeSync.js';
456
- import UserModel from '../store/UserModel.js';
457
-
458
- export default define({
459
- tag: 'app-router',
460
- stack: router(HomeView, { url: '/' }),
461
- connection: {
462
- value: undefined,
463
- connect(host) {
464
- const disconnect = connectRealtime('/api/events', {
465
- user: UserModel,
466
- });
467
- return disconnect;
468
- },
472
+ connection: {
473
+ value: undefined,
474
+ connect(host) {
475
+ return connectRealtime('/api/events', { user: UserModel });
469
476
  },
470
- render: ({ stack }) => html` <template layout="column height::100vh">${stack}</template> `,
471
- });
477
+ },
472
478
  ```
473
479
 
474
- When the backend sends `{ type: "user", id: "42" }` over SSE, the store
475
- cache for `UserModel` is cleared, and any component displaying that user
476
- re-fetches automatically.
480
+ When the backend sends `{ type: "user" }` over SSE, the store cache for
481
+ `UserModel` is cleared and any bound component re-fetches automatically.
477
482
 
478
483
  ### Debouncing Batch Operations
479
484
 
@@ -169,36 +169,31 @@ describe('GET /api/users?schema=true', () => {
169
169
 
170
170
  ### Component Tests (browser)
171
171
 
172
- Using `@web/test-runner` with `@open-wc/testing`:
172
+ Using `@web/test-runner` with `@open-wc/testing`. Since all components
173
+ use light DOM (`shadow: false`), query the host element directly — not
174
+ `shadowRoot`:
173
175
 
174
176
  ```javascript
175
177
  // src/components/atoms/app-button/app-button.test.js
176
- import { fixture, html, expect } from '@open-wc/testing';
178
+ import { fixture, expect } from '@open-wc/testing';
177
179
  import './app-button.js';
178
180
 
179
181
  describe('app-button', () => {
180
182
  it('renders with label', async () => {
181
- const el = await fixture(html`<app-button label="Click me"></app-button>`);
182
- const button = el.shadowRoot.querySelector('button');
183
+ const el = await fixture(`<app-button label="Click me"></app-button>`);
184
+ await new Promise((r) => requestAnimationFrame(r));
185
+ const button = el.querySelector('button');
183
186
  expect(button.textContent).to.contain('Click me');
184
187
  });
188
+ });
189
+ ```
185
190
 
186
- it('reflects disabled attribute', async () => {
187
- const el = await fixture(html`<app-button disabled></app-button>`);
188
- const button = el.shadowRoot.querySelector('button');
189
- expect(button.hasAttribute('disabled')).to.be.true;
190
- });
191
+ The `requestAnimationFrame` wait gives hybrids time to complete its
192
+ render cycle. For components with async store data, use a longer timeout:
191
193
 
192
- it('dispatches click event', async () => {
193
- const el = await fixture(html`<app-button label="Go"></app-button>`);
194
- let clicked = false;
195
- el.addEventListener('click', () => {
196
- clicked = true;
197
- });
198
- el.shadowRoot.querySelector('button').click();
199
- expect(clicked).to.be.true;
200
- });
201
- });
194
+ ```javascript
195
+ const tick = () => new Promise((r) => setTimeout(r, 100));
196
+ await tick(); // after fixture, before assertions
202
197
  ```
203
198
 
204
199
  ### Store Integration Tests (browser)
@@ -227,16 +222,54 @@ describe('task-list', () => {
227
222
 
228
223
  ### web-test-runner.config.js
229
224
 
225
+ `@web/test-runner` uses `nodeResolve` for bare specifiers like `hybrids`,
226
+ but it does **not** support browser import maps. The `#prefix/` aliases
227
+ from `index.html` won't resolve in tests without a custom plugin:
228
+
230
229
  ```javascript
231
230
  import { playwrightLauncher } from '@web/test-runner-playwright';
231
+ import { dirname, resolve } from 'node:path';
232
+ import { fileURLToPath } from 'node:url';
233
+
234
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
235
+
236
+ const aliases = {
237
+ '#store/': '/src/store/',
238
+ '#utils/': '/src/utils/',
239
+ '#atoms/': '/src/components/atoms/',
240
+ '#molecules/': '/src/components/molecules/',
241
+ '#organisms/': '/src/components/organisms/',
242
+ '#templates/': '/src/components/templates/',
243
+ '#pages/': '/src/pages/',
244
+ };
245
+
246
+ function importMapPlugin() {
247
+ return {
248
+ name: 'import-map-aliases',
249
+ resolveImport({ source }) {
250
+ for (const [prefix, target] of Object.entries(aliases)) {
251
+ if (source.startsWith(prefix)) return source.replace(prefix, target);
252
+ }
253
+ },
254
+ };
255
+ }
232
256
 
233
257
  export default {
234
258
  files: 'src/components/**/*.test.js',
235
259
  nodeResolve: true,
260
+ rootDir: ROOT,
236
261
  browsers: [playwrightLauncher({ product: 'chromium' })],
262
+ plugins: [importMapPlugin()],
237
263
  };
238
264
  ```
239
265
 
266
+ Key points:
267
+
268
+ - **`rootDir`** must be the project root so `/src/store/` resolves correctly.
269
+ - **Aliases must match the import map** in `index.html`. If you add a prefix
270
+ there, add it here too.
271
+ - Test files can use `#prefix/` imports just like app code.
272
+
240
273
  ### package.json Scripts
241
274
 
242
275
  ```json
@@ -1,252 +0,0 @@
1
- # Build Log
2
-
3
- ## How This Project Was Built
4
-
5
- > This entire repository — specification, implementation, tests, and this
6
- > document — was authored in a single continuous conversation between a human
7
- > and an LLM (Amazon Q). No code was written outside this collaboration.
8
-
9
- ---
10
-
11
- ## Timeline
12
-
13
- The project was built over ~1.5 days in a single LLM context window.
14
- Each phase was implemented, tested, and verified before proceeding.
15
-
16
- ### Phase 1: Specification
17
-
18
- - Wrote the initial spec as one file, hit ~500 lines
19
- - Split into 7 topic-specific documents (the spec eating its own dogfood)
20
- - Chose Hybrids.js after reading its source and type definitions from node_modules
21
-
22
- ### Phase 2: Infrastructure
23
-
24
- - Express server, vendor-deps script, import maps, HTML shell
25
- - JSON Schema registry with seed data
26
- - Generic CRUD router — one router handles all entity types
27
- - 15 server tests passing before writing any frontend code
28
-
29
- ### Phase 3: Store Models & Utils
30
-
31
- - Singleton models (AppState, UserPrefs) with localStorage connectors
32
- - Enumerable models (ProjectModel, TaskModel) with API connectors
33
- - Utility functions (formatDate, timeAgo, statusColors)
34
- - 35 tests passing
35
-
36
- ### Phase 4: Components (Atoms → Molecules → Organisms)
37
-
38
- - Built bottom-up: app-button, app-badge, app-icon → task-card, project-card → task-list, project-header
39
- - Browser tests via @web/test-runner + Playwright
40
- - 63 tests passing
41
-
42
- ### Phase 5: Pages, Router & Integration
43
-
44
- - page-layout template, home-view, project-view, app-router
45
- - SSE realtime sync wired at the router level
46
- - SPA fallback for direct URL navigation
47
-
48
- ### Phase 6: Schema-Driven Forms
49
-
50
- - OPTIONS endpoint returns JSON Schema + form layout
51
- - schema-form organism auto-generates fields from schema
52
- - Native HTML validation from schema constraints
53
- - Server-side validation returns 422 with per-field errors
54
- - Form layout system with column grouping and action alignment
55
-
56
- ### Phase 7: Polish & Tooling
57
-
58
- - Dark mode via CSS custom property overrides
59
- - Spec checker CLI with interactive menu
60
- - ESLint + Prettier + tsc --checkJs for JSDoc type validation
61
- - GitHub Actions CI pipeline
62
- - File reorganization (docs/, .configs/, tests/)
63
-
64
- ### Phase 8: Drag Reorder & Whiteboard
65
-
66
- - Drag-to-reorder with visual gap indicator and backend persistence
67
- - WebSocket server for real-time canvas collaboration
68
- - SVG whiteboard with drawing tools (in progress)
69
-
70
- ---
71
-
72
- ## Discoveries & Corrections
73
-
74
- The spec was written first, then corrected as implementation revealed gaps.
75
- These are the significant corrections:
76
-
77
- ### Light DOM breaks slots
78
-
79
- - **Expected:** `<slot>` for content composition
80
- - **Actual:** Hybrids throws on `<slot>` in light DOM
81
- - **Fix:** Template functions instead of template components
82
- - **Documented in:** COMPONENT_PATTERNS.md → Content Composition
83
-
84
- ### Template components break host context
85
-
86
- - **Expected:** Event handlers in content templates resolve to the page
87
- - **Actual:** `host` resolves to the nearest hybrids component (the template)
88
- - **Fix:** Use plain functions that return `html`, not `define()`'d components
89
- - **Documented in:** COMPONENT_PATTERNS.md → Event Handler Host Context
90
-
91
- ### Custom events don't bubble by default
92
-
93
- - **Expected:** `dispatch(host, 'press')` reaches parent listeners
94
- - **Actual:** Without `bubbles: true`, events stop at the dispatching element
95
- - **Fix:** Always `dispatch(host, 'event', { bubbles: true })`
96
- - **Documented in:** COMPONENT_PATTERNS.md → Custom Events Must Bubble
97
-
98
- ### store.clear(Model) vs store.clear([Model])
99
-
100
- - **Expected:** `store.clear(TaskModel)` refreshes task lists
101
- - **Actual:** Only clears singular cache, not list cache
102
- - **Fix:** Use `store.clear([TaskModel])` for list stores
103
- - **Documented in:** STATE_AND_ROUTING.md → Store API Quick Reference
104
-
105
- ### store.ready(list) doesn't guarantee item readiness
106
-
107
- - **Expected:** If the list is ready, items are ready
108
- - **Actual:** Individual items can be pending after cache clear
109
- - **Fix:** Guard each item with `store.ready(task)` in map()
110
- - **Documented in:** STATE_AND_ROUTING.md → Guarding List Item Access
111
-
112
- ### localStorage connector must return {}, not undefined
113
-
114
- - **Expected:** Returning undefined uses model defaults
115
- - **Actual:** Hybrids treats undefined as a failed get → error state
116
- - **Fix:** Return `{}` so hybrids merges with defaults
117
- - **Documented in:** STATE_AND_ROUTING.md → localStorage Connector
118
-
119
- ### Batch operations cause SSE storms
120
-
121
- - **Expected:** Reordering N tasks sends N updates, UI handles it
122
- - **Actual:** N SSE events → N store.clear() calls → cascading errors
123
- - **Fix:** Debounce SSE handler per entity type (300ms)
124
- - **Documented in:** STATE_AND_ROUTING.md → Debouncing Batch Operations
125
-
126
- ### Server sort used string comparison for numbers
127
-
128
- - **Expected:** sortOrder field sorts numerically
129
- - **Actual:** `localeCompare` on numbers gives wrong order
130
- - **Fix:** Detect numeric values and use `a - b` comparison
131
-
132
- ### SVG transforms: two-group rotation approach
133
-
134
- - **Expected:** Embed rotation in shapeTransform string
135
- - **Actual:** Rotation center in local coords doesn't match screen position
136
- - **Fix:** Outer `<g>` for rotation (screen-space center), inner `<g>` for translate+scale
137
- - **Documented in:** COMPONENT_PATTERNS.md → Coordinate Transforms
138
-
139
- ### Move after rotation: unrotate is wrong for translate
140
-
141
- - **Expected:** Unrotate screen delta for the inner translate
142
- - **Actual:** Causes magnified/skewed movement
143
- - **Fix:** Move both rotation center AND inner translate by raw screen delta
144
- - **Key insight:** `rotate(deg, cx, cy)` = `translate(cx,cy) rotate(deg) translate(-cx,-cy)`. Shifting cx,cy and translate by the same delta cancels out.
145
-
146
- ### Resize after rotation: unrotate IS needed
147
-
148
- - **Expected:** Same approach as move
149
- - **Actual:** Handles are visually rotated, so screen drag doesn't align with object axes
150
- - **Fix:** Unrotate resize deltas only, not move deltas
151
-
152
- ### SVG innerHTML destroys event listeners
153
-
154
- - **Expected:** Event listeners persist across renders
155
- - **Actual:** `innerHTML` replaces the DOM, losing all listeners
156
- - **Fix:** Re-bind mouse listeners in `observe`, attach keyboard to host element
157
- - **Documented in:** COMPONENT_PATTERNS.md → SVG Content via innerHTML
158
-
159
- ### Path d-string manipulation breaks arc commands
160
-
161
- - **Expected:** Regex replace on coordinate pairs works for all paths
162
- - **Actual:** Arc commands have flags (0/1) that get mangled
163
- - **Fix:** Use `shapeTransform` for complex shapes, only rewrite d for M/L paths
164
-
165
- ### Canvas pan offset not applied to drawing coordinates
166
-
167
- - **Expected:** Drawing at the visual position works after panning
168
- - **Actual:** Coordinates calculated from SVG rect, not accounting for pan
169
- - **Fix:** Shared `canvasPos()` utility subtracts pan offset from all tools
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
-
207
- ---
208
-
209
- ## Metrics
210
-
211
- | Metric | Value |
212
- | ---------------------------- | ------------------------------------------------ |
213
- | Total source files | 108 |
214
- | Utility modules | 25 |
215
- | Component files | 13 |
216
- | Style sheets | 6 |
217
- | API modules | 6 |
218
- | Page views | 2 |
219
- | Test files | 14 |
220
- | Node tests | 65 |
221
- | Browser tests | 41 |
222
- | Spec documents | 10 |
223
- | Max lines per file | 150 (enforced) |
224
- | Max lines per doc | 500 (enforced) |
225
- | Automated checks | 7 (line counts, lint, format, types, tests) |
226
- | Build phases | 8 + whiteboard |
227
- | Bugs found & fixed | ~25 significant |
228
- | Bugs requiring >4 iterations | 3 (drag reorder, event bubbling, SVG transforms) |
229
- | External dependencies | 4 runtime (hybrids, express, ws, lucide-static) |
230
- | Build tools | 0 |
231
-
232
- ---
233
-
234
- ## What This Proves
235
-
236
- 1. **Small files work for LLMs.** Every file fits in a single read. No scrolling,
237
- no "show me lines 200-300", no losing context.
238
-
239
- 2. **The spec catches drift.** When implementation diverged from the spec
240
- (e.g. using slots in light DOM), the spec checker or manual review caught
241
- it, and both the code and spec were corrected.
242
-
243
- 3. **Test-at-the-boundary works.** Each phase was tested before the next began.
244
- The two hardest bugs (drag reorder, event bubbling) were in the last phases
245
- where components composed deeply — exactly where integration tests matter.
246
-
247
- 4. **No-build is viable.** The app loads in 0.17s LCP, handles real-time sync
248
- across browsers, and every file is debuggable as-written in devtools.
249
-
250
- 5. **The spec improves through implementation.** 9 significant corrections were
251
- made to the spec based on what we learned building the proof. The spec is
252
- now more accurate than if it had been written in isolation.