Files
whynot-design/MultiFrameworkSupport.md
tegwick 80252baf53
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
version 0.2.0 replaces fromer version!
2026-05-25 19:32:22 +02:00

419 lines
17 KiB
Markdown

# Multi-Framework Support
> How to consume `@whynot/design` from React, Django, Vue, Svelte, or plain HTML — without forking, restyling, or re-implementing components per framework.
---
## TL;DR
`whynot-design` ships **Web Components** (custom elements built with Lit) as the canonical component layer. They render to **light DOM** — meaning the global stylesheets style them — and work identically in any HTML context.
```html
<wn-button variant="primary">Promote prototype</wn-button>
```
That tag is valid in:
- A Django template
- A React JSX file
- A Vue Single-File Component
- A Svelte component
- `index.html` with no JS framework at all
- An HTMX `hx-get` fragment response
You do not write a different component per framework. You write the same custom element. The framework decides how to pass props (`variant="primary"` in HTML, `variant="primary"` in JSX, `:variant="…"` in Vue).
---
## Architecture recap
```
┌─────────────────────────────────────────────────────────────┐
│ Layer 3 — Framework adapters (optional) │
│ Django {% include %} partials, Vue/React typed wrappers │
├─────────────────────────────────────────────────────────────┤
│ Layer 2 — Custom elements (canonical) │
│ <wn-button>, <wn-card>, <wn-modal>, <wn-prototype-card>… │
│ Lit-based, light DOM, ~5kb gzipped runtime total │
├─────────────────────────────────────────────────────────────┤
│ Layer 1 — Tokens + CSS │
│ colors_and_type.css + components.css │
└─────────────────────────────────────────────────────────────┘
```
Most consumers need only Layer 1 + Layer 2. Layer 3 is sugar.
---
## How light DOM rendering works
A Lit component normally renders into a **shadow root** — its own isolated DOM tree with style encapsulation. That's wrong for a design system: it would prevent the global CSS from styling components, force every component to ship its own copy of the rules, and break server-side rendering.
`whynot-design` components override the render root to be light DOM:
```js
class WnButton extends LitElement {
createRenderRoot() { return this; } // ← light DOM
render() { return html`<button class="wn-btn wn-btn--${this.variant}"><slot></slot></button>`; }
}
```
Consequences:
1. **Global stylesheets style them.** Your `components.css` rules for `.wn-btn` apply to the button inside `<wn-button>`. No `::part`, no `::slotted`, no CSS variables-as-API.
2. **SSR is trivial.** The server renders the same `<wn-button><button class="wn-btn">…</button></wn-button>` HTML it would render for any tag. The browser later "upgrades" the element to add behaviour, but the HTML is already there — no FOUC, no hydration mismatch.
3. **Form participation works.** `<wn-input>` containing a real `<input>` participates in `<form>` submission naturally. No ElementInternals contortions.
4. **HTMX swaps just work.** When HTMX replaces a fragment of the page with new HTML from the server, any `<wn-*>` elements in the new fragment are upgraded automatically by the browser's `connectedCallback`.
The trade-off you give up is style isolation. For a design system, that's the right trade — the system *wants* its CSS to apply everywhere.
---
## Per-framework setup
### Plain HTML
The minimum viable setup. No build step.
```html
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/whynot/styles/colors_and_type.css">
<link rel="stylesheet" href="/whynot/styles/components.css">
<script type="module" src="/whynot/index.js"></script>
</head>
<body>
<wn-page-header eyebrow="whynot · closed beta">
<span slot="title">Concierge prototype triage</span>
<span slot="actions"><wn-button variant="primary">Request invite</wn-button></span>
</wn-page-header>
</body>
</html>
```
Vendor the three files (`colors_and_type.css`, `components.css`, `index.js`) into your static directory. That's the whole integration.
---
### Django (server-rendered templates + HTMX)
This is the canonical non-React case and worth covering in detail.
#### 1. Install
```sh
# In the Django app's repo.
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.2.0
# Or vendor without Node tooling — copy three files:
mkdir -p myapp/static/whynot
cp node_modules/@whynot/design/src/styles/colors_and_type.css myapp/static/whynot/
cp node_modules/@whynot/design/src/styles/components.css myapp/static/whynot/
cp node_modules/@whynot/design/dist/index.bundle.js myapp/static/whynot/
```
A small `Makefile` or `package.json` script makes this idempotent — re-run after every dependency bump:
```makefile
sync-whynot:
cp node_modules/@whynot/design/src/styles/*.css myapp/static/whynot/
cp node_modules/@whynot/design/dist/*.js myapp/static/whynot/
```
#### 2. Wire it into your base template
```django
{# templates/base.html #}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="{% static 'whynot/colors_and_type.css' %}">
<link rel="stylesheet" href="{% static 'whynot/components.css' %}">
<script type="module" src="{% static 'whynot/index.js' %}" defer></script>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
```
That's it. From now on, **any Django template** can use `<wn-*>` elements with no further setup.
#### 3. Use components in templates
```django
{# templates/prototypes/detail.html #}
{% extends "base.html" %}
{% block content %}
<wn-top-nav active="prototypes"></wn-top-nav>
<main class="wn-main">
<wn-breadcrumb>
<a href="{% url 'prototypes' %}">Prototypes</a>
<span>{{ prototype.id }}</span>
</wn-breadcrumb>
<wn-page-header eyebrow="{{ prototype.id }} · Prototype">
<span slot="title">{{ prototype.pitch }}</span>
<span slot="actions">
<wn-button variant="secondary" icon="archive">Park</wn-button>
<wn-button variant="primary" icon="arrow-right">
Promote → {{ prototype.target }}
</wn-button>
</span>
</wn-page-header>
<wn-pipeline active-idx="{{ prototype.stage_idx }}"></wn-pipeline>
<wn-field-row label="Learning question">
{{ prototype.learning_question }}
</wn-field-row>
<wn-field-row label="Smallest useful test">
{{ prototype.smallest_test }}
</wn-field-row>
<wn-field-row label="Risks">
{{ prototype.risks }}
</wn-field-row>
</main>
{% endblock %}
```
No `{% include %}` files needed for this to work. The components are *real HTML*; Django renders them like any other tag.
#### 4. HTMX fragment swaps
When HTMX swaps a fragment into the page, the new HTML contains `<wn-*>` elements. The browser upgrades them automatically — no extra wiring.
```django
{# views.py #}
def signal_create(request):
if request.method == "POST":
sig = Signal.objects.create(…)
return render(request, "signals/_row.html", {"signal": sig}) # fragment
return render(request, "signals/_form.html")
{# templates/signals/_row.html — the fragment HTMX swaps in #}
<wn-table-row>
<wn-table-cell><code>{{ signal.id }}</code></wn-table-cell>
<wn-table-cell><wn-stage-dot level="{{ signal.level }}"></wn-stage-dot></wn-table-cell>
<wn-table-cell>{{ signal.what }}</wn-table-cell>
</wn-table-row>
```
```django
{# Where HTMX swaps it in #}
<form hx-post="{% url 'signal-create' %}" hx-target="#signals tbody" hx-swap="afterbegin">
</form>
```
The new row's `<wn-stage-dot>` is upgraded on insertion. No `htmx:afterSettle` listener needed.
#### 5. Django partials (optional Layer 3)
If a particular component pattern recurs and the raw HTML is verbose, ship a `{% include %}` partial:
```django
{# adapters/django/templates/whynot/_prototype_card.html — vendored in your project #}
<wn-prototype-card id="{{ p.id }}" signal="{{ p.signal }}" stage-label="{{ p.stage_label }}">
<span slot="pitch">{{ p.pitch }}</span>
<span slot="learning">{{ p.learning }}</span>
<span slot="test">{{ p.test }}</span>
<span slot="target">→ {{ p.target }}</span>
</wn-prototype-card>
```
Then:
```django
{% include "whynot/_prototype_card.html" with p=prototype %}
```
`whynot-design` ships a starter set of these in `adapters/django/templates/whynot/`. **Copy them into your Django app's templates dir; don't add `adapters/django/templates/` to `TEMPLATES.DIRS` directly** — that would couple your `INSTALLED_APPS` to the design system's repo layout. Treat the partials as starting templates you can customise per-app.
#### 6. Form participation
`<wn-input>`, `<wn-textarea>`, `<wn-select>` each wrap a real native input. Django's form rendering works as expected:
```django
<form method="post">
{% csrf_token %}
<wn-field-row label="Prototype name">
<wn-input name="name" value="{{ form.name.value|default:'' }}" required></wn-input>
</wn-field-row>
<wn-button variant="primary" type="submit">Save</wn-button>
</form>
```
The `name` attribute reaches the native input inside; the form submits normally.
---
### React (Next.js, Vite, CRA, Remix)
React supports custom elements natively since v19; in v18 the support is mostly fine for the common cases (attributes-not-properties, no event handlers via props). The whynot components are designed against the v18 baseline.
#### 1. Install
```sh
pnpm add @whynot/design
```
#### 2. Wire it into your app entry
```jsx
// app/layout.jsx (Next.js) or src/main.jsx (Vite)
import "@whynot/design/styles/colors_and_type.css";
import "@whynot/design/styles/components.css";
import "@whynot/design"; // registers all custom elements
```
#### 3. Use components in JSX
```jsx
export default function PrototypeDetailPage({ prototype }) {
return (
<main>
<wn-page-header eyebrow={`${prototype.id} · Prototype`}>
<span slot="title">{prototype.pitch}</span>
<span slot="actions">
<wn-button variant="primary" onClick={() => promote(prototype)}>
Promote
</wn-button>
</span>
</wn-page-header>
<wn-field-row label="Learning question">
{prototype.learningQuestion}
</wn-field-row>
</main>
);
}
```
#### 4. React-specific notes
- **Attributes vs properties.** React passes string-typed props as attributes, which is what whynot components expect for all public APIs (`variant`, `signal`, `active-idx`). Boolean attributes work too (`<wn-tag active>`).
- **Events.** React 18 doesn't bind `onMyEvent`-style props to custom-element events. Use `useEffect` + `addEventListener` for component-emitted events:
```jsx
const ref = useRef();
useEffect(() => {
const el = ref.current;
el?.addEventListener("wn-dismiss", handleDismiss);
return () => el?.removeEventListener("wn-dismiss", handleDismiss);
}, []);
return <wn-toast ref={ref}>Saved.</wn-toast>;
```
React 19 supports `onWnDismiss` directly.
- **JSX typing.** If you use TypeScript, add a `whynot.d.ts` declaring the custom elements as JSX intrinsic elements. `@whynot/design` does not ship this yet (deferred — see CHANGELOG).
#### 5. Typed React wrappers (Layer 3, optional)
If your team prefers `<WnButton variant="primary">` over `<wn-button variant="primary">`, one-line wrappers are trivial:
```jsx
// myapp/lib/wn.jsx
export const WnButton = (props) => <wn-button {...props} />;
export const WnCard = (props) => <wn-card {...props} />;
```
Whether `whynot-design` ships these officially is on the deferred list — most React teams find them unnecessary once the IDE handles the custom-element completion.
---
### Vue, Svelte, SolidJS
All work out of the box; custom elements are platform features, not framework features.
```vue
<!-- Vue 3 -->
<template>
<wn-button variant="primary" @click="promote">Promote</wn-button>
</template>
```
```svelte
<!-- Svelte 5 -->
<wn-button variant="primary" on:click={promote}>Promote</wn-button>
```
Vue may warn about unknown elements unless you configure `compilerOptions.isCustomElement` to recognise `wn-`-prefixed tags. One-line vite plugin config.
---
## Server-side rendering specifics
### When SSR matters
For Django (or any server-rendered framework), `<wn-button>` reaches the browser as inert markup. It's *upgraded* — gains interactive behaviour — when the browser parses `index.js` and runs `customElements.define()`. Between those two moments, the user may see:
- **The button** — fully styled by `components.css`, looking correct.
- **Click behaviour** — depends on the component. Pure-visual components (Eyebrow, Card, Tag) have no behaviour to gain. Interactive components (Modal, Toast, Select) need the upgrade.
### Strategies for behaviour-during-load
1. **Defer behaviour-required interaction.** A `<wn-modal>` that's initially closed doesn't care about the upgrade window. Only when the user clicks "Open" does the upgrade matter — and by then `index.js` has loaded.
2. **Block on the module if it's truly critical.** Remove `defer` from the script tag for a smaller-than-50kb runtime — acceptable. Or use `type="module"` (which defers by default but blocks `DOMContentLoaded`).
3. **Use `noscript` fallbacks.** Components like `<wn-toast>` already work as inert static HTML if JS never loads. Add `<noscript>` siblings for behaviour-only flourishes (e.g. dismiss buttons).
### Declarative Shadow DOM
Not used by `whynot-design`. Light-DOM rendering makes Declarative Shadow DOM unnecessary — the markup the server emits is the markup the browser shows.
---
## Visual regression in consumer repos
Both the design-system repo and consuming repos run their own visual-regression suites against the same components. If the design system bumps a token value and `examples/showcase/` passes (because the regression baselines were updated), but the consumer's app screenshots fail, **the consumer's CI catches the regression first**. That's the point of the dual-CI setup.
In Django/Playwright:
```js
// consumer-repo/tests/visual/dashboard.spec.mjs
test("prototypes dashboard", async ({ page }) => {
await page.goto("/prototypes/");
await page.waitForSelector("wn-prototype-card");
await expect(page).toHaveScreenshot("dashboard.png");
});
```
When `@whynot/design` ships `0.3.1` with a thicker hover bar, this test fails on the consumer side. Reviewer accepts the change, updates the snapshot, merges.
---
## What you don't have to worry about
- **Framework version drift.** Web Components are platform tech. A v0.3.0 component works in Lit 3, Lit 4, and post-Lit if Lit is ever replaced.
- **Multiple copies of the runtime.** Lit deduplicates at the module level. Importing `@whynot/design` from 12 consumer pages still loads one copy.
- **Bundler edge cases.** Vite, Next, Webpack 5, Rollup, esbuild all handle Lit and `customElements.define()` correctly out of the box.
- **A11y.** Components emit semantic HTML (`<button>`, `<input>`, `<dialog>`) and inherit native accessibility. Where extra ARIA is needed (e.g. `<wn-modal>` traps focus, `<wn-tag>` doesn't claim a role), it's documented inline.
---
## Open questions / known gaps
- **Native `<form>` participation for `<wn-select>`** — currently fine because the inner native `<select>` carries the `name` attribute. ElementInternals-based participation may be added for richer custom selects later.
- **Server-side declarative shadow DOM** — not needed today; light DOM covers all known consumers. Re-evaluate if a consumer ever needs strict CSS isolation per component.
- **TypeScript types for JSX** — deferred. Type declarations for the custom elements (`JSX.IntrinsicElements['wn-button']`) will ship in a `types/` folder once the first TS consumer asks.
- **Vue/Svelte adapter folders** — none ship today. Add when a real consumer appears.
---
## Summary
| You're using… | Setup | Use |
|---|---|---|
| Plain HTML | `<link>` + `<script type="module">` | `<wn-button>` |
| Django | Vendor 3 files into `static/whynot/`, link from `base.html` | `<wn-button>` in any template |
| HTMX | Same as Django | Fragments containing `<wn-*>` upgrade on insertion |
| React | `import "@whynot/design"` once | `<wn-button>` in JSX |
| Vue / Svelte / Solid | Same as React (one-line `isCustomElement` config in Vue) | `<wn-button>` in templates |
One canonical implementation. No re-implementation per framework. No framework lock-in.
> *whynot-design is the implementation surface — not a UI kit. The discipline that keeps it framework-agnostic is the discipline that lets it survive technology shifts.*