version 0.2.0 replaces fromer version!
This commit is contained in:
418
MultiFrameworkSupport.md
Normal file
418
MultiFrameworkSupport.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# 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.*
|
||||
Reference in New Issue
Block a user