@colletdev/docs 0.2.1

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/core.md ADDED
@@ -0,0 +1,684 @@
1
+ # Collet Core — Framework-Agnostic Reference
2
+
3
+ Shared architecture, initialization, theming, CSS, SSR, and conventions
4
+ that apply to all Collet framework wrappers and vanilla Custom Element usage.
5
+
6
+ ---
7
+
8
+ ## Architecture
9
+
10
+ Framework wrappers (`@colletdev/react`, `@colletdev/vue`, `@colletdev/svelte`,
11
+ `@colletdev/angular`) are thin adapters that render the underlying `<cx-*>`
12
+ Custom Element. They handle:
13
+
14
+ - **Attribute serialization** -- objects/arrays go through `JSON.stringify`
15
+ - **Event bridging** -- framework event props attach listeners for `cx-{event}` CustomEvents
16
+ - **Slot projection** -- named slots via `<div slot="name">` (display strategy varies by framework)
17
+ - **Typed imperative handles** -- expose typed methods like `.open()`, `.close()`, `.focus()`
18
+
19
+ ### Package structure
20
+
21
+ ```
22
+ packages/
23
+ core/ <- Custom Elements, runtime, WASM, CSS
24
+ src/
25
+ elements/ <- Per-component Custom Element definitions
26
+ runtime.js <- Shadow DOM, adopted stylesheets, __cx namespace
27
+ styles.js <- Inlined CSS strings (tokens + utilities)
28
+ index.js <- init(), element registration
29
+ server.js <- createRenderer() for SSR/DSD
30
+ markdown.js <- renderMarkdown(), renderMarkdownSync()
31
+ dist/
32
+ tokens.css <- Document-level CSS vars, themes, fonts
33
+ tokens-shadow.css <- Shadow DOM adopted stylesheet (keyframes, motion)
34
+ cx-utilities.css <- Tailwind utility classes for shadow DOM
35
+ syntax.css <- Code viewer syntax highlighting theme
36
+ react/
37
+ generated/ <- Auto-generated React wrappers (DO NOT EDIT)
38
+ index.ts <- Re-exports all components + hooks
39
+ vue/
40
+ src/ <- Auto-generated Vue 3.3+ wrappers (Composition API)
41
+ dist/ <- Compiled .js + .d.ts files
42
+ svelte/
43
+ src/ <- Auto-generated Svelte 5 runes-first wrappers
44
+ angular/
45
+ src/ <- Auto-generated Angular 16+ standalone components
46
+ ```
47
+
48
+ ### Regeneration
49
+
50
+ ```bash
51
+ bash scripts/build-packages.sh
52
+ ```
53
+
54
+ This runs: `gen_tokens` + `gen_shadow_tokens` -> `wasm-pack` -> Tailwind extract -> `inline-css.mjs` -> `generate-elements.mjs` -> `generate-react.mjs` -> `generate-vue.mjs` -> `generate-svelte.mjs` -> `generate-angular.mjs` -> `generate-skill-docs.mjs`
55
+
56
+ ---
57
+
58
+ ## CSS Architecture (Build-Time Split)
59
+
60
+ The build produces **two** token CSS files instead of one:
61
+
62
+ | File | Scope | Contents |
63
+ |------|-------|----------|
64
+ | `tokens.css` | Document `<head>` | CSS vars, fonts, themes, floating rules, view transitions |
65
+ | `tokens-shadow.css` | Shadow DOM adopted stylesheet | Keyframes, component motion, slider/scrollbar/texture/prose |
66
+
67
+ `init()` injects `tokens.css` into `<head>` (CSS variables inherit into Shadow DOM).
68
+ The shadow subset is adopted directly -- no runtime CSS parsing.
69
+
70
+ ```
71
+ Document <head>
72
+ tokens.css <- CSS vars (--color-*, --spacing-*, --duration-*, etc.)
73
+ Fonts, themes, floating panel rules, view transitions
74
+
75
+ Shadow DOM (per component)
76
+ cx-utilities.css <- Tailwind utility classes (adopted stylesheet)
77
+ tokens-shadow.css <- Keyframes + component motion (adopted stylesheet)
78
+ Component HTML <- Semantic HTML with Tailwind classes
79
+ ```
80
+
81
+ **Previous approach (removed):** `stripTokensForShadowDom()` was a runtime
82
+ regex/brace-balanced parser that tried to filter a single monolithic CSS blob.
83
+ It broke across three patch versions. The build-time split eliminates this
84
+ entire class of regressions.
85
+
86
+ ---
87
+
88
+ ## Initialization & Tree-Shaking
89
+
90
+ The `init()` function from `@colletdev/core` registers Custom Elements and loads
91
+ behavior JS. It supports selective component registration so bundlers (Vite,
92
+ Webpack, Rollup) can tree-shake unused behavior modules via dynamic `import()`.
93
+
94
+ ### Usage
95
+
96
+ ```js
97
+ import { init } from '@colletdev/core';
98
+
99
+ // Zero-config -- all 48 components (recommended for prototyping)
100
+ await init();
101
+
102
+ // Selective -- only 3 components downloaded (recommended for production)
103
+ await init({ components: ['button', 'text-input', 'select'] });
104
+
105
+ // Lazy -- WASM loads in background, no first-paint blocking
106
+ await init({ lazy: true });
107
+
108
+ // Lazy + selective -- fastest possible first paint
109
+ await init({ lazy: true, components: ['button', 'card'] });
110
+ ```
111
+
112
+ ### Options
113
+
114
+ | Option | Type | Default | Description |
115
+ |--------|------|---------|-------------|
116
+ | `components` | `string[]` | all | Component names to register. Only these components' behavior JS files are downloaded. |
117
+ | `lazy` | `boolean` | `false` | When `true`, WASM loads in the background after first paint instead of blocking. |
118
+
119
+ ### How it works
120
+
121
+ **Lazy by design:** The heavy assets (WASM binary ~893 KB, CSS strings ~173 KB,
122
+ WASM glue ~21 KB) are NOT loaded at module level. They're fetched via dynamic
123
+ `import()` inside `init()`, which means `import { init } from '@colletdev/core'`
124
+ costs only ~48 KB. Bundlers (Vite, Webpack, Rollup) automatically code-split
125
+ the WASM and CSS into separate async chunks.
126
+
127
+ Each component's behavior JS (`static/_behaviors/*.js`) is also loaded via
128
+ dynamic `import()`. When `components` is provided, only the listed components'
129
+ chunks are fetched -- the rest never leave the server.
130
+
131
+ ### Bundle impact
132
+
133
+ | Asset | Raw | Gzip | Brotli | When loaded |
134
+ |-------|-----|------|--------|-------------|
135
+ | Entry point (index.js + runtime.js) | 48 KB | ~10 KB | ~8 KB | `import { init }` |
136
+ | CSS strings (styles.js) | 173 KB | 33 KB | ~28 KB | `init()` call |
137
+ | WASM glue (wasm_api.js) | 21 KB | ~5 KB | ~4 KB | `init()` call |
138
+ | WASM binary (wasm_api_bg.wasm) | 893 KB | 318 KB | 232 KB | `init()` call |
139
+ | Per-component behavior JS | 1-20 KB each | varies | varies | Component registration |
140
+
141
+ **Total first-paint cost with brotli:** ~8 KB (just the import).
142
+ **Total after init():** ~272 KB brotli (all assets loaded).
143
+ **WASM is cached:** After first visit, the browser serves it from disk cache.
144
+
145
+ **Recommendation:** Use `components` in production builds. Use bare `init()`
146
+ during prototyping when the component set is still changing.
147
+
148
+ ---
149
+
150
+ ## Custom Theming with Collet Tokens
151
+
152
+ Collet components use CSS custom properties for all visual values. Override them
153
+ with a custom `tokens.css` generated by [Collet Tokens](https://github.com/Danrozen87/collet-tokens):
154
+
155
+ ```bash
156
+ # Install the token compiler
157
+ cargo install collet-tokens-cli
158
+
159
+ # Create a token file with your brand
160
+ collet-tokens init
161
+ # Edit tokens.yaml with your colors, fonts, spacing
162
+
163
+ # Generate CSS
164
+ collet-tokens build --input tokens.yaml --outdir public/
165
+ ```
166
+
167
+ Then load your `tokens.css` after Collet's default:
168
+
169
+ ```html
170
+ <!-- Your custom tokens override Collet defaults via CSS cascade -->
171
+ <link rel="stylesheet" href="/@colletdev/tokens.css"> <!-- Collet defaults -->
172
+ <link rel="stylesheet" href="/public/tokens.css"> <!-- Your brand overrides -->
173
+ ```
174
+
175
+ Or in JS:
176
+
177
+ ```js
178
+ import '@colletdev/core/dist/tokens.css'; // Collet defaults
179
+ import './public/tokens.css'; // Your brand overrides
180
+ ```
181
+
182
+ Every Collet component instantly reflects your brand. No code changes needed.
183
+
184
+ **White-labeling:** Load different `tokens.css` per tenant for multi-brand apps.
185
+ See [Theme Registry docs](https://github.com/Danrozen87/collet-tokens/blob/main/docs/theme-registry.md).
186
+
187
+ ---
188
+
189
+ ## Server Rendering & Declarative Shadow DOM
190
+
191
+ Collet can pre-render components to HTML at build time or on the server.
192
+ The browser paints them **instantly** -- no JavaScript needed for first render.
193
+ When the Custom Element class registers, the element "upgrades" and becomes
194
+ interactive. No hydration step. This is resumability via native browser APIs.
195
+
196
+ ### Server Renderer (Node.js)
197
+
198
+ ```js
199
+ import { createRenderer } from '@colletdev/core/server';
200
+
201
+ const cx = await createRenderer();
202
+
203
+ // Full DSD element -- browser renders this without JS
204
+ const html = cx.renderDSD('button', {
205
+ label: 'Click me',
206
+ variant: 'filled',
207
+ id: 'btn-1',
208
+ });
209
+ // -> '<cx-button variant="filled" ...>
210
+ // <template shadowrootmode="open">
211
+ // <link rel="stylesheet" href="/@colletdev/cx-utilities.css">
212
+ // <link rel="stylesheet" href="/@colletdev/tokens-shadow.css">
213
+ // <button class="inline-flex items-center ...">Click me</button>
214
+ // </template>
215
+ // </cx-button>'
216
+
217
+ // Just the inner HTML (for custom injection)
218
+ const inner = cx.renderToString('button', { label: 'Click', id: 'btn-2' });
219
+ // -> { html: '<button class="...">Click</button>', sprites: '', a11y: {...} }
220
+
221
+ // Just the <template> fragment (for existing host elements)
222
+ const frag = cx.renderDSDFragment('badge', { label: 'New', id: 'b-1' });
223
+ // -> '<template shadowrootmode="open">...</template>'
224
+ ```
225
+
226
+ ### Renderer Options
227
+
228
+ ```js
229
+ const cx = await createRenderer({
230
+ stylesUrl: '/assets/cx-utilities.css', // URL for CSS <link> in DSD templates
231
+ motionUrl: '/assets/tokens-shadow.css', // URL for motion CSS <link>
232
+ inlineStyles: true, // Inline CSS instead of <link> (SSG)
233
+ });
234
+ ```
235
+
236
+ ### Vite Setup
237
+
238
+ The Vite plugin handles WASM MIME types, binary copying, preload hints, and
239
+ dependency pre-bundling automatically:
240
+
241
+ ```js
242
+ import { colletPlugin } from '@colletdev/core/vite-plugin';
243
+
244
+ export default defineConfig({
245
+ plugins: [colletPlugin({
246
+ prerender: true, // Pre-render <cx-*> in index.html (optional)
247
+ preload: true, // Inject WASM preload hint (default)
248
+ })],
249
+ });
250
+ ```
251
+
252
+ The plugin automatically:
253
+ - Copies `wasm_api_bg.wasm` to `public/` during dev
254
+ - Serves `.wasm` files with correct MIME type (`application/wasm`)
255
+ - Emits the WASM binary in production builds
256
+ - Configures `optimizeDeps` to pre-bundle `@colletdev/core` (prevents full-page reloads during dev)
257
+ - Excludes the WASM glue module from pre-bundling (must stay as native ESM)
258
+
259
+ **Without the plugin**, manually configure:
260
+
261
+ ```js
262
+ export default defineConfig({
263
+ optimizeDeps: {
264
+ include: ['@colletdev/core'],
265
+ exclude: ['@colletdev/core/wasm/wasm_api.js'],
266
+ },
267
+ });
268
+ ```
269
+
270
+ **For large apps**, pass an explicit component list to `init()` for faster startup
271
+ (avoids O(n) DOM scan):
272
+
273
+ ```js
274
+ await init({ components: ['button', 'dialog', 'text-input', 'select'] });
275
+ ```
276
+
277
+ ### Framework SSR Integration
278
+
279
+ | Framework | Approach |
280
+ |-----------|----------|
281
+ | **Next.js (App Router)** | Use `createRenderer()` in a Server Component or API route |
282
+ | **Nuxt** | Use `createRenderer()` in a server plugin or composable |
283
+ | **SvelteKit** | Use `createRenderer()` in a server `load` function |
284
+ | **Angular Universal** | Use `createRenderer()` in a transfer state resolver |
285
+ | **Remix** | Use `createRenderer()` in the loader, inject DSD in the template |
286
+ | **Astro** | Use `createRenderer()` in `.astro` components (SSG or SSR) |
287
+ | **Plain HTML** | Use `colletPlugin({ prerender: true })` in Vite |
288
+ | **SPA (no SSR)** | Use `init({ lazy: true })` -- DSD not applicable |
289
+
290
+ ### How DSD Upgrade Works
291
+
292
+ 1. Server/build produces `<cx-button><template shadowrootmode="open">...</template></cx-button>`
293
+ 2. Browser creates the shadow root immediately from the DSD template (spec behavior)
294
+ 3. Component is **visible and styled** -- zero JavaScript needed
295
+ 4. When `init()` runs, `customElements.define()` triggers element upgrade
296
+ 5. The existing shadow root is reused (not recreated) -- `this.shadowRoot` already exists
297
+ 6. Event handlers attach, adopted stylesheets replace `<link>` tags -- zero visual flash
298
+
299
+ ---
300
+
301
+ ## Event Convention
302
+
303
+ All Collet components emit events with the `cx-` prefix as `CustomEvent` instances.
304
+
305
+ ### Naming
306
+
307
+ ```
308
+ cx-{action}
309
+ ```
310
+
311
+ Examples: `cx-close`, `cx-navigate`, `cx-action`, `cx-input`, `cx-change`,
312
+ `cx-sort`, `cx-select`, `cx-page`, `cx-expand`, `cx-dismiss`, `cx-stream-end`
313
+
314
+ ### Listening (Vanilla JS)
315
+
316
+ ```js
317
+ const el = document.querySelector('cx-dialog');
318
+
319
+ el.addEventListener('cx-close', (e) => {
320
+ console.log('Dialog closed', e.detail);
321
+ });
322
+ ```
323
+
324
+ ### Detail Types
325
+
326
+ Every event carries a typed `detail` payload:
327
+
328
+ | Event | Detail Type | Fields |
329
+ |-------|-------------|--------|
330
+ | `cx-close` | `CloseDetail` | `{}` |
331
+ | `cx-navigate` | `NavigateDetail` | `{ label, href }` |
332
+ | `cx-action` | `MenuActionDetail` | `{ id, label }` |
333
+ | `cx-input` | `InputDetail` | `{ value }` |
334
+ | `cx-change` | varies | Component-specific |
335
+ | `cx-sort` | `SortDetail` | `{ column, direction }` |
336
+ | `cx-select` | `SelectDetail` | `{ value, label }` |
337
+ | `cx-page` | `PageDetail` | `{ page }` |
338
+ | `cx-expand` | `TableExpandDetail` | `{ rowId, expanded }` |
339
+ | `cx-dismiss` | `DismissDetail` | `{}` |
340
+ | `cx-focus` | `FocusDetail` | `{}` |
341
+ | `cx-blur` | `FocusDetail` | `{}` |
342
+ | `cx-keydown` | `KeyboardDetail` | `{ key, code, ... }` |
343
+ | `cx-click` | `ClickDetail` | `{}` |
344
+ | `cx-stream-end` | `{}` | Streaming complete |
345
+
346
+ ### Framework wrapper mapping
347
+
348
+ Framework wrappers map these to idiomatic event props:
349
+
350
+ - **React:** `onClose`, `onNavigate`, `onAction`, `onInput`, `onChange`, etc.
351
+ - **Vue:** `@close`, `@navigate`, `@action`, `@input`, `@change`, etc.
352
+ - **Svelte:** `onclose`, `onnavigate`, `onaction`, `oninput`, `onchange`, etc.
353
+ - **Angular:** `(cxClose)`, `(cxNavigate)`, `(cxAction)`, `(cxInput)`, `(cxChange)`, etc.
354
+
355
+ ---
356
+
357
+ ## Slot Convention
358
+
359
+ Collet uses the web standard `<slot>` mechanism via Shadow DOM.
360
+
361
+ ### Default slot
362
+
363
+ The default slot receives children:
364
+
365
+ ```html
366
+ <cx-card>
367
+ <p>This goes into the default slot</p>
368
+ </cx-card>
369
+ ```
370
+
371
+ ### Named slots
372
+
373
+ Named slots target specific content areas:
374
+
375
+ ```html
376
+ <cx-card>
377
+ <div slot="header"><h3>Card Title</h3></div>
378
+ <p>Default slot content</p>
379
+ <div slot="footer"><button>Save</button></div>
380
+ </cx-card>
381
+
382
+ <cx-dialog title="Confirm">
383
+ <p>Are you sure?</p>
384
+ <div slot="footer"><button>OK</button></div>
385
+ </cx-dialog>
386
+ ```
387
+
388
+ ### Common named slots
389
+
390
+ | Slot | Used by | Purpose |
391
+ |------|---------|---------|
392
+ | `header` | Card, Dialog, Drawer, Sidebar | Top section content |
393
+ | `footer` | Card, Dialog, Drawer, Sidebar | Bottom section content |
394
+ | `actions` | Alert | Action buttons area |
395
+ | `trigger` | Tooltip, Popover | Element that triggers the floating panel |
396
+
397
+ ### Framework wrapper slot projection
398
+
399
+ Each framework wrapper maps slot children to the correct `slot` attribute:
400
+
401
+ - **Vanilla JS / @colletdev/core:** Use `slot="name"` attribute directly
402
+ - **React:** Pass as named props (e.g., `header={<h3>Title</h3>}`), wrapper adds `<div slot="name" style={{display:'contents'}}>`
403
+ - **Vue:** Use `<template v-slot:header>` or `<template #header>`
404
+ - **Svelte:** Use `{#snippet header()}` (Svelte 5) or `<div slot="header">`
405
+ - **Angular:** Use `<ng-container slot="header">` or `ngProjectAs`
406
+
407
+ ---
408
+
409
+ ## Form Integration
410
+
411
+ Form-associated Collet components (`TextInput`, `Select`, `Checkbox`, `Switch`,
412
+ `RadioGroup`, `DatePicker`, `Slider`) participate in native `<form>` submission
413
+ through the `ElementInternals` API.
414
+
415
+ ### How it works
416
+
417
+ ```html
418
+ <form id="signup">
419
+ <cx-text-input name="email" label="Email" required></cx-text-input>
420
+ <cx-select name="role" label="Role" items='[...]'></cx-select>
421
+ <cx-checkbox name="terms" label="Accept terms" required></cx-checkbox>
422
+ <button type="submit">Submit</button>
423
+ </form>
424
+
425
+ <script>
426
+ document.getElementById('signup').addEventListener('submit', (e) => {
427
+ e.preventDefault();
428
+ const data = new FormData(e.target);
429
+ console.log(data.get('email'), data.get('role'), data.get('terms'));
430
+ });
431
+ </script>
432
+ ```
433
+
434
+ Each form component:
435
+ - Implements `static formAssociated = true`
436
+ - Uses `ElementInternals.setFormValue()` to sync the current value
437
+ - Participates in `formdata`, `reset`, and `submit` events
438
+ - Reports validity via `ElementInternals.setValidity()`
439
+
440
+ ---
441
+
442
+ ## DOM Collision Handling
443
+
444
+ Some HTML attributes (`title`, `width`, `loading`, `name`, `value`) collide
445
+ with Custom Element attribute names. Collet handles this by routing colliding
446
+ attributes through explicit DOM operations rather than relying on the
447
+ framework's default attribute/property sync.
448
+
449
+ ### Affected attributes
450
+
451
+ | Attribute | Components | Issue |
452
+ |-----------|-----------|-------|
453
+ | `title` | Dialog, Drawer, Card | Browser shows native tooltip |
454
+ | `name` | TextInput, Select, RadioGroup | React 19 property-first sets wrong target |
455
+ | `value` | TextInput, ChatInput, Slider | React 19 property-first sets wrong target |
456
+ | `loading` | Table | Conflicts with native `loading` attribute |
457
+
458
+ ### How collisions are resolved
459
+
460
+ Framework wrappers use effect-based routing: the colliding prop is applied via
461
+ `setAttribute()` in a post-render effect rather than as a JSX attribute. This
462
+ ensures the value reaches the Custom Element's `attributeChangedCallback`
463
+ correctly regardless of framework property-setting behavior.
464
+
465
+ ---
466
+
467
+ ## Floating Components in Shadow DOM
468
+
469
+ Components with floating panels (Select, Autocomplete, Menu, Popover,
470
+ DatePicker, SpeedDial, SplitButton, ProfileMenu) manage their own visibility
471
+ inside Shadow DOM.
472
+
473
+ **Key architectural detail:** The SSR gallery uses `[data-floating]` CSS rules
474
+ from `tokens.css` to control floating panel visibility. These rules are
475
+ **stripped** from the adopted stylesheet in Shadow DOM because they would make
476
+ panels permanently invisible inside Custom Elements.
477
+
478
+ Instead, each Custom Element's `#open()` / `#close()` methods control visibility
479
+ directly via JS:
480
+ - `classList.remove('hidden')` / `classList.add('hidden')`
481
+ - `style.display = 'block'` / `style.display = 'none'`
482
+ - `style.pointerEvents = 'auto'` / `style.pointerEvents = ''`
483
+ - `style.opacity = '1'` / `style.opacity = ''`
484
+
485
+ ### Fixed positioning (escape scroll container clipping)
486
+
487
+ All floating panels use `position: fixed` with viewport coordinates calculated
488
+ from `trigger.getBoundingClientRect()`. This ensures panels escape `overflow:
489
+ auto/scroll/hidden` on ancestor elements (e.g. when a Select or Popover is
490
+ inside `<cx-scrollbar>` or any scroll container).
491
+
492
+ The base class `CxElement` provides two shared utilities:
493
+
494
+ ```js
495
+ // In #open() — positions panel with fixed coordinates + above/below flip logic
496
+ this._positionFloatingFixed(trigger, panel, { matchWidth: true, gap: 4 });
497
+
498
+ // In #close() — resets all inline position styles
499
+ this._resetFloatingFixed(panel);
500
+ ```
501
+
502
+ Tooltip uses a different approach: the `tooltip.js` behavior module creates a
503
+ single shared tooltip element on `document.body` (fully outside all scroll
504
+ containers). The `<cx-tooltip>` CE connects its shadow DOM wrapper to this
505
+ shared floating tooltip via `__cx._behaviors.tooltip.init(wrapper)`.
506
+
507
+ **When writing new Custom Elements with floating panels:** Never use `position:
508
+ absolute` -- it clips inside scroll containers. Always use
509
+ `_positionFloatingFixed()` or portal to `document.body`.
510
+
511
+ ### Shadow DOM containment rule (CRITICAL)
512
+
513
+ `Node.contains()` does NOT cross shadow DOM boundaries. When checking if focus
514
+ or a click target is "inside" the component, you MUST check BOTH trees:
515
+
516
+ ```js
517
+ // WRONG -- misses elements inside shadow root
518
+ if (!this.contains(active)) { this.#close(); }
519
+
520
+ // CORRECT -- checks light DOM AND shadow DOM
521
+ if (!this.contains(active) && !this._shadow.contains(active) && active !== this) {
522
+ this.#close();
523
+ }
524
+ ```
525
+
526
+ This applies to:
527
+ - **focusout handlers** -- `activeElement` after focus leaves
528
+ - **outside click handlers** -- `e.target` from document mousedown
529
+ - **relatedTarget checks** -- `e.relatedTarget` in focusin/focusout
530
+
531
+ Every Custom Element with a close-on-blur or close-on-outside-click pattern
532
+ must use the dual check. Without it, the focusout fires and closes the panel
533
+ before the click event can register on the shadow DOM option.
534
+
535
+ ---
536
+
537
+ ## Markdown Rendering
538
+
539
+ Collet provides GFM markdown rendering via WASM with compile-time XSS safety.
540
+ No runtime HTML sanitizer -- raw HTML in source is escaped by the Rust type system.
541
+
542
+ ### Static Rendering (Vanilla JS)
543
+
544
+ ```js
545
+ import { renderMarkdown, renderMarkdownSync } from '@colletdev/core/markdown';
546
+
547
+ // Async -- waits for WASM if needed
548
+ const html = await renderMarkdown('**Hello** world');
549
+ // -> '<div class="cx-prose"><p><strong>Hello</strong> world</p>\n</div>'
550
+
551
+ // Sync -- returns '' if WASM not loaded yet
552
+ const html2 = renderMarkdownSync('# Heading');
553
+ ```
554
+
555
+ ### Streaming Rendering (SSE/WebSocket)
556
+
557
+ For AI chat interfaces with token-by-token streaming, use the `MessagePart`
558
+ Custom Element directly:
559
+
560
+ ```js
561
+ const part = document.querySelector('cx-message-part');
562
+ part.startStream();
563
+
564
+ const source = new EventSource('/api/chat');
565
+ source.onmessage = (e) => part.appendTokens(e.data);
566
+ source.addEventListener('done', () => {
567
+ source.close();
568
+ part.endStream(); // final WASM sanitization pass
569
+ });
570
+ ```
571
+
572
+ ### Markdown API Reference
573
+
574
+ | Export | Type | Description |
575
+ |--------|------|-------------|
576
+ | `renderMarkdown(input)` | `async (string) -> string` | One-shot async rendering. Waits for WASM if needed. |
577
+ | `renderMarkdownSync(input)` | `(string) -> string` | Synchronous rendering. Returns `''` if WASM not loaded. |
578
+
579
+ ### Security Model
580
+
581
+ XSS safety is achieved through **compile-time structural guarantees**, not runtime sanitization:
582
+
583
+ 1. `pulldown-cmark` parses markdown into typed Rust events (not strings)
584
+ 2. `Event::Html` and `Event::InlineHtml` (raw HTML) are converted to `Event::Text` (escaped)
585
+ 3. Dangerous URL schemes (`javascript:`, `data:`, `vbscript:`) are stripped from links
586
+ 4. All text content is HTML-escaped by pulldown-cmark's renderer
587
+
588
+ This removed the `ammonia` + `html5ever` dependency chain (22 crates, ~400KB WASM),
589
+ cutting the binary by ~49% gzipped.
590
+
591
+ ### Streaming Safety
592
+
593
+ During streaming, `streaming-markdown` (3KB vendored) renders directly to DOM
594
+ for instant visual feedback. When `endStream()` is called, the accumulated text
595
+ is re-rendered through the WASM pipeline for defense-in-depth XSS sanitization.
596
+
597
+ ### Code Viewer (Mac Terminal Style)
598
+
599
+ `MessagePart` with `kind="code_block"` renders a macOS-style terminal with
600
+ traffic light dots, title bar, copy button, and syntax highlighting for 200+
601
+ languages.
602
+
603
+ Syntax highlighting uses `syntect` (SSR-side, feature-gated). The CSS theme
604
+ is in `packages/core/dist/syntax.css` -- include it alongside `tokens.css`
605
+ and `cx-utilities.css`.
606
+
607
+ ### Component Preview (Sandboxed)
608
+
609
+ `MessagePart` with `kind="preview"` renders a sandboxed iframe with framework
610
+ tabs. The iframe uses `sandbox="allow-scripts"` for isolation. Tab switching
611
+ is handled by the `message-part` behavior module.
612
+
613
+ ---
614
+
615
+ ## Behavior Module Architecture
616
+
617
+ Component interactivity uses a three-layer runtime:
618
+
619
+ 1. **Core Loader** (`static/loader.js`, ~200 lines) -- event delegation, WASM loading,
620
+ state R/W, floating positioning, behavior routing via `__cx` namespace
621
+ 2. **Behavior Modules** (`static/_behaviors/*.js`, loaded via `<script defer>`) --
622
+ per-component DOM orchestration (focus, ARIA, classList, scrollIntoView)
623
+ 3. **WASM Handlers** (`crates/handlers/src/*.rs`, lazy-loaded) -- pure business
624
+ logic (filtering, sorting, state transitions)
625
+
626
+ Rules:
627
+ - Core loader must stay under ~200 lines (core infrastructure only)
628
+ - Behavior modules register via `__cx.behavior('name', handler)`
629
+ - WASM handlers are pure: `(state) -> new_state`, no DOM access
630
+ - Behavior modules include JS fallbacks for WASM functions
631
+ - Each Custom Element loads only its needed behavior `<script>` tag
632
+
633
+ ---
634
+
635
+ ## WASM Dispatcher
636
+
637
+ Collet uses a single WASM entry point instead of per-component exports:
638
+
639
+ ```js
640
+ // Internal -- called by Custom Element definitions
641
+ import { cx_render } from '@colletdev/core/wasm';
642
+
643
+ const html = cx_render('button', { label: 'Click', variant: 'filled' });
644
+ ```
645
+
646
+ The dispatcher pattern (`cx_render(component, config)`) plus a separate
647
+ `cx_render_markdown(input)` export. This gives 2 WASM exports instead
648
+ of 45, yielding cleaner binaries and faster WASM instantiation.
649
+
650
+ ---
651
+
652
+ ## TypeScript Types
653
+
654
+ Shared types are exported from `@colletdev/core`:
655
+
656
+ ```ts
657
+ import type {
658
+ CloseDetail,
659
+ NavigateDetail,
660
+ InputDetail,
661
+ SelectDetail,
662
+ MenuActionDetail,
663
+ SidebarGroup,
664
+ SelectOption,
665
+ OptionGroup,
666
+ TableColumn,
667
+ TableRow,
668
+ TabItem,
669
+ MenuEntry,
670
+ CarouselSlide,
671
+ StepperStep,
672
+ RadioOption,
673
+ ToggleGroupItem,
674
+ AccordionItem,
675
+ BreadcrumbItem,
676
+ SpeedDialAction,
677
+ SplitMenuEntry,
678
+ MessageGroupPart,
679
+ } from '@colletdev/core';
680
+ ```
681
+
682
+ Complex props (`items`, `groups`, `entries`, `slides`, etc.) accept either
683
+ a typed object/array or a pre-serialized JSON string. When an object is passed,
684
+ the wrapper calls `JSON.stringify` automatically.