@techninja/clearstack 0.3.17 → 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.
@@ -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
 
@@ -1,349 +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.
253
-
254
- ---
255
-
256
- ## StatiCart: Second Project Validation
257
-
258
- StatiCart is a full e-commerce platform built on Clearstack — the second
259
- project built against the spec, validating that the patterns hold beyond
260
- the original POC. Three phases (catalog, cart, polish) were completed in
261
- a single LLM session.
262
-
263
- ### Velocity
264
-
265
- | Metric | Value |
266
- | --- | --- |
267
- | Phases completed | 3 of 8 |
268
- | Components created | 7 (2 atoms, 2 molecules, 2 organisms, 0 templates) |
269
- | Pages created | 5 (catalog, product-detail, cart, order-success, order-cancelled) |
270
- | Store models | 2 (Product enumerable, CartState singleton) |
271
- | Utilities | 4 (formatPrice, setPageMeta, productVariants, statusColors) |
272
- | Tests | 41 (10 node, 31 browser) |
273
- | Spec checks | 9/9 passing |
274
- | Files over 150 lines | 0 (2 hit the limit, both split successfully) |
275
-
276
- Three phases of a real app in one session. No blocked-on-framework moments.
277
- Every blocker was a hybrids runtime behavior, not a Clearstack constraint.
278
-
279
- ### What the 150-Line Limit Actually Did
280
-
281
- The limit forced two splits during this build:
282
-
283
- 1. `shared.css` hit 203 lines after adding page layout styles → split into
284
- `pages.css`. Later `pages.css` hit 163 → split into `pages-cart.css`.
285
- 2. `product-detail-view.js` hit 154 after adding the image gallery →
286
- extracted `productVariants.js` utility.
287
-
288
- Both splits improved the code. The page styles split by concern (catalog
289
- vs cart) which made each file scannable. The utility extraction made
290
- `effectivePrice`/`effectiveStock` reusable and independently testable.
291
-
292
- Without the limit, these would have stayed as 300-line monoliths. The
293
- LLM would have needed to re-read them on every edit, and the utility
294
- functions would never have gotten their own test file.
295
-
296
- ### What the Spec Documentation Prevented
297
-
298
- The Clearstack spec docs prevented several classes of bugs entirely:
299
-
300
- - **No `../` imports.** The import map alias rule (`#store/`, `#atoms/`)
301
- was enforced from the first file. Zero refactoring of import paths.
302
- - **`shadow: false` everywhere.** No time wasted on shadow DOM styling
303
- issues. Global CSS just worked.
304
- - **Named event handlers.** No "button doesn't work" debugging from
305
- inline arrows in nested templates.
306
- - **`store.connect.get` returns `{}`.** The localStorage pattern was
307
- correct on first write for CartState.
308
- - **Template functions over template components.** No host context bugs.
309
-
310
- ### What the Spec Didn't Prevent (New Discoveries)
311
-
312
- Five new hybrids gotchas were found and documented:
313
-
314
- 1. **Empty arrays throw** — `items: []` needs a prototype item
315
- 2. **`id` is reserved in nested models** — even inside array prototypes
316
- 3. **Enumerable models need `@type {any}`** — tsc can't type `id: true`
317
- 4. **List store descriptors need 3 casts** — descriptor, ready(), and map()
318
- 5. **`list` connector params need cast** — `ModelIdentifier` not plain object
319
-
320
- All five are hybrids type system friction, not architectural issues. The
321
- runtime behavior is correct — it's the JSDoc/tsc layer that struggles.
322
- Each was fixed in under 2 minutes once identified.
323
-
324
- ### Test Runner Discovery
325
-
326
- `@web/test-runner` doesn't support browser import maps. A custom
327
- `resolveImport` plugin was needed to map `#prefix/` aliases to absolute
328
- paths. This is now documented in TESTING.md with the full config pattern.
329
-
330
- ### Framework Reuse Validation
331
-
332
- The Clearstack scaffolder (`npm run spec update`) worked correctly for
333
- doc syncing. The config overwrite issue (jsconfig.json, eslint.config.js)
334
- was found and fixed upstream — `update` now skips existing configs unless
335
- `--force` is passed. This validates the "dependency, not template" model.
336
-
337
- ### Key Insight
338
-
339
- The speed came from **not having to make architectural decisions**. The
340
- spec answered every "where does this go" question before it was asked:
341
- store models in `src/store/`, atoms vs molecules vs organisms, CSS in
342
- per-component files registered in `components.css`, pages in `src/pages/`.
343
- The LLM never had to ask "how should I structure this?" — it read the
344
- spec and built.
345
-
346
- This is the core value proposition: **Clearstack trades flexibility for
347
- velocity.** A team (or LLM) that knows the spec can build without
348
- discussion. The constraints aren't limitations — they're decisions that
349
- have already been made.