@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.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/bin/cli.js +62 -0
- package/docs/BACKEND_API_SPEC.md +281 -0
- package/docs/BUILD_LOG.md +193 -0
- package/docs/COMPONENT_PATTERNS.md +481 -0
- package/docs/CONVENTIONS.md +226 -0
- package/docs/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
- package/docs/JSDOC_TYPING.md +86 -0
- package/docs/QUICKSTART.md +190 -0
- package/docs/SERVER_AND_DEPS.md +163 -0
- package/docs/STATE_AND_ROUTING.md +363 -0
- package/docs/TESTING.md +268 -0
- package/docs/app-spec/ENTITIES.md +37 -0
- package/docs/app-spec/README.md +19 -0
- package/lib/check.js +115 -0
- package/lib/copy.js +43 -0
- package/lib/init.js +73 -0
- package/lib/package-gen.js +83 -0
- package/lib/update.js +73 -0
- package/package.json +69 -0
- package/templates/fullstack/data/seed.json +1 -0
- package/templates/fullstack/src/api/db.js +75 -0
- package/templates/fullstack/src/api/entities.js +114 -0
- package/templates/fullstack/src/api/events.js +35 -0
- package/templates/fullstack/src/api/schemas.js +104 -0
- package/templates/fullstack/src/api/validate.js +52 -0
- package/templates/fullstack/src/pages/home/home-view.js +19 -0
- package/templates/fullstack/src/router/index.js +16 -0
- package/templates/fullstack/src/server.js +46 -0
- package/templates/fullstack/src/store/AppState.js +33 -0
- package/templates/fullstack/src/store/UserPrefs.js +31 -0
- package/templates/fullstack/src/store/realtimeSync.js +54 -0
- package/templates/shared/.configs/.prettierrc +8 -0
- package/templates/shared/.configs/eslint.config.js +64 -0
- package/templates/shared/.configs/jsconfig.json +24 -0
- package/templates/shared/.configs/web-test-runner.config.js +8 -0
- package/templates/shared/.env +9 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/bug_report.md +42 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/feature_request.md +30 -0
- package/templates/shared/.github/ISSUE_TEMPLATE/spec_correction.md +26 -0
- package/templates/shared/.github/pull_request_template.md +51 -0
- package/templates/shared/.github/workflows/spec.yml +46 -0
- package/templates/shared/README.md +22 -0
- package/templates/shared/docs/app-spec/README.md +40 -0
- package/templates/shared/docs/clearstack/BACKEND_API_SPEC.md +281 -0
- package/templates/shared/docs/clearstack/BUILD_LOG.md +193 -0
- package/templates/shared/docs/clearstack/COMPONENT_PATTERNS.md +481 -0
- package/templates/shared/docs/clearstack/CONVENTIONS.md +226 -0
- package/templates/shared/docs/clearstack/FRONTEND_IMPLEMENTATION_RULES.md +239 -0
- package/templates/shared/docs/clearstack/JSDOC_TYPING.md +86 -0
- package/templates/shared/docs/clearstack/QUICKSTART.md +190 -0
- package/templates/shared/docs/clearstack/SERVER_AND_DEPS.md +163 -0
- package/templates/shared/docs/clearstack/STATE_AND_ROUTING.md +363 -0
- package/templates/shared/docs/clearstack/TESTING.md +268 -0
- package/templates/shared/public/index.html +26 -0
- package/templates/shared/scripts/build-icons.js +86 -0
- package/templates/shared/scripts/vendor-deps.js +25 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.css +4 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.js +23 -0
- package/templates/shared/src/components/atoms/app-badge/app-badge.test.js +26 -0
- package/templates/shared/src/components/atoms/app-badge/index.js +1 -0
- package/templates/shared/src/components/atoms/app-button/app-button.css +3 -0
- package/templates/shared/src/components/atoms/app-button/app-button.js +41 -0
- package/templates/shared/src/components/atoms/app-button/app-button.test.js +43 -0
- package/templates/shared/src/components/atoms/app-button/index.js +1 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.css +4 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.js +57 -0
- package/templates/shared/src/components/atoms/app-icon/app-icon.test.js +30 -0
- package/templates/shared/src/components/atoms/app-icon/index.js +1 -0
- package/templates/shared/src/components/atoms/theme-toggle/index.js +1 -0
- package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.css +10 -0
- package/templates/shared/src/components/atoms/theme-toggle/theme-toggle.js +42 -0
- package/templates/shared/src/styles/buttons.css +79 -0
- package/templates/shared/src/styles/components.css +31 -0
- package/templates/shared/src/styles/forms.css +20 -0
- package/templates/shared/src/styles/reset.css +32 -0
- package/templates/shared/src/styles/shared.css +135 -0
- package/templates/shared/src/styles/tokens.css +65 -0
- package/templates/shared/src/utils/formatDate.js +41 -0
- package/templates/shared/src/utils/statusColors.js +60 -0
- package/templates/static/src/pages/home/home-view.js +38 -0
- package/templates/static/src/router/index.js +16 -0
- package/templates/static/src/store/AppState.js +26 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# Component Patterns
|
|
2
|
+
## Authoring, Styling, Templates & JSDoc Typing
|
|
3
|
+
|
|
4
|
+
> How to write, style, and type components in this framework.
|
|
5
|
+
> See [FRONTEND_IMPLEMENTATION_RULES.md](./FRONTEND_IMPLEMENTATION_RULES.md) for
|
|
6
|
+
> project structure and atomic design hierarchy.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Component Authoring
|
|
11
|
+
|
|
12
|
+
Every component is a plain object passed to `define()`. No classes.
|
|
13
|
+
|
|
14
|
+
### Minimal Component
|
|
15
|
+
|
|
16
|
+
```javascript
|
|
17
|
+
import { html, define } from 'hybrids';
|
|
18
|
+
|
|
19
|
+
export default define({
|
|
20
|
+
tag: 'app-button',
|
|
21
|
+
label: '',
|
|
22
|
+
render: {
|
|
23
|
+
value: ({ label }) => html`<button>${label}</button>`,
|
|
24
|
+
shadow: false,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Note: `shadow: false` is required on every component in this framework.
|
|
30
|
+
See the Light DOM section below for why.
|
|
31
|
+
|
|
32
|
+
### Property Types
|
|
33
|
+
|
|
34
|
+
Properties are declared as default values. Hybrids infers the type:
|
|
35
|
+
|
|
36
|
+
| Declaration | Type | Reflected to attribute |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `count: 0` | Number | Yes |
|
|
39
|
+
| `label: ''` | String | Yes |
|
|
40
|
+
| `active: false` | Boolean | Yes |
|
|
41
|
+
| `items: []` | Array/Object | No |
|
|
42
|
+
| `onClick: () => {}` | Function | No |
|
|
43
|
+
|
|
44
|
+
### Property Descriptors
|
|
45
|
+
|
|
46
|
+
For advanced behavior, use the descriptor form:
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
export default define({
|
|
50
|
+
tag: 'app-timer',
|
|
51
|
+
elapsed: {
|
|
52
|
+
value: 0,
|
|
53
|
+
connect(host, key, invalidate) {
|
|
54
|
+
const id = setInterval(() => { host.elapsed++; }, 1000);
|
|
55
|
+
return () => clearInterval(id); // cleanup on disconnect
|
|
56
|
+
},
|
|
57
|
+
observe(host, value) {
|
|
58
|
+
if (value >= 60) dispatch(host, 'timeout');
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
render: ({ elapsed }) => html`<span>${elapsed}s</span>`,
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Descriptor field | Purpose |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `value` | Default value or factory function |
|
|
68
|
+
| `connect(host, key, invalidate)` | Runs on DOM connect. Return cleanup fn. |
|
|
69
|
+
| `observe(host, value, lastValue)` | Runs when value changes |
|
|
70
|
+
| `reflect` | `true` or `(value) => string` — sync to attribute |
|
|
71
|
+
|
|
72
|
+
### Event Handling
|
|
73
|
+
|
|
74
|
+
Always bind events in templates. Never use `addEventListener`.
|
|
75
|
+
|
|
76
|
+
```javascript
|
|
77
|
+
function handleClick(host, event) {
|
|
78
|
+
host.count++;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default define({
|
|
82
|
+
tag: 'app-counter',
|
|
83
|
+
count: 0,
|
|
84
|
+
render: ({ count }) => html`
|
|
85
|
+
<button onclick="${handleClick}">Count: ${count}</button>
|
|
86
|
+
`,
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For input binding, use `html.set()`:
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
render: ({ query }) => html`
|
|
94
|
+
<input value="${query}" oninput="${html.set('query')}" />
|
|
95
|
+
`,
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Event Handler Host Context
|
|
99
|
+
|
|
100
|
+
In hybrids, the `host` parameter in an event handler resolves to the
|
|
101
|
+
**nearest hybrids component ancestor** in the DOM, not necessarily the
|
|
102
|
+
component that defined the handler.
|
|
103
|
+
|
|
104
|
+
This matters when passing templates as properties to other components
|
|
105
|
+
(e.g. `page-layout`'s `content` property). Inline arrow functions will
|
|
106
|
+
receive the **wrong host** — the template component, not your page.
|
|
107
|
+
|
|
108
|
+
```javascript
|
|
109
|
+
// ❌ BAD — host is page-layout, not my-page
|
|
110
|
+
render: {
|
|
111
|
+
value: ({ showForm }) => html`
|
|
112
|
+
<page-layout content="${html`
|
|
113
|
+
<app-button onpress="${(host) => { host.showForm = true; }}"></app-button>
|
|
114
|
+
`}"></page-layout>
|
|
115
|
+
`,
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// ✅ GOOD — named function, hybrids resolves host to the defining component
|
|
119
|
+
function toggleForm(host) {
|
|
120
|
+
host.showForm = !host.showForm;
|
|
121
|
+
}
|
|
122
|
+
// ... in render:
|
|
123
|
+
<app-button onpress="${toggleForm}"></app-button>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Rule: always use named functions for event handlers.** This ensures
|
|
127
|
+
hybrids resolves `host` to the component that owns the property, not
|
|
128
|
+
the nearest ancestor in the DOM tree. Inline arrows in nested templates
|
|
129
|
+
are the #1 source of "nothing happens when I click" bugs.
|
|
130
|
+
|
|
131
|
+
### Custom Events Must Bubble
|
|
132
|
+
|
|
133
|
+
In light DOM, custom events dispatched from child components must set
|
|
134
|
+
`bubbles: true` to reach parent listeners — especially when content is
|
|
135
|
+
passed as a template property through intermediate components.
|
|
136
|
+
|
|
137
|
+
```javascript
|
|
138
|
+
// ❌ BAD — event won't reach listeners above the dispatching component
|
|
139
|
+
dispatch(host, 'press');
|
|
140
|
+
|
|
141
|
+
// ✅ GOOD — event bubbles through the DOM tree
|
|
142
|
+
dispatch(host, 'press', { bubbles: true });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### When to Use `app-button` vs Plain `<button>`
|
|
146
|
+
|
|
147
|
+
Custom events from atoms can be unreliable when templates are passed as
|
|
148
|
+
properties through intermediate components (e.g. `page-layout`'s `content`).
|
|
149
|
+
The host context and event bubbling path may not resolve as expected.
|
|
150
|
+
|
|
151
|
+
| Context | Use | Why |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| Inside a component's own template | `app-button` | Host context is correct, events bubble normally |
|
|
154
|
+
| Inside a `content` template property | Plain `<button class="btn">` | Direct `onclick` handler, no custom event needed |
|
|
155
|
+
| Reusable molecule/organism | `app-button` | Encapsulated, predictable host |
|
|
156
|
+
| Page-level actions | Plain `<button class="btn">` | Simplest, most reliable |
|
|
157
|
+
|
|
158
|
+
The `.btn` classes are global (defined in `buttons.css`), so plain buttons
|
|
159
|
+
look identical to `app-button`. Use the atom when you need its component
|
|
160
|
+
API; use a plain button when you need reliable click handling in nested
|
|
161
|
+
templates.
|
|
162
|
+
|
|
163
|
+
### SVG Content via innerHTML
|
|
164
|
+
|
|
165
|
+
Hybrids' `html` template doesn't support dynamic SVG content well. For
|
|
166
|
+
canvas/whiteboard components that build SVG from data, use `innerHTML`
|
|
167
|
+
on a wrapper div:
|
|
168
|
+
|
|
169
|
+
```javascript
|
|
170
|
+
render: {
|
|
171
|
+
value: (host) => html`
|
|
172
|
+
<div class="canvas" innerHTML="${buildSvgString(host.objects)}"></div>
|
|
173
|
+
`,
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Event listeners must be re-attached in `observe` since `innerHTML`
|
|
178
|
+
replaces the DOM. Use a flag to avoid duplicate binding, and attach
|
|
179
|
+
persistent listeners (like `keydown`) to the host element instead.
|
|
180
|
+
|
|
181
|
+
### Coordinate Transforms for Rotated Objects
|
|
182
|
+
|
|
183
|
+
When objects have rotation via an outer `<g transform="rotate(...)">`:
|
|
184
|
+
|
|
185
|
+
- **Move:** Shift both the inner translate AND the rotation center by the
|
|
186
|
+
same screen-space delta. No trigonometry needed.
|
|
187
|
+
- **Resize:** Unrotate the drag delta to align with the object's local axes.
|
|
188
|
+
- **Rotation center:** Store `rotationCx`/`rotationCy` on the object data
|
|
189
|
+
so it persists across renders and reloads.
|
|
190
|
+
|
|
191
|
+
### Light DOM (Default)
|
|
192
|
+
|
|
193
|
+
All components in this framework use **light DOM** (`shadow: false`). This
|
|
194
|
+
means global stylesheets, shared CSS classes, and design tokens all apply
|
|
195
|
+
automatically — no style injection boilerplate.
|
|
196
|
+
|
|
197
|
+
```javascript
|
|
198
|
+
export default define({
|
|
199
|
+
tag: 'app-button',
|
|
200
|
+
label: '',
|
|
201
|
+
render: {
|
|
202
|
+
value: ({ label }) => html`<button>${label}</button>`,
|
|
203
|
+
shadow: false,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
With light DOM, the component's rendered HTML lives in the main document
|
|
209
|
+
tree. CSS scoping is achieved through **native CSS nesting** on the tag name
|
|
210
|
+
(see Styling section below), not through shadow boundary.
|
|
211
|
+
|
|
212
|
+
### When to Use Shadow DOM
|
|
213
|
+
|
|
214
|
+
Shadow DOM is the exception, not the rule. Enable it only when:
|
|
215
|
+
|
|
216
|
+
| Situation | Why shadow DOM |
|
|
217
|
+
|---|---|
|
|
218
|
+
| Wrapping a third-party widget | Prevent its styles from leaking out |
|
|
219
|
+
| Distributing a standalone component | Consumer's styles must not break it |
|
|
220
|
+
| Embedding untrusted content | Hard style boundary needed |
|
|
221
|
+
|
|
222
|
+
For an internal application where you control all the CSS, shadow DOM
|
|
223
|
+
creates more problems than it solves — you end up fighting it to inject
|
|
224
|
+
shared styles into every component.
|
|
225
|
+
|
|
226
|
+
### Inline Styles for Small Additions
|
|
227
|
+
|
|
228
|
+
When a component needs a few custom styles that don't warrant a `.css` file,
|
|
229
|
+
use `.css()` directly on the template:
|
|
230
|
+
|
|
231
|
+
```javascript
|
|
232
|
+
render: {
|
|
233
|
+
value: ({ active }) => html`
|
|
234
|
+
<span class="${active ? 'active' : ''}">${label}</span>
|
|
235
|
+
`.css`
|
|
236
|
+
:host { display: inline-block; }
|
|
237
|
+
.active { font-weight: bold; color: var(--color-primary); }
|
|
238
|
+
`,
|
|
239
|
+
shadow: false,
|
|
240
|
+
},
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
This keeps small style additions co-located with the template. When inline
|
|
244
|
+
styles grow past ~10 rules, move them to the component's `.css` file.
|
|
245
|
+
|
|
246
|
+
### Content Composition (No Slots)
|
|
247
|
+
|
|
248
|
+
**`<slot>` is a shadow DOM feature.** It does not work with `shadow: false`.
|
|
249
|
+
Hybrids will throw if it finds a `<slot>` in a light DOM template.
|
|
250
|
+
|
|
251
|
+
**Template components break host context.** If you pass content as a property
|
|
252
|
+
to another component (e.g. `<page-layout content="${html`...`}">`), event
|
|
253
|
+
handlers inside that content will resolve `host` to the template component,
|
|
254
|
+
not the page that defined the handler. This makes buttons and forms silently
|
|
255
|
+
fail.
|
|
256
|
+
|
|
257
|
+
The solution: **use template functions, not template components**, for
|
|
258
|
+
page-level layout:
|
|
259
|
+
|
|
260
|
+
```javascript
|
|
261
|
+
// Template as a function — no component boundary, host context preserved
|
|
262
|
+
export function pageLayout(title, content) {
|
|
263
|
+
return html`
|
|
264
|
+
<div class="page-layout">
|
|
265
|
+
<header>${title}</header>
|
|
266
|
+
<main>${content}</main>
|
|
267
|
+
</div>
|
|
268
|
+
`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Page calls the function directly in its render
|
|
272
|
+
render: {
|
|
273
|
+
value: ({ items }) => pageLayout('Home', html`
|
|
274
|
+
<button onclick="${handleClick}">Works!</button>
|
|
275
|
+
<ul>${items.map(...)}</ul>
|
|
276
|
+
`),
|
|
277
|
+
shadow: false,
|
|
278
|
+
},
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Because `pageLayout` is a plain function (not a `define()`'d component),
|
|
282
|
+
there is no intermediate hybrids element in the DOM tree. The `onclick`
|
|
283
|
+
handler resolves `host` to the page component as expected.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Styling
|
|
288
|
+
|
|
289
|
+
### Style Inheritance Model
|
|
290
|
+
|
|
291
|
+
Because components use light DOM, styles flow naturally:
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
public/index.html
|
|
295
|
+
├── <link> src/styles/reset.css ← base reset
|
|
296
|
+
├── <link> src/styles/tokens.css ← :root custom properties
|
|
297
|
+
├── <link> src/styles/shared.css ← error states, icons, utilities
|
|
298
|
+
└── <link> src/styles/components.css ← all component styles (loaded once)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Every component automatically inherits all shared styles. No injection needed.
|
|
302
|
+
|
|
303
|
+
### Three Layers of CSS
|
|
304
|
+
|
|
305
|
+
| Layer | File(s) | What goes here |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| **Tokens** | `tokens.css` | `:root` custom properties — colors, spacing, type, radii, shadows |
|
|
308
|
+
| **Shared** | `shared.css` | Error states, loading states, icon base, screen-reader utils, transitions |
|
|
309
|
+
| **Components** | `components.css` + per-component `.css` | Styles scoped to tag names via native CSS nesting |
|
|
310
|
+
|
|
311
|
+
### Per-Component CSS with Native Nesting
|
|
312
|
+
|
|
313
|
+
Each component has a `.css` file that scopes styles using the tag name
|
|
314
|
+
as the top-level selector, with native CSS nesting inside:
|
|
315
|
+
|
|
316
|
+
```css
|
|
317
|
+
/* app-button/app-button.css */
|
|
318
|
+
app-button {
|
|
319
|
+
display: inline-block;
|
|
320
|
+
|
|
321
|
+
& button {
|
|
322
|
+
padding: var(--space-sm) var(--space-md);
|
|
323
|
+
border: none;
|
|
324
|
+
border-radius: var(--radius-md);
|
|
325
|
+
background: var(--color-primary);
|
|
326
|
+
color: white;
|
|
327
|
+
font: inherit;
|
|
328
|
+
|
|
329
|
+
&:hover { background: var(--color-primary-hover); }
|
|
330
|
+
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
&[variant="secondary"] button {
|
|
334
|
+
background: var(--color-surface);
|
|
335
|
+
color: var(--color-text);
|
|
336
|
+
border: 1px solid var(--color-border);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
&[variant="ghost"] button {
|
|
340
|
+
background: transparent;
|
|
341
|
+
color: var(--color-primary);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
This gives you **scoping by convention** — styles only apply inside the
|
|
347
|
+
component's tag. No shadow DOM needed, no style collisions.
|
|
348
|
+
|
|
349
|
+
### Loading Component Styles
|
|
350
|
+
|
|
351
|
+
Component `.css` files are imported into a single `src/styles/components.css`
|
|
352
|
+
that aggregates them:
|
|
353
|
+
|
|
354
|
+
```css
|
|
355
|
+
/* src/styles/components.css */
|
|
356
|
+
@import '../components/atoms/app-button/app-button.css';
|
|
357
|
+
@import '../components/atoms/app-badge/app-badge.css';
|
|
358
|
+
/* ... add as components are created ... */
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
This file is loaded once in `index.html`. Adding a new component means
|
|
362
|
+
adding one `@import` line.
|
|
363
|
+
|
|
364
|
+
### Design Tokens
|
|
365
|
+
|
|
366
|
+
Shared tokens live in `src/styles/tokens.css` as custom properties on `:root`.
|
|
367
|
+
These pierce all boundaries (even shadow DOM if ever used):
|
|
368
|
+
|
|
369
|
+
```css
|
|
370
|
+
:root {
|
|
371
|
+
--color-primary: #2563eb;
|
|
372
|
+
--space-md: 1rem;
|
|
373
|
+
--radius-md: 0.375rem;
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Components reference tokens, never hardcode values.
|
|
378
|
+
|
|
379
|
+
### Rules
|
|
380
|
+
|
|
381
|
+
- No CSS-in-JS libraries. No preprocessors. Native CSS only.
|
|
382
|
+
- One `.css` file per component, scoped via tag-name nesting.
|
|
383
|
+
- Shared patterns (error, loading, icons) go in `shared.css`.
|
|
384
|
+
- Use custom properties for all colors, spacing, and typography.
|
|
385
|
+
- Small inline styles via `.css()` are fine — move to file at ~10 rules.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## Templates & Layout Engine
|
|
390
|
+
|
|
391
|
+
### Tagged Template Literals
|
|
392
|
+
|
|
393
|
+
All templates use `html` from hybrids:
|
|
394
|
+
|
|
395
|
+
```javascript
|
|
396
|
+
html`<div>${expression}</div>`
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Expressions can be: strings, numbers, booleans, other templates, arrays of
|
|
400
|
+
templates, Promises (via `html.resolve()`), or event handlers.
|
|
401
|
+
|
|
402
|
+
### Layout Attributes
|
|
403
|
+
|
|
404
|
+
Hybrids' layout engine lets you declare CSS layout inline:
|
|
405
|
+
|
|
406
|
+
```javascript
|
|
407
|
+
render: () => html`
|
|
408
|
+
<template layout="column gap:2">
|
|
409
|
+
<header layout="row center gap">...</header>
|
|
410
|
+
<main layout="grow">...</main>
|
|
411
|
+
<footer layout@768px="hidden">...</footer>
|
|
412
|
+
</template>
|
|
413
|
+
`,
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
| Attribute | Effect |
|
|
417
|
+
|---|---|
|
|
418
|
+
| `layout="row"` | Flexbox row |
|
|
419
|
+
| `layout="column"` | Flexbox column |
|
|
420
|
+
| `layout="grid:1\|max"` | CSS grid with defined tracks |
|
|
421
|
+
| `layout="grow"` | `flex-grow: 1` |
|
|
422
|
+
| `layout="center"` | Center content |
|
|
423
|
+
| `layout="gap:2"` | Gap using spacing scale |
|
|
424
|
+
| `layout@768px="hidden"` | Responsive — applies at ≥768px |
|
|
425
|
+
|
|
426
|
+
### Keyed Lists
|
|
427
|
+
|
|
428
|
+
For efficient list rendering, use `.key()`:
|
|
429
|
+
|
|
430
|
+
```javascript
|
|
431
|
+
render: ({ items }) => html`
|
|
432
|
+
<ul>
|
|
433
|
+
${items.map(item => html`
|
|
434
|
+
<li>${item.name}</li>
|
|
435
|
+
`.key(item.id))}
|
|
436
|
+
</ul>
|
|
437
|
+
`,
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Async Content
|
|
441
|
+
|
|
442
|
+
```javascript
|
|
443
|
+
render: ({ dataPromise }) => html`
|
|
444
|
+
<div>
|
|
445
|
+
${html.resolve(dataPromise, html`<span>Loading...</span>`)}
|
|
446
|
+
</div>
|
|
447
|
+
`,
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## File Size & Decomposition
|
|
453
|
+
|
|
454
|
+
**Hard limit: 150 lines per file.** This applies to `.js` and `.css` alike.
|
|
455
|
+
|
|
456
|
+
### When a file grows too large
|
|
457
|
+
|
|
458
|
+
| Situation | Action |
|
|
459
|
+
|---|---|
|
|
460
|
+
| Component logic exceeds 150 lines | Extract helpers to `src/utils/` |
|
|
461
|
+
| Template is too complex | Split into child sub-components |
|
|
462
|
+
| CSS exceeds 150 lines | Extract shared patterns to `src/styles/` |
|
|
463
|
+
| Store model has many relations | Split related models into own files |
|
|
464
|
+
|
|
465
|
+
### What counts toward the limit
|
|
466
|
+
|
|
467
|
+
- All lines including imports, blank lines, and comments.
|
|
468
|
+
- JSDoc blocks count. Keep them concise.
|
|
469
|
+
|
|
470
|
+
### What does NOT split
|
|
471
|
+
|
|
472
|
+
- The 3-file component structure (`component.js`, `component.css`, `index.js`)
|
|
473
|
+
stays together in one directory. Don't add more files to a component dir.
|
|
474
|
+
|
|
475
|
+
---
|
|
476
|
+
|
|
477
|
+
## JSDoc Typing Strategy
|
|
478
|
+
|
|
479
|
+
See [JSDOC_TYPING.md](./JSDOC_TYPING.md) for the full JSDoc typing
|
|
480
|
+
specification, including component properties, store models, event handlers,
|
|
481
|
+
and validation rules.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Conventions
|
|
2
|
+
## Naming Rules & Anti-Patterns
|
|
3
|
+
|
|
4
|
+
> Quick reference for naming and what to avoid.
|
|
5
|
+
> See [FRONTEND_IMPLEMENTATION_RULES.md](./FRONTEND_IMPLEMENTATION_RULES.md) for
|
|
6
|
+
> the full specification index.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Naming Conventions
|
|
11
|
+
|
|
12
|
+
### Component Tags
|
|
13
|
+
|
|
14
|
+
All custom element tags use **kebab-case** with an `app-` prefix:
|
|
15
|
+
|
|
16
|
+
| Thing | Name | Tag |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Button atom | `app-button` | `<app-button>` |
|
|
19
|
+
| Header organism | `app-header` | `<app-header>` |
|
|
20
|
+
| Page layout template | `page-layout` | `<page-layout>` |
|
|
21
|
+
| Home page view | `home-view` | `<home-view>` |
|
|
22
|
+
|
|
23
|
+
The `app-` prefix prevents collisions with native elements and third-party
|
|
24
|
+
components. Page views and templates may drop the prefix when unambiguous.
|
|
25
|
+
|
|
26
|
+
### Files & Directories
|
|
27
|
+
|
|
28
|
+
| Type | Convention | Example |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| Component file | Match tag name | `app-button.js` |
|
|
31
|
+
| Component CSS | Match tag name | `app-button.css` |
|
|
32
|
+
| Component dir | Match tag name | `app-button/` |
|
|
33
|
+
| Re-export | Always `index.js` | `index.js` |
|
|
34
|
+
| Store model | PascalCase | `UserModel.js` |
|
|
35
|
+
| Utility function | camelCase | `formatDate.js` |
|
|
36
|
+
| Shared CSS | Descriptive kebab | `tokens.css`, `reset.css` |
|
|
37
|
+
|
|
38
|
+
### JavaScript
|
|
39
|
+
|
|
40
|
+
| Type | Convention | Example |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Event handlers | `handle` + action | `handleClick`, `handleSubmit` |
|
|
43
|
+
| Store models | PascalCase noun | `UserModel`, `AppState` |
|
|
44
|
+
| Utility functions | camelCase verb | `formatDate`, `parseQuery` |
|
|
45
|
+
| Constants | UPPER_SNAKE | `MAX_RETRIES`, `API_BASE` |
|
|
46
|
+
| JSDoc typedefs | PascalCase | `@typedef {Object} User` |
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Anti-Patterns
|
|
51
|
+
|
|
52
|
+
### ❌ Never Do This
|
|
53
|
+
|
|
54
|
+
**DOM queries inside components:**
|
|
55
|
+
```javascript
|
|
56
|
+
// BAD — breaks encapsulation, ignores shadow DOM
|
|
57
|
+
const el = document.querySelector('.my-thing');
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Manual event listeners:**
|
|
61
|
+
```javascript
|
|
62
|
+
// BAD — leaks memory, bypasses hybrids lifecycle
|
|
63
|
+
connectedCallback() {
|
|
64
|
+
this.addEventListener('click', this.onClick);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Global mutable state:**
|
|
69
|
+
```javascript
|
|
70
|
+
// BAD — invisible dependencies, untraceable bugs
|
|
71
|
+
window.appState = { user: null };
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Imperative DOM manipulation:**
|
|
75
|
+
```javascript
|
|
76
|
+
// BAD — fights the reactive render cycle
|
|
77
|
+
host.shadowRoot.querySelector('span').textContent = 'updated';
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Business logic in render:**
|
|
81
|
+
```javascript
|
|
82
|
+
// BAD — render should be pure projection of state
|
|
83
|
+
render: ({ items }) => html`
|
|
84
|
+
<ul>${items.filter(i => i.active).sort((a,b) => a.name.localeCompare(b.name)).map(...)}</ul>
|
|
85
|
+
`,
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Files over 150 lines:**
|
|
89
|
+
```
|
|
90
|
+
// BAD — extract to utils/ or split into sub-components
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Deep nesting (>3 component levels):**
|
|
94
|
+
```
|
|
95
|
+
// BAD — flatten by composing at the page level
|
|
96
|
+
<app-layout>
|
|
97
|
+
<app-sidebar>
|
|
98
|
+
<nav-section>
|
|
99
|
+
<nav-group> ← too deep
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Error Handling
|
|
105
|
+
|
|
106
|
+
Errors are handled **at the boundary where they are actionable.** Each layer
|
|
107
|
+
has a single responsibility — catch only what you can meaningfully respond to,
|
|
108
|
+
let everything else propagate.
|
|
109
|
+
|
|
110
|
+
### Boundary Rules
|
|
111
|
+
|
|
112
|
+
| Layer | Responsibility | Example |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| **Utils** | Never catch. Return error values or throw. Caller decides. | `formatDate(null)` returns `''` |
|
|
115
|
+
| **Store connectors** | Let fetch failures propagate. Hybrids' `store.error()` catches them. | Don't wrap fetch in try/catch |
|
|
116
|
+
| **Components** | Display `store.pending()` / `store.error()` states. Never try/catch in render. | `${store.error(model) && html`<div class="error-message">...</div>`}` |
|
|
117
|
+
| **Event handlers** | Guard with `store.ready()` before accessing store properties. | `if (!store.ready(host.state)) return;` |
|
|
118
|
+
| **Server routes** | Return HTTP status + JSON error body. Never crash the process. | `res.status(404).json({ error: 'Not found' })` |
|
|
119
|
+
| **Server infra** | Handle process-level errors with clear messages and exit codes. | Port conflict → log message → `process.exit(1)` |
|
|
120
|
+
|
|
121
|
+
### Why This Matters
|
|
122
|
+
|
|
123
|
+
If a store connector catches its own fetch error, `store.error()` never fires
|
|
124
|
+
and the component can't show an error state. If a utility swallows an
|
|
125
|
+
exception, the caller can't decide how to handle it. Each layer trusts the
|
|
126
|
+
next layer up to handle what it can't.
|
|
127
|
+
|
|
128
|
+
### ❌ Don't
|
|
129
|
+
|
|
130
|
+
```javascript
|
|
131
|
+
// BAD — swallows the error, store.error() never fires
|
|
132
|
+
[store.connect]: {
|
|
133
|
+
get: async (id) => {
|
|
134
|
+
try { return await fetch(`/api/items/${id}`).then(r => r.json()); }
|
|
135
|
+
catch { return null; } // silent failure
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```javascript
|
|
141
|
+
// BAD — accesses store properties without ready guard
|
|
142
|
+
function toggle(host) {
|
|
143
|
+
const next = host.state.theme === 'light' ? 'dark' : 'light';
|
|
144
|
+
store.set(host.state, { theme: next }); // crashes if model is in error state
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### ✅ Do
|
|
149
|
+
|
|
150
|
+
```javascript
|
|
151
|
+
// GOOD — let it throw, component handles via store.error()
|
|
152
|
+
[store.connect]: {
|
|
153
|
+
get: (id) => fetch(`/api/items/${id}`).then(r => r.json()),
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```javascript
|
|
158
|
+
// GOOD — guard before accessing properties
|
|
159
|
+
function toggle(host) {
|
|
160
|
+
if (!store.ready(host.state)) return;
|
|
161
|
+
const next = host.state.theme === 'light' ? 'dark' : 'light';
|
|
162
|
+
store.set(host.state, { theme: next });
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
### ✅ Always Do This
|
|
169
|
+
|
|
170
|
+
**Declarative event binding:**
|
|
171
|
+
```javascript
|
|
172
|
+
html`<button onclick="${handleClick}">Go</button>`
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Store for shared state:**
|
|
176
|
+
```javascript
|
|
177
|
+
state: store(AppState),
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Pure functions for logic:**
|
|
181
|
+
```javascript
|
|
182
|
+
// In src/utils/filterActive.js
|
|
183
|
+
export const filterActive = (items) => items.filter(i => i.active);
|
|
184
|
+
|
|
185
|
+
// In component
|
|
186
|
+
import { filterActive } from '../../utils/filterActive.js';
|
|
187
|
+
render: ({ items }) => html`<ul>${filterActive(items).map(...)}</ul>`,
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**JSDoc on all exports:**
|
|
191
|
+
```javascript
|
|
192
|
+
/** @param {User} user */
|
|
193
|
+
export const fullName = (user) => `${user.firstName} ${user.lastName}`;
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## File Size: Soft Warnings Before Hard Limits
|
|
199
|
+
|
|
200
|
+
The 150-line limit is a hard gate in CI, but treat **~120 lines as a yellow
|
|
201
|
+
light.** When a file passes 120 lines:
|
|
202
|
+
|
|
203
|
+
1. Add a `// SPLIT CANDIDATE:` comment noting where a logical split could happen
|
|
204
|
+
2. Continue working — don't split mid-feature
|
|
205
|
+
3. Split when the file hits 150 or when the feature is complete
|
|
206
|
+
|
|
207
|
+
This prevents premature extraction while keeping the eventual split obvious.
|
|
208
|
+
|
|
209
|
+
```javascript
|
|
210
|
+
// SPLIT CANDIDATE: moveObj/resizeObj could extract to canvasTransform.js
|
|
211
|
+
function moveObj(o, dx, dy) { ... }
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Session Retrospective
|
|
217
|
+
|
|
218
|
+
At the end of each implementation session, ask:
|
|
219
|
+
|
|
220
|
+
1. **What patterns did we discover?** Document in the relevant spec doc.
|
|
221
|
+
2. **What broke that we didn't expect?** Add to BUILD_LOG discoveries.
|
|
222
|
+
3. **What tests would catch the bugs we found?** Write them before committing.
|
|
223
|
+
4. **Did any files grow past the yellow light?** Split or add split markers.
|
|
224
|
+
5. **Did the spec need correction?** Update it — the spec improves through use.
|
|
225
|
+
|
|
226
|
+
This practice is what keeps the spec alive and accurate.
|