@techninja/clearstack 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/bin/cli.js +62 -0
  4. package/docs/BACKEND_API_SPEC.md +281 -0
  5. package/docs/BUILD_LOG.md +193 -0
  6. package/docs/COMPONENT_PATTERNS.md +481 -0
  7. package/docs/CONVENTIONS.md +226 -0
  8. package/docs/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
  9. package/docs/JSDOC_TYPING.md +86 -0
  10. package/docs/QUICKSTART.md +190 -0
  11. package/docs/SERVER_AND_DEPS.md +163 -0
  12. package/docs/STATE_AND_ROUTING.md +363 -0
  13. package/docs/TESTING.md +268 -0
  14. package/docs/app-spec/ENTITIES.md +37 -0
  15. package/docs/app-spec/README.md +19 -0
  16. package/lib/check.js +115 -0
  17. package/lib/copy.js +43 -0
  18. package/lib/init.js +73 -0
  19. package/lib/package-gen.js +83 -0
  20. package/lib/update.js +73 -0
  21. package/package.json +69 -0
  22. package/templates/fullstack/data/seed.json +1 -0
  23. package/templates/fullstack/src/api/db.js +75 -0
  24. package/templates/fullstack/src/api/entities.js +114 -0
  25. package/templates/fullstack/src/api/events.js +35 -0
  26. package/templates/fullstack/src/api/schemas.js +104 -0
  27. package/templates/fullstack/src/api/validate.js +52 -0
  28. package/templates/fullstack/src/pages/home/home-view.js +19 -0
  29. package/templates/fullstack/src/router/index.js +16 -0
  30. package/templates/fullstack/src/server.js +46 -0
  31. package/templates/fullstack/src/store/AppState.js +33 -0
  32. package/templates/fullstack/src/store/UserPrefs.js +31 -0
  33. package/templates/fullstack/src/store/realtimeSync.js +54 -0
  34. package/templates/shared/.configs/.prettierrc +8 -0
  35. package/templates/shared/.configs/eslint.config.js +64 -0
  36. package/templates/shared/.configs/jsconfig.json +24 -0
  37. package/templates/shared/.configs/web-test-runner.config.js +8 -0
  38. package/templates/shared/.env +9 -0
  39. package/templates/shared/.github/ISSUE_TEMPLATE/bug_report.md +42 -0
  40. package/templates/shared/.github/ISSUE_TEMPLATE/feature_request.md +30 -0
  41. package/templates/shared/.github/ISSUE_TEMPLATE/spec_correction.md +26 -0
  42. package/templates/shared/.github/pull_request_template.md +51 -0
  43. package/templates/shared/.github/workflows/spec.yml +46 -0
  44. package/templates/shared/README.md +22 -0
  45. package/templates/shared/docs/app-spec/README.md +40 -0
  46. package/templates/shared/docs/clearstack/BACKEND_API_SPEC.md +281 -0
  47. package/templates/shared/docs/clearstack/BUILD_LOG.md +193 -0
  48. package/templates/shared/docs/clearstack/COMPONENT_PATTERNS.md +481 -0
  49. package/templates/shared/docs/clearstack/CONVENTIONS.md +226 -0
  50. package/templates/shared/docs/clearstack/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
  51. package/templates/shared/docs/clearstack/JSDOC_TYPING.md +86 -0
  52. package/templates/shared/docs/clearstack/QUICKSTART.md +190 -0
  53. package/templates/shared/docs/clearstack/SERVER_AND_DEPS.md +163 -0
  54. package/templates/shared/docs/clearstack/STATE_AND_ROUTING.md +363 -0
  55. package/templates/shared/docs/clearstack/TESTING.md +268 -0
  56. package/templates/shared/public/index.html +26 -0
  57. package/templates/shared/scripts/build-icons.js +86 -0
  58. package/templates/shared/scripts/vendor-deps.js +25 -0
  59. package/templates/shared/src/components/atoms/app-badge/app-badge.css +4 -0
  60. package/templates/shared/src/components/atoms/app-badge/app-badge.js +23 -0
  61. package/templates/shared/src/components/atoms/app-badge/app-badge.test.js +26 -0
  62. package/templates/shared/src/components/atoms/app-badge/index.js +1 -0
  63. package/templates/shared/src/components/atoms/app-button/app-button.css +3 -0
  64. package/templates/shared/src/components/atoms/app-button/app-button.js +41 -0
  65. package/templates/shared/src/components/atoms/app-button/app-button.test.js +43 -0
  66. package/templates/shared/src/components/atoms/app-button/index.js +1 -0
  67. package/templates/shared/src/components/atoms/app-icon/app-icon.css +4 -0
  68. package/templates/shared/src/components/atoms/app-icon/app-icon.js +57 -0
  69. package/templates/shared/src/components/atoms/app-icon/app-icon.test.js +30 -0
  70. package/templates/shared/src/components/atoms/app-icon/index.js +1 -0
  71. package/templates/shared/src/components/atoms/theme-toggle/index.js +1 -0
  72. package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.css +10 -0
  73. package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.js +42 -0
  74. package/templates/shared/src/styles/buttons.css +79 -0
  75. package/templates/shared/src/styles/components.css +31 -0
  76. package/templates/shared/src/styles/forms.css +20 -0
  77. package/templates/shared/src/styles/reset.css +32 -0
  78. package/templates/shared/src/styles/shared.css +135 -0
  79. package/templates/shared/src/styles/tokens.css +65 -0
  80. package/templates/shared/src/utils/formatDate.js +41 -0
  81. package/templates/shared/src/utils/statusColors.js +60 -0
  82. package/templates/static/src/pages/home/home-view.js +38 -0
  83. package/templates/static/src/router/index.js +16 -0
  84. package/templates/static/src/store/AppState.js +26 -0
@@ -0,0 +1,363 @@
1
+ # State & Routing
2
+ ## Store, Routing, Unified App State & Realtime Sync
3
+
4
+ > How data flows through the application.
5
+ > See [FRONTEND_IMPLEMENTATION_RULES.md](./FRONTEND_IMPLEMENTATION_RULES.md) for
6
+ > project structure and [BACKEND_API_SPEC.md](./BACKEND_API_SPEC.md) for the
7
+ > server-side data contract.
8
+
9
+ ---
10
+
11
+ ## State Management
12
+
13
+ ### Component-Local State
14
+
15
+ For state that belongs to a single component instance, use plain properties:
16
+
17
+ ```javascript
18
+ export default define({
19
+ tag: 'app-toggle',
20
+ open: false,
21
+ render: ({ open }) => html`
22
+ <button onclick="${host => { host.open = !host.open; }}">
23
+ ${open ? 'Close' : 'Open'}
24
+ </button>
25
+ `,
26
+ });
27
+ ```
28
+
29
+ Local state resets when the component disconnects from the DOM.
30
+
31
+ ### Shared State via Store
32
+
33
+ For state shared across components or persisted beyond a component's lifetime,
34
+ use `store()` with a model definition.
35
+
36
+ #### Singleton Model (App-Wide State)
37
+
38
+ One instance, no `id`. Lives in `src/store/AppState.js`:
39
+
40
+ ```javascript
41
+ /** @typedef {{ theme: 'light'|'dark', sidebarOpen: boolean }} AppState */
42
+
43
+ /** @type {import('hybrids').Model<AppState>} */
44
+ const AppState = {
45
+ theme: 'light',
46
+ sidebarOpen: false,
47
+ };
48
+
49
+ export default AppState;
50
+ ```
51
+
52
+ #### localStorage Connector: Return `{}`, Never `undefined`
53
+
54
+ When a localStorage-backed singleton has no stored value yet (first visit),
55
+ the `get` connector **must return `{}`**, not `undefined` or `null`.
56
+
57
+ Hybrids treats `undefined` from `get` as a failed fetch and puts the model
58
+ into an error state. Returning `{}` lets hybrids merge with the model's
59
+ default values, so the model initializes cleanly:
60
+
61
+ ```javascript
62
+ [store.connect]: {
63
+ // ✅ GOOD — returns empty object, hybrids merges with defaults
64
+ get: () => {
65
+ const raw = localStorage.getItem('appState');
66
+ return raw ? JSON.parse(raw) : {};
67
+ },
68
+ // ❌ BAD — undefined triggers error state
69
+ // get: () => {
70
+ // const raw = localStorage.getItem('appState');
71
+ // return raw ? JSON.parse(raw) : undefined;
72
+ // },
73
+ }
74
+ ```
75
+
76
+ This applies to all localStorage-backed singletons (`AppState`, `UserPrefs`).
77
+
78
+ Consume in any component:
79
+
80
+ ```javascript
81
+ import { store, html, define } from 'hybrids';
82
+ import AppState from '../../store/AppState.js';
83
+
84
+ export default define({
85
+ tag: 'theme-toggle',
86
+ state: store(AppState),
87
+ render: ({ state }) => html`
88
+ <button onclick="${host => {
89
+ store.set(host.state, { theme: host.state.theme === 'light' ? 'dark' : 'light' });
90
+ }}">
91
+ Theme: ${state.theme}
92
+ </button>
93
+ `,
94
+ });
95
+ ```
96
+
97
+ #### Enumerable Model (Entity Records)
98
+
99
+ Has `id: true`. Lives in `src/store/UserModel.js`:
100
+
101
+ ```javascript
102
+ /**
103
+ * @typedef {Object} User
104
+ * @property {string} id
105
+ * @property {string} firstName
106
+ * @property {string} lastName
107
+ * @property {string} email
108
+ */
109
+
110
+ /** @type {import('hybrids').Model<User>} */
111
+ const UserModel = {
112
+ id: true,
113
+ firstName: '',
114
+ lastName: '',
115
+ email: '',
116
+ [store.connect]: {
117
+ get: (id) => fetch(`/api/users/${id}`).then(r => r.json()),
118
+ set: (id, values) => fetch(`/api/users/${id}`, {
119
+ method: 'PUT',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify(values),
122
+ }).then(r => r.json()),
123
+ list: (id) => fetch(`/api/users?${new URLSearchParams(id)}`).then(r => r.json()),
124
+ },
125
+ };
126
+
127
+ export default UserModel;
128
+ ```
129
+
130
+ #### Store API Quick Reference
131
+
132
+ | Method | Purpose |
133
+ |---|---|
134
+ | `store(Model)` | Descriptor — binds model to a component property |
135
+ | `store.get(Model, id)` | Get a cached instance (triggers fetch if needed) |
136
+ | `store.set(model, values)` | Update (async, returns Promise) |
137
+ | `store.sync(model, values)` | Update (sync, immediate) |
138
+ | `store.pending(model)` | `false` or `Promise` while loading |
139
+ | `store.ready(model)` | `true` when loaded and valid |
140
+ | `store.error(model)` | `false` or `Error` |
141
+ | `store.clear(Model)` | Invalidate singular model cache |
142
+ | `store.clear([Model])` | Invalidate list cache — **required for list stores** |
143
+ | `store.submit(draft)` | Submit draft mode changes |
144
+ | `store.resolve(Model, id)` | Returns Promise that resolves when ready |
145
+
146
+ #### Decision Tree: Local vs Shared State
147
+
148
+ ```
149
+ Is this state used by more than one component?
150
+ YES → store()
151
+ NO → Does it need to survive component disconnect?
152
+ YES → store()
153
+ NO → plain property
154
+ ```
155
+
156
+ #### Guarding List Item Access
157
+
158
+ `store.ready(list)` checks if the list itself has loaded, but individual
159
+ items within the list can still be in a pending state (e.g. after a cache
160
+ clear triggers re-fetch). Always guard property access on list items:
161
+
162
+ ```javascript
163
+ // ❌ BAD — task may be pending, accessing .title throws
164
+ tasks.map((task) => html`<span>${task.title}</span>`)
165
+
166
+ // ✅ GOOD — guard each item, show fallback for pending items
167
+ tasks.map((task) => store.ready(task)
168
+ ? html`<span>${task.title}</span>`
169
+ : html`<span class="spinner"></span>`
170
+ )
171
+ ```
172
+
173
+ This is especially important after batch operations (e.g. drag reorder)
174
+ where multiple items are invalidated simultaneously.
175
+
176
+ ---
177
+
178
+ ## Routing
179
+
180
+ ### Router Shell
181
+
182
+ The app has one router shell component that manages the view stack.
183
+ Lives in `src/router/index.js`:
184
+
185
+ ```javascript
186
+ import { define, html, router } from 'hybrids';
187
+ import HomeView from '../pages/home/index.js';
188
+
189
+ export default define({
190
+ tag: 'app-router',
191
+ stack: router(HomeView, { url: '/' }),
192
+ render: ({ stack }) => html`
193
+ <template layout="column height::100vh">
194
+ ${stack}
195
+ </template>
196
+ `,
197
+ });
198
+ ```
199
+
200
+ ### View Configuration
201
+
202
+ Each page declares its routing config via `[router.connect]`:
203
+
204
+ ```javascript
205
+ import { define, html, router } from 'hybrids';
206
+ import AboutView from '../about/index.js';
207
+
208
+ export default define({
209
+ tag: 'home-view',
210
+ [router.connect]: {
211
+ url: '/',
212
+ stack: [AboutView],
213
+ },
214
+ render: () => html`
215
+ <template layout="column gap:2 padding:2">
216
+ <h1>Home</h1>
217
+ <a href="${router.url(AboutView)}">About</a>
218
+ </template>
219
+ `,
220
+ });
221
+ ```
222
+
223
+ ### Routing Patterns
224
+
225
+ | Pattern | Code |
226
+ |---|---|
227
+ | Navigate to view | `<a href="${router.url(View)}">` |
228
+ | Navigate with params | `router.url(View, { id: '42' })` |
229
+ | Back button | `<a href="${router.backUrl()}">Back</a>` |
230
+ | Check active view | `router.active(View)` |
231
+ | Guarded route | `guard: () => isAuthenticated()` |
232
+ | Dialog overlay | `dialog: true` on the view config |
233
+
234
+ ---
235
+
236
+ ## Unified App State
237
+
238
+ The frontend maintains a single `AppState` singleton that acts as the
239
+ **source of truth for UI state**. Entity data lives in enumerable store
240
+ models that sync with the backend.
241
+
242
+ ### Architecture
243
+
244
+ ```
245
+ Backend REST API
246
+ ↕ fetch / SSE
247
+ Store Models (UserModel, etc.) ←→ [store.connect] storage
248
+ ↕ store()
249
+ Component Properties
250
+ ↕ render()
251
+ DOM
252
+ ```
253
+
254
+ ### AppState vs Entity Models
255
+
256
+ | Concern | Where |
257
+ |---|---|
258
+ | Theme, sidebar, UI flags | `AppState` (singleton) |
259
+ | User records, posts, etc. | `UserModel`, `PostModel` (enumerable) |
260
+ | Form draft state | `store(Model, { draft: true })` |
261
+ | Route state | `router()` — managed by hybrids |
262
+
263
+ ---
264
+
265
+ ## Realtime Sync
266
+
267
+ For live data updates, the backend pushes events via **Server-Sent Events
268
+ (SSE)**. The frontend listens and invalidates the relevant store cache.
269
+
270
+ ### Frontend: SSE Listener
271
+
272
+ Lives in `src/utils/realtimeSync.js`:
273
+
274
+ ```javascript
275
+ import { store } from 'hybrids';
276
+
277
+ export function connectRealtime(url, modelMap) {
278
+ const source = new EventSource(url);
279
+
280
+ source.addEventListener('update', (event) => {
281
+ const { type } = JSON.parse(event.data);
282
+ const Model = modelMap[type];
283
+ if (Model) store.clear(Model); // full clear triggers re-fetch
284
+ });
285
+
286
+ source.addEventListener('error', () => {
287
+ source.close();
288
+ setTimeout(() => connectRealtime(url, modelMap), 5000);
289
+ });
290
+
291
+ return () => source.close();
292
+ }
293
+ ```
294
+
295
+ `store.clear(Model)` fully invalidates the cache for that model type.
296
+ Any component bound to the model via `store()` will automatically re-fetch
297
+ from the API. This is the mechanism that makes multi-user realtime work —
298
+ when user A creates a task, user B's task list updates automatically.
299
+
300
+ For the local user, form submit handlers also call `store.clear(Model)`
301
+ immediately after a successful save. The SSE event that follows is
302
+ redundant for the local user but ensures other connected clients update.
303
+
304
+ ### Backend: SSE Endpoint
305
+
306
+ See [BACKEND_API_SPEC.md](./BACKEND_API_SPEC.md) for the `/api/events` SSE
307
+ contract.
308
+
309
+ ### Wiring It Up
310
+
311
+ In the router shell's `connect` descriptor:
312
+
313
+ ```javascript
314
+ import { connectRealtime } from '../utils/realtimeSync.js';
315
+ import UserModel from '../store/UserModel.js';
316
+
317
+ export default define({
318
+ tag: 'app-router',
319
+ stack: router(HomeView, { url: '/' }),
320
+ connection: {
321
+ value: undefined,
322
+ connect(host) {
323
+ const disconnect = connectRealtime('/api/events', {
324
+ user: UserModel,
325
+ });
326
+ return disconnect;
327
+ },
328
+ },
329
+ render: ({ stack }) => html`
330
+ <template layout="column height::100vh">${stack}</template>
331
+ `,
332
+ });
333
+ ```
334
+
335
+ When the backend sends `{ type: "user", id: "42" }` over SSE, the store
336
+ cache for `UserModel` is cleared, and any component displaying that user
337
+ re-fetches automatically.
338
+
339
+ ### Debouncing Batch Operations
340
+
341
+ Operations like drag-to-reorder send multiple PUTs, each triggering an SSE
342
+ event. Without debouncing, each event clears the store and triggers a
343
+ re-render while the previous render is still pending — causing cascading
344
+ errors.
345
+
346
+ The `connectRealtime()` utility debounces by entity type: multiple SSE
347
+ events within 300ms trigger only one `store.clear()`. This means a reorder
348
+ of 5 tasks sends 5 PUTs → 5 SSE events → 1 store clear after 300ms.
349
+
350
+ ```javascript
351
+ // Inside connectRealtime — debounce per entity type
352
+ const timers = {};
353
+ source.addEventListener('update', (event) => {
354
+ const { type } = JSON.parse(event.data);
355
+ clearTimeout(timers[type]);
356
+ timers[type] = setTimeout(() => {
357
+ store.clear([Model]); // one clear after the batch settles
358
+ }, 300);
359
+ });
360
+ ```
361
+
362
+ For the local user, the UI should not call `store.clear()` explicitly
363
+ after batch operations — let the debounced SSE handler do it once.
@@ -0,0 +1,268 @@
1
+ # Testing
2
+ ## Philosophy, Tools & Patterns
3
+
4
+ > How we test in a no-build web component project.
5
+ > See [FRONTEND_IMPLEMENTATION_RULES.md](./FRONTEND_IMPLEMENTATION_RULES.md) for
6
+ > the full specification index.
7
+
8
+ ---
9
+
10
+ ## Philosophy
11
+
12
+ ### Test What Matters, When It Matters
13
+
14
+ Tests are written **alongside implementation, not after**. Each phase of
15
+ development produces tests before moving to the next phase. This catches
16
+ bugs at the boundary where they're introduced, not 5 layers up.
17
+
18
+ ### Core Principles
19
+
20
+ | Principle | Rule |
21
+ |---|---|
22
+ | Test at the right level | Pure logic → unit. Components → browser. API → integration. |
23
+ | No mocking the framework | Don't mock `html`, `store`, or `define`. Test through them. |
24
+ | Real browser for components | Web components need a real DOM. No jsdom, no happy-dom. |
25
+ | Zero build for tests | Test files are ES modules, same as app code. |
26
+ | Small test files | Same 150-line rule applies to test files. |
27
+ | Test behavior, not implementation | Assert what the user sees, not internal state shape. |
28
+ | Co-locate tests | Tests live next to the code they test. |
29
+
30
+ ---
31
+
32
+ ## Tools
33
+
34
+ ### Two Test Runners, Clear Boundaries
35
+
36
+ | Tool | Tests | Runs in |
37
+ |---|---|---|
38
+ | `node:test` (built-in) | Server, utils, store model shapes | Node.js |
39
+ | `@web/test-runner` | Components, pages, browser integration | Real Chromium |
40
+
41
+ No other test frameworks. No Jest, no Mocha, no Jasmine.
42
+
43
+ ### Why Two Runners
44
+
45
+ - **`node:test`** is zero-dependency and fast. Perfect for pure functions
46
+ and server-side code that doesn't touch the DOM.
47
+ - **`@web/test-runner`** launches a real browser, serves ES modules natively,
48
+ and respects import maps. Components mount into a real DOM with shadow roots,
49
+ events, and rendering — exactly like production.
50
+
51
+ ---
52
+
53
+ ## File Conventions
54
+
55
+ ### Test File Location
56
+
57
+ Tests live **next to the code they test**, with a `.test.js` suffix:
58
+
59
+ ```
60
+ src/utils/
61
+ ├── formatDate.js
62
+ └── formatDate.test.js ← node:test
63
+
64
+ src/components/atoms/app-button/
65
+ ├── app-button.js
66
+ ├── app-button.css
67
+ ├── app-button.test.js ← @web/test-runner
68
+ └── index.js
69
+
70
+ src/store/
71
+ ├── UserModel.js
72
+ └── UserModel.test.js ← node:test
73
+
74
+ src/server.js
75
+ server.test.js ← node:test
76
+ ```
77
+
78
+ ### Test File Naming
79
+
80
+ | Code file | Test file |
81
+ |---|---|
82
+ | `app-button.js` | `app-button.test.js` |
83
+ | `formatDate.js` | `formatDate.test.js` |
84
+ | `UserModel.js` | `UserModel.test.js` |
85
+ | `src/server.js` | `server.test.js` |
86
+
87
+ ### What Gets Tested
88
+
89
+ | Layer | What to assert |
90
+ |---|---|
91
+ | **Utils** | Input → output. Edge cases. |
92
+ | **Store models** | Shape is correct. Computed fields work. Storage connector URLs are right. |
93
+ | **Atoms** | Renders correct HTML. Props reflect to attributes. Events fire. |
94
+ | **Molecules** | Child atoms are present. Composed behavior works. |
95
+ | **Organisms** | Store integration. Data flows to children. |
96
+ | **Server API** | CRUD responses. Schema endpoint. Status codes. |
97
+ | **Pages** | Don't unit-test pages. Test via manual or E2E if needed. |
98
+
99
+ ---
100
+
101
+ ## Patterns
102
+
103
+ ### Node Tests (utils, store, server)
104
+
105
+ Using the built-in `node:test` and `node:assert`:
106
+
107
+ ```javascript
108
+ // src/utils/formatDate.test.js
109
+ import { describe, it } from 'node:test';
110
+ import assert from 'node:assert/strict';
111
+ import { formatDate } from './formatDate.js';
112
+
113
+ describe('formatDate', () => {
114
+ it('formats ISO string to readable date', () => {
115
+ assert.equal(formatDate('2024-01-15T09:00:00Z'), 'Jan 15, 2024');
116
+ });
117
+
118
+ it('returns empty string for null', () => {
119
+ assert.equal(formatDate(null), '');
120
+ });
121
+ });
122
+ ```
123
+
124
+ Run with:
125
+
126
+ ```bash
127
+ node --test src/utils/*.test.js
128
+ ```
129
+
130
+ ### Server Tests
131
+
132
+ Test the API endpoints using fetch against a running server:
133
+
134
+ ```javascript
135
+ // server.test.js
136
+ import { describe, it, before, after } from 'node:test';
137
+ import assert from 'node:assert/strict';
138
+
139
+ let server;
140
+ const BASE = 'http://localhost:3001';
141
+
142
+ before(async () => {
143
+ // Import and start server on test port
144
+ const app = await import('./src/server.js');
145
+ server = app.start(3001);
146
+ });
147
+
148
+ after(() => server?.close());
149
+
150
+ describe('GET /api/users', () => {
151
+ it('returns a list with data array', async () => {
152
+ const res = await fetch(`${BASE}/api/users`);
153
+ const body = await res.json();
154
+ assert.equal(res.status, 200);
155
+ assert.ok(Array.isArray(body.data));
156
+ });
157
+ });
158
+
159
+ describe('GET /api/users?schema=true', () => {
160
+ it('returns JSON Schema with properties', async () => {
161
+ const res = await fetch(`${BASE}/api/users?schema=true`);
162
+ const schema = await res.json();
163
+ assert.equal(schema.title, 'User');
164
+ assert.ok(schema.properties.firstName);
165
+ });
166
+ });
167
+ ```
168
+
169
+ ### Component Tests (browser)
170
+
171
+ Using `@web/test-runner` with `@open-wc/testing`:
172
+
173
+ ```javascript
174
+ // src/components/atoms/app-button/app-button.test.js
175
+ import { fixture, html, expect } from '@open-wc/testing';
176
+ import './app-button.js';
177
+
178
+ describe('app-button', () => {
179
+ it('renders with label', async () => {
180
+ const el = await fixture(html`<app-button label="Click me"></app-button>`);
181
+ const button = el.shadowRoot.querySelector('button');
182
+ expect(button.textContent).to.contain('Click me');
183
+ });
184
+
185
+ it('reflects disabled attribute', async () => {
186
+ const el = await fixture(html`<app-button disabled></app-button>`);
187
+ const button = el.shadowRoot.querySelector('button');
188
+ expect(button.hasAttribute('disabled')).to.be.true;
189
+ });
190
+
191
+ it('dispatches click event', async () => {
192
+ const el = await fixture(html`<app-button label="Go"></app-button>`);
193
+ let clicked = false;
194
+ el.addEventListener('click', () => { clicked = true; });
195
+ el.shadowRoot.querySelector('button').click();
196
+ expect(clicked).to.be.true;
197
+ });
198
+ });
199
+ ```
200
+
201
+ ### Store Integration Tests (browser)
202
+
203
+ For components that bind to the store, test the full cycle:
204
+
205
+ ```javascript
206
+ // src/components/organisms/task-list/task-list.test.js
207
+ import { fixture, html, expect, waitUntil } from '@open-wc/testing';
208
+ import { store } from 'hybrids';
209
+ import './task-list.js';
210
+
211
+ describe('task-list', () => {
212
+ it('renders tasks from store', async () => {
213
+ const el = await fixture(html`<task-list project-id="1"></task-list>`);
214
+ await waitUntil(() => el.shadowRoot.querySelectorAll('task-card').length > 0);
215
+ const cards = el.shadowRoot.querySelectorAll('task-card');
216
+ expect(cards.length).to.be.greaterThan(0);
217
+ });
218
+ });
219
+ ```
220
+
221
+ ---
222
+
223
+ ## Configuration
224
+
225
+ ### web-test-runner.config.js
226
+
227
+ ```javascript
228
+ import { playwrightLauncher } from '@web/test-runner-playwright';
229
+
230
+ export default {
231
+ files: 'src/components/**/*.test.js',
232
+ nodeResolve: true,
233
+ browsers: [
234
+ playwrightLauncher({ product: 'chromium' }),
235
+ ],
236
+ };
237
+ ```
238
+
239
+ ### package.json Scripts
240
+
241
+ ```json
242
+ {
243
+ "scripts": {
244
+ "test": "npm run test:node && npm run test:browser",
245
+ "test:node": "node --test 'src/**/*.test.js' 'server.test.js'",
246
+ "test:browser": "web-test-runner",
247
+ "test:watch": "web-test-runner --watch"
248
+ }
249
+ }
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Build-Phase Test Checkpoints
255
+
256
+ Each implementation phase must pass its tests before proceeding:
257
+
258
+ | Phase | Implement | Then test |
259
+ |---|---|---|
260
+ | 1. Infrastructure | server, vendor script, index.html | Server starts, routes respond, vendor files exist |
261
+ | 2. Store + Utils | models, formatDate, realtimeSync | Model shapes, util outputs, localStorage round-trip |
262
+ | 3. Atoms | app-button, app-badge, app-icon | Render, props, events |
263
+ | 4. Molecules | task-card, project-card | Composition, slot content |
264
+ | 5. Organisms | task-list, project-header | Store binding, data rendering |
265
+ | 6. Pages + Router | views, app-router | Navigation, full page render |
266
+
267
+ **Rule: never skip a checkpoint.** If phase 3 tests fail, fix before
268
+ starting phase 4. Bugs compound; catch them at the boundary.
@@ -0,0 +1,26 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{name}}</title>
7
+ <link rel="stylesheet" href="/src/styles/reset.css">
8
+ <link rel="stylesheet" href="/src/styles/tokens.css">
9
+ <link rel="stylesheet" href="/src/styles/shared.css">
10
+ <link rel="stylesheet" href="/src/styles/buttons.css">
11
+ <link rel="stylesheet" href="/src/styles/forms.css">
12
+ <link rel="stylesheet" href="/src/styles/components.css">
13
+
14
+ <script type="importmap">
15
+ {
16
+ "imports": {
17
+ "hybrids": "/vendor/hybrids/index.js"
18
+ }
19
+ }
20
+ </script>
21
+ </head>
22
+ <body>
23
+ <app-router></app-router>
24
+ <script type="module" src="/src/router/index.js"></script>
25
+ </body>
26
+ </html>