version 0.2.0 replaces fromer version!
This commit is contained in:
43
BOOTSTRAP.md
43
BOOTSTRAP.md
@@ -1,6 +1,6 @@
|
||||
# Bootstrap: prime `whynot-design` from this seed
|
||||
|
||||
You're holding a zip — `whynot-design-seed.zip` — that contains a complete first commit for the `whynot-design` repository.
|
||||
You're holding a zip — `whynot-design-seed.zip` — that contains a complete first commit for the `whynot-design` repository at **v0.2.0**.
|
||||
|
||||
## Step-by-step
|
||||
|
||||
@@ -13,22 +13,32 @@ rmdir whynot-design-seed
|
||||
|
||||
# 2. Sanity-check the tree.
|
||||
ls -la
|
||||
# Expect: README.md DesignSystemIntroduction.md SKILL.md CONTRIBUTING.md
|
||||
# CHANGELOG.md package.json src/ tokens/ assets/ examples/
|
||||
# Expect: README.md DesignSystemIntroduction.md MultiFrameworkSupport.md
|
||||
# SKILL.md CONTRIBUTING.md CHANGELOG.md BOOTSTRAP.md
|
||||
# package.json src/ tokens/ assets/ adapters/ examples/
|
||||
# .gitea/ .github/ scripts/ tests/
|
||||
|
||||
# 3. First commit.
|
||||
# 3. Replace placeholder host names.
|
||||
# Search-and-replace `gitea.example.com` with your actual Gitea host in:
|
||||
# - package.json
|
||||
# - .npmrc
|
||||
# - .gitea/workflows/ci.yml (and .github/ if you keep that)
|
||||
# - README.md (one quick-start block)
|
||||
|
||||
# 4. First commit.
|
||||
git add -A
|
||||
git commit -m "feat: seed whynot-design from atelier — v0.1.0"
|
||||
git tag v0.1.0
|
||||
git commit -m "feat: seed whynot-design at v0.2.0 — three-layer architecture, Lit web components"
|
||||
git tag v0.2.0
|
||||
git push origin main --tags
|
||||
|
||||
# 4. Verify the example renders.
|
||||
# 5. Install + smoke-test.
|
||||
pnpm install
|
||||
pnpm example
|
||||
# Open http://localhost:3000 — should show the whynot-control kit.
|
||||
pnpm showcase
|
||||
# Visit http://localhost:4321/examples/showcase/
|
||||
# Every <wn-*> component should render. If Lit fails to load,
|
||||
# check that `lit` ^3.2.1 resolved in node_modules.
|
||||
|
||||
# 5. (Optional) Generate Playwright baselines locally.
|
||||
# 6. Generate Playwright baselines locally.
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
pnpm test:visual:update
|
||||
git add tests/visual/__screenshots__
|
||||
@@ -36,17 +46,18 @@ git commit -m "test: add initial visual-regression baselines"
|
||||
git push
|
||||
```
|
||||
|
||||
## After bootstrap
|
||||
|
||||
1. **Record the bootstrap as `DEC-004` in `whynot-control/DECISIONS.md`** — something like *"Established whynot-design as the implementation surface, three-layer architecture, Lit web components as the canonical component layer."*
|
||||
2. **Mention `whynot-design` in `whynot-control/SCOPE.md`** as a sibling repository.
|
||||
3. **Add `@whynot/design` as a dependency in your first consuming app** — Django, React, or both. Follow `MultiFrameworkSupport.md` for the per-framework wiring.
|
||||
|
||||
## Notes
|
||||
|
||||
- The `git+ssh` URL in `package.json` (`gitea.example.com/whynot/whynot-design.git`) is a placeholder. Replace with your actual Gitea host.
|
||||
- The same goes for `.npmrc` and the registry URL in `.gitea/workflows/ci.yml` (commented out — uncomment when you stand up a Gitea Packages registry).
|
||||
- `.gitea/workflows/ci.yml` and `.github/workflows/ci.yml` are identical. Keep whichever your forge uses and delete the other.
|
||||
|
||||
## After bootstrap
|
||||
|
||||
1. Record the bootstrap as `DEC-004` in `whynot-control/DECISIONS.md`.
|
||||
2. Mention `whynot-design` in `whynot-control/SCOPE.md` as a sibling repository (out of scope for `whynot-control`, in scope for the org).
|
||||
3. Add `@whynot/design` as a dependency in your first consuming prototype to close the loop.
|
||||
- The `examples/showcase/index.html` page uses `importmap` to load Lit from esm.sh **for the standalone-no-build case**. When you have a bundler in the consuming app, the bundler resolves `lit` from `node_modules` and the importmap is irrelevant.
|
||||
|
||||
## You can delete this file after bootstrap
|
||||
|
||||
|
||||
72
CHANGELOG.md
72
CHANGELOG.md
@@ -2,32 +2,72 @@
|
||||
|
||||
All notable changes to `@whynot/design` are recorded here. Hand-edited until release cadence makes it painful.
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning rules: see [`DesignSystemIntroduction.md` §5](./DesignSystemIntroduction.md#5-versioning-discipline).
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning rules: see [`DesignSystemIntroduction.md` §6](./DesignSystemIntroduction.md#6-versioning-discipline).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_Nothing yet. Add entries above the next `[vX.Y.Z]` block as PRs land._
|
||||
|
||||
## [0.2.0] — 2026-05-25
|
||||
|
||||
**Architectural reframe.** The system is now delivered as three stacked layers — tokens + CSS, Lit web components, optional framework adapters. The previous React-only component layer has been removed.
|
||||
|
||||
### Added
|
||||
|
||||
- `MultiFrameworkSupport.md` — full integration guide for React, Django, HTMX, Vue, Svelte, plain HTML.
|
||||
- `src/elements/` — Lit-based web components, light-DOM rendered:
|
||||
- **Atoms** (`atoms.js`): `<wn-button>`, `<wn-tag>`, `<wn-eyebrow>`, `<wn-stamp>`, `<wn-stage-dot>`, `<wn-phase-dot>`, `<wn-icon>`.
|
||||
- **Form** (`form.js`): `<wn-input>`, `<wn-textarea>`, `<wn-select>`, `<wn-search-input>`, `<wn-field-row>`.
|
||||
- **Layout** (`layout.js`): `<wn-card>`, `<wn-modal>`, `<wn-table>` + `<wn-table-row>` + `<wn-table-cell>`, `<wn-banner>`, `<wn-toast>` + `<wn-toast-region>`, `<wn-empty-state>`, `<wn-breadcrumb>`.
|
||||
- **Chrome** (`chrome.js`): `<wn-top-nav>`, `<wn-sidebar>` + `<wn-sidebar-group>` + `<wn-sidebar-item>`, `<wn-page-header>`, `<wn-pipeline>`, `<wn-prototype-card>`.
|
||||
- `src/elements/icons.js` — Lucide-derived inline icon paths (no runtime CDN dependency).
|
||||
- `src/styles/components.css` — utility-class layer (`wn-btn`, `wn-card`, `wn-tag`, etc.). Consumable directly from any HTML for the "Layer 1 only" use case.
|
||||
- `adapters/django/templates/whynot/*.html` — `{% include %}`-ready partials for Django consumers (button, eyebrow, tag, stage-dot, page-header, pipeline, field-row, prototype-card, banner, empty-state).
|
||||
- `adapters/django/README.md` — how to wire the partials into a Django app.
|
||||
- `examples/showcase/index.html` — single-page reference rendering every component. Doubles as the Playwright visual-regression baseline.
|
||||
- `lit` ^3.2.1 as a runtime dependency.
|
||||
|
||||
### Changed
|
||||
|
||||
- `DesignSystemIntroduction.md` — updated to describe the three-layer architecture, multi-framework consumption, and the revised propagation pipeline.
|
||||
- `README.md` — top-level rewrite around the new architecture, with quick-start blocks per framework.
|
||||
- `package.json` — adds per-group `exports` (`@whynot/design/atoms`, `/form`, `/layout`, `/chrome`, `/icons`).
|
||||
- CI now runs visual regression against `examples/showcase/index.html` *and* `examples/whynot-control/index.html`.
|
||||
|
||||
### Removed
|
||||
|
||||
- `src/components/Atoms.jsx`, `src/components/Chrome.jsx` — the React-only component layer. Consumers using these from v0.1.0 should swap to the corresponding custom elements (drop-in replacements; see `MultiFrameworkSupport.md` § React).
|
||||
- `peerDependencies` on React. React is no longer required to consume `@whynot/design`.
|
||||
|
||||
### Migration
|
||||
|
||||
If you were on v0.1.0 with React imports like `import { Button } from "@whynot/design"`:
|
||||
|
||||
| Before (v0.1.0) | After (v0.2.0) |
|
||||
|---|---|
|
||||
| `import { Button } from "@whynot/design"; <Button variant="primary">…</Button>` | `import "@whynot/design"; <wn-button variant="primary">…</wn-button>` |
|
||||
| `import { Tag } from "@whynot/design"; <Tag active>…</Tag>` | `<wn-tag active>…</wn-tag>` |
|
||||
| `<StageDot level="S2" />` | `<wn-stage-dot level="S2"></wn-stage-dot>` |
|
||||
|
||||
CSS imports are unchanged; add the new `components.css` import alongside `colors_and_type.css`.
|
||||
|
||||
### Known caveats
|
||||
|
||||
- IBM Plex is loaded from Google Fonts. Drop `.woff2` files into `fonts/` and swap to a local `@font-face` for offline use.
|
||||
- The showcase uses `importmap` + esm.sh to load Lit (no bundler). Real consumers using `pnpm add` will pick up Lit from `node_modules` via the package's `dependencies` entry.
|
||||
- No TypeScript declarations for JSX yet — deferred until a TS consumer asks.
|
||||
- `<wn-select>` uses a native `<select>` internally; richer custom selects (ElementInternals-based) will appear when a real need shows up.
|
||||
|
||||
## [0.1.0] — 2026-05-23
|
||||
|
||||
**Initial seed.** Established `whynot-design` as the implementation surface for the `whynot` visual language. Decision recorded in `whynot-control/DECISIONS.md` as DEC-004 (pending).
|
||||
**Initial seed.** Established `whynot-design` as the implementation surface for the `whynot` visual language. (Pre-architectural reframe — see v0.2.0.)
|
||||
|
||||
### Added
|
||||
|
||||
- `src/styles/colors_and_type.css` — full token set + semantic element styles.
|
||||
- `tokens/` — source-of-truth JSON tokens for colours, type, spacing, radii, shadows.
|
||||
- `src/components/Atoms.jsx` — `Eyebrow`, `Tag`, `Button`, `StageDot`, `Stamp`, `Icon`.
|
||||
- `src/components/Chrome.jsx` — `TopNav`, `Sidebar`, `PageHeader`, `PipelineStrip`.
|
||||
- `examples/whynot-control/` — click-through UI kit recreating the `whynot-control` surface (inbox, prototypes, prototype detail, signals, betas, decisions, control-doc viewer).
|
||||
- `src/components/Atoms.jsx` — React: `Eyebrow`, `Tag`, `Button`, `StageDot`, `Stamp`, `Icon`.
|
||||
- `src/components/Chrome.jsx` — React: `TopNav`, `Sidebar`, `PageHeader`, `PipelineStrip`.
|
||||
- `examples/whynot-control/` — click-through UI kit recreating the `whynot-control` surface.
|
||||
- `assets/whynot-logo.png` — LEGO-brick + `?!` mark.
|
||||
- `SKILL.md` — Agent Skill manifest, cross-compatible with Claude Code.
|
||||
- `README.md` — full design language.
|
||||
- `DesignSystemIntroduction.md` — integration guide.
|
||||
- `CONTRIBUTING.md` — contribution flow + house rules.
|
||||
|
||||
### Known caveats
|
||||
|
||||
- IBM Plex is loaded from Google Fonts. Drop `.woff2` files into `fonts/` and swap to a local `@font-face` for offline use.
|
||||
- No build step — consumers import JSX directly. Promote to a build when the system has >30 components or needs non-React consumers.
|
||||
- Playwright visual regression covers `examples/whynot-control/index.html` only. Add per-component coverage when component count exceeds ~20.
|
||||
- `assets/whynot-logo.png` is raster. Re-draw as SVG before promoting to `1.0.0`.
|
||||
- `SKILL.md`, `README.md`, `DesignSystemIntroduction.md`, `CONTRIBUTING.md`, `BOOTSTRAP.md`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Design System Introduction
|
||||
|
||||
> How `whynot-design` fits into the broader `whynot` workflow — from atelier exploration to production deploys.
|
||||
> How `whynot-design` fits into the broader `whynot` workflow — from atelier exploration to production deploys, across React, Django, plain HTML, or any future consumer.
|
||||
>
|
||||
> Audience: anyone (human or agent) about to add `whynot-design` as a dependency, or contribute changes back to it.
|
||||
|
||||
@@ -12,92 +12,143 @@
|
||||
|
||||
| Place | Job | What lives there |
|
||||
|---|---|---|
|
||||
| **Claude atelier project** (`WhyNot Design System` template) | Explore, decide, mock | HTML cards, UI-kit JSX prototypes, the README that defines the rules. Source of truth for the *language* of the system. |
|
||||
| **`whynot-design` repo** (this one) | Distribute | A versioned, publishable package: CSS tokens, components, the logo bundle. Source of truth for the *artefact*. |
|
||||
| **`whynot-*` consuming repos** (apps, prototypes, marketing sites) | Use | `pnpm add @whynot/design`, import tokens + components, build the app. |
|
||||
| **Claude atelier project** (`WhyNot Design System` template) | Explore, decide, mock | HTML cards, prototypes of new components, the README that defines the rules. Source of truth for the *language* of the system. |
|
||||
| **`whynot-design` repo** (this one) | Distribute | A versioned, publishable package: tokens, CSS, web components, the logo bundle. Source of truth for the *artefact*. |
|
||||
| **`whynot-*` consuming repos** (apps, prototypes, marketing sites) | Use | Add `@whynot/design` as a dependency. Use it from React, Django, Vue, or plain HTML — see [`MultiFrameworkSupport.md`](./MultiFrameworkSupport.md). |
|
||||
|
||||
This mirrors the existing organisational logic. `whynot-control` is the *control* surface (intent, scope, decisions, governance). `whynot-design` is the *implementation* surface for the visual language. Same separation, same vocabulary.
|
||||
This mirrors the existing organisational logic. `whynot-control` is the *control* surface (intent, scope, decisions, governance). `whynot-design` is the *implementation* surface for the visual language.
|
||||
|
||||
> The Claude-Design template is the atelier for the *next* design exploration — mocking a new screen, a beta landing page, a signal-record view. It is **not** what production code consumes. Production consumes this repo.
|
||||
> The Claude-Design template is the atelier for the *next* design exploration. It is **not** what production code consumes. Production consumes this repo.
|
||||
|
||||
---
|
||||
|
||||
## 2. What this repo contains
|
||||
## 2. The three-layer architecture
|
||||
|
||||
`whynot-design` is **deliberately framework-agnostic**. It ships in three stacked layers, and consumers pick how deep they go.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Layer 3 — Framework adapters (optional) │
|
||||
│ Django partials · React thin wrappers · Vue, Svelte, … │
|
||||
│ All thin. All delegate to Layer 2. │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Layer 2 — Web components (canonical) │
|
||||
│ <wn-button>, <wn-card>, <wn-modal>, <wn-prototype-card>… │
|
||||
│ Lit-based. Light DOM. SSR-friendly. Work in any framework. │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Layer 1 — Tokens + CSS │
|
||||
│ colors_and_type.css · components.css · tokens/*.json │
|
||||
│ Plain CSS variables and utility classes. Works in any HTML.│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Why this shape
|
||||
|
||||
- **Layer 1 is the bedrock.** Tokens are framework-agnostic by definition. `colors_and_type.css` is just CSS variables — Django, Rails, Phoenix, plain HTML, any framework can `<link>` to it and inherit the system's typography and palette tomorrow. The component utility classes in `components.css` (e.g. `.wn-btn`, `.wn-card`) compose those tokens into the canonical recipes. **Anything in this repo can be consumed with no JS at all.**
|
||||
|
||||
- **Layer 2 adds behaviour without lock-in.** Web Components are a stable platform feature — they're not a framework dependency that ages out. Lit is the implementation detail; the *contract* is the HTML custom-element API. A `<wn-button variant="primary">` works in a React JSX file, a Django template, a Vue SFC, and `index.html` identically. **Layer 2 components render to light DOM**, which means Layer 1's CSS styles them — no shadow-DOM style isolation, no FOUC, no SSR friction.
|
||||
|
||||
- **Layer 3 is convenience, not commitment.** If a consumer wants typed React props or Django `{% include %}` shorthand, the repo ships *thin* wrappers in `adapters/`. They delegate to Layer 2. Removing or refactoring them does not break Layer 2 consumers.
|
||||
|
||||
### Which layer should *you* use?
|
||||
|
||||
| If your consumer is… | Use this layer | Example |
|
||||
|---|---|---|
|
||||
| Django (server-rendered HTML + HTMX) | Layer 1 + 2 directly, or Layer 3 partials | `<wn-button variant="primary">{{ label }}</wn-button>` |
|
||||
| React (any meta-framework) | Layer 2 directly | `<wn-button variant="primary">Promote</wn-button>` — React handles custom elements natively |
|
||||
| Plain HTML page / a prototype landing page | Layer 2 directly | `<wn-button variant="primary">Sign up</wn-button>` |
|
||||
| Email, PDF, or no-JS context | Layer 1 only | `<button class="wn-btn wn-btn--primary">Open</button>` |
|
||||
| You only need typography & colours | Layer 1 only | Just `<link>` the CSS. |
|
||||
|
||||
The discipline: **do not subclass, fork, or restyle Layer 2 components in your repo**. If a component doesn't fit a use case, add a variant *upstream* in `whynot-design`. The thing that makes a design system valuable is the discipline of not forking it locally.
|
||||
|
||||
For the full how-to per framework — including SSR, hydration, the `noscript` story, form participation, and HTMX integration — see [`MultiFrameworkSupport.md`](./MultiFrameworkSupport.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. What this repo contains
|
||||
|
||||
```
|
||||
whynot-design/
|
||||
├── README.md Quick start + manifest.
|
||||
├── README.md Full design language.
|
||||
├── DesignSystemIntroduction.md This file.
|
||||
├── SKILL.md Agent Skill manifest — also usable by Claude Code.
|
||||
├── MultiFrameworkSupport.md How to consume from React, Django, Vue, plain HTML.
|
||||
├── SKILL.md Agent Skill manifest, cross-compatible with Claude Code.
|
||||
├── CONTRIBUTING.md How to propose, review, and ship a change.
|
||||
├── CHANGELOG.md Hand-edited, one entry per release.
|
||||
├── package.json Name: @whynot/design.
|
||||
├── BOOTSTRAP.md First-push instructions. Delete after use.
|
||||
├── package.json @whynot/design — Lit is the one runtime dependency.
|
||||
│
|
||||
├── tokens/ Source-of-truth design tokens (JSON).
|
||||
│ ├── colors.json
|
||||
│ ├── type.json
|
||||
│ └── spacing.json
|
||||
│
|
||||
├── src/
|
||||
│ ├── styles/
|
||||
│ │ └── colors_and_type.css Drop-in CSS variables + semantic element styles.
|
||||
│ ├── components/ JSX, consumed from source (no build step at A1).
|
||||
│ └── index.js Barrel export.
|
||||
│ │ ├── colors_and_type.css Layer 1 — tokens + semantic element styles.
|
||||
│ │ └── components.css Layer 1 — utility classes for all components.
|
||||
│ ├── elements/ Layer 2 — Lit web components, light DOM.
|
||||
│ │ ├── atoms.js button, tag, eyebrow, stamp, stage-dot, phase-dot, icon
|
||||
│ │ ├── form.js input, textarea, select, search-input, field-row
|
||||
│ │ ├── layout.js card, modal, table, toast, empty-state, breadcrumb
|
||||
│ │ └── chrome.js top-nav, sidebar, page-header, pipeline, prototype-card
|
||||
│ └── index.js Side-effect import: registers all custom elements.
|
||||
│
|
||||
├── assets/ Logo, favicon-ready marks, future imagery.
|
||||
├── adapters/ Layer 3 — optional wrappers.
|
||||
│ └── django/templates/whynot/ Django {% include %} partials over the web components.
|
||||
│
|
||||
├── assets/ Logo, mark, future imagery.
|
||||
├── examples/
|
||||
│ └── whynot-control/ Working UI-kit recreation — also the visual-regression target.
|
||||
└── .gitea/workflows/
|
||||
└── ci.yml Lint + Playwright screenshot diff on PR.
|
||||
│ ├── showcase/ Plain-HTML demo of every component.
|
||||
│ └── whynot-control/ React + custom-element click-through UI kit.
|
||||
└── .gitea/workflows/ Lint + Playwright visual regression on PR.
|
||||
```
|
||||
|
||||
There is intentionally **no build step**. Consumers import the JSX and CSS directly. This keeps the A1 pipeline trivially debuggable. Add a build step when the system grows past ~30 components or needs to support non-React consumers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Integrating with a consuming codebase
|
||||
## 4. Integrating with a consuming codebase
|
||||
|
||||
### 3.1 The two viable distribution channels at A1
|
||||
### 4.1 Distribution channels at A1
|
||||
|
||||
In order of effort:
|
||||
|
||||
**a) pnpm workspaces (recommended for now)** — put `whynot-design` and your consuming app in the same monorepo (or use `file:` / `link:` references). Zero registry, zero auth, instant updates.
|
||||
|
||||
```jsonc
|
||||
// consuming-app/package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@whynot/design": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**b) Install directly from Gitea** — no registry needed.
|
||||
|
||||
```sh
|
||||
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.3.1
|
||||
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.2.0
|
||||
```
|
||||
|
||||
Pin to a tag, not `main`. Tag-pinning is the entire versioning discipline at A1.
|
||||
|
||||
When you outgrow either of these (a second team needs read access without repo cloning, or you want semver resolution), publish to **Gitea Packages** (supports npm protocol natively) or set up a private Verdaccio.
|
||||
When you outgrow either (second team needs read access without cloning, semver resolution becomes valuable), publish to **Gitea Packages** (native npm protocol) or a private Verdaccio.
|
||||
|
||||
### 3.2 What a consumer imports
|
||||
### 4.2 What a consumer imports
|
||||
|
||||
The smallest viable consumption is:
|
||||
|
||||
```html
|
||||
<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"></script>
|
||||
```
|
||||
|
||||
That's it. Now `<wn-button>`, `<wn-card>`, `<wn-modal>` etc. work everywhere on the page, in any framework, server-rendered or client-rendered.
|
||||
|
||||
For a Node-tooled consumer:
|
||||
|
||||
```jsx
|
||||
// At the root of the consuming app — once.
|
||||
// At app root, once.
|
||||
import "@whynot/design/styles/colors_and_type.css";
|
||||
import "@whynot/design/styles/components.css";
|
||||
import "@whynot/design"; // side-effect: registers all custom elements
|
||||
|
||||
// In any component file.
|
||||
import { Button, Tag, PrototypeCard, Eyebrow } from "@whynot/design";
|
||||
|
||||
export default function NewBetaPage() {
|
||||
// In any component file — React, Vue, Svelte, all behave the same.
|
||||
function NewBetaPage() {
|
||||
return (
|
||||
<article>
|
||||
<Eyebrow>whynot · closed beta</Eyebrow>
|
||||
<wn-eyebrow>whynot · closed beta</wn-eyebrow>
|
||||
<h1>Concierge prototype triage</h1>
|
||||
<p className="lead">Five seats. Two weeks. One learning question.</p>
|
||||
<Button variant="primary">Request an invite</Button>
|
||||
<wn-button variant="primary">Request an invite</wn-button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -105,26 +156,28 @@ export default function NewBetaPage() {
|
||||
|
||||
Three rules of consumption:
|
||||
|
||||
1. **Import the CSS exactly once**, at the app's root.
|
||||
2. **Use CSS variables for any colour, type, or spacing decision**, not hex codes. If the token doesn't exist, that's a signal to extend the system — not to invent.
|
||||
3. **Don't restyle components by overriding their CSS.** If a component doesn't fit a use case, add a variant in `whynot-design` itself. The discipline that makes a design system valuable is the discipline of not forking it locally.
|
||||
1. **Import the CSS once at the app's root.** Both stylesheets.
|
||||
2. **Use tokens for any colour, type, or spacing decision** that components don't cover. If the token doesn't exist, that's a signal to extend the system upstream — not to invent.
|
||||
3. **Don't restyle components by overriding their CSS.** If a component doesn't fit, contribute a variant.
|
||||
|
||||
### 3.3 Bootstrapping a new consuming repo
|
||||
### 4.3 Bootstrapping a new consuming repo
|
||||
|
||||
```sh
|
||||
mkdir whynot-prototype-WNO-022
|
||||
cd whynot-prototype-WNO-022
|
||||
pnpm init
|
||||
pnpm add react react-dom
|
||||
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.1.0
|
||||
echo 'import "@whynot/design/styles/colors_and_type.css"' >> src/main.jsx
|
||||
pnpm add @whynot/design # or the git+ssh URL
|
||||
# Then in your entry point:
|
||||
echo 'import "@whynot/design/styles/colors_and_type.css"' >> src/main.js
|
||||
echo 'import "@whynot/design/styles/components.css"' >> src/main.js
|
||||
echo 'import "@whynot/design"' >> src/main.js
|
||||
```
|
||||
|
||||
That's the whole onboarding. If it takes longer than this, the design system is fighting you.
|
||||
If it takes longer than this, the design system is fighting you.
|
||||
|
||||
---
|
||||
|
||||
## 4. Propagation pipeline — five hops
|
||||
## 5. Propagation pipeline — five hops
|
||||
|
||||
The end-to-end flow for a single design change:
|
||||
|
||||
@@ -146,84 +199,84 @@ The end-to-end flow for a single design change:
|
||||
|
||||
### Hop-by-hop
|
||||
|
||||
1. **Atelier → `whynot-design` PR.** Someone (you, a designer, or an agent) takes a change agreed in the Claude project — "the prototype card's hover bar is 3px not 2px" — and opens a PR against `whynot-design`. The PR description quotes the atelier decision, e.g. *"Decided in atelier 2026-04-12: thicker hover bar reads better at 14” preview sizes."*
|
||||
1. **Atelier → `whynot-design` PR.** Someone (you, a designer, or an agent) takes a change agreed in the Claude project and opens a PR against `whynot-design`. The PR description quotes the atelier decision.
|
||||
|
||||
2. **`whynot-design` CI** runs:
|
||||
- Lint + (optional) typecheck.
|
||||
- **Visual regression** — Playwright screenshots `examples/whynot-control/index.html` and any other registered example, diffs against baselines. This is the single most valuable piece of automation; everything else is bookkeeping.
|
||||
- A `CHANGELOG.md` entry is required (enforce with a small CI check, or use `changesets` once you outgrow hand-editing).
|
||||
- **Visual regression** — Playwright screenshots `examples/showcase/index.html` and `examples/whynot-control/index.html`, diffs against baselines. This catches both styling regressions and behavioural ones.
|
||||
- A `CHANGELOG.md` entry is required.
|
||||
|
||||
3. **Merge → tag → publish.** On merge to `main`:
|
||||
- Bump `package.json` (patch for token tweaks, minor for new components, major for renames/removals).
|
||||
- Tag `v0.3.1`.
|
||||
- Push tag. CI uploads release notes from the CHANGELOG. (Once a registry is in use, this step also runs `npm publish`.)
|
||||
- Push tag. CI uploads release notes from the CHANGELOG.
|
||||
|
||||
4. **Consumer auto-PR.** Renovate or Dependabot watches `@whynot/design` and opens a PR in every consuming repo bumping the version. Renovate config groups these so all `whynot-*` prototypes update on the same schedule (e.g. weekly).
|
||||
4. **Consumer auto-PR.** Renovate or Dependabot watches `@whynot/design` and opens a PR in every consuming repo bumping the version.
|
||||
|
||||
5. **Consumer CI + deploy.** The consuming repo's *own* CI runs *its* visual regression — does the new token change anything in *this* app's screens? If not, auto-merge (Renovate can do this for patch versions with green CI). If yes, a human reviews. Merge triggers the existing deploy to staging → prod.
|
||||
5. **Consumer CI + deploy.** The consuming repo's *own* CI runs *its* visual regression. If unchanged, auto-merge. If changed, a human reviews. Merge triggers existing deploys to staging → prod.
|
||||
|
||||
The whole loop, warm, takes minutes. The key insight: **automation works only because every step has a deterministic check** — visual regression on the design-system side, visual regression on the consumer side, semver, changelogs. Skip those and the "pipeline" is a slow manual process with extra tools.
|
||||
The whole loop, warm, takes minutes. **Automation works only because every step has a deterministic check** — visual regression on both sides, semver, changelogs. Skip those and the pipeline is a slow manual process with extra tools.
|
||||
|
||||
---
|
||||
|
||||
## 5. Versioning discipline
|
||||
## 6. Versioning discipline
|
||||
|
||||
Strict semver, even at A1.
|
||||
|
||||
| Change | Bump |
|
||||
|---|---|
|
||||
| Token value tweak that doesn't visibly break any existing example | **patch** — `0.1.0 → 0.1.1` |
|
||||
| New component, new variant, new token | **minor** — `0.1.1 → 0.2.0` |
|
||||
| Removing / renaming a component, prop, or token; changing default behaviour | **major** — `0.2.0 → 1.0.0` |
|
||||
| Token value tweak that doesn't visibly break any existing example | **patch** — `0.2.0 → 0.2.1` |
|
||||
| New component, new variant, new token | **minor** — `0.2.0 → 0.3.0` |
|
||||
| Removing / renaming a component, prop, or token; changing default behaviour | **major** — `0.3.0 → 1.0.0` |
|
||||
|
||||
Stay in `0.x.x` until something is in production. While in `0.x.x`, **minor bumps are allowed to break things** — that's the convention. This gives you permission to iterate without ceremony.
|
||||
Stay in `0.x.x` until something built with this system is in production. While in `0.x.x`, **minor bumps are allowed to break things** — that's the convention. This gives you permission to iterate without ceremony.
|
||||
|
||||
Promotion past `1.0.0` should appear in `whynot-control/DECISIONS.md`. Same rule as promotion to Helix or Coulomb: it's a deliberate act, not a release-script side-effect.
|
||||
|
||||
---
|
||||
|
||||
## 6. Where Claude fits
|
||||
## 7. Where Claude fits
|
||||
|
||||
Two distinct roles, both useful:
|
||||
|
||||
- **The Claude atelier template** — used at hop 1. Designer (or you) opens a new project from the template, mocks variations, decides, hands off a PR description + diff to whoever (or whatever) writes the `whynot-design` PR. The atelier never publishes anything to production directly.
|
||||
- **The Claude atelier template** — used at hop 1. Designer (or you) opens a new project, mocks variations, decides, hands off a PR description + diff to whoever writes the `whynot-design` PR. The atelier never publishes anything to production directly.
|
||||
|
||||
- **Claude Code with `SKILL.md`** — used at hop 1 *and* hop 5. The same SKILL file works in both contexts:
|
||||
- Pointed at `whynot-design`, Claude Code knows the rules and can write component PRs.
|
||||
- Pointed at a consuming repo, Claude Code knows the rules and can build screens that respect them.
|
||||
- Pointed at `whynot-design`, Claude Code can write component PRs.
|
||||
- Pointed at a consuming repo, Claude Code can build screens that respect the rules.
|
||||
|
||||
That's why `SKILL.md` ships with this repo. Drop it into `.claude/skills/` of any consuming repo (or copy its contents into a project-level `CLAUDE.md`) and any agent operating in that repo will know the visual language.
|
||||
That's why `SKILL.md` ships with this repo. Drop it into `.claude/skills/` of any consuming repo and any agent operating in that repo will know the visual language.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pragmatic A1 staging — don't build the whole pipeline yet
|
||||
## 8. Pragmatic A1 staging — don't build the whole pipeline yet
|
||||
|
||||
Right now, `whynot` is at A1 Incubating with one or two prototype apps in flight. Build the smallest pipeline that still has the right *shape*. Promote each piece only when a real signal demands it.
|
||||
Right now, `whynot` is at A1 Incubating. Build the smallest pipeline that still has the right *shape*. Promote each piece only when a real signal demands it.
|
||||
|
||||
| Hop | A1 version | Promote to full when… |
|
||||
|---|---|---|
|
||||
| Atelier | Claude template, as-is. | Never — stays here. |
|
||||
| `whynot-design` repo | This seed. CSS + JSX consumed from source. No build step. | Second non-React consumer appears, or bundle size becomes a measurable problem. |
|
||||
| `whynot-design` repo | This seed. Tokens + CSS + Lit web components. | Never — this is the canonical shape. |
|
||||
| Distribution | pnpm workspace, or `git+ssh` install from tags. | An external collaborator needs read access without cloning. |
|
||||
| Visual regression | One Playwright test screenshotting `examples/whynot-control/index.html`. | The system has >20 components or >2 consuming apps. |
|
||||
| Visual regression | Playwright over `examples/showcase/` and `examples/whynot-control/`. | The system has >40 components or >3 consuming apps. |
|
||||
| Dependency updates | Manual `pnpm up` once a week. | More than two consuming repos. |
|
||||
| Release notes | Hand-edited `CHANGELOG.md`. | More than two contributors, or releases become weekly. |
|
||||
| Component-level visual coverage | One screenshot of the whole UI kit. | The system has >20 components. |
|
||||
| Release notes | Hand-edited `CHANGELOG.md`. | More than two contributors. |
|
||||
| Per-framework adapters | Django partials only, for the 4–5 most-used components. | Another non-React, non-Django consumer appears. |
|
||||
|
||||
This staging is exactly the *"low-cost learning first"* posture in `whynot-control/OPERATING_MODEL.md`. A design system with one consumer and one author does not need Chromatic. A design system with five consumers and three authors absolutely does. Don't pay the cost of the second one until you're in it.
|
||||
This staging is exactly the *"low-cost learning first"* posture in `whynot-control/OPERATING_MODEL.md`. A design system with one consumer and one author does not need Chromatic. A design system with five consumers and three authors absolutely does.
|
||||
|
||||
---
|
||||
|
||||
## 8. First-week checklist
|
||||
## 9. First-week checklist
|
||||
|
||||
For whoever is bootstrapping this repo right now:
|
||||
|
||||
- [ ] Push the seed contents to `gitea.example.com/whynot/whynot-design`.
|
||||
- [ ] Tag `v0.1.0` immediately so consumers can pin.
|
||||
- [ ] Add the repo as a remote dependency in **one** consuming app and verify imports work.
|
||||
- [ ] Tag `v0.2.0` immediately so consumers can pin.
|
||||
- [ ] Add the repo as a remote dependency in **one** consuming app (the Django one) and verify imports work end-to-end. Follow [`MultiFrameworkSupport.md` §Django](./MultiFrameworkSupport.md#django-server-rendered-templates--htmx).
|
||||
- [ ] Open one trivial PR against `whynot-design` (e.g. a CHANGELOG typo) to confirm CI passes end-to-end.
|
||||
- [ ] Record this bootstrap in `whynot-control/DECISIONS.md` as DEC-004 or similar — *"Established whynot-design as the implementation surface for the visual language."*
|
||||
- [ ] Update `whynot-control/SCOPE.md` to mention `whynot-design` in the out-of-scope list (it's a sibling, not absorbed scope).
|
||||
- [ ] Record this bootstrap in `whynot-control/DECISIONS.md` as DEC-004 — *"Established whynot-design as the implementation surface, three-layer architecture, Lit web components as the canonical component layer."*
|
||||
- [ ] Update `whynot-control/SCOPE.md` to mention `whynot-design` as a sibling.
|
||||
|
||||
That's it. Anything more is over-engineering for the current stage.
|
||||
|
||||
|
||||
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.*
|
||||
109
README.md
109
README.md
@@ -4,44 +4,109 @@ The neutral, mostly-black-and-white visual language for **whynot** — Tegwick's
|
||||
|
||||
> A prototype is a question made tangible. — `whynot-control/INTENT.md`
|
||||
|
||||
This repository is the **implementation surface**. The *language* of the system — voice, casing, motifs, the reasoning behind each rule — lives in this README. The *artefact* — the CSS, the components, the assets — lives in `src/` and `assets/`.
|
||||
This repository is the **implementation surface** for `whynot`'s visual language. It ships:
|
||||
|
||||
If you're new here, read these in order:
|
||||
- **Tokens** (`tokens/*.json`) — source-of-truth colour, type, spacing values.
|
||||
- **CSS** (`src/styles/*.css`) — drop-in stylesheets for any HTML context.
|
||||
- **Web components** (`src/elements/*.js`) — Lit-based custom elements that work in React, Django, Vue, Svelte, plain HTML, anywhere.
|
||||
- **Adapters** (`adapters/django/`) — optional `{% include %}` partials for Django teams.
|
||||
|
||||
1. **`DesignSystemIntroduction.md`** — how this repo relates to `whynot-control`, the Claude atelier, and consuming apps. Pipeline, versioning, propagation.
|
||||
2. **`SKILL.md`** — the Agent Skill manifest. Read this if you (or an agent) will be generating new artefacts in this style.
|
||||
3. **This README** — the full design language: tokens, components, content rules, iconography.
|
||||
4. **`CONTRIBUTING.md`** — how to propose, review, and ship a change.
|
||||
Framework-agnostic by design. Consumers do **not** re-implement components per framework — they use the same `<wn-button>` everywhere.
|
||||
|
||||
## Read first
|
||||
|
||||
| File | What's in it |
|
||||
|---|---|
|
||||
| `DesignSystemIntroduction.md` | How this repo relates to `whynot-control`, the Claude atelier, and consuming apps. Three-layer architecture, propagation pipeline, versioning, A1 staging. |
|
||||
| `MultiFrameworkSupport.md` | How to consume from React, Django, HTMX, Vue, plain HTML. Per-framework setup, SSR specifics, hydration story. |
|
||||
| `SKILL.md` | Agent Skill manifest, cross-compatible with Claude Code. Drop into `.claude/skills/` of any consuming repo. |
|
||||
| This README | Full design language: tokens, components, content rules, iconography. |
|
||||
| `CONTRIBUTING.md` | How to propose, review, and ship a change. |
|
||||
| `BOOTSTRAP.md` | First-push instructions for priming this repo. Delete after use. |
|
||||
|
||||
## Quick start
|
||||
|
||||
### Plain HTML
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="/whynot/colors_and_type.css">
|
||||
<link rel="stylesheet" href="/whynot/components.css">
|
||||
<script type="module" src="/whynot/index.js"></script>
|
||||
|
||||
<wn-button variant="primary">Promote prototype</wn-button>
|
||||
```
|
||||
|
||||
### Node-tooled consumer (React, Vite, Next, Vue, …)
|
||||
|
||||
```sh
|
||||
# in a consuming repo
|
||||
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.1.0
|
||||
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.2.0
|
||||
```
|
||||
|
||||
```js
|
||||
// once, at app root
|
||||
import "@whynot/design/styles/colors_and_type.css";
|
||||
import "@whynot/design/styles/components.css";
|
||||
import "@whynot/design";
|
||||
```
|
||||
|
||||
```jsx
|
||||
// at the app root, once
|
||||
import "@whynot/design/styles/colors_and_type.css";
|
||||
|
||||
// anywhere
|
||||
import { Button, Tag, Eyebrow, StageDot } from "@whynot/design";
|
||||
<wn-button variant="primary">Promote prototype</wn-button>
|
||||
<wn-pipeline active-idx="3"></wn-pipeline>
|
||||
```
|
||||
|
||||
### Django
|
||||
|
||||
```django
|
||||
{# templates/base.html #}
|
||||
{% include "whynot/_base_head.html" %}
|
||||
|
||||
{# any template #}
|
||||
<wn-page-header eyebrow="whynot · prototypes" title="Prototypes"></wn-page-header>
|
||||
<wn-prototype-card card-id="{{ p.id }}" signal="{{ p.signal }}">
|
||||
<span slot="pitch">{{ p.pitch }}</span>
|
||||
</wn-prototype-card>
|
||||
```
|
||||
|
||||
See `MultiFrameworkSupport.md` for the full integration story per framework.
|
||||
|
||||
## What lives where
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `tokens/` | Source-of-truth design tokens as JSON. |
|
||||
| `src/styles/colors_and_type.css` | All CSS variables + semantic element styles. The single file every consumer imports. |
|
||||
| `src/components/` | React components (JSX, no build step). |
|
||||
| `src/index.js` | Barrel export. |
|
||||
| `assets/` | Logo, mark, future imagery. |
|
||||
| `examples/whynot-control/` | Live click-through UI kit. Also the Playwright visual-regression target. |
|
||||
```
|
||||
whynot-design/
|
||||
├── tokens/ Source-of-truth design tokens (JSON).
|
||||
├── src/
|
||||
│ ├── styles/
|
||||
│ │ ├── colors_and_type.css Layer 1 — tokens + semantic element styles.
|
||||
│ │ └── components.css Layer 1 — utility classes for every component.
|
||||
│ ├── elements/ Layer 2 — Lit web components.
|
||||
│ │ ├── atoms.js wn-button, wn-tag, wn-eyebrow, wn-stamp,
|
||||
│ │ │ wn-stage-dot, wn-phase-dot, wn-icon
|
||||
│ │ ├── form.js wn-input, wn-textarea, wn-select,
|
||||
│ │ │ wn-search-input, wn-field-row
|
||||
│ │ ├── layout.js wn-card, wn-modal, wn-table, wn-banner,
|
||||
│ │ │ wn-toast, wn-empty-state, wn-breadcrumb
|
||||
│ │ ├── chrome.js wn-top-nav, wn-sidebar, wn-page-header,
|
||||
│ │ │ wn-pipeline, wn-prototype-card
|
||||
│ │ └── icons.js Lucide-derived icon paths.
|
||||
│ └── index.js Side-effect import — registers all elements.
|
||||
├── adapters/django/ Layer 3 — optional Django partials.
|
||||
├── examples/
|
||||
│ ├── showcase/index.html Every component on one page. Playwright target.
|
||||
│ └── whynot-control/index.html Full app composition (React + custom elements).
|
||||
└── assets/ Logo, mark.
|
||||
```
|
||||
|
||||
## Open this locally
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm showcase # then visit http://localhost:4321/examples/showcase/
|
||||
pnpm example # then visit http://localhost:4322 (whynot-control kit)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> The remainder of this README is the full design language — colour reasoning, type stack, content rules, iconography. It is identical to the language defined in the Claude atelier project and should stay in sync. Treat it as authoritative.
|
||||
> The remainder of this README is the full design language — colour reasoning, type stack, content rules, iconography. Treat it as authoritative.
|
||||
|
||||
|
||||
## CONTENT FUNDAMENTALS
|
||||
|
||||
35
SKILL.md
35
SKILL.md
@@ -1,34 +1,45 @@
|
||||
---
|
||||
name: whynot-design
|
||||
description: Use this skill to generate well-branded interfaces and assets for whynot — Tegwick's prototype-and-market-signal organisation that discovers the weird and the useful — either for production or throwaway prototypes, mocks, signal records, beta plans, and decision documents. Contains essential design guidelines, neutral colour system, IBM Plex type stack, the LEGO-brick logo, and a click-through UI kit recreating the whynot-control surface.
|
||||
description: Use this skill to generate well-branded interfaces and assets for whynot — Tegwick's prototype-and-market-signal organisation that discovers the weird and the useful — either for production or throwaway prototypes, mocks, signal records, beta plans, and decision documents. The system is framework-agnostic — its canonical components are Lit-based web components consumable from React, Django, HTMX, Vue, plain HTML, or anywhere a custom element runs.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
Read the `README.md` file within this skill, and explore the other available files.
|
||||
Read the `README.md` file within this skill, and explore the other available files. Pay particular attention to:
|
||||
|
||||
- `DesignSystemIntroduction.md` — three-layer architecture, propagation pipeline, A1 pragmatic staging.
|
||||
- `MultiFrameworkSupport.md` — how to consume from React, Django, HTMX, Vue, plain HTML.
|
||||
- `examples/showcase/index.html` — every component on one page; copy-paste-able.
|
||||
- `examples/whynot-control/index.html` — full app composition.
|
||||
|
||||
If creating visual artefacts (slides, mocks, throwaway prototypes, signal records, beta plans, etc.), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand.
|
||||
|
||||
If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artefacts *or* production code, depending on the need.
|
||||
|
||||
## Quick orientation
|
||||
## Architecture in one paragraph
|
||||
|
||||
- **`README.md`** — full design system: company context, content rules (voice, casing, person, tone), visual foundations (color, type, spacing, animation, hover, borders, shadows, cards, imagery), and iconography.
|
||||
- **`colors_and_type.css`** — drop-in CSS with every CSS variable and semantic element style. Import this on every artefact.
|
||||
- **`assets/`** — `whynot-logo.png` (LEGO brick + ?!).
|
||||
- **`preview/`** — 23 reference cards (palettes, type specimens, components, motifs) you can read to understand any single concept in isolation.
|
||||
- **`ui_kits/whynot-control/`** — React kit recreating the `whynot-control` web app. Use these components when building prototype dashboards, signal records, decision documents.
|
||||
The system is delivered as three stacked layers. **Layer 1** is tokens + CSS (`colors_and_type.css` + `components.css`) — framework-agnostic, consumable from any HTML. **Layer 2** is Lit-based web components (`<wn-button>`, `<wn-card>`, etc.) that render to light DOM so Layer 1's CSS styles them — work identically in React JSX, Django templates, Vue, plain HTML, HTMX-swapped fragments. **Layer 3** is optional framework adapters (currently: Django `{% include %}` partials). When generating code or markup, **prefer Layer 2** — `<wn-button>` works everywhere; do not write a `<Button>` React component or a Django partial unless asked specifically. **Never re-implement a component per framework.**
|
||||
|
||||
## Available components (v0.2.0)
|
||||
|
||||
| Group | Tags |
|
||||
|---|---|
|
||||
| Atoms | `<wn-button>`, `<wn-tag>`, `<wn-eyebrow>`, `<wn-stamp>`, `<wn-stage-dot>`, `<wn-phase-dot>`, `<wn-icon>` |
|
||||
| Form | `<wn-input>`, `<wn-textarea>`, `<wn-select>`, `<wn-search-input>`, `<wn-field-row>` |
|
||||
| Layout | `<wn-card>`, `<wn-modal>`, `<wn-table>` + `<wn-table-row>` + `<wn-table-cell>`, `<wn-banner>`, `<wn-toast>` + `<wn-toast-region>`, `<wn-empty-state>`, `<wn-breadcrumb>` |
|
||||
| Chrome | `<wn-top-nav>`, `<wn-sidebar>` + `<wn-sidebar-group>` + `<wn-sidebar-item>`, `<wn-page-header>`, `<wn-pipeline>`, `<wn-prototype-card>` |
|
||||
|
||||
## House rules (do not violate)
|
||||
|
||||
1. **Quiet voice.** No marketing language, no hype, no emoji. Sentence case everywhere. Distinguish idea / hypothesis / signal / decision.
|
||||
2. **Mostly black & white.** Color is one warm yellow accent (`--hi: #FFE14A`) used only as highlighter / draft stamp / S4 signal indicator. Never as a button fill, never as a hero background, never as a gradient.
|
||||
3. **No gradients. Anywhere.**
|
||||
4. **Wireframe vibes.** 1px hairlines, generous whitespace, monospace eyebrow labels in margins. The aesthetic is engineering graph-paper, not SaaS dashboard.
|
||||
4. **Wireframe vibes.** 1px hairlines, generous whitespace, monospace eyebrow labels in margins. Engineering graph-paper, not SaaS dashboard.
|
||||
5. **Square corners on big things.** 0–4px radii for cards/sheets; 8px reserved for large modals; pill only for tag capsules.
|
||||
6. **No shadows on cards.** Elevation is intentionally near-zero; only popovers get a soft 4–12px shadow.
|
||||
7. **No fake illustrations.** Use the logo, use Lucide icons at 1.5px stroke, use placeholders. Never hand-roll SVG icons or emoji.
|
||||
6. **No shadows on cards.** Elevation is intentionally near-zero; only popovers / modals get a soft 4–12px shadow.
|
||||
7. **No fake illustrations.** Use the logo, use `<wn-icon>`, use placeholders. Never hand-roll SVG icons or emoji.
|
||||
8. **Lowercase organisation name** in body: `whynot`. The logo wordmark may use `WhyWhyNot` for legacy reasons.
|
||||
9. **No re-implementing Layer 2 components in framework code.** Use `<wn-*>` tags directly. If a component doesn't fit, propose extending Layer 2 upstream — do not fork it locally.
|
||||
|
||||
## When in doubt
|
||||
|
||||
Read `README.md` and the closest matching card in `preview/`. If still unsure, ask the user — `whynot` prefers explicit uncertainty over confident guesses.
|
||||
Read `README.md` and the closest matching component in `examples/showcase/index.html`. If still unsure, ask the user — `whynot` prefers explicit uncertainty over confident guesses.
|
||||
|
||||
56
adapters/django/README.md
Normal file
56
adapters/django/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Django partials
|
||||
|
||||
Optional Layer 3 — `{% include %}`-ready templates that wrap the canonical web components in conveniences for Django teams.
|
||||
|
||||
## How to use
|
||||
|
||||
Copy this folder into your Django app's templates directory:
|
||||
|
||||
```sh
|
||||
cp -r adapters/django/templates/whynot myapp/templates/whynot
|
||||
```
|
||||
|
||||
Then reference partials by their template name:
|
||||
|
||||
```django
|
||||
{% include "whynot/_prototype_card.html" with p=prototype %}
|
||||
{% include "whynot/_page_header.html" with eyebrow="whynot · signals" title="Signals" %}
|
||||
{% include "whynot/_pipeline.html" with active_idx=3 %}
|
||||
```
|
||||
|
||||
**Don't** add `adapters/django/` to `TEMPLATES.DIRS` directly — that couples your `INSTALLED_APPS` to the design-system repo layout. Copy what you need.
|
||||
|
||||
## What's in here
|
||||
|
||||
| Partial | Component it wraps | Context variables |
|
||||
|---|---|---|
|
||||
| `_base_head.html` | (none — `<link>` + `<script>` tags) | — |
|
||||
| `_button.html` | `<wn-button>` | `variant`, `icon`, `label`, `href`, `type` |
|
||||
| `_eyebrow.html` | `<wn-eyebrow>` | `text` |
|
||||
| `_tag.html` | `<wn-tag>` | `text`, `active`, `draft` |
|
||||
| `_stage_dot.html` | `<wn-stage-dot>` | `level`, `label` |
|
||||
| `_page_header.html` | `<wn-page-header>` | `eyebrow`, `title`, `lede` |
|
||||
| `_pipeline.html` | `<wn-pipeline>` | `active_idx` |
|
||||
| `_field_row.html` | `<wn-field-row>` | `label`, `value`, `aside` |
|
||||
| `_prototype_card.html` | `<wn-prototype-card>` | `p` (a prototype object) |
|
||||
| `_banner.html` | `<wn-banner>` | `variant`, `title`, `body`, `dismissible` |
|
||||
| `_empty_state.html` | `<wn-empty-state>` | `icon`, `title`, `body`, `cta_label`, `cta_href` |
|
||||
|
||||
These partials are deliberately thin. If you need more flexibility, write the component HTML inline in your template — `<wn-button>` is no less ergonomic than `{% include %}`.
|
||||
|
||||
## What they don't do
|
||||
|
||||
- They don't validate inputs.
|
||||
- They don't auto-escape — Django does that.
|
||||
- They don't carry app-specific styling overrides — those go in your app, not here.
|
||||
|
||||
## Updating after a design system bump
|
||||
|
||||
After `pnpm up @whynot/design`:
|
||||
|
||||
```sh
|
||||
# Diff the upstream partials against your local copies.
|
||||
diff -r node_modules/@whynot/design/adapters/django/templates/whynot/ myapp/templates/whynot/
|
||||
|
||||
# Cherry-pick changes you want. The partials are stable but may gain new optional context vars across minor versions.
|
||||
```
|
||||
6
adapters/django/templates/whynot/_banner.html
Normal file
6
adapters/django/templates/whynot/_banner.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{# Banner.
|
||||
|
||||
{% include "whynot/_banner.html" with variant="success" title="Promoted" body="WNO-017 was promoted to Helix." %} #}
|
||||
<wn-banner variant="{{ variant|default:'info' }}"{% if title %} title="{{ title }}"{% endif %}{% if dismissible %} dismissible{% endif %}>
|
||||
{{ body }}
|
||||
</wn-banner>
|
||||
9
adapters/django/templates/whynot/_base_head.html
Normal file
9
adapters/django/templates/whynot/_base_head.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{# Link & register the design system. Include in <head> of your base template.
|
||||
|
||||
{% include "whynot/_base_head.html" %}
|
||||
|
||||
Customise STATIC_URL prefix if your collectstatic layout differs. #}
|
||||
{% load static %}
|
||||
<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>
|
||||
6
adapters/django/templates/whynot/_button.html
Normal file
6
adapters/django/templates/whynot/_button.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{# Button.
|
||||
|
||||
{% include "whynot/_button.html" with label="Promote" variant="primary" icon="arrow-right" %}
|
||||
{% include "whynot/_button.html" with label="Cancel" %}
|
||||
{% include "whynot/_button.html" with label="Read" href="/docs" variant="ghost" %} #}
|
||||
<wn-button{% if variant %} variant="{{ variant }}"{% endif %}{% if icon %} icon="{{ icon }}"{% endif %}{% if href %} href="{{ href }}"{% endif %}{% if type %} type="{{ type }}"{% endif %}{% if disabled %} disabled{% endif %}>{{ label }}</wn-button>
|
||||
9
adapters/django/templates/whynot/_empty_state.html
Normal file
9
adapters/django/templates/whynot/_empty_state.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{# Empty state.
|
||||
|
||||
{% include "whynot/_empty_state.html" with icon="activity" title="No signals yet." body="Lack of signal is also information." cta_label="Record a signal" cta_href="/signals/new/" %} #}
|
||||
<wn-empty-state{% if icon %} icon="{{ icon }}"{% endif %}{% if title %} title="{{ title }}"{% endif %}>
|
||||
{{ body }}
|
||||
{% if cta_label %}
|
||||
<wn-button slot="cta" variant="primary"{% if cta_href %} href="{{ cta_href }}"{% endif %}>{{ cta_label }}</wn-button>
|
||||
{% endif %}
|
||||
</wn-empty-state>
|
||||
4
adapters/django/templates/whynot/_eyebrow.html
Normal file
4
adapters/django/templates/whynot/_eyebrow.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Eyebrow label.
|
||||
|
||||
{% include "whynot/_eyebrow.html" with text="Stage 3 · Experiment" %} #}
|
||||
<wn-eyebrow{% if strong %} strong{% endif %}>{{ text }}</wn-eyebrow>
|
||||
10
adapters/django/templates/whynot/_field_row.html
Normal file
10
adapters/django/templates/whynot/_field_row.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{# Field row — label / value (+ optional aside).
|
||||
|
||||
{% include "whynot/_field_row.html" with label="Learning question" value=prototype.learning_question %}
|
||||
{% include "whynot/_field_row.html" with label="Target" value=prototype.target aside="2026-04-01" %}
|
||||
|
||||
For arbitrary value markup, write <wn-field-row> inline and slot children. #}
|
||||
<wn-field-row label="{{ label }}"{% if stacked %} stacked{% endif %}{% if narrow %} narrow{% endif %}>
|
||||
{{ value }}
|
||||
{% if aside %}<span slot="aside">{{ aside }}</span>{% endif %}
|
||||
</wn-field-row>
|
||||
6
adapters/django/templates/whynot/_page_header.html
Normal file
6
adapters/django/templates/whynot/_page_header.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{# Page header with eyebrow, title, optional lede.
|
||||
|
||||
{% include "whynot/_page_header.html" with eyebrow="whynot · prototypes" title="Prototypes" lede="Structured prototype cards." %}
|
||||
|
||||
For actions, write the component inline rather than using this partial. #}
|
||||
<wn-page-header{% if eyebrow %} eyebrow="{{ eyebrow }}"{% endif %} title="{{ title }}"{% if lede %} lede="{{ lede }}"{% endif %}></wn-page-header>
|
||||
4
adapters/django/templates/whynot/_pipeline.html
Normal file
4
adapters/django/templates/whynot/_pipeline.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Pipeline strip — 5 stages, active index 0–4.
|
||||
|
||||
{% include "whynot/_pipeline.html" with active_idx=3 %} #}
|
||||
<wn-pipeline active-idx="{{ active_idx|default:0 }}"></wn-pipeline>
|
||||
12
adapters/django/templates/whynot/_prototype_card.html
Normal file
12
adapters/django/templates/whynot/_prototype_card.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Prototype card.
|
||||
|
||||
Context: `p` — a prototype-like object with attributes:
|
||||
id, signal, stage_label, pitch, learning, smallest_test, target, href
|
||||
|
||||
{% include "whynot/_prototype_card.html" with p=prototype %} #}
|
||||
<wn-prototype-card card-id="{{ p.id }}" signal="{{ p.signal }}" stage-label="{{ p.stage_label }}"{% if p.href %} href="{{ p.href }}"{% endif %}>
|
||||
<span slot="pitch">{{ p.pitch }}</span>
|
||||
<span slot="learning">{{ p.learning }}</span>
|
||||
<span slot="test">{{ p.smallest_test }}</span>
|
||||
<span slot="target">→ {{ p.target }}</span>
|
||||
</wn-prototype-card>
|
||||
4
adapters/django/templates/whynot/_stage_dot.html
Normal file
4
adapters/django/templates/whynot/_stage_dot.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Signal-strength dot.
|
||||
|
||||
{% include "whynot/_stage_dot.html" with level="S2" label="Medium" %} #}
|
||||
<wn-stage-dot level="{{ level }}">{% if label %}{{ label }}{% else %}{{ level }}{% endif %}</wn-stage-dot>
|
||||
4
adapters/django/templates/whynot/_tag.html
Normal file
4
adapters/django/templates/whynot/_tag.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{# Tag pill.
|
||||
|
||||
{% include "whynot/_tag.html" with text="Experiment" active=True %} #}
|
||||
<wn-tag{% if active %} active{% endif %}{% if draft %} draft{% endif %}>{{ text }}</wn-tag>
|
||||
347
examples/showcase/index.html
Normal file
347
examples/showcase/index.html
Normal file
@@ -0,0 +1,347 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>@whynot/design — Showcase</title>
|
||||
<link rel="icon" href="../../assets/whynot-logo.png">
|
||||
<link rel="stylesheet" href="../../src/styles/colors_and_type.css">
|
||||
<link rel="stylesheet" href="../../src/styles/components.css">
|
||||
|
||||
<!-- Lit comes via importmap. In a real consumer this would be bundled. -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lit": "https://esm.sh/lit@3.2.1",
|
||||
"lit/": "https://esm.sh/lit@3.2.1/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="../../src/index.js"></script>
|
||||
|
||||
<style>
|
||||
body { background: var(--paper); }
|
||||
.page { max-width: 1080px; margin: 0 auto; padding: 48px 32px 96px; display: flex; flex-direction: column; gap: 64px; }
|
||||
section { display: flex; flex-direction: column; gap: 16px; }
|
||||
section > h2 { font: 500 22px/1.25 var(--ff-sans); margin: 0; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
||||
section > p.lede { margin: 0; max-width: 60ch; color: var(--fg-2); }
|
||||
.demo { display: flex; gap: 16px; flex-wrap: wrap; padding: 24px; border: 1px solid var(--border); border-radius: 4px; background: var(--paper-2); align-items: flex-start; }
|
||||
.demo--grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.demo--stack { flex-direction: column; align-items: stretch; }
|
||||
.label { font: 500 11px/1 var(--ff-mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--fg-3); margin-bottom: 6px; display: block; }
|
||||
[data-meta] { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<wn-top-nav logo-src="../../assets/whynot-logo.png" brand="whynot" slug="design / showcase">
|
||||
<a slot="links" class="wn-topnav__link wn-topnav__link--active" href="#">Showcase</a>
|
||||
<a slot="links" class="wn-topnav__link" href="../whynot-control/index.html">whynot-control kit</a>
|
||||
<wn-search-input slot="right" placeholder="Find a component…"></wn-search-input>
|
||||
<wn-button slot="right" variant="primary" icon="plus">New idea</wn-button>
|
||||
</wn-top-nav>
|
||||
|
||||
<main class="page">
|
||||
<wn-page-header
|
||||
eyebrow="@whynot/design · v0.2.0"
|
||||
title="Component showcase"
|
||||
lede="Every web component the design system currently ships, in one scrollable page. Use this as the canonical visual reference and as the Playwright regression target. Every section also doubles as copy-pasteable HTML for a Django template.">
|
||||
<wn-button slot="actions" variant="secondary" icon="arrow-left" href="../whynot-control/index.html">Back to UI kit</wn-button>
|
||||
</wn-page-header>
|
||||
|
||||
<!-- ATOMS -->
|
||||
<section>
|
||||
<h2>Atoms — buttons</h2>
|
||||
<p class="lede">Three variants, three sizes. Primary is ink-on-paper; secondary is the outline default; ghost has no chrome until hover.</p>
|
||||
<div class="demo">
|
||||
<wn-button variant="primary">Promote prototype</wn-button>
|
||||
<wn-button>Park</wn-button>
|
||||
<wn-button variant="ghost">View signal</wn-button>
|
||||
<wn-button variant="primary" disabled>Disabled</wn-button>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<wn-button variant="primary" icon="arrow-right">Promote</wn-button>
|
||||
<wn-button icon="archive">Park</wn-button>
|
||||
<wn-button variant="ghost" icon="x">Dismiss</wn-button>
|
||||
<wn-button size="sm" variant="primary">Small</wn-button>
|
||||
<wn-button size="lg" variant="primary">Large</wn-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Atoms — tags, eyebrows, stamps</h2>
|
||||
<div class="demo">
|
||||
<wn-eyebrow>Stage 3 · Experiment</wn-eyebrow>
|
||||
<wn-eyebrow strong>Signal · S2</wn-eyebrow>
|
||||
<wn-stamp>Draft · WNO-014</wn-stamp>
|
||||
</div>
|
||||
<div class="demo">
|
||||
<wn-tag>Raw Idea</wn-tag>
|
||||
<wn-tag>Prototype Candidate</wn-tag>
|
||||
<wn-tag active>Experiment</wn-tag>
|
||||
<wn-tag>Promotion Candidate</wn-tag>
|
||||
<wn-tag>Parked</wn-tag>
|
||||
<wn-tag draft>Draft</wn-tag>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Atoms — stage dots (signal strength)</h2>
|
||||
<p class="lede">Desaturated by design. S4 is the only level that uses the yellow accent — the threshold where a prototype actually matters commercially.</p>
|
||||
<div class="demo">
|
||||
<wn-stage-dot level="S0">S0 · No signal</wn-stage-dot>
|
||||
<wn-stage-dot level="S1">S1 · Weak</wn-stage-dot>
|
||||
<wn-stage-dot level="S2">S2 · Medium</wn-stage-dot>
|
||||
<wn-stage-dot level="S3">S3 · Strong</wn-stage-dot>
|
||||
<wn-stage-dot level="S4">S4 · Commercial</wn-stage-dot>
|
||||
</div>
|
||||
<h2 style="margin-top: 32px;">Atoms — phase dots</h2>
|
||||
<p class="lede">Numbered phases, distinct from signal strength. Used in pipelines, checklists, onboarding flows.</p>
|
||||
<div class="demo">
|
||||
<wn-phase-dot state="done" num="1">Triage</wn-phase-dot>
|
||||
<wn-phase-dot state="done" num="2">Card</wn-phase-dot>
|
||||
<wn-phase-dot state="active" num="3">Experiment</wn-phase-dot>
|
||||
<wn-phase-dot state="todo" num="4">Signal review</wn-phase-dot>
|
||||
<wn-phase-dot state="warn" num="!">Blocked</wn-phase-dot>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Atoms — icons</h2>
|
||||
<p class="lede">Lucide subset, inlined as paths. Stroke 1.5px, inherits <code>currentColor</code>. Add new icons in <code>src/elements/icons.js</code> — not in consuming repos.</p>
|
||||
<div class="demo" style="gap: 24px;">
|
||||
<wn-icon name="inbox" size="lg"></wn-icon>
|
||||
<wn-icon name="lightbulb" size="lg"></wn-icon>
|
||||
<wn-icon name="flask-conical" size="lg"></wn-icon>
|
||||
<wn-icon name="activity" size="lg"></wn-icon>
|
||||
<wn-icon name="users" size="lg"></wn-icon>
|
||||
<wn-icon name="git-branch" size="lg"></wn-icon>
|
||||
<wn-icon name="check-square" size="lg"></wn-icon>
|
||||
<wn-icon name="archive" size="lg"></wn-icon>
|
||||
<wn-icon name="arrow-right" size="lg"></wn-icon>
|
||||
<wn-icon name="search" size="lg"></wn-icon>
|
||||
<wn-icon name="filter" size="lg"></wn-icon>
|
||||
<wn-icon name="settings" size="lg"></wn-icon>
|
||||
<wn-icon name="circle-help" size="lg"></wn-icon>
|
||||
<wn-icon name="circle-alert" size="lg"></wn-icon>
|
||||
<wn-icon name="circle-check" size="lg"></wn-icon>
|
||||
<wn-icon name="more-horizontal" size="lg"></wn-icon>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FORMS -->
|
||||
<section>
|
||||
<h2>Form — inputs</h2>
|
||||
<div class="demo demo--grid">
|
||||
<wn-field-row label="Prototype name">
|
||||
<wn-input name="name" placeholder="e.g. relevant-coronapolitics-timeline" required></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="One-line pitch">
|
||||
<wn-input name="pitch" value="Discover the weird and the useful." help="120 char limit · plain sentence"></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Learning question">
|
||||
<wn-textarea name="learning" rows="3">What would we need to learn to know whether this idea deserves another step?</wn-textarea>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Smallest useful test">
|
||||
<wn-input error error-text="Required — describe in one sentence."></wn-input>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Promotion target">
|
||||
<wn-select name="target">
|
||||
<option value="">— Select a target —</option>
|
||||
<option value="helix">Helix</option>
|
||||
<option value="coulomb" selected>Coulomb</option>
|
||||
<option value="plenitude">Plenitude</option>
|
||||
<option value="binky">Binky</option>
|
||||
<option value="tegwick">Tegwick</option>
|
||||
</wn-select>
|
||||
</wn-field-row>
|
||||
<wn-field-row label="Search">
|
||||
<wn-search-input placeholder="Search ideas, prototypes, signals…"></wn-search-input>
|
||||
</wn-field-row>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LAYOUT -->
|
||||
<section>
|
||||
<h2>Layout — cards</h2>
|
||||
<div class="demo demo--grid">
|
||||
<wn-card>
|
||||
<wn-eyebrow slot="header">Signal · S2</wn-eyebrow>
|
||||
<wn-stage-dot slot="header" level="S2">Medium</wn-stage-dot>
|
||||
<p>Three founders independently asked for "something to capture the half-formed stuff" in yesterday's calls.</p>
|
||||
<span slot="footer">2026-03-02</span>
|
||||
<span slot="footer">interview</span>
|
||||
</wn-card>
|
||||
<wn-card variant="inset" clickable>
|
||||
<wn-eyebrow slot="header">Decision · DEC-002</wn-eyebrow>
|
||||
<wn-tag slot="header" draft>Open</wn-tag>
|
||||
<p>Maintain A1 Incubating until first prototype candidates review.</p>
|
||||
<span slot="footer">→ pending</span>
|
||||
<span slot="footer">whynot-control</span>
|
||||
</wn-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layout — prototype card (composite)</h2>
|
||||
<div class="demo demo--grid">
|
||||
<wn-prototype-card card-id="WNO-014" signal="S1" stage-label="Experiment">
|
||||
<span slot="pitch">A field-notebook for catching weird ideas before they evaporate.</span>
|
||||
<span slot="learning">Do people return to capture more than once?</span>
|
||||
<span slot="test">One-page landing + email capture, 14 days.</span>
|
||||
<span slot="target">→ Coulomb</span>
|
||||
</wn-prototype-card>
|
||||
<wn-prototype-card card-id="WNO-017" signal="S3" stage-label="Signal review" href="#">
|
||||
<span slot="pitch">A LEGO-brick mood board for engineers who don't think in mood boards.</span>
|
||||
<span slot="learning">Will engineers attach metaphors to their tickets?</span>
|
||||
<span slot="test">Slack bot, three teams, two weeks.</span>
|
||||
<span slot="target">→ Helix</span>
|
||||
</wn-prototype-card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layout — pipeline</h2>
|
||||
<div class="demo demo--stack">
|
||||
<wn-pipeline active-idx="3"></wn-pipeline>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layout — table</h2>
|
||||
<div class="demo demo--stack" style="padding: 0; background: var(--paper); display: block;">
|
||||
<wn-table id="signals-table">
|
||||
<wn-table-row>
|
||||
<wn-table-cell variant="mono">SIG-031</wn-table-cell>
|
||||
<wn-table-cell variant="mono">WNO-017</wn-table-cell>
|
||||
<wn-table-cell><wn-stage-dot level="S3"></wn-stage-dot></wn-table-cell>
|
||||
<wn-table-cell>Two teams shipped public README sections labelled "brick: scope" after using the bot for a week.</wn-table-cell>
|
||||
<wn-table-cell variant="meta">usage log</wn-table-cell>
|
||||
<wn-table-cell variant="meta">2026-03-04</wn-table-cell>
|
||||
</wn-table-row>
|
||||
<wn-table-row>
|
||||
<wn-table-cell variant="mono">SIG-030</wn-table-cell>
|
||||
<wn-table-cell variant="mono">WNO-017</wn-table-cell>
|
||||
<wn-table-cell><wn-stage-dot level="S2"></wn-stage-dot></wn-table-cell>
|
||||
<wn-table-cell>Three engineers DM'd asking for an export-to-Notion option.</wn-table-cell>
|
||||
<wn-table-cell variant="meta">Slack</wn-table-cell>
|
||||
<wn-table-cell variant="meta">2026-03-03</wn-table-cell>
|
||||
</wn-table-row>
|
||||
<wn-table-row>
|
||||
<wn-table-cell variant="mono">SIG-029</wn-table-cell>
|
||||
<wn-table-cell variant="mono">WNO-014</wn-table-cell>
|
||||
<wn-table-cell><wn-stage-dot level="S1"></wn-stage-dot></wn-table-cell>
|
||||
<wn-table-cell>Landing page: 34 visits, 7 emails, 0 returns in week 1.</wn-table-cell>
|
||||
<wn-table-cell variant="meta">Plausible</wn-table-cell>
|
||||
<wn-table-cell variant="meta">2026-03-01</wn-table-cell>
|
||||
</wn-table-row>
|
||||
</wn-table>
|
||||
<script>
|
||||
// Columns are set via the property (so we can pass an array).
|
||||
document.getElementById("signals-table").columns = [
|
||||
{ label: "ID", width: 90 },
|
||||
{ label: "Prototype", width: 100 },
|
||||
{ label: "Level", width: 80 },
|
||||
{ label: "What happened" },
|
||||
{ label: "Source", width: 110 },
|
||||
{ label: "Date", width: 100 },
|
||||
];
|
||||
</script>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layout — banners</h2>
|
||||
<div class="demo demo--stack">
|
||||
<wn-banner variant="info" title="Heads up">A prototype can be interesting and still be parked. Capture is not commitment.</wn-banner>
|
||||
<wn-banner variant="success" title="Promoted" dismissible>WNO-017 was promoted to Helix on 2026-03-12. See DECISIONS.md.</wn-banner>
|
||||
<wn-banner variant="warn" title="Weak signal" dismissible>Landing page: 12 visits in 30 days, 0 returns. Consider parking this prototype.</wn-banner>
|
||||
<wn-banner variant="error" title="CI failed">Visual regression failed on examples/showcase. Two snapshots changed.</wn-banner>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layout — empty state</h2>
|
||||
<div class="demo demo--stack">
|
||||
<wn-empty-state icon="activity" title="No signals yet.">
|
||||
Lack of signal is also information.
|
||||
<wn-button slot="cta" variant="primary" icon="plus">Record a signal</wn-button>
|
||||
</wn-empty-state>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layout — breadcrumb</h2>
|
||||
<div class="demo demo--stack">
|
||||
<wn-breadcrumb>
|
||||
<a href="#">whynot</a>
|
||||
<a href="#">Prototypes</a>
|
||||
<span>WNO-014</span>
|
||||
</wn-breadcrumb>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layout — modal (click to open)</h2>
|
||||
<div class="demo">
|
||||
<wn-button variant="primary" id="open-modal">Open dialog</wn-button>
|
||||
<wn-modal id="my-modal" title="Park this prototype?">
|
||||
<p>Parking is not rejection. The card stays in <code>prototypes/</code> with stage <code>parked</code>; it can be resumed at any time.</p>
|
||||
<p>If a new signal arrives, you'll see it in this prototype's signal log even while parked.</p>
|
||||
<wn-button slot="footer" id="cancel-modal">Cancel</wn-button>
|
||||
<wn-button slot="footer" variant="primary" icon="archive" id="confirm-park">Park prototype</wn-button>
|
||||
</wn-modal>
|
||||
</div>
|
||||
<script>
|
||||
const modal = document.getElementById("my-modal");
|
||||
document.getElementById("open-modal").addEventListener("click", () => modal.open = true);
|
||||
document.getElementById("cancel-modal").addEventListener("click", () => modal.open = false);
|
||||
document.getElementById("confirm-park").addEventListener("click", () => modal.open = false);
|
||||
</script>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Layout — toast region (click to show)</h2>
|
||||
<div class="demo">
|
||||
<wn-button id="show-toast" variant="primary">Show toast</wn-button>
|
||||
</div>
|
||||
<wn-toast-region id="toasts"></wn-toast-region>
|
||||
<script>
|
||||
document.getElementById("show-toast").addEventListener("click", () => {
|
||||
const region = document.getElementById("toasts");
|
||||
const t = document.createElement("wn-toast");
|
||||
t.setAttribute("variant", "success");
|
||||
t.setAttribute("title", "Captured");
|
||||
t.setAttribute("dismissible", "");
|
||||
t.textContent = "Idea saved to inbox/. Capture is not commitment.";
|
||||
region.appendChild(t);
|
||||
setTimeout(() => t.remove(), 4000);
|
||||
});
|
||||
</script>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Chrome — sidebar (illustrative slice)</h2>
|
||||
<p class="lede">Normally lives inside <code>.wn-app</code> alongside the main column. Shown here as a standalone fragment.</p>
|
||||
<div class="demo" style="padding: 0;">
|
||||
<wn-sidebar activation="A1 · Incubating" style="height: auto; position: static; width: 240px;">
|
||||
<wn-sidebar-group label="Work">
|
||||
<wn-sidebar-item icon="inbox" href="#" count="7">Inbox</wn-sidebar-item>
|
||||
<wn-sidebar-item icon="flask-conical" href="#" active count="4">Prototypes</wn-sidebar-item>
|
||||
<wn-sidebar-item icon="activity" href="#" count="12">Signals</wn-sidebar-item>
|
||||
<wn-sidebar-item icon="users" href="#" count="1">Betas</wn-sidebar-item>
|
||||
<wn-sidebar-item icon="check-square" href="#" count="3">Decisions</wn-sidebar-item>
|
||||
</wn-sidebar-group>
|
||||
<wn-sidebar-group label="Control docs">
|
||||
<wn-sidebar-item variant="doc" icon="file-text" href="#">INTENT.md</wn-sidebar-item>
|
||||
<wn-sidebar-item variant="doc" icon="file-text" href="#">SCOPE.md</wn-sidebar-item>
|
||||
<wn-sidebar-item variant="doc" icon="file-text" href="#">OPERATING_MODEL.md</wn-sidebar-item>
|
||||
</wn-sidebar-group>
|
||||
</wn-sidebar>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="opacity: 0.7;">
|
||||
<p data-meta>End of showcase. See <code>examples/whynot-control/index.html</code> for a full app composition.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
31
package.json
31
package.json
@@ -1,19 +1,26 @@
|
||||
{
|
||||
"name": "@whynot/design",
|
||||
"version": "0.1.0",
|
||||
"description": "The neutral, mostly-black-and-white visual language for whynot — prototypes, signal records, beta plans, decision documents, and any other deliberately-unfinished artefact.",
|
||||
"version": "0.2.0",
|
||||
"description": "The neutral, mostly-black-and-white visual language for whynot — prototype cards, signal records, beta plans, decision documents, and any other deliberately-unfinished artefact. Ships tokens, CSS, and Lit-based web components consumable from React, Django, Vue, plain HTML, or anywhere a custom element runs.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.js",
|
||||
"module": "./src/index.js",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./atoms": "./src/elements/atoms.js",
|
||||
"./form": "./src/elements/form.js",
|
||||
"./layout": "./src/elements/layout.js",
|
||||
"./chrome": "./src/elements/chrome.js",
|
||||
"./icons": "./src/elements/icons.js",
|
||||
"./styles/colors_and_type.css": "./src/styles/colors_and_type.css",
|
||||
"./styles/components.css": "./src/styles/components.css",
|
||||
"./styles": "./src/styles/colors_and_type.css",
|
||||
"./tokens": "./tokens/index.json",
|
||||
"./tokens/colors": "./tokens/colors.json",
|
||||
"./tokens/type": "./tokens/type.json",
|
||||
"./tokens/spacing": "./tokens/spacing.json",
|
||||
"./adapters/django": "./adapters/django/README.md",
|
||||
"./assets/*": "./assets/*",
|
||||
"./skill": "./SKILL.md"
|
||||
},
|
||||
@@ -21,32 +28,32 @@
|
||||
"src",
|
||||
"tokens",
|
||||
"assets",
|
||||
"adapters",
|
||||
"SKILL.md",
|
||||
"DesignSystemIntroduction.md",
|
||||
"MultiFrameworkSupport.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
"**/*.css",
|
||||
"./src/index.js",
|
||||
"./src/elements/*.js"
|
||||
],
|
||||
"scripts": {
|
||||
"check": "node ./scripts/check-changelog.mjs",
|
||||
"test:visual": "playwright test",
|
||||
"test:visual:update": "playwright test --update-snapshots",
|
||||
"example": "npx --yes serve examples/whynot-control"
|
||||
"showcase": "npx --yes serve -l 4321 .",
|
||||
"example": "npx --yes serve -l 4322 examples/whynot-control"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": { "optional": true },
|
||||
"react-dom": { "optional": true }
|
||||
"dependencies": {
|
||||
"lit": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.45.0"
|
||||
},
|
||||
"keywords": ["whynot", "design-system", "wireframe", "prototype", "minimal"],
|
||||
"keywords": ["whynot", "design-system", "wireframe", "prototype", "lit", "web-components", "django", "react"],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@gitea.example.com/whynot/whynot-design.git"
|
||||
|
||||
@@ -15,7 +15,8 @@ export default defineConfig({
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
],
|
||||
webServer: {
|
||||
command: "npx --yes serve -l 4321 examples/whynot-control",
|
||||
// Serve the entire repo root so showcase + UI kit + assets all resolve via relative URLs.
|
||||
command: "npx --yes serve -l 4321 .",
|
||||
url: "http://localhost:4321",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: "ignore",
|
||||
@@ -23,7 +24,6 @@ export default defineConfig({
|
||||
},
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
// Reasonable tolerance for font hinting / sub-pixel jitter.
|
||||
maxDiffPixelRatio: 0.005,
|
||||
},
|
||||
},
|
||||
|
||||
29
scripts/sync-shared-styles.mjs
Normal file
29
scripts/sync-shared-styles.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// Regenerate src/elements/_styles.js from src/styles/components.css.
|
||||
// Run after every edit to components.css.
|
||||
//
|
||||
// node scripts/sync-shared-styles.mjs
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const css = readFileSync(resolve(here, "../src/styles/components.css"), "utf8");
|
||||
const js = `/* Auto-generated from src/styles/components.css by scripts/sync-shared-styles.mjs.
|
||||
* Do NOT edit by hand. Edit components.css and re-run the script.
|
||||
*/
|
||||
|
||||
export const SHARED_CSS = String.raw\`${css.replace(/\\/g, "\\\\").replace(/\`/g, "\\\`")}\`;
|
||||
|
||||
let _sheet = null;
|
||||
export function getSharedSheet() {
|
||||
if (!_sheet) {
|
||||
_sheet = new CSSStyleSheet();
|
||||
_sheet.replaceSync(SHARED_CSS);
|
||||
}
|
||||
return _sheet;
|
||||
}
|
||||
`;
|
||||
writeFileSync(resolve(here, "../src/elements/_styles.js"), js);
|
||||
console.log("Wrote src/elements/_styles.js (" + js.length + " chars).");
|
||||
@@ -1,102 +0,0 @@
|
||||
// =============================================================
|
||||
// Atoms — Eyebrow, Tag, Button, StageDot, Stamp, IconBtn
|
||||
// =============================================================
|
||||
|
||||
function Eyebrow({ children, style }) {
|
||||
return (
|
||||
<span style={{
|
||||
font: '500 11px/1.2 var(--ff-mono)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--fg-3)',
|
||||
...style,
|
||||
}}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Tag({ children, active, draft, style }) {
|
||||
const base = {
|
||||
font: '500 10px/1 var(--ff-mono)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
padding: '5px 10px',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--fg-2)',
|
||||
background: 'var(--paper)',
|
||||
display: 'inline-block',
|
||||
};
|
||||
if (active) Object.assign(base, { background: 'var(--ink)', color: 'var(--paper)', borderColor: 'var(--ink)' });
|
||||
if (draft) Object.assign(base, { background: 'var(--hi)', color: 'var(--hi-ink)', borderColor: 'transparent' });
|
||||
return <span style={{ ...base, ...style }}>{children}</span>;
|
||||
}
|
||||
|
||||
function Button({ children, variant = 'secondary', onClick, style, icon }) {
|
||||
const base = {
|
||||
font: '500 13px var(--ff-sans)',
|
||||
letterSpacing: '-0.005em',
|
||||
padding: '9px 14px',
|
||||
borderRadius: 'var(--r-2)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--paper)',
|
||||
color: 'var(--ink)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background 120ms ease, border-color 120ms ease',
|
||||
};
|
||||
if (variant === 'primary') Object.assign(base, { background: 'var(--ink)', color: 'var(--paper)', borderColor: 'var(--ink)' });
|
||||
if (variant === 'ghost') Object.assign(base, { background: 'transparent', borderColor: 'transparent', padding: '7px 10px' });
|
||||
return (
|
||||
<button onClick={onClick} style={{ ...base, ...style }}>
|
||||
{icon && <i data-lucide={icon} style={{ width: 14, height: 14, strokeWidth: 1.5 }}></i>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const STAGE_COLORS = {
|
||||
S0: '#B5B5B3', S1: '#8A8A8A', S2: '#5C5C5C', S3: '#0A0A0A', S4: '#FFD400',
|
||||
};
|
||||
|
||||
function StageDot({ level = 'S2', label, style }) {
|
||||
return (
|
||||
<span style={{
|
||||
font: '500 10px/1 var(--ff-mono)',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--fg-2)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
...style,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: STAGE_COLORS[level] }}></span>
|
||||
{label || level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stamp({ children, style }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
background: 'var(--hi)',
|
||||
color: 'var(--hi-ink)',
|
||||
padding: '5px 10px 3px',
|
||||
font: '500 10px/1 var(--ff-mono)',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
transform: 'rotate(-1.5deg)',
|
||||
...style,
|
||||
}}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ name, size = 16, style }) {
|
||||
return <i data-lucide={name} style={{ width: size, height: size, strokeWidth: 1.5, ...style }}></i>;
|
||||
}
|
||||
|
||||
Object.assign(window, { Eyebrow, Tag, Button, StageDot, Stamp, Icon, STAGE_COLORS });
|
||||
@@ -1,165 +0,0 @@
|
||||
// =============================================================
|
||||
// Chrome — TopNav, Sidebar, PageHeader, PipelineStrip
|
||||
// =============================================================
|
||||
|
||||
function TopNav({ onNew }) {
|
||||
return (
|
||||
<nav style={{
|
||||
height: 56,
|
||||
background: 'rgba(255,255,255,0.92)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 28,
|
||||
padding: '0 24px',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img src="../../assets/whynot-logo.png" alt="" style={{ width: 22, height: 22 }} />
|
||||
<span style={{ font: '500 14px var(--ff-sans)' }}>whynot</span>
|
||||
<span style={{ font: '400 12px var(--ff-mono)', color: 'var(--fg-3)', letterSpacing: '0.04em' }}>/ control</span>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
font: '400 12px var(--ff-mono)',
|
||||
color: 'var(--fg-3)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 'var(--r-1)',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
minWidth: 240,
|
||||
}}>
|
||||
<Icon name="search" size={14} />
|
||||
<span>Search ideas, prototypes, signals…</span>
|
||||
<span style={{ marginLeft: 'auto', padding: '1px 5px', border: '1px solid var(--border)', borderRadius: 2, fontSize: 10 }}>⌘ K</span>
|
||||
</div>
|
||||
<Button variant="primary" icon="plus" onClick={onNew}>New idea</Button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: 'inbox', label: 'Inbox', icon: 'inbox', count: 7 },
|
||||
{ key: 'prototypes', label: 'Prototypes', icon: 'flask-conical', count: 4 },
|
||||
{ key: 'signals', label: 'Signals', icon: 'activity', count: 12 },
|
||||
{ key: 'betas', label: 'Betas', icon: 'users', count: 1 },
|
||||
{ key: 'decisions', label: 'Decisions', icon: 'check-square', count: 3 },
|
||||
];
|
||||
|
||||
const DOC_ITEMS = [
|
||||
{ key: 'intent', label: 'INTENT.md' },
|
||||
{ key: 'scope', label: 'SCOPE.md' },
|
||||
{ key: 'operating', label: 'OPERATING_MODEL.md' },
|
||||
{ key: 'pipeline', label: 'PROTOTYPE_PIPELINE.md' },
|
||||
{ key: 'agent', label: 'AGENT_RULES.md' },
|
||||
];
|
||||
|
||||
function Sidebar({ current, onNav }) {
|
||||
const itemStyle = (active) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 4,
|
||||
color: active ? 'var(--fg-1)' : 'var(--fg-2)',
|
||||
background: active ? 'var(--paper)' : 'transparent',
|
||||
boxShadow: active ? '0 0 0 1px var(--border) inset' : 'none',
|
||||
font: '500 13px var(--ff-sans)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
transition: 'background 120ms ease, color 120ms ease',
|
||||
});
|
||||
return (
|
||||
<aside style={{
|
||||
width: 240, flex: 'none',
|
||||
background: 'var(--paper-2)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
padding: '24px 16px',
|
||||
display: 'flex', flexDirection: 'column', gap: 24,
|
||||
height: 'calc(100vh - 56px)',
|
||||
position: 'sticky', top: 56,
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
<div>
|
||||
<Eyebrow style={{ paddingLeft: 12, marginBottom: 8, display: 'block' }}>Work</Eyebrow>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{NAV_ITEMS.map(item => (
|
||||
<a key={item.key} onClick={() => onNav(item.key)} style={itemStyle(current === item.key)}>
|
||||
<Icon name={item.icon} size={16} />
|
||||
<span>{item.label}</span>
|
||||
<span style={{ marginLeft: 'auto', font: '400 11px var(--ff-mono)', color: 'var(--fg-3)' }}>{item.count}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow style={{ paddingLeft: 12, marginBottom: 8, display: 'block' }}>Control docs</Eyebrow>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{DOC_ITEMS.map(item => (
|
||||
<a key={item.key} onClick={() => onNav('doc:' + item.key)} style={{ ...itemStyle(current === 'doc:' + item.key), font: '400 12px var(--ff-mono)' }}>
|
||||
<Icon name="file-text" size={14} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 'auto', paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--hi-2)' }}></span>
|
||||
<span style={{ font: '500 11px var(--ff-mono)', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--fg-2)' }}>A1 · Incubating</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PageHeader({ eyebrow, title, lede, actions }) {
|
||||
return (
|
||||
<header style={{ marginBottom: 32, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 24 }}>
|
||||
<h1 style={{ font: '500 32px/1.15 var(--ff-sans)', letterSpacing: '-0.015em', margin: 0, flex: 1 }}>{title}</h1>
|
||||
{actions && <div style={{ display: 'flex', gap: 8 }}>{actions}</div>}
|
||||
</div>
|
||||
{lede && <p style={{ font: '400 16px/1.55 var(--ff-sans)', color: 'var(--fg-2)', margin: 0, maxWidth: '60ch' }}>{lede}</p>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function PipelineStrip({ activeIdx = 3 }) {
|
||||
const stages = [
|
||||
{ num: 'Stage 0', name: 'Raw idea', meta: 'inbox/' },
|
||||
{ num: 'Stage 1', name: 'Triage', meta: '2026-02-12' },
|
||||
{ num: 'Stage 2', name: 'Prototype card', meta: 'prototypes/' },
|
||||
{ num: 'Stage 3', name: 'Experiment', meta: 'ends 2026-04-01' },
|
||||
{ num: 'Stage 4', name: 'Signal review', meta: '— pending' },
|
||||
];
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 0, position: 'relative', margin: '0 0 32px' }}>
|
||||
{stages.map((s, i) => {
|
||||
const state = i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending';
|
||||
const topColor = state === 'done' ? 'var(--ink)' : state === 'active' ? 'var(--hi-2)' : 'var(--border)';
|
||||
return (
|
||||
<div key={i} style={{
|
||||
padding: '10px 12px 14px',
|
||||
borderTop: `2px solid ${topColor}`,
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
position: 'relative',
|
||||
}}>
|
||||
<span style={{ font: '500 10px/1 var(--ff-mono)', letterSpacing: '0.1em', textTransform: 'uppercase', color: state === 'pending' ? 'var(--fg-3)' : 'var(--fg-1)' }}>{s.num}</span>
|
||||
<span style={{ font: '500 14px/1.25 var(--ff-sans)', color: state === 'pending' ? 'var(--fg-3)' : 'var(--fg-1)' }}>{s.name}</span>
|
||||
<span style={{ font: '400 11px/1.35 var(--ff-mono)', color: 'var(--fg-3)' }}>{s.meta}</span>
|
||||
{i > 0 && (
|
||||
<span style={{ position: 'absolute', top: -8, right: -7, font: '400 14px var(--ff-mono)', color: state === 'pending' ? 'var(--ink-5)' : 'var(--ink)' }}>→</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { TopNav, Sidebar, PageHeader, PipelineStrip, NAV_ITEMS, DOC_ITEMS });
|
||||
604
src/elements/_styles.js
Normal file
604
src/elements/_styles.js
Normal file
@@ -0,0 +1,604 @@
|
||||
/* Auto-generated from src/styles/components.css by scripts/sync-shared-styles.mjs.
|
||||
* Do NOT edit by hand. Edit components.css and re-run the script.
|
||||
*/
|
||||
|
||||
export const SHARED_CSS = String.raw`/* ============================================================
|
||||
WhyNot Design System — Component Styles
|
||||
------------------------------------------------------------
|
||||
Utility classes that the Lit web components render to. These
|
||||
are also consumable directly from any HTML (no JS required)
|
||||
for the "Layer 1 only" use case — see MultiFrameworkSupport.md.
|
||||
============================================================ */
|
||||
|
||||
/* ====== Custom-element display defaults ======
|
||||
* For shadow-DOM components, the wn-* host has display: inline by default.
|
||||
* Set sensible defaults so layout works without the consumer specifying them.
|
||||
*/
|
||||
wn-eyebrow, wn-tag, wn-stage-dot, wn-phase-dot, wn-stamp, wn-icon,
|
||||
wn-search-input, wn-button { display: inline-block; }
|
||||
|
||||
wn-card, wn-modal, wn-top-nav, wn-sidebar, wn-page-header,
|
||||
wn-pipeline, wn-prototype-card, wn-field-row, wn-breadcrumb,
|
||||
wn-table, wn-banner, wn-empty-state,
|
||||
wn-input, wn-textarea, wn-select { display: block; }
|
||||
|
||||
wn-toast-region { display: block; }
|
||||
wn-toast { display: block; }
|
||||
|
||||
wn-sidebar-group, wn-sidebar-item { display: block; }
|
||||
wn-table-row, wn-table-cell { display: contents; }
|
||||
|
||||
/* host hidden state — needed because shadow-DOM components don't inherit
|
||||
* \`[hidden]\` semantics in light DOM. Lit's host attribute reflection
|
||||
* handles attributes, but \`hidden\` on the host itself should still work. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ====== Buttons ====== */
|
||||
.wn-btn {
|
||||
font: 500 13px var(--ff-sans);
|
||||
letter-spacing: -0.005em;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--r-2);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
text-decoration: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wn-btn:hover { border-color: var(--ink); }
|
||||
.wn-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
.wn-btn:active { background: var(--bg-3); }
|
||||
.wn-btn[disabled], .wn-btn.is-disabled {
|
||||
color: var(--ink-5); border-color: var(--border); cursor: not-allowed; background: var(--paper);
|
||||
}
|
||||
|
||||
.wn-btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
.wn-btn--primary:hover { background: var(--ink-2); border-color: var(--ink-2); }
|
||||
.wn-btn--primary:active { background: var(--ink); }
|
||||
.wn-btn--primary[disabled], .wn-btn--primary.is-disabled {
|
||||
background: var(--ink-5); border-color: var(--ink-5); color: var(--paper);
|
||||
}
|
||||
|
||||
.wn-btn--ghost { background: transparent; border-color: transparent; padding: 7px 10px; }
|
||||
.wn-btn--ghost:hover { background: var(--bg-3); border-color: transparent; }
|
||||
|
||||
.wn-btn--danger { background: var(--paper); color: var(--ink); border-color: var(--ink); }
|
||||
|
||||
.wn-btn--sm { padding: 5px 10px; font-size: 12px; }
|
||||
.wn-btn--lg { padding: 12px 20px; font-size: 14px; }
|
||||
|
||||
.wn-btn__icon { width: 14px; height: 14px; flex: none; }
|
||||
.wn-btn--lg .wn-btn__icon { width: 16px; height: 16px; }
|
||||
|
||||
/* ====== Eyebrows & labels ====== */
|
||||
.wn-eyebrow {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
display: inline-block;
|
||||
}
|
||||
.wn-eyebrow--strong { color: var(--fg-1); }
|
||||
|
||||
/* ====== Tags ====== */
|
||||
.wn-tag {
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-2);
|
||||
background: var(--paper);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wn-tag--active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
.wn-tag--draft { background: var(--hi); color: var(--hi-ink); border-color: transparent; }
|
||||
|
||||
/* ====== Stage / Phase dots ====== */
|
||||
.wn-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.wn-dot__bullet { width: 8px; height: 8px; border-radius: 999px; background: var(--ink); flex: none; }
|
||||
|
||||
/* signal levels (S0–S4) */
|
||||
.wn-stage-dot--s0 .wn-dot__bullet { background: var(--status-raw); }
|
||||
.wn-stage-dot--s1 .wn-dot__bullet { background: var(--status-weak); }
|
||||
.wn-stage-dot--s2 .wn-dot__bullet { background: var(--status-medium); }
|
||||
.wn-stage-dot--s3 .wn-dot__bullet { background: var(--status-strong); }
|
||||
.wn-stage-dot--s4 .wn-dot__bullet { background: var(--status-commercial); }
|
||||
|
||||
/* phase states (todo / active / done / warn) — numbered phases, distinct from signal */
|
||||
.wn-phase-dot__bullet {
|
||||
width: 18px; height: 18px; border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--paper);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font: 500 10px/1 var(--ff-mono); color: var(--fg-3);
|
||||
flex: none;
|
||||
}
|
||||
.wn-phase-dot--todo .wn-phase-dot__bullet { border-color: var(--border-strong); color: var(--fg-3); background: var(--paper); }
|
||||
.wn-phase-dot--active .wn-phase-dot__bullet { border-color: var(--ink); color: var(--ink); background: var(--paper); box-shadow: 0 0 0 3px rgba(10,10,10,0.06); }
|
||||
.wn-phase-dot--done .wn-phase-dot__bullet { border-color: var(--ink); color: var(--paper); background: var(--ink); }
|
||||
.wn-phase-dot--warn .wn-phase-dot__bullet { border-color: var(--hi-2); color: var(--hi-ink); background: var(--hi); }
|
||||
|
||||
/* ====== Stamp ====== */
|
||||
.wn-stamp {
|
||||
display: inline-block;
|
||||
background: var(--hi);
|
||||
color: var(--hi-ink);
|
||||
padding: 5px 10px 3px;
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
|
||||
/* ====== Icon ====== */
|
||||
.wn-icon { stroke-width: 1.5; stroke: currentColor; fill: none; display: inline-block; vertical-align: middle; }
|
||||
.wn-icon--sm { width: 14px; height: 14px; }
|
||||
.wn-icon--md { width: 16px; height: 16px; }
|
||||
.wn-icon--lg { width: 20px; height: 20px; }
|
||||
.wn-icon--xl { width: 24px; height: 24px; }
|
||||
|
||||
/* ====== Card ====== */
|
||||
.wn-card {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
position: relative;
|
||||
}
|
||||
.wn-card--inset { background: var(--paper-2); border-color: var(--border); }
|
||||
.wn-card--recessed { background: var(--paper-3); }
|
||||
.wn-card--lg { padding: var(--sp-6); border-radius: var(--r-3); }
|
||||
.wn-card--sm { padding: var(--sp-4); gap: var(--sp-2); }
|
||||
.wn-card--clickable { cursor: pointer; transition: border-color 120ms ease; }
|
||||
.wn-card--clickable:hover { border-color: var(--ink); }
|
||||
.wn-card--clickable:hover::before {
|
||||
content: ""; position: absolute; left: -1px; top: -1px; bottom: -1px;
|
||||
width: 2px; background: var(--ink); border-radius: 2px 0 0 2px;
|
||||
}
|
||||
.wn-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); }
|
||||
.wn-card__title { font: 500 17px/1.35 var(--ff-sans); margin: 4px 0 8px; color: var(--fg-1); }
|
||||
.wn-card__foot {
|
||||
display: flex; justify-content: space-between; gap: var(--sp-3);
|
||||
padding-top: var(--sp-3); margin-top: 4px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ====== Field row (label + value, 3-col grid) ====== */
|
||||
.wn-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: var(--sp-4) var(--sp-5);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.wn-field-row:last-child { border-bottom: 0; }
|
||||
.wn-field-row__label {
|
||||
font: 500 11px/1.5 var(--ff-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-field-row__value { font: 400 15px/1.55 var(--ff-sans); color: var(--fg-1); }
|
||||
.wn-field-row__aside { font: 400 12px var(--ff-mono); color: var(--fg-3); text-align: right; }
|
||||
.wn-field-row--stacked { grid-template-columns: 1fr; gap: 6px; }
|
||||
.wn-field-row--narrow { grid-template-columns: 120px 1fr; }
|
||||
|
||||
/* ====== Form inputs ====== */
|
||||
.wn-form-label {
|
||||
font: 500 11px/1 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wn-input, .wn-textarea, .wn-select {
|
||||
font: 400 14px var(--ff-sans);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-1);
|
||||
color: var(--fg-1);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color 120ms ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wn-input:hover, .wn-textarea:hover, .wn-select:hover { border-color: var(--border-strong); }
|
||||
.wn-input:focus, .wn-textarea:focus, .wn-select:focus { border-color: var(--ink); }
|
||||
.wn-input::placeholder, .wn-textarea::placeholder { color: var(--ink-5); }
|
||||
.wn-input[disabled], .wn-textarea[disabled], .wn-select[disabled] {
|
||||
background: var(--paper-2); color: var(--fg-3); cursor: not-allowed;
|
||||
}
|
||||
.wn-textarea { resize: vertical; min-height: 96px; font-family: var(--ff-sans); }
|
||||
.wn-select {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%235C5C5C' stroke-width='1.5'><path d='M1 1l4 4 4-4'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.wn-input--error, .wn-textarea--error, .wn-select--error {
|
||||
border-color: var(--ink); border-bottom-width: 2px;
|
||||
}
|
||||
.wn-form-help { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 6px; display: block; }
|
||||
.wn-form-error { font: 400 11px var(--ff-mono); color: var(--ink); margin-top: 6px; display: block; }
|
||||
|
||||
/* Search input — extracted from TopNav, also usable standalone */
|
||||
.wn-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-1);
|
||||
background: var(--paper);
|
||||
color: var(--fg-3);
|
||||
font: 400 12px var(--ff-mono);
|
||||
min-width: 200px;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.wn-search:focus-within { border-color: var(--ink); }
|
||||
.wn-search input {
|
||||
border: 0; outline: 0; background: none; flex: 1;
|
||||
font: inherit; color: var(--fg-1); padding: 0;
|
||||
}
|
||||
.wn-search input::placeholder { color: var(--ink-5); }
|
||||
.wn-search__kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ====== Breadcrumb ====== */
|
||||
.wn-breadcrumb {
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
gap: 6px;
|
||||
font: 400 12px/1.5 var(--ff-mono);
|
||||
color: var(--fg-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.wn-breadcrumb a {
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.wn-breadcrumb a:hover { color: var(--fg-1); border-bottom-color: var(--border-strong); }
|
||||
.wn-breadcrumb__sep { color: var(--ink-5); user-select: none; }
|
||||
.wn-breadcrumb__current { color: var(--fg-1); }
|
||||
|
||||
/* ====== Modal / Dialog ====== */
|
||||
.wn-modal__backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(10, 10, 10, 0.40);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
.wn-modal__panel {
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-3);
|
||||
box-shadow: var(--shadow-3);
|
||||
max-width: 560px; width: 100%;
|
||||
max-height: calc(100vh - 64px);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wn-modal__head {
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4);
|
||||
}
|
||||
.wn-modal__title { font: 500 20px/1.25 var(--ff-sans); margin: 0; color: var(--fg-1); }
|
||||
.wn-modal__close {
|
||||
background: none; border: 0; cursor: pointer; padding: 4px;
|
||||
color: var(--fg-3); border-radius: var(--r-1);
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.wn-modal__close:hover { color: var(--fg-1); }
|
||||
.wn-modal__body {
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
font: 400 15px/1.6 var(--ff-sans);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.wn-modal__foot {
|
||||
padding: var(--sp-4) var(--sp-6) var(--sp-5);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ====== Table ======
|
||||
* Note: shadow-DOM-rendered rows can't be children of a real <table> (the
|
||||
* HTML table model rejects unknown elements between <table> and <tr>). The
|
||||
* <wn-table> component therefore renders a CSS-grid imitation. For real
|
||||
* <table> markup (Django QuerySet rendering, etc.) use these classes
|
||||
* directly on <table>/<tr>/<td> elements — see also the .wn-table--native
|
||||
* variant below.
|
||||
*/
|
||||
|
||||
/* CSS-grid imitation (default <wn-table>) */
|
||||
.wn-table {
|
||||
width: 100%;
|
||||
font-size: var(--fs-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wn-table__thead { border-bottom: 1px solid var(--border); }
|
||||
.wn-table__tbody { display: flex; flex-direction: column; }
|
||||
.wn-table__tr {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.wn-table__tr:last-child { border-bottom: 0; }
|
||||
.wn-table__tr--head { border-bottom: 0; padding: var(--sp-3) var(--sp-4); }
|
||||
.wn-table__th {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-table__td {
|
||||
color: var(--fg-1);
|
||||
line-height: 1.5;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.wn-table--compact .wn-table__tr { padding: var(--sp-2) var(--sp-3); }
|
||||
.wn-table__cell--mono { font-family: var(--ff-mono); color: var(--fg-2); font-size: 12px; }
|
||||
.wn-table__cell--meta { color: var(--fg-3); font: 400 12px var(--ff-mono); }
|
||||
.wn-table__cell--right { text-align: right; }
|
||||
|
||||
/* Native <table> variant — for Django QuerySet rendering etc. */
|
||||
.wn-table--native {
|
||||
border-collapse: collapse;
|
||||
display: table;
|
||||
}
|
||||
.wn-table--native thead th {
|
||||
text-align: left;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-table--native tbody td {
|
||||
padding: var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
vertical-align: top;
|
||||
color: var(--fg-1);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wn-table--native tbody tr:hover { background: var(--paper-2); }
|
||||
.wn-table--native tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ====== Banner / Toast (success / info / warn) ====== */
|
||||
.wn-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-2);
|
||||
font: 400 14px/1.5 var(--ff-sans);
|
||||
color: var(--fg-1);
|
||||
position: relative;
|
||||
}
|
||||
.wn-banner__icon { color: var(--fg-2); flex: none; padding-top: 2px; }
|
||||
.wn-banner__body { flex: 1; }
|
||||
.wn-banner__title {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.wn-banner__dismiss {
|
||||
background: none; border: 0; cursor: pointer;
|
||||
color: var(--fg-3); padding: 4px;
|
||||
}
|
||||
.wn-banner__dismiss:hover { color: var(--fg-1); }
|
||||
.wn-banner--success { border-left: 2px solid var(--ink); }
|
||||
.wn-banner--warn { border-left: 2px solid var(--hi-2); background: #FFFCEB; }
|
||||
.wn-banner--error { border-left: 2px solid var(--ink); background: var(--paper); }
|
||||
.wn-banner--info { border-left: 2px solid var(--border-strong); }
|
||||
|
||||
.wn-toast-region {
|
||||
position: fixed;
|
||||
bottom: var(--sp-5); right: var(--sp-5);
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
z-index: 200;
|
||||
max-width: 380px;
|
||||
}
|
||||
.wn-toast { box-shadow: var(--shadow-3); }
|
||||
|
||||
/* ====== Empty state ====== */
|
||||
.wn-empty {
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-7);
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-empty__icon { color: var(--fg-3); margin-bottom: var(--sp-2); }
|
||||
.wn-empty__title { font: 500 14px var(--ff-sans); color: var(--fg-2); margin: 0; }
|
||||
.wn-empty__body { font: 400 13px/1.5 var(--ff-sans); color: var(--fg-3); max-width: 40ch; margin: 0; }
|
||||
.wn-empty__cta { margin-top: var(--sp-2); }
|
||||
|
||||
/* ====== Top navigation ====== */
|
||||
.wn-topnav {
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center;
|
||||
gap: var(--sp-6);
|
||||
padding: 0 var(--sp-5);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.wn-topnav__brand { display: flex; align-items: center; gap: 10px; font: 500 14px var(--ff-sans); }
|
||||
.wn-topnav__brand img { width: 22px; height: 22px; }
|
||||
.wn-topnav__brand-slug { font-family: var(--ff-mono); font-size: 12px; color: var(--fg-3); letter-spacing: 0.04em; }
|
||||
.wn-topnav__links { display: flex; gap: 22px; }
|
||||
.wn-topnav__link {
|
||||
font: 500 13px var(--ff-sans);
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.wn-topnav__link:hover { color: var(--fg-1); }
|
||||
.wn-topnav__link--active { color: var(--fg-1); border-bottom-color: var(--ink); }
|
||||
.wn-topnav__right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
/* ====== Sidebar ====== */
|
||||
.wn-sidebar {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
background: var(--paper-2);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
display: flex; flex-direction: column; gap: var(--sp-5);
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky; top: 56px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wn-sidebar__group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.wn-sidebar__group-label { padding-left: 12px; }
|
||||
.wn-sidebar__item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--fg-2);
|
||||
font: 500 13px var(--ff-sans);
|
||||
cursor: pointer; text-decoration: none;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.wn-sidebar__item:hover { color: var(--fg-1); }
|
||||
.wn-sidebar__item--active {
|
||||
color: var(--fg-1); background: var(--paper);
|
||||
box-shadow: 0 0 0 1px var(--border) inset;
|
||||
}
|
||||
.wn-sidebar__item--doc { font-family: var(--ff-mono); font-size: 12px; }
|
||||
.wn-sidebar__count { margin-left: auto; font: 400 11px var(--ff-mono); color: var(--fg-3); }
|
||||
.wn-sidebar__footer { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
|
||||
.wn-sidebar__activation {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
|
||||
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.wn-sidebar__activation-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--hi-2); }
|
||||
|
||||
/* ====== Page header ====== */
|
||||
.wn-page-header {
|
||||
margin-bottom: var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.wn-page-header__row { display: flex; align-items: flex-end; gap: var(--sp-5); }
|
||||
.wn-page-header__title {
|
||||
font: 500 32px/1.15 var(--ff-sans);
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0; flex: 1; color: var(--fg-1);
|
||||
}
|
||||
.wn-page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.wn-page-header__lede {
|
||||
font: 400 16px/1.55 var(--ff-sans);
|
||||
color: var(--fg-2);
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* ====== Pipeline ====== */
|
||||
.wn-pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
margin: 0 0 var(--sp-6);
|
||||
}
|
||||
.wn-pipeline__stage {
|
||||
padding: 10px 12px 14px;
|
||||
border-top: 2px solid var(--border);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
.wn-pipeline__stage--done { border-top-color: var(--ink); }
|
||||
.wn-pipeline__stage--active { border-top-color: var(--hi-2); }
|
||||
.wn-pipeline__num {
|
||||
font: 500 10px/1 var(--ff-mono); letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-pipeline__stage--done .wn-pipeline__num,
|
||||
.wn-pipeline__stage--active .wn-pipeline__num { color: var(--fg-1); }
|
||||
.wn-pipeline__name { font: 500 14px/1.25 var(--ff-sans); color: var(--fg-1); }
|
||||
.wn-pipeline__stage--pending .wn-pipeline__name { color: var(--fg-3); }
|
||||
.wn-pipeline__meta { font: 400 11px/1.35 var(--ff-mono); color: var(--fg-3); }
|
||||
.wn-pipeline__arrow {
|
||||
position: absolute; top: -8px; right: -7px;
|
||||
font: 400 14px var(--ff-mono); color: var(--ink-5);
|
||||
}
|
||||
.wn-pipeline__stage--done .wn-pipeline__arrow,
|
||||
.wn-pipeline__stage--active .wn-pipeline__arrow { color: var(--ink); }
|
||||
|
||||
/* ====== Prototype card (combined card variant) ====== */
|
||||
.wn-prototype-card { /* extends .wn-card */ }
|
||||
.wn-prototype-card__qrow {
|
||||
display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px;
|
||||
font-size: 13px; color: var(--fg-1);
|
||||
}
|
||||
.wn-prototype-card__qkey {
|
||||
font: 500 11px/1.5 var(--ff-mono);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-prototype-card__qval { line-height: 1.45; }
|
||||
|
||||
/* ====== Layout helpers ====== */
|
||||
.wn-main { padding: 40px 48px 80px; max-width: 1180px; }
|
||||
.wn-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
`;
|
||||
|
||||
let _sheet = null;
|
||||
export function getSharedSheet() {
|
||||
if (!_sheet) {
|
||||
_sheet = new CSSStyleSheet();
|
||||
_sheet.replaceSync(SHARED_CSS);
|
||||
}
|
||||
return _sheet;
|
||||
}
|
||||
164
src/elements/atoms.js
Normal file
164
src/elements/atoms.js
Normal file
@@ -0,0 +1,164 @@
|
||||
/* =============================================================
|
||||
* @whynot/design — atoms.js
|
||||
* ------------------------------------------------------------
|
||||
* <wn-button>, <wn-tag>, <wn-eyebrow>, <wn-stamp>,
|
||||
* <wn-stage-dot>, <wn-phase-dot>, <wn-icon>
|
||||
*
|
||||
* Shadow-DOM components. Each adopts the shared component
|
||||
* stylesheet so utility classes inside the shadow root work.
|
||||
* Token CSS variables cascade through shadow boundaries
|
||||
* because they're inherited properties.
|
||||
* ============================================================= */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { getSharedSheet } from "./_styles.js";
|
||||
import { ICON_PATHS } from "./icons.js";
|
||||
|
||||
class WnBase extends LitElement {
|
||||
static styles = [];
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Adopt the shared sheet on first connect, after super() has built the shadow root.
|
||||
const root = this.shadowRoot;
|
||||
if (root && !root.adoptedStyleSheets.includes(getSharedSheet())) {
|
||||
root.adoptedStyleSheets = [...root.adoptedStyleSheets, getSharedSheet()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-button> ---------- */
|
||||
export class WnButton extends WnBase {
|
||||
static properties = {
|
||||
variant: { type: String, reflect: true },
|
||||
size: { type: String, reflect: true },
|
||||
icon: { type: String },
|
||||
iconEnd: { type: String, attribute: "icon-end" },
|
||||
type: { type: String },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
href: { type: String },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.variant = "secondary";
|
||||
this.size = "md";
|
||||
this.type = "button";
|
||||
this.disabled = false;
|
||||
}
|
||||
render() {
|
||||
const cls = [
|
||||
"wn-btn",
|
||||
this.variant && this.variant !== "secondary" ? `wn-btn--${this.variant}` : "",
|
||||
this.size === "sm" ? "wn-btn--sm" : this.size === "lg" ? "wn-btn--lg" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const iconStart = this.icon
|
||||
? html`<wn-icon name=${this.icon} size="sm" class="wn-btn__icon"></wn-icon>`
|
||||
: nothing;
|
||||
const iconEnd = this.iconEnd
|
||||
? html`<wn-icon name=${this.iconEnd} size="sm" class="wn-btn__icon"></wn-icon>`
|
||||
: nothing;
|
||||
if (this.href) {
|
||||
return html`<a class=${cls} href=${this.href} part="button"
|
||||
aria-disabled=${this.disabled ? "true" : "false"}>${iconStart}<slot></slot>${iconEnd}</a>`;
|
||||
}
|
||||
return html`<button class=${cls} part="button"
|
||||
type=${this.type} ?disabled=${this.disabled}>${iconStart}<slot></slot>${iconEnd}</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-tag> ---------- */
|
||||
export class WnTag extends WnBase {
|
||||
static properties = {
|
||||
active: { type: Boolean, reflect: true },
|
||||
draft: { type: Boolean, reflect: true },
|
||||
};
|
||||
render() {
|
||||
const cls = ["wn-tag",
|
||||
this.active ? "wn-tag--active" : "",
|
||||
this.draft ? "wn-tag--draft" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return html`<span class=${cls} part="tag"><slot></slot></span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-eyebrow> ---------- */
|
||||
export class WnEyebrow extends WnBase {
|
||||
static properties = { strong: { type: Boolean, reflect: true } };
|
||||
render() {
|
||||
const cls = "wn-eyebrow" + (this.strong ? " wn-eyebrow--strong" : "");
|
||||
return html`<span class=${cls} part="eyebrow"><slot></slot></span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-stamp> ---------- */
|
||||
export class WnStamp extends WnBase {
|
||||
render() { return html`<span class="wn-stamp" part="stamp"><slot></slot></span>`; }
|
||||
}
|
||||
|
||||
/* ---------- <wn-stage-dot> ---------- */
|
||||
export class WnStageDot extends WnBase {
|
||||
static properties = {
|
||||
level: { type: String, reflect: true },
|
||||
label: { type: String },
|
||||
};
|
||||
constructor() { super(); this.level = "S2"; }
|
||||
render() {
|
||||
const lvl = String(this.level || "S2").toLowerCase();
|
||||
const cls = `wn-dot wn-stage-dot wn-stage-dot--${lvl}`;
|
||||
return html`
|
||||
<span class=${cls} part="root">
|
||||
<span class="wn-dot__bullet"></span>
|
||||
<slot>${this.label || this.level}</slot>
|
||||
</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-phase-dot> ---------- */
|
||||
export class WnPhaseDot extends WnBase {
|
||||
static properties = {
|
||||
state: { type: String, reflect: true },
|
||||
num: { type: String, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.state = "todo"; this.num = ""; }
|
||||
render() {
|
||||
const cls = `wn-phase-dot wn-phase-dot--${this.state}`;
|
||||
const glyph = this.state === "done" ? "✓" : this.num;
|
||||
return html`
|
||||
<span class=${cls} part="root">
|
||||
<span class="wn-phase-dot__bullet">${glyph}</span>
|
||||
<slot></slot>
|
||||
</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-icon> ---------- */
|
||||
export class WnIcon extends WnBase {
|
||||
static properties = {
|
||||
name: { type: String, reflect: true },
|
||||
size: { type: String, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.size = "md"; }
|
||||
render() {
|
||||
const path = ICON_PATHS[this.name];
|
||||
const cls = `wn-icon wn-icon--${this.size || "md"}`;
|
||||
if (!path) {
|
||||
return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true" part="svg">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor"/>
|
||||
</svg>`;
|
||||
}
|
||||
return html`<svg class=${cls} viewBox="0 0 24 24" aria-hidden="true" part="svg">${
|
||||
path.map(d => html`<path d=${d}></path>`)
|
||||
}</svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function defineAtoms() {
|
||||
if (!customElements.get("wn-button")) customElements.define("wn-button", WnButton);
|
||||
if (!customElements.get("wn-tag")) customElements.define("wn-tag", WnTag);
|
||||
if (!customElements.get("wn-eyebrow")) customElements.define("wn-eyebrow", WnEyebrow);
|
||||
if (!customElements.get("wn-stamp")) customElements.define("wn-stamp", WnStamp);
|
||||
if (!customElements.get("wn-stage-dot")) customElements.define("wn-stage-dot", WnStageDot);
|
||||
if (!customElements.get("wn-phase-dot")) customElements.define("wn-phase-dot", WnPhaseDot);
|
||||
if (!customElements.get("wn-icon")) customElements.define("wn-icon", WnIcon);
|
||||
}
|
||||
|
||||
export { WnBase };
|
||||
206
src/elements/chrome.js
Normal file
206
src/elements/chrome.js
Normal file
@@ -0,0 +1,206 @@
|
||||
/* =============================================================
|
||||
* @whynot/design — chrome.js
|
||||
* ------------------------------------------------------------
|
||||
* <wn-top-nav>, <wn-sidebar> / <wn-sidebar-group> /
|
||||
* <wn-sidebar-item>, <wn-page-header>, <wn-pipeline>,
|
||||
* <wn-prototype-card>
|
||||
* ============================================================= */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { WnBase } from "./atoms.js";
|
||||
|
||||
/* ---------- <wn-top-nav> ---------- */
|
||||
export class WnTopNav extends WnBase {
|
||||
static properties = {
|
||||
logoSrc: { type: String, attribute: "logo-src" },
|
||||
brand: { type: String },
|
||||
slug: { type: String },
|
||||
};
|
||||
constructor() { super(); this.brand = "whynot"; this.slug = "control"; }
|
||||
render() {
|
||||
return html`
|
||||
<nav class="wn-topnav" part="nav">
|
||||
<div class="wn-topnav__brand">
|
||||
${this.logoSrc ? html`<img src=${this.logoSrc} alt="">` : nothing}
|
||||
<span>${this.brand}</span>
|
||||
${this.slug ? html`<span class="wn-topnav__brand-slug">/ ${this.slug}</span>` : nothing}
|
||||
</div>
|
||||
<div class="wn-topnav__links"><slot name="links"></slot></div>
|
||||
<div class="wn-topnav__right"><slot name="right"></slot></div>
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-sidebar> ---------- */
|
||||
export class WnSidebar extends WnBase {
|
||||
static properties = { activation: { type: String } };
|
||||
render() {
|
||||
return html`
|
||||
<aside class="wn-sidebar" part="sidebar">
|
||||
<slot></slot>
|
||||
${this.activation
|
||||
? html`<div class="wn-sidebar__footer">
|
||||
<div class="wn-sidebar__activation">
|
||||
<span class="wn-sidebar__activation-dot"></span>
|
||||
<span>${this.activation}</span>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WnSidebarGroup extends WnBase {
|
||||
static properties = { label: { type: String } };
|
||||
render() {
|
||||
return html`
|
||||
<div class="wn-sidebar__group" part="group">
|
||||
${this.label ? html`<wn-eyebrow class="wn-sidebar__group-label">${this.label}</wn-eyebrow>` : nothing}
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WnSidebarItem extends WnBase {
|
||||
static properties = {
|
||||
href: { type: String },
|
||||
icon: { type: String },
|
||||
active: { type: Boolean, reflect: true },
|
||||
count: { type: String },
|
||||
variant: { type: String, reflect: true },
|
||||
};
|
||||
render() {
|
||||
const cls = ["wn-sidebar__item",
|
||||
this.active ? "wn-sidebar__item--active" : "",
|
||||
this.variant === "doc" ? "wn-sidebar__item--doc" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const inner = html`
|
||||
${this.icon ? html`<wn-icon name=${this.icon} size=${this.variant === "doc" ? "sm" : "md"}></wn-icon>` : nothing}
|
||||
<slot></slot>
|
||||
${this.count ? html`<span class="wn-sidebar__count">${this.count}</span>` : nothing}
|
||||
`;
|
||||
return this.href
|
||||
? html`<a class=${cls} href=${this.href} part="item">${inner}</a>`
|
||||
: html`<div class=${cls} part="item">${inner}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-page-header> ---------- */
|
||||
export class WnPageHeader extends WnBase {
|
||||
static properties = {
|
||||
eyebrow: { type: String },
|
||||
title: { type: String },
|
||||
lede: { type: String },
|
||||
hasActions: { state: true },
|
||||
};
|
||||
constructor() { super(); this.hasActions = false; }
|
||||
_onSlot() { this.hasActions = !!this.querySelector('[slot="actions"]'); }
|
||||
render() {
|
||||
return html`
|
||||
<header class="wn-page-header" part="root">
|
||||
${this.eyebrow ? html`<wn-eyebrow>${this.eyebrow}</wn-eyebrow>` : nothing}
|
||||
<slot name="eyebrow"></slot>
|
||||
<div class="wn-page-header__row">
|
||||
<h1 class="wn-page-header__title">${this.title}<slot name="title"></slot></h1>
|
||||
<div class="wn-page-header__actions" ?hidden=${!this.hasActions}>
|
||||
<slot name="actions" @slotchange=${this._onSlot}></slot>
|
||||
</div>
|
||||
</div>
|
||||
${this.lede ? html`<p class="wn-page-header__lede">${this.lede}</p>` : nothing}
|
||||
<slot name="lede"></slot>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-pipeline> ---------- */
|
||||
export class WnPipeline extends WnBase {
|
||||
static properties = {
|
||||
stages: { type: Array },
|
||||
activeIdx: { type: Number, attribute: "active-idx" },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.stages = [
|
||||
{ num: "Stage 0", name: "Raw idea", meta: "inbox/" },
|
||||
{ num: "Stage 1", name: "Triage", meta: "" },
|
||||
{ num: "Stage 2", name: "Prototype card", meta: "prototypes/" },
|
||||
{ num: "Stage 3", name: "Experiment", meta: "" },
|
||||
{ num: "Stage 4", name: "Signal review", meta: "" },
|
||||
];
|
||||
this.activeIdx = 0;
|
||||
}
|
||||
render() {
|
||||
return html`
|
||||
<div class="wn-pipeline" part="root">
|
||||
${this.stages.map((s, i) => {
|
||||
const state = i < this.activeIdx ? "done" : i === this.activeIdx ? "active" : "pending";
|
||||
const cls = `wn-pipeline__stage wn-pipeline__stage--${state}`;
|
||||
return html`
|
||||
<div class=${cls}>
|
||||
<span class="wn-pipeline__num">${s.num}</span>
|
||||
<span class="wn-pipeline__name">${s.name}</span>
|
||||
${s.meta ? html`<span class="wn-pipeline__meta">${s.meta}</span>` : nothing}
|
||||
${i > 0 ? html`<span class="wn-pipeline__arrow">→</span>` : nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-prototype-card> ---------- */
|
||||
export class WnPrototypeCard extends WnBase {
|
||||
static properties = {
|
||||
cardId: { type: String, attribute: "card-id", reflect: true },
|
||||
signal: { type: String, reflect: true },
|
||||
stageLabel: { type: String, attribute: "stage-label" },
|
||||
href: { type: String },
|
||||
};
|
||||
constructor() { super(); this.signal = "S1"; }
|
||||
_onClick() {
|
||||
if (this.href) window.location.href = this.href;
|
||||
this.dispatchEvent(new CustomEvent("wn-open", { detail: { id: this.cardId }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
const clickable = !!this.href;
|
||||
const cls = "wn-card wn-prototype-card" + (clickable ? " wn-card--clickable" : "");
|
||||
return html`
|
||||
<article class=${cls}
|
||||
role=${clickable ? "button" : nothing}
|
||||
tabindex=${clickable ? "0" : nothing}
|
||||
@click=${clickable ? this._onClick : nothing}
|
||||
part="card">
|
||||
<header class="wn-card__head">
|
||||
<wn-eyebrow>${this.cardId ? this.cardId + " · " : ""}Prototype</wn-eyebrow>
|
||||
<wn-stage-dot level=${this.signal}>${this.stageLabel || this.signal}</wn-stage-dot>
|
||||
</header>
|
||||
<h3 class="wn-card__title"><slot name="pitch"></slot></h3>
|
||||
<div class="wn-prototype-card__qrow">
|
||||
<span class="wn-prototype-card__qkey">Learning q.</span>
|
||||
<span class="wn-prototype-card__qval"><slot name="learning"></slot></span>
|
||||
<span class="wn-prototype-card__qkey">Smallest test</span>
|
||||
<span class="wn-prototype-card__qval"><slot name="test"></slot></span>
|
||||
</div>
|
||||
<footer class="wn-card__foot">
|
||||
<span><slot name="target"></slot></span>
|
||||
<span>${this.signal} signal</span>
|
||||
</footer>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function defineChrome() {
|
||||
if (!customElements.get("wn-top-nav")) customElements.define("wn-top-nav", WnTopNav);
|
||||
if (!customElements.get("wn-sidebar")) customElements.define("wn-sidebar", WnSidebar);
|
||||
if (!customElements.get("wn-sidebar-group")) customElements.define("wn-sidebar-group", WnSidebarGroup);
|
||||
if (!customElements.get("wn-sidebar-item")) customElements.define("wn-sidebar-item", WnSidebarItem);
|
||||
if (!customElements.get("wn-page-header")) customElements.define("wn-page-header", WnPageHeader);
|
||||
if (!customElements.get("wn-pipeline")) customElements.define("wn-pipeline", WnPipeline);
|
||||
if (!customElements.get("wn-prototype-card")) customElements.define("wn-prototype-card", WnPrototypeCard);
|
||||
}
|
||||
205
src/elements/form.js
Normal file
205
src/elements/form.js
Normal file
@@ -0,0 +1,205 @@
|
||||
/* =============================================================
|
||||
* @whynot/design — form.js
|
||||
* ------------------------------------------------------------
|
||||
* <wn-input>, <wn-textarea>, <wn-select>,
|
||||
* <wn-search-input>, <wn-field-row>
|
||||
*
|
||||
* Each wraps a real native element. Form participation works
|
||||
* because the native input is part of the light DOM via the
|
||||
* `name` attribute being copied through; for richer integration
|
||||
* use ElementInternals (deferred — see CHANGELOG).
|
||||
* ============================================================= */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { WnBase } from "./atoms.js";
|
||||
|
||||
/* ---------- <wn-input> ---------- */
|
||||
export class WnInput extends WnBase {
|
||||
static properties = {
|
||||
name: { type: String, reflect: true },
|
||||
type: { type: String, reflect: true },
|
||||
value: { type: String },
|
||||
placeholder: { type: String },
|
||||
required: { type: Boolean, reflect: true },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
readonly: { type: Boolean, reflect: true },
|
||||
autocomplete:{ type: String },
|
||||
error: { type: Boolean, reflect: true },
|
||||
help: { type: String },
|
||||
errorText: { type: String, attribute: "error-text" },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.type = "text";
|
||||
this.value = "";
|
||||
this.required = false;
|
||||
this.disabled = false;
|
||||
this.readonly = false;
|
||||
this.error = false;
|
||||
}
|
||||
_onInput(e) {
|
||||
this.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
const cls = "wn-input" + (this.error ? " wn-input--error" : "");
|
||||
return html`
|
||||
<input class=${cls}
|
||||
part="input"
|
||||
name=${this.name ?? nothing}
|
||||
type=${this.type}
|
||||
.value=${this.value}
|
||||
placeholder=${this.placeholder ?? nothing}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
?readonly=${this.readonly}
|
||||
autocomplete=${this.autocomplete ?? nothing}
|
||||
@input=${this._onInput}>
|
||||
${this.error && this.errorText
|
||||
? html`<span class="wn-form-error">${this.errorText}</span>`
|
||||
: this.help
|
||||
? html`<span class="wn-form-help">${this.help}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-textarea> ---------- */
|
||||
export class WnTextarea extends WnBase {
|
||||
static properties = {
|
||||
name: { type: String, reflect: true },
|
||||
value: { type: String },
|
||||
placeholder: { type: String },
|
||||
rows: { type: Number },
|
||||
required: { type: Boolean, reflect: true },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
error: { type: Boolean, reflect: true },
|
||||
help: { type: String },
|
||||
errorText: { type: String, attribute: "error-text" },
|
||||
};
|
||||
constructor() { super(); this.value = ""; this.rows = 4; }
|
||||
_onInput(e) {
|
||||
this.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
const cls = "wn-textarea" + (this.error ? " wn-textarea--error" : "");
|
||||
return html`
|
||||
<textarea class=${cls}
|
||||
part="textarea"
|
||||
name=${this.name ?? nothing}
|
||||
rows=${this.rows}
|
||||
placeholder=${this.placeholder ?? nothing}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
@input=${this._onInput}
|
||||
.value=${this.value}></textarea>
|
||||
${this.error && this.errorText
|
||||
? html`<span class="wn-form-error">${this.errorText}</span>`
|
||||
: this.help
|
||||
? html`<span class="wn-form-help">${this.help}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-select> ----------
|
||||
* Slot <option> elements; they're cloned into the inner <select>. */
|
||||
export class WnSelect extends WnBase {
|
||||
static properties = {
|
||||
name: { type: String, reflect: true },
|
||||
value: { type: String },
|
||||
required: { type: Boolean, reflect: true },
|
||||
disabled: { type: Boolean, reflect: true },
|
||||
error: { type: Boolean, reflect: true },
|
||||
help: { type: String },
|
||||
};
|
||||
_onSlotChange(e) {
|
||||
const slot = e.target;
|
||||
const select = this.shadowRoot?.querySelector("select.wn-select");
|
||||
if (!select) return;
|
||||
const options = slot.assignedElements({ flatten: true }).filter(el => el.tagName === "OPTION");
|
||||
select.innerHTML = "";
|
||||
for (const opt of options) select.appendChild(opt.cloneNode(true));
|
||||
if (this.value != null) select.value = this.value;
|
||||
}
|
||||
_onChange(e) {
|
||||
this.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent("wn-change", { detail: { value: this.value }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
const cls = "wn-select" + (this.error ? " wn-select--error" : "");
|
||||
return html`
|
||||
<select class=${cls}
|
||||
part="select"
|
||||
name=${this.name ?? nothing}
|
||||
?required=${this.required}
|
||||
?disabled=${this.disabled}
|
||||
@change=${this._onChange}></select>
|
||||
<slot @slotchange=${this._onSlotChange} style="display:none"></slot>
|
||||
${this.help ? html`<span class="wn-form-help">${this.help}</span>` : nothing}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-search-input> ---------- */
|
||||
export class WnSearchInput extends WnBase {
|
||||
static properties = {
|
||||
placeholder: { type: String },
|
||||
kbd: { type: String },
|
||||
value: { type: String },
|
||||
name: { type: String, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.placeholder = "Search…"; this.kbd = "⌘ K"; this.value = ""; }
|
||||
_onInput(e) {
|
||||
this.value = e.target.value;
|
||||
this.dispatchEvent(new CustomEvent("wn-input", { detail: { value: this.value }, bubbles: true, composed: true }));
|
||||
}
|
||||
render() {
|
||||
return html`
|
||||
<label class="wn-search" part="root">
|
||||
<wn-icon name="search" size="sm"></wn-icon>
|
||||
<input type="search"
|
||||
name=${this.name ?? nothing}
|
||||
.value=${this.value}
|
||||
placeholder=${this.placeholder}
|
||||
@input=${this._onInput}>
|
||||
${this.kbd ? html`<span class="wn-search__kbd">${this.kbd}</span>` : nothing}
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-field-row> ---------- */
|
||||
export class WnFieldRow extends WnBase {
|
||||
static properties = {
|
||||
label: { type: String },
|
||||
aside: { type: String },
|
||||
stacked: { type: Boolean, reflect: true },
|
||||
narrow: { type: Boolean, reflect: true },
|
||||
htmlFor: { type: String, attribute: "for" },
|
||||
};
|
||||
render() {
|
||||
const cls = ["wn-field-row",
|
||||
this.stacked ? "wn-field-row--stacked" : "",
|
||||
this.narrow ? "wn-field-row--narrow" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return html`
|
||||
<div class=${cls} part="root">
|
||||
<label class="wn-field-row__label" for=${this.htmlFor ?? nothing}>${this.label}</label>
|
||||
<div class="wn-field-row__value"><slot></slot></div>
|
||||
${this.aside
|
||||
? html`<div class="wn-field-row__aside">${this.aside}<slot name="aside"></slot></div>`
|
||||
: html`<div class="wn-field-row__aside"><slot name="aside"></slot></div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function defineForm() {
|
||||
if (!customElements.get("wn-input")) customElements.define("wn-input", WnInput);
|
||||
if (!customElements.get("wn-textarea")) customElements.define("wn-textarea", WnTextarea);
|
||||
if (!customElements.get("wn-select")) customElements.define("wn-select", WnSelect);
|
||||
if (!customElements.get("wn-search-input")) customElements.define("wn-search-input", WnSearchInput);
|
||||
if (!customElements.get("wn-field-row")) customElements.define("wn-field-row", WnFieldRow);
|
||||
}
|
||||
45
src/elements/icons.js
Normal file
45
src/elements/icons.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/* =============================================================
|
||||
* @whynot/design — icons.js
|
||||
* ------------------------------------------------------------
|
||||
* Inline Lucide-style icon paths (24×24, stroke 1.5, fill none).
|
||||
* Only the icons used by the system ship here; consumers can
|
||||
* extend by importing extras directly from `lucide`.
|
||||
*
|
||||
* Each value is an array of `d` attributes — multi-path icons
|
||||
* are rendered as multiple <path> elements.
|
||||
*
|
||||
* Paths derived from Lucide (ISC). If you need an icon not in
|
||||
* this list, add it here, not in a consuming repo.
|
||||
* ============================================================= */
|
||||
|
||||
/* prettier-ignore */
|
||||
export const ICON_PATHS = {
|
||||
/* Navigation */
|
||||
"inbox": ["M22 12h-6l-2 3h-4l-2-3H2", "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"],
|
||||
"lightbulb": ["M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5", "M9 18h6", "M10 22h4"],
|
||||
"flask-conical": ["M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2", "M6.453 15h11.094", "M8.5 2h7"],
|
||||
"activity": ["M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.5.5 0 0 1-.96 0L9.24 2.18a.5.5 0 0 0-.96 0l-2.35 8.36A2 2 0 0 1 4 12H2"],
|
||||
"users": ["M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", "M22 21v-2a4 4 0 0 0-3-3.87", "M16 3.13a4 4 0 0 1 0 7.75", "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"],
|
||||
"git-branch": ["M6 3v12", "M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", "M15 6a9 9 0 0 0-9 9"],
|
||||
"check-square": ["M9 11l3 3L22 4", "M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"],
|
||||
"archive": ["M21 8v13H3V8", "M1 3h22v5H1z", "M10 12h4"],
|
||||
"file-text": ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z", "M14 2v5h6", "M16 13H8", "M16 17H8", "M10 9H8"],
|
||||
"folder": ["M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"],
|
||||
|
||||
/* Actions / signals */
|
||||
"arrow-right": ["M5 12h14", "M12 5l7 7-7 7"],
|
||||
"arrow-left": ["M19 12H5", "M12 19l-7-7 7-7"],
|
||||
"plus": ["M12 5v14", "M5 12h14"],
|
||||
"x": ["M18 6L6 18", "M6 6l12 12"],
|
||||
"check": ["M20 6 9 17l-5-5"],
|
||||
"search": ["M21 21l-4.34-4.34", "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"],
|
||||
"filter": ["M22 3H2l8 9.46V19l4 2v-8.54L22 3z"],
|
||||
"circle-help": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3", "M12 17h.01"],
|
||||
"circle-alert": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M12 8v4", "M12 16h.01"],
|
||||
"circle-check": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M9 12l2 2 4-4"],
|
||||
"circle-info": ["M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z", "M12 16v-4", "M12 8h.01"],
|
||||
"settings": ["M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z", "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"],
|
||||
"more-horizontal": ["M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"],
|
||||
"chevron-down": ["M6 9l6 6 6-6"],
|
||||
"chevron-right": ["M9 18l6-6-6-6"],
|
||||
};
|
||||
277
src/elements/layout.js
Normal file
277
src/elements/layout.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/* =============================================================
|
||||
* @whynot/design — layout.js
|
||||
* ------------------------------------------------------------
|
||||
* <wn-card>, <wn-modal>, <wn-table> / <wn-table-row> /
|
||||
* <wn-table-cell>, <wn-banner>, <wn-toast>, <wn-toast-region>,
|
||||
* <wn-empty-state>, <wn-breadcrumb>
|
||||
* ============================================================= */
|
||||
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { WnBase } from "./atoms.js";
|
||||
|
||||
/* ---------- <wn-card> ---------- */
|
||||
export class WnCard extends WnBase {
|
||||
static properties = {
|
||||
variant: { type: String, reflect: true },
|
||||
size: { type: String, reflect: true },
|
||||
clickable: { type: Boolean, reflect: true },
|
||||
hasHeader: { state: true },
|
||||
hasFooter: { state: true },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.hasHeader = false;
|
||||
this.hasFooter = false;
|
||||
}
|
||||
_onSlotChange() {
|
||||
this.hasHeader = !!this.querySelector('[slot="header"]');
|
||||
this.hasFooter = !!this.querySelector('[slot="footer"]');
|
||||
}
|
||||
render() {
|
||||
const cls = ["wn-card",
|
||||
this.variant && this.variant !== "default" ? `wn-card--${this.variant}` : "",
|
||||
this.size === "sm" ? "wn-card--sm" : this.size === "lg" ? "wn-card--lg" : "",
|
||||
this.clickable ? "wn-card--clickable" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return html`
|
||||
<div class=${cls}
|
||||
role=${this.clickable ? "button" : nothing}
|
||||
tabindex=${this.clickable ? "0" : nothing}
|
||||
part="card">
|
||||
<header class="wn-card__head" ?hidden=${!this.hasHeader}>
|
||||
<slot name="header" @slotchange=${this._onSlotChange}></slot>
|
||||
</header>
|
||||
<slot @slotchange=${this._onSlotChange}></slot>
|
||||
<footer class="wn-card__foot" ?hidden=${!this.hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
|
||||
</footer>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-modal> ---------- */
|
||||
export class WnModal extends WnBase {
|
||||
static properties = {
|
||||
open: { type: Boolean, reflect: true },
|
||||
title: { type: String },
|
||||
dismissible: { type: Boolean, reflect: true },
|
||||
hasFooter: { state: true },
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.open = false;
|
||||
this.dismissible = true;
|
||||
this.hasFooter = false;
|
||||
}
|
||||
_onSlotChange() {
|
||||
this.hasFooter = !!this.querySelector('[slot="footer"]');
|
||||
}
|
||||
_onBackdrop(e) {
|
||||
if (e.target === e.currentTarget && this.dismissible) this._dismiss();
|
||||
}
|
||||
_dismiss() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
|
||||
}
|
||||
_bindKey = (e) => {
|
||||
if (e.key === "Escape" && this.dismissible && this.open) this._dismiss();
|
||||
};
|
||||
connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this._bindKey); }
|
||||
disconnectedCallback() { document.removeEventListener("keydown", this._bindKey); super.disconnectedCallback(); }
|
||||
|
||||
render() {
|
||||
if (!this.open) return nothing;
|
||||
return html`
|
||||
<div class="wn-modal__backdrop" @click=${this._onBackdrop} role="presentation" part="backdrop">
|
||||
<div class="wn-modal__panel" role="dialog" aria-modal="true" aria-label=${this.title ?? nothing} part="panel">
|
||||
<header class="wn-modal__head">
|
||||
<h2 class="wn-modal__title">${this.title}<slot name="title"></slot></h2>
|
||||
${this.dismissible
|
||||
? html`<button class="wn-modal__close" type="button" aria-label="Close" @click=${this._dismiss}>
|
||||
<wn-icon name="x" size="md"></wn-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
</header>
|
||||
<div class="wn-modal__body"><slot @slotchange=${this._onSlotChange}></slot></div>
|
||||
<footer class="wn-modal__foot" ?hidden=${!this.hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-table>, <wn-table-row>, <wn-table-cell> ----------
|
||||
* Tables in shadow DOM can't render real <table>/<tr> with slotted rows —
|
||||
* the table model requires the row to be a child of <table>. So these
|
||||
* components use CSS grid + flexbox to imitate a table visually. For real
|
||||
* <table> + Django QuerySet rendering, write raw <table class="wn-table">
|
||||
* markup directly using utility classes.
|
||||
*/
|
||||
export class WnTable extends WnBase {
|
||||
static properties = {
|
||||
columns: { type: Array },
|
||||
compact: { type: Boolean, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.columns = []; }
|
||||
render() {
|
||||
const cols = this.columns || [];
|
||||
const cls = "wn-table" + (this.compact ? " wn-table--compact" : "");
|
||||
return html`
|
||||
<div class=${cls} part="table" role="table">
|
||||
${cols.length
|
||||
? html`<div class="wn-table__thead" role="rowgroup">
|
||||
<div class="wn-table__tr wn-table__tr--head" role="row"
|
||||
style=${`grid-template-columns: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`}>
|
||||
${cols.map(c => html`<div class="wn-table__th" role="columnheader">${typeof c === "string" ? c : c.label}</div>`)}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="wn-table__tbody" role="rowgroup"
|
||||
style=${cols.length
|
||||
? `--wn-cols: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`
|
||||
: nothing}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WnTableRow extends WnBase {
|
||||
render() {
|
||||
return html`<div class="wn-table__tr" role="row" part="row"
|
||||
style="grid-template-columns: var(--wn-cols, repeat(auto-fit, minmax(80px, 1fr)));">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WnTableCell extends WnBase {
|
||||
static properties = { variant: { type: String, reflect: true } };
|
||||
render() {
|
||||
const cls = "wn-table__td" + (this.variant ? ` wn-table__cell--${this.variant}` : "");
|
||||
return html`<div class=${cls} role="cell" part="cell"><slot></slot></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-banner> ---------- */
|
||||
export class WnBanner extends WnBase {
|
||||
static properties = {
|
||||
variant: { type: String, reflect: true },
|
||||
title: { type: String },
|
||||
icon: { type: String },
|
||||
dismissible: { type: Boolean, reflect: true },
|
||||
};
|
||||
constructor() { super(); this.variant = "info"; }
|
||||
_dismiss() {
|
||||
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
|
||||
this.remove();
|
||||
}
|
||||
render() {
|
||||
const iconName = this.icon || ({
|
||||
info: "circle-info", success: "circle-check",
|
||||
warn: "circle-alert", error: "circle-alert",
|
||||
}[this.variant]);
|
||||
const cls = `wn-banner wn-banner--${this.variant}`;
|
||||
return html`
|
||||
<div class=${cls} role=${this.variant === "error" || this.variant === "warn" ? "alert" : "status"} part="banner">
|
||||
${iconName ? html`<span class="wn-banner__icon"><wn-icon name=${iconName} size="md"></wn-icon></span>` : nothing}
|
||||
<div class="wn-banner__body">
|
||||
${this.title ? html`<p class="wn-banner__title">${this.title}</p>` : nothing}
|
||||
<slot></slot>
|
||||
</div>
|
||||
${this.dismissible
|
||||
? html`<button class="wn-banner__dismiss" type="button" aria-label="Dismiss" @click=${this._dismiss}>
|
||||
<wn-icon name="x" size="sm"></wn-icon>
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-toast> / <wn-toast-region> ---------- */
|
||||
export class WnToast extends WnBanner {
|
||||
constructor() { super(); this.dismissible = true; }
|
||||
render() {
|
||||
const base = super.render();
|
||||
return html`<div class="wn-toast" part="toast">${base}</div>`;
|
||||
}
|
||||
}
|
||||
export class WnToastRegion extends WnBase {
|
||||
render() {
|
||||
return html`<div class="wn-toast-region" role="region" aria-label="Notifications" part="root">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-empty-state> ---------- */
|
||||
export class WnEmptyState extends WnBase {
|
||||
static properties = {
|
||||
icon: { type: String },
|
||||
title: { type: String },
|
||||
hasCta: { state: true },
|
||||
};
|
||||
constructor() { super(); this.hasCta = false; }
|
||||
_onSlot() { this.hasCta = !!this.querySelector('[slot="cta"]'); }
|
||||
render() {
|
||||
return html`
|
||||
<div class="wn-empty" part="empty">
|
||||
${this.icon ? html`<wn-icon class="wn-empty__icon" name=${this.icon} size="lg"></wn-icon>` : nothing}
|
||||
${this.title ? html`<p class="wn-empty__title">${this.title}</p>` : nothing}
|
||||
<p class="wn-empty__body"><slot @slotchange=${this._onSlot}></slot></p>
|
||||
<div class="wn-empty__cta" ?hidden=${!this.hasCta}>
|
||||
<slot name="cta" @slotchange=${this._onSlot}></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- <wn-breadcrumb> ---------- */
|
||||
export class WnBreadcrumb extends WnBase {
|
||||
_onSlot(e) {
|
||||
const slot = e.target;
|
||||
const items = slot.assignedElements({ flatten: true });
|
||||
// Build the rendered tree: each item + a separator after it.
|
||||
const wrapper = this.shadowRoot?.querySelector('.wn-breadcrumb__list');
|
||||
if (!wrapper) return;
|
||||
wrapper.querySelectorAll('.wn-breadcrumb__sep').forEach(s => s.remove());
|
||||
items.forEach((el, i) => {
|
||||
el.classList.toggle("wn-breadcrumb__current", i === items.length - 1);
|
||||
if (i > 0) {
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "wn-breadcrumb__sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
sep.textContent = "/";
|
||||
// Use light-DOM-relative insertion: items are still in light DOM,
|
||||
// so DOM-order separators between them belong in light DOM too.
|
||||
el.parentNode.insertBefore(sep, el);
|
||||
}
|
||||
});
|
||||
}
|
||||
render() {
|
||||
return html`
|
||||
<nav class="wn-breadcrumb" aria-label="Breadcrumb" part="root">
|
||||
<span class="wn-breadcrumb__list"><slot @slotchange=${this._onSlot}></slot></span>
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export function defineLayout() {
|
||||
if (!customElements.get("wn-card")) customElements.define("wn-card", WnCard);
|
||||
if (!customElements.get("wn-modal")) customElements.define("wn-modal", WnModal);
|
||||
if (!customElements.get("wn-table")) customElements.define("wn-table", WnTable);
|
||||
if (!customElements.get("wn-table-row")) customElements.define("wn-table-row", WnTableRow);
|
||||
if (!customElements.get("wn-table-cell")) customElements.define("wn-table-cell", WnTableCell);
|
||||
if (!customElements.get("wn-banner")) customElements.define("wn-banner", WnBanner);
|
||||
if (!customElements.get("wn-toast")) customElements.define("wn-toast", WnToast);
|
||||
if (!customElements.get("wn-toast-region")) customElements.define("wn-toast-region", WnToastRegion);
|
||||
if (!customElements.get("wn-empty-state")) customElements.define("wn-empty-state", WnEmptyState);
|
||||
if (!customElements.get("wn-breadcrumb")) customElements.define("wn-breadcrumb", WnBreadcrumb);
|
||||
}
|
||||
49
src/index.js
49
src/index.js
@@ -1,18 +1,35 @@
|
||||
// @whynot/design — barrel export.
|
||||
//
|
||||
// At A1 there is no build step: consumers import these JSX files directly.
|
||||
// Any modern bundler (Vite, Next.js, Webpack 5 with @babel/preset-react,
|
||||
// esbuild, Bun) handles JSX-in-.jsx out of the box.
|
||||
//
|
||||
// If you need to support a bundler that doesn't, fall back to either
|
||||
// (a) importing from `examples/whynot-control/` as inline <script type="text/babel">
|
||||
// or (b) adding a build step here when you next bump minor.
|
||||
/* =============================================================
|
||||
* @whynot/design — entry point
|
||||
* ------------------------------------------------------------
|
||||
* Side-effect import that registers every <wn-*> custom element.
|
||||
*
|
||||
* import "@whynot/design";
|
||||
*
|
||||
* If you only need a subset, import the per-group files instead:
|
||||
*
|
||||
* import "@whynot/design/atoms";
|
||||
* import "@whynot/design/form";
|
||||
* import "@whynot/design/layout";
|
||||
* import "@whynot/design/chrome";
|
||||
*
|
||||
* CSS is imported separately:
|
||||
*
|
||||
* import "@whynot/design/styles/colors_and_type.css";
|
||||
* import "@whynot/design/styles/components.css";
|
||||
* ============================================================= */
|
||||
|
||||
export * from "./components/Atoms.jsx";
|
||||
export * from "./components/Chrome.jsx";
|
||||
import { defineAtoms } from "./elements/atoms.js";
|
||||
import { defineForm } from "./elements/form.js";
|
||||
import { defineLayout } from "./elements/layout.js";
|
||||
import { defineChrome } from "./elements/chrome.js";
|
||||
|
||||
// CSS is exported as a side-effect import:
|
||||
//
|
||||
// import "@whynot/design/styles/colors_and_type.css";
|
||||
//
|
||||
// Do this once, at the app root.
|
||||
defineAtoms();
|
||||
defineForm();
|
||||
defineLayout();
|
||||
defineChrome();
|
||||
|
||||
// Re-export classes for consumers that want to extend or reference them.
|
||||
export * from "./elements/atoms.js";
|
||||
export * from "./elements/form.js";
|
||||
export * from "./elements/layout.js";
|
||||
export * from "./elements/chrome.js";
|
||||
|
||||
590
src/styles/components.css
Normal file
590
src/styles/components.css
Normal file
@@ -0,0 +1,590 @@
|
||||
/* ============================================================
|
||||
WhyNot Design System — Component Styles
|
||||
------------------------------------------------------------
|
||||
Utility classes that the Lit web components render to. These
|
||||
are also consumable directly from any HTML (no JS required)
|
||||
for the "Layer 1 only" use case — see MultiFrameworkSupport.md.
|
||||
============================================================ */
|
||||
|
||||
/* ====== Custom-element display defaults ======
|
||||
* For shadow-DOM components, the wn-* host has display: inline by default.
|
||||
* Set sensible defaults so layout works without the consumer specifying them.
|
||||
*/
|
||||
wn-eyebrow, wn-tag, wn-stage-dot, wn-phase-dot, wn-stamp, wn-icon,
|
||||
wn-search-input, wn-button { display: inline-block; }
|
||||
|
||||
wn-card, wn-modal, wn-top-nav, wn-sidebar, wn-page-header,
|
||||
wn-pipeline, wn-prototype-card, wn-field-row, wn-breadcrumb,
|
||||
wn-table, wn-banner, wn-empty-state,
|
||||
wn-input, wn-textarea, wn-select { display: block; }
|
||||
|
||||
wn-toast-region { display: block; }
|
||||
wn-toast { display: block; }
|
||||
|
||||
wn-sidebar-group, wn-sidebar-item { display: block; }
|
||||
wn-table-row, wn-table-cell { display: contents; }
|
||||
|
||||
/* host hidden state — needed because shadow-DOM components don't inherit
|
||||
* `[hidden]` semantics in light DOM. Lit's host attribute reflection
|
||||
* handles attributes, but `hidden` on the host itself should still work. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* ====== Buttons ====== */
|
||||
.wn-btn {
|
||||
font: 500 13px var(--ff-sans);
|
||||
letter-spacing: -0.005em;
|
||||
padding: 9px 16px;
|
||||
border-radius: var(--r-2);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
text-decoration: none;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.wn-btn:hover { border-color: var(--ink); }
|
||||
.wn-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
||||
.wn-btn:active { background: var(--bg-3); }
|
||||
.wn-btn[disabled], .wn-btn.is-disabled {
|
||||
color: var(--ink-5); border-color: var(--border); cursor: not-allowed; background: var(--paper);
|
||||
}
|
||||
|
||||
.wn-btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
.wn-btn--primary:hover { background: var(--ink-2); border-color: var(--ink-2); }
|
||||
.wn-btn--primary:active { background: var(--ink); }
|
||||
.wn-btn--primary[disabled], .wn-btn--primary.is-disabled {
|
||||
background: var(--ink-5); border-color: var(--ink-5); color: var(--paper);
|
||||
}
|
||||
|
||||
.wn-btn--ghost { background: transparent; border-color: transparent; padding: 7px 10px; }
|
||||
.wn-btn--ghost:hover { background: var(--bg-3); border-color: transparent; }
|
||||
|
||||
.wn-btn--danger { background: var(--paper); color: var(--ink); border-color: var(--ink); }
|
||||
|
||||
.wn-btn--sm { padding: 5px 10px; font-size: 12px; }
|
||||
.wn-btn--lg { padding: 12px 20px; font-size: 14px; }
|
||||
|
||||
.wn-btn__icon { width: 14px; height: 14px; flex: none; }
|
||||
.wn-btn--lg .wn-btn__icon { width: 16px; height: 16px; }
|
||||
|
||||
/* ====== Eyebrows & labels ====== */
|
||||
.wn-eyebrow {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
display: inline-block;
|
||||
}
|
||||
.wn-eyebrow--strong { color: var(--fg-1); }
|
||||
|
||||
/* ====== Tags ====== */
|
||||
.wn-tag {
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg-2);
|
||||
background: var(--paper);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wn-tag--active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
.wn-tag--draft { background: var(--hi); color: var(--hi-ink); border-color: transparent; }
|
||||
|
||||
/* ====== Stage / Phase dots ====== */
|
||||
.wn-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.wn-dot__bullet { width: 8px; height: 8px; border-radius: 999px; background: var(--ink); flex: none; }
|
||||
|
||||
/* signal levels (S0–S4) */
|
||||
.wn-stage-dot--s0 .wn-dot__bullet { background: var(--status-raw); }
|
||||
.wn-stage-dot--s1 .wn-dot__bullet { background: var(--status-weak); }
|
||||
.wn-stage-dot--s2 .wn-dot__bullet { background: var(--status-medium); }
|
||||
.wn-stage-dot--s3 .wn-dot__bullet { background: var(--status-strong); }
|
||||
.wn-stage-dot--s4 .wn-dot__bullet { background: var(--status-commercial); }
|
||||
|
||||
/* phase states (todo / active / done / warn) — numbered phases, distinct from signal */
|
||||
.wn-phase-dot__bullet {
|
||||
width: 18px; height: 18px; border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--paper);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font: 500 10px/1 var(--ff-mono); color: var(--fg-3);
|
||||
flex: none;
|
||||
}
|
||||
.wn-phase-dot--todo .wn-phase-dot__bullet { border-color: var(--border-strong); color: var(--fg-3); background: var(--paper); }
|
||||
.wn-phase-dot--active .wn-phase-dot__bullet { border-color: var(--ink); color: var(--ink); background: var(--paper); box-shadow: 0 0 0 3px rgba(10,10,10,0.06); }
|
||||
.wn-phase-dot--done .wn-phase-dot__bullet { border-color: var(--ink); color: var(--paper); background: var(--ink); }
|
||||
.wn-phase-dot--warn .wn-phase-dot__bullet { border-color: var(--hi-2); color: var(--hi-ink); background: var(--hi); }
|
||||
|
||||
/* ====== Stamp ====== */
|
||||
.wn-stamp {
|
||||
display: inline-block;
|
||||
background: var(--hi);
|
||||
color: var(--hi-ink);
|
||||
padding: 5px 10px 3px;
|
||||
font: 500 10px/1 var(--ff-mono);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
|
||||
/* ====== Icon ====== */
|
||||
.wn-icon { stroke-width: 1.5; stroke: currentColor; fill: none; display: inline-block; vertical-align: middle; }
|
||||
.wn-icon--sm { width: 14px; height: 14px; }
|
||||
.wn-icon--md { width: 16px; height: 16px; }
|
||||
.wn-icon--lg { width: 20px; height: 20px; }
|
||||
.wn-icon--xl { width: 24px; height: 24px; }
|
||||
|
||||
/* ====== Card ====== */
|
||||
.wn-card {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
position: relative;
|
||||
}
|
||||
.wn-card--inset { background: var(--paper-2); border-color: var(--border); }
|
||||
.wn-card--recessed { background: var(--paper-3); }
|
||||
.wn-card--lg { padding: var(--sp-6); border-radius: var(--r-3); }
|
||||
.wn-card--sm { padding: var(--sp-4); gap: var(--sp-2); }
|
||||
.wn-card--clickable { cursor: pointer; transition: border-color 120ms ease; }
|
||||
.wn-card--clickable:hover { border-color: var(--ink); }
|
||||
.wn-card--clickable:hover::before {
|
||||
content: ""; position: absolute; left: -1px; top: -1px; bottom: -1px;
|
||||
width: 2px; background: var(--ink); border-radius: 2px 0 0 2px;
|
||||
}
|
||||
.wn-card__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-3); }
|
||||
.wn-card__title { font: 500 17px/1.35 var(--ff-sans); margin: 4px 0 8px; color: var(--fg-1); }
|
||||
.wn-card__foot {
|
||||
display: flex; justify-content: space-between; gap: var(--sp-3);
|
||||
padding-top: var(--sp-3); margin-top: 4px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ====== Field row (label + value, 3-col grid) ====== */
|
||||
.wn-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: var(--sp-4) var(--sp-5);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.wn-field-row:last-child { border-bottom: 0; }
|
||||
.wn-field-row__label {
|
||||
font: 500 11px/1.5 var(--ff-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-field-row__value { font: 400 15px/1.55 var(--ff-sans); color: var(--fg-1); }
|
||||
.wn-field-row__aside { font: 400 12px var(--ff-mono); color: var(--fg-3); text-align: right; }
|
||||
.wn-field-row--stacked { grid-template-columns: 1fr; gap: 6px; }
|
||||
.wn-field-row--narrow { grid-template-columns: 120px 1fr; }
|
||||
|
||||
/* ====== Form inputs ====== */
|
||||
.wn-form-label {
|
||||
font: 500 11px/1 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wn-input, .wn-textarea, .wn-select {
|
||||
font: 400 14px var(--ff-sans);
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-1);
|
||||
color: var(--fg-1);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color 120ms ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wn-input:hover, .wn-textarea:hover, .wn-select:hover { border-color: var(--border-strong); }
|
||||
.wn-input:focus, .wn-textarea:focus, .wn-select:focus { border-color: var(--ink); }
|
||||
.wn-input::placeholder, .wn-textarea::placeholder { color: var(--ink-5); }
|
||||
.wn-input[disabled], .wn-textarea[disabled], .wn-select[disabled] {
|
||||
background: var(--paper-2); color: var(--fg-3); cursor: not-allowed;
|
||||
}
|
||||
.wn-textarea { resize: vertical; min-height: 96px; font-family: var(--ff-sans); }
|
||||
.wn-select {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%235C5C5C' stroke-width='1.5'><path d='M1 1l4 4 4-4'/></svg>");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.wn-input--error, .wn-textarea--error, .wn-select--error {
|
||||
border-color: var(--ink); border-bottom-width: 2px;
|
||||
}
|
||||
.wn-form-help { font: 400 11px var(--ff-mono); color: var(--fg-3); margin-top: 6px; display: block; }
|
||||
.wn-form-error { font: 400 11px var(--ff-mono); color: var(--ink); margin-top: 6px; display: block; }
|
||||
|
||||
/* Search input — extracted from TopNav, also usable standalone */
|
||||
.wn-search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-1);
|
||||
background: var(--paper);
|
||||
color: var(--fg-3);
|
||||
font: 400 12px var(--ff-mono);
|
||||
min-width: 200px;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.wn-search:focus-within { border-color: var(--ink); }
|
||||
.wn-search input {
|
||||
border: 0; outline: 0; background: none; flex: 1;
|
||||
font: inherit; color: var(--fg-1); padding: 0;
|
||||
}
|
||||
.wn-search input::placeholder { color: var(--ink-5); }
|
||||
.wn-search__kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ====== Breadcrumb ====== */
|
||||
.wn-breadcrumb {
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
gap: 6px;
|
||||
font: 400 12px/1.5 var(--ff-mono);
|
||||
color: var(--fg-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.wn-breadcrumb a {
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.wn-breadcrumb a:hover { color: var(--fg-1); border-bottom-color: var(--border-strong); }
|
||||
.wn-breadcrumb__sep { color: var(--ink-5); user-select: none; }
|
||||
.wn-breadcrumb__current { color: var(--fg-1); }
|
||||
|
||||
/* ====== Modal / Dialog ====== */
|
||||
.wn-modal__backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(10, 10, 10, 0.40);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
.wn-modal__panel {
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-3);
|
||||
box-shadow: var(--shadow-3);
|
||||
max-width: 560px; width: 100%;
|
||||
max-height: calc(100vh - 64px);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wn-modal__head {
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4);
|
||||
}
|
||||
.wn-modal__title { font: 500 20px/1.25 var(--ff-sans); margin: 0; color: var(--fg-1); }
|
||||
.wn-modal__close {
|
||||
background: none; border: 0; cursor: pointer; padding: 4px;
|
||||
color: var(--fg-3); border-radius: var(--r-1);
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.wn-modal__close:hover { color: var(--fg-1); }
|
||||
.wn-modal__body {
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
font: 400 15px/1.6 var(--ff-sans);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.wn-modal__foot {
|
||||
padding: var(--sp-4) var(--sp-6) var(--sp-5);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; justify-content: flex-end; gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ====== Table ======
|
||||
* Note: shadow-DOM-rendered rows can't be children of a real <table> (the
|
||||
* HTML table model rejects unknown elements between <table> and <tr>). The
|
||||
* <wn-table> component therefore renders a CSS-grid imitation. For real
|
||||
* <table> markup (Django QuerySet rendering, etc.) use these classes
|
||||
* directly on <table>/<tr>/<td> elements — see also the .wn-table--native
|
||||
* variant below.
|
||||
*/
|
||||
|
||||
/* CSS-grid imitation (default <wn-table>) */
|
||||
.wn-table {
|
||||
width: 100%;
|
||||
font-size: var(--fs-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wn-table__thead { border-bottom: 1px solid var(--border); }
|
||||
.wn-table__tbody { display: flex; flex-direction: column; }
|
||||
.wn-table__tr {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
align-items: baseline;
|
||||
}
|
||||
.wn-table__tr:last-child { border-bottom: 0; }
|
||||
.wn-table__tr--head { border-bottom: 0; padding: var(--sp-3) var(--sp-4); }
|
||||
.wn-table__th {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-table__td {
|
||||
color: var(--fg-1);
|
||||
line-height: 1.5;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.wn-table--compact .wn-table__tr { padding: var(--sp-2) var(--sp-3); }
|
||||
.wn-table__cell--mono { font-family: var(--ff-mono); color: var(--fg-2); font-size: 12px; }
|
||||
.wn-table__cell--meta { color: var(--fg-3); font: 400 12px var(--ff-mono); }
|
||||
.wn-table__cell--right { text-align: right; }
|
||||
|
||||
/* Native <table> variant — for Django QuerySet rendering etc. */
|
||||
.wn-table--native {
|
||||
border-collapse: collapse;
|
||||
display: table;
|
||||
}
|
||||
.wn-table--native thead th {
|
||||
text-align: left;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-table--native tbody td {
|
||||
padding: var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
vertical-align: top;
|
||||
color: var(--fg-1);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wn-table--native tbody tr:hover { background: var(--paper-2); }
|
||||
.wn-table--native tbody tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* ====== Banner / Toast (success / info / warn) ====== */
|
||||
.wn-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper);
|
||||
border-radius: var(--r-2);
|
||||
font: 400 14px/1.5 var(--ff-sans);
|
||||
color: var(--fg-1);
|
||||
position: relative;
|
||||
}
|
||||
.wn-banner__icon { color: var(--fg-2); flex: none; padding-top: 2px; }
|
||||
.wn-banner__body { flex: 1; }
|
||||
.wn-banner__title {
|
||||
font: 500 11px/1.2 var(--ff-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.wn-banner__dismiss {
|
||||
background: none; border: 0; cursor: pointer;
|
||||
color: var(--fg-3); padding: 4px;
|
||||
}
|
||||
.wn-banner__dismiss:hover { color: var(--fg-1); }
|
||||
.wn-banner--success { border-left: 2px solid var(--ink); }
|
||||
.wn-banner--warn { border-left: 2px solid var(--hi-2); background: #FFFCEB; }
|
||||
.wn-banner--error { border-left: 2px solid var(--ink); background: var(--paper); }
|
||||
.wn-banner--info { border-left: 2px solid var(--border-strong); }
|
||||
|
||||
.wn-toast-region {
|
||||
position: fixed;
|
||||
bottom: var(--sp-5); right: var(--sp-5);
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
z-index: 200;
|
||||
max-width: 380px;
|
||||
}
|
||||
.wn-toast { box-shadow: var(--shadow-3); }
|
||||
|
||||
/* ====== Empty state ====== */
|
||||
.wn-empty {
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-7);
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: var(--sp-2);
|
||||
text-align: center;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-empty__icon { color: var(--fg-3); margin-bottom: var(--sp-2); }
|
||||
.wn-empty__title { font: 500 14px var(--ff-sans); color: var(--fg-2); margin: 0; }
|
||||
.wn-empty__body { font: 400 13px/1.5 var(--ff-sans); color: var(--fg-3); max-width: 40ch; margin: 0; }
|
||||
.wn-empty__cta { margin-top: var(--sp-2); }
|
||||
|
||||
/* ====== Top navigation ====== */
|
||||
.wn-topnav {
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center;
|
||||
gap: var(--sp-6);
|
||||
padding: 0 var(--sp-5);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.wn-topnav__brand { display: flex; align-items: center; gap: 10px; font: 500 14px var(--ff-sans); }
|
||||
.wn-topnav__brand img { width: 22px; height: 22px; }
|
||||
.wn-topnav__brand-slug { font-family: var(--ff-mono); font-size: 12px; color: var(--fg-3); letter-spacing: 0.04em; }
|
||||
.wn-topnav__links { display: flex; gap: 22px; }
|
||||
.wn-topnav__link {
|
||||
font: 500 13px var(--ff-sans);
|
||||
color: var(--fg-2);
|
||||
text-decoration: none;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.wn-topnav__link:hover { color: var(--fg-1); }
|
||||
.wn-topnav__link--active { color: var(--fg-1); border-bottom-color: var(--ink); }
|
||||
.wn-topnav__right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
|
||||
|
||||
/* ====== Sidebar ====== */
|
||||
.wn-sidebar {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
background: var(--paper-2);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
display: flex; flex-direction: column; gap: var(--sp-5);
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky; top: 56px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.wn-sidebar__group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.wn-sidebar__group-label { padding-left: 12px; }
|
||||
.wn-sidebar__item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
color: var(--fg-2);
|
||||
font: 500 13px var(--ff-sans);
|
||||
cursor: pointer; text-decoration: none;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.wn-sidebar__item:hover { color: var(--fg-1); }
|
||||
.wn-sidebar__item--active {
|
||||
color: var(--fg-1); background: var(--paper);
|
||||
box-shadow: 0 0 0 1px var(--border) inset;
|
||||
}
|
||||
.wn-sidebar__item--doc { font-family: var(--ff-mono); font-size: 12px; }
|
||||
.wn-sidebar__count { margin-left: auto; font: 400 11px var(--ff-mono); color: var(--fg-3); }
|
||||
.wn-sidebar__footer { margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
|
||||
.wn-sidebar__activation {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
|
||||
font: 500 11px var(--ff-mono); letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
.wn-sidebar__activation-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--hi-2); }
|
||||
|
||||
/* ====== Page header ====== */
|
||||
.wn-page-header {
|
||||
margin-bottom: var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.wn-page-header__row { display: flex; align-items: flex-end; gap: var(--sp-5); }
|
||||
.wn-page-header__title {
|
||||
font: 500 32px/1.15 var(--ff-sans);
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0; flex: 1; color: var(--fg-1);
|
||||
}
|
||||
.wn-page-header__actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.wn-page-header__lede {
|
||||
font: 400 16px/1.55 var(--ff-sans);
|
||||
color: var(--fg-2);
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* ====== Pipeline ====== */
|
||||
.wn-pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
position: relative;
|
||||
margin: 0 0 var(--sp-6);
|
||||
}
|
||||
.wn-pipeline__stage {
|
||||
padding: 10px 12px 14px;
|
||||
border-top: 2px solid var(--border);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
.wn-pipeline__stage--done { border-top-color: var(--ink); }
|
||||
.wn-pipeline__stage--active { border-top-color: var(--hi-2); }
|
||||
.wn-pipeline__num {
|
||||
font: 500 10px/1 var(--ff-mono); letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-pipeline__stage--done .wn-pipeline__num,
|
||||
.wn-pipeline__stage--active .wn-pipeline__num { color: var(--fg-1); }
|
||||
.wn-pipeline__name { font: 500 14px/1.25 var(--ff-sans); color: var(--fg-1); }
|
||||
.wn-pipeline__stage--pending .wn-pipeline__name { color: var(--fg-3); }
|
||||
.wn-pipeline__meta { font: 400 11px/1.35 var(--ff-mono); color: var(--fg-3); }
|
||||
.wn-pipeline__arrow {
|
||||
position: absolute; top: -8px; right: -7px;
|
||||
font: 400 14px var(--ff-mono); color: var(--ink-5);
|
||||
}
|
||||
.wn-pipeline__stage--done .wn-pipeline__arrow,
|
||||
.wn-pipeline__stage--active .wn-pipeline__arrow { color: var(--ink); }
|
||||
|
||||
/* ====== Prototype card (combined card variant) ====== */
|
||||
.wn-prototype-card { /* extends .wn-card */ }
|
||||
.wn-prototype-card__qrow {
|
||||
display: grid; grid-template-columns: 110px 1fr; gap: 6px 12px;
|
||||
font-size: 13px; color: var(--fg-1);
|
||||
}
|
||||
.wn-prototype-card__qkey {
|
||||
font: 500 11px/1.5 var(--ff-mono);
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
.wn-prototype-card__qval { line-height: 1.45; }
|
||||
|
||||
/* ====== Layout helpers ====== */
|
||||
.wn-main { padding: 40px 48px 80px; max-width: 1180px; }
|
||||
.wn-app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
|
||||
@@ -1,48 +1,52 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Visual-regression baseline for the whynot-control UI kit.
|
||||
// Snapshots live next to this file under __screenshots__/.
|
||||
// Visual-regression baselines for @whynot/design.
|
||||
//
|
||||
// Two example pages are covered:
|
||||
// 1. examples/showcase/index.html — every component, one page
|
||||
// 2. examples/whynot-control/index.html — full app composition
|
||||
//
|
||||
// To update intentionally: pnpm test:visual:update
|
||||
|
||||
test.describe("showcase — every component", () => {
|
||||
test("renders", async ({ page }) => {
|
||||
await page.goto("/examples/showcase/index.html");
|
||||
// Wait for custom elements to register + Lit to render.
|
||||
await page.waitForFunction(() => !!customElements.get("wn-button"));
|
||||
await page.waitForTimeout(800);
|
||||
await expect(page).toHaveScreenshot("showcase.png", { fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("whynot-control UI kit", () => {
|
||||
test("prototypes index", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
// Wait for Babel + Lucide to hydrate
|
||||
await page.goto("/examples/whynot-control/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("aside"));
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForTimeout(800);
|
||||
await expect(page).toHaveScreenshot("01-prototypes.png", { fullPage: true });
|
||||
});
|
||||
|
||||
test("inbox", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
await page.goto("/examples/whynot-control/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("aside a"));
|
||||
await page.click("aside a:has-text('Inbox')");
|
||||
await page.waitForTimeout(400);
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveScreenshot("02-inbox.png", { fullPage: true });
|
||||
});
|
||||
|
||||
test("signals", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
await page.goto("/examples/whynot-control/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("aside a"));
|
||||
await page.click("aside a:has-text('Signals')");
|
||||
await page.waitForTimeout(400);
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveScreenshot("03-signals.png", { fullPage: true });
|
||||
});
|
||||
|
||||
test("prototype detail", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("article"));
|
||||
await page.click("article:nth-of-type(1)");
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page).toHaveScreenshot("04-prototype-detail.png", { fullPage: true });
|
||||
});
|
||||
|
||||
test("control doc — INTENT.md", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
await page.goto("/examples/whynot-control/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("aside a"));
|
||||
await page.click("aside a:has-text('INTENT.md')");
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page).toHaveScreenshot("05-doc-intent.png", { fullPage: true });
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveScreenshot("04-doc-intent.png", { fullPage: true });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user