Integrate whynot-design into Economic Observatory UI

Vendor whynot-design Layer 1 (tokens, CSS) and Layer 2 (<wn-*>
components) via scripts/sync-whynot-design.sh with a pinned ref.
Migrate the observatory shell to canonical web components, keep
observatory-specific layout in styles.css, and add vendor integrity
tests plus correct JS MIME types on the dev server.
This commit is contained in:
2026-06-22 03:09:44 +02:00
parent 9c1c2142fc
commit da3b7d66f0
21 changed files with 2903 additions and 354 deletions

View File

@@ -35,12 +35,15 @@ cd projects/coulomb-pricing
python3 -m pytest -q
python3 -m observatory --period 2026-06
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
./scripts/sync-whynot-design.sh # pull whynot-design tokens/CSS/components
python3 -m observatory.server
```
Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from
`ui/`, data via `/api/dashboard`). Design reference:
https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511
`ui/`, data via `/api/dashboard`). The UI consumes **whynot-design** (Layer 1 CSS
+ Layer 2 `<wn-*>` components) from `ui/vendor/whynot-design/` — re-run the sync
script after bumping the pinned ref in `.whynot-design-ref`. Claude atelier mock
(reference only): https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511
Manual ledgers will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and
OpenRouter (Sprint 4) importers.

View File

@@ -11,6 +11,13 @@ from .api import build_dashboard_payload
ROOT = Path(__file__).resolve().parent.parent
UI_DIR = ROOT / "ui"
UI_CONTENT_TYPES = {
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".html": "text/html; charset=utf-8",
}
class ObservatoryHandler(BaseHTTPRequestHandler):
data_dir: Path = ROOT / "data"
@@ -38,8 +45,10 @@ class ObservatoryHandler(BaseHTTPRequestHandler):
relative = parsed.path.removeprefix("/ui/")
target = UI_DIR / relative
if target.exists() and target.is_file():
content_type = "text/css" if target.suffix == ".css" else "application/javascript"
return self._serve_file(target, f"{content_type}; charset=utf-8")
content_type = UI_CONTENT_TYPES.get(target.suffix, "application/octet-stream")
if "charset" not in content_type:
content_type = f"{content_type}; charset=utf-8"
return self._serve_file(target, content_type)
self._send(404, b"Not found", "text/plain")

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Synchronises the vendored copy of whynot-design from a pinned upstream commit.
# Source: ~/whynot-design (worktree) or a clone from gitea.
#
# Usage: ./scripts/sync-whynot-design.sh [<commit-or-ref>]
# Default: reads .whynot-design-ref from the vendor directory, else HEAD.
#
# Pulls Layer 1 (tokens + CSS) and Layer 2 (Lit web components) so the
# observatory UI picks up design-system changes on re-run.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
VENDOR_DIR="$ROOT/ui/vendor/whynot-design"
REF_FILE="$VENDOR_DIR/.whynot-design-ref"
SRC_REPO="${WHYNOT_DESIGN_SRC:-$HOME/whynot-design}"
REF="${1:-}"
if [[ -z "$REF" && -f "$REF_FILE" ]]; then
REF="$(cat "$REF_FILE")"
fi
if [[ -z "$REF" ]]; then
REF="HEAD"
fi
if [[ ! -d "$SRC_REPO/.git" ]]; then
echo "Source not found: $SRC_REPO" >&2
echo "Set WHYNOT_DESIGN_SRC or clone gitea:whynot/whynot-design there." >&2
exit 1
fi
mkdir -p "$VENDOR_DIR/tokens" "$VENDOR_DIR/elements"
git -C "$SRC_REPO" show "$REF:src/styles/colors_and_type.css" > "$VENDOR_DIR/colors_and_type.css"
git -C "$SRC_REPO" show "$REF:src/styles/components.css" > "$VENDOR_DIR/components.css"
git -C "$SRC_REPO" show "$REF:src/index.js" > "$VENDOR_DIR/index.js"
for f in atoms.js chrome.js form.js icons.js layout.js _styles.js; do
git -C "$SRC_REPO" show "$REF:src/elements/$f" > "$VENDOR_DIR/elements/$f"
done
for f in colors.json type.json spacing.json index.json; do
git -C "$SRC_REPO" show "$REF:tokens/$f" > "$VENDOR_DIR/tokens/$f"
done
git -C "$SRC_REPO" rev-parse "$REF" > "$REF_FILE"
echo "Vendor synced → $VENDOR_DIR (ref: $(cat "$REF_FILE"))"

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import importlib
import threading
from http.server import HTTPServer
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
VENDOR = ROOT / "ui" / "vendor" / "whynot-design"
REQUIRED_VENDOR_FILES = [
VENDOR / ".whynot-design-ref",
VENDOR / "colors_and_type.css",
VENDOR / "components.css",
VENDOR / "index.js",
VENDOR / "elements" / "atoms.js",
VENDOR / "elements" / "chrome.js",
VENDOR / "elements" / "form.js",
VENDOR / "elements" / "layout.js",
VENDOR / "elements" / "icons.js",
VENDOR / "elements" / "_styles.js",
VENDOR / "tokens" / "colors.json",
]
def test_vendor_tree_is_complete() -> None:
missing = [path for path in REQUIRED_VENDOR_FILES if not path.exists()]
assert not missing, f"Missing vendored whynot-design files: {missing}"
def test_vendor_ref_is_pinned() -> None:
ref = (VENDOR / ".whynot-design-ref").read_text(encoding="utf-8").strip()
assert len(ref) == 40
def test_server_serves_vendor_modules() -> None:
server_module = importlib.import_module("observatory.server")
handler = server_module.ObservatoryHandler
handler.data_dir = ROOT / "data"
httpd = HTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
import urllib.request
index = urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2)
assert "wn-top-nav" in index.read().decode("utf-8")
module = urllib.request.urlopen(
f"http://127.0.0.1:{port}/ui/vendor/whynot-design/index.js",
timeout=2,
)
assert module.headers["Content-Type"].startswith("application/javascript")
assert "defineAtoms" in module.read().decode("utf-8")
finally:
httpd.shutdown()
thread.join(timeout=2)

View File

@@ -9,10 +9,11 @@ async function loadDashboard(period) {
function setBadge(el, status) {
el.textContent = status;
el.classList.toggle("ok", status === "generating" || status === "neutral");
el.active = status === "generating" || status === "neutral";
el.draft = status === "burning";
}
function renderHero(data) {
function renderMetrics(data) {
const { snapshot, liquidity } = data;
const cards = [
{
@@ -47,14 +48,14 @@ function renderHero(data) {
},
];
document.getElementById("hero-grid").innerHTML = cards
document.getElementById("metric-grid").innerHTML = cards
.map(
(card) => `
<article class="hero-card">
<div class="label">${card.label}</div>
<div class="value">${card.value}</div>
<div class="sub">${card.sub}</div>
</article>`
<wn-card size="sm">
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
<div class="obs-metric__value">${card.value}</div>
<span slot="footer">${card.sub}</span>
</wn-card>`
)
.join("");
}
@@ -64,9 +65,13 @@ function renderBudget(data) {
const initial = Number(liquidity.initial_budget);
const remaining = Number(liquidity.remaining_budget);
const pct = Math.max(0, Math.min(100, (remaining / initial) * 100));
const banner = document.getElementById("budget-banner");
const caption = remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
document.getElementById("budget-fill").style.width = `${pct}%`;
document.getElementById("budget-caption").textContent =
remaining >= 0 ? "Within allocated budget" : "Over allocated budget";
document.getElementById("budget-caption").textContent = caption;
banner.variant = remaining < initial * 0.25 ? "warn" : "info";
document.getElementById("budget-stats").innerHTML = [
["Initial budget", euro(initial)],
["Cumulative member payments (net)", euro(liquidity.cumulative_member_payments)],
@@ -74,11 +79,10 @@ function renderBudget(data) {
["Cumulative processing", euro(liquidity.cumulative_payment_processing_cost)],
]
.map(
([k, v]) => `
<div class="stat">
<div class="k">${k}</div>
<div class="v">${v}</div>
</div>`
([label, value]) => `
<wn-field-row label="${label}" narrow>
<span class="wn-field-row__value">${value}</span>
</wn-field-row>`
)
.join("");
}
@@ -89,15 +93,15 @@ function renderChart(history) {
.map((row) => {
const value = Number(row.net_liquidity);
const width = (Math.abs(value) / max) * 50;
const bar =
value < 0
? `<div class="chart-bar neg" style="width:${width}%"></div>`
: `<div class="chart-bar pos" style="width:${width}%"></div>`;
const barClass = value < 0 ? "obs-liquidity-row__bar--neg" : "obs-liquidity-row__bar--pos";
const valueClass = value < 0 ? "obs-liquidity-row__value--neg" : "";
return `
<div class="chart-row">
<div class="chart-label">${row.period}</div>
<div class="chart-bar-wrap">${bar}</div>
<div class="chart-value">${euro(value)}</div>
<div class="obs-liquidity-row">
<div class="obs-liquidity-row__period">${row.period}</div>
<div class="obs-liquidity-row__bar-wrap">
<div class="obs-liquidity-row__bar ${barClass}" style="width:${width}%"></div>
</div>
<div class="obs-liquidity-row__value ${valueClass}">${euro(value)}</div>
</div>`;
})
.join("");
@@ -107,18 +111,19 @@ function renderInfra(data) {
const domains = data.infrastructure.domains?.domains ?? [];
const servers = data.infrastructure.virtual_servers?.servers ?? [];
const stripe = data.infrastructure.stripe?.membership ?? {};
const payout = data.infrastructure.stripe?.payout_account ?? "payout";
const items = [
...domains.map(
(d) =>
`<div class="infra-item"><strong>${d.name}</strong><span>${d.monthly_eur} EUR/mo · ${d.tld}</span></div>`
`<wn-field-row label="${d.name}" narrow><span class="wn-field-row__value">${d.monthly_eur} EUR/mo · ${d.tld}</span></wn-field-row>`
),
...servers.map(
(s) =>
`<div class="infra-item"><strong>${s.name}</strong><span>${s.monthly_eur} EUR/mo · since ${s.started}</span></div>`
`<wn-field-row label="${s.name}" narrow><span class="wn-field-row__value">${s.monthly_eur} EUR/mo · since ${s.started}</span></wn-field-row>`
),
`<div class="infra-item"><strong>Stripe · ${stripe.member_username ?? "member"}</strong><span>${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${data.infrastructure.stripe?.payout_account ?? "payout"}</span></div>`,
`<wn-field-row label="Stripe · ${stripe.member_username ?? "member"}" narrow><span class="wn-field-row__value">${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${payout}</span></wn-field-row>`,
];
document.getElementById("infra-stack").innerHTML = `<div class="infra-list">${items.join("")}</div>`;
document.getElementById("infra-stack").innerHTML = `<div class="obs-infra-list">${items.join("")}</div>`;
}
function renderTables(data) {
@@ -126,12 +131,12 @@ function renderTables(data) {
.map(
(row) => `
<tr>
<td>${row.period}</td>
<td class="num">${row.active_members}</td>
<td class="num">${euro(row.gross_revenue)}</td>
<td class="num">${euro(row.infrastructure_cost)}</td>
<td class="num">${euro(row.payment_processing_cost)}</td>
<td class="num">${euro(row.net_liquidity)}</td>
<td class="mono">${row.period}</td>
<td class="mono">${row.active_members}</td>
<td class="obs-num mono">${euro(row.gross_revenue)}</td>
<td class="obs-num mono">${euro(row.infrastructure_cost)}</td>
<td class="obs-num mono">${euro(row.payment_processing_cost)}</td>
<td class="obs-num mono">${euro(row.net_liquidity)}</td>
</tr>`
)
.join("");
@@ -140,10 +145,10 @@ function renderTables(data) {
.map(
(model) => `
<tr>
<td>${model.id}</td>
<td class="mono">${model.id}</td>
<td>${model.name}</td>
<td>${model.model_type}</td>
<td>${model.status}</td>
<td class="mono">${model.model_type}</td>
<td><wn-tag>${model.status}</wn-tag></td>
</tr>`
)
.join("");
@@ -154,16 +159,35 @@ function populatePeriods(history, current) {
select.innerHTML = [...history].reverse().map(
(row) => `<option value="${row.period}" ${row.period === current ? "selected" : ""}>${row.period}</option>`
);
select.onchange = async () => {
const next = await loadDashboard(select.value);
render(next);
};
select.value = current;
if (!select._obsBound) {
select.addEventListener("wn-change", async (event) => {
const next = await loadDashboard(event.detail.value);
render(next);
});
select._obsBound = true;
}
}
async function loadDesignRefLabel() {
try {
const response = await fetch("/ui/vendor/whynot-design/.whynot-design-ref");
if (!response.ok) return;
const ref = (await response.text()).trim().slice(0, 7);
const label = document.getElementById("design-ref-label");
if (label && ref) label.textContent = `whynot-design @ ${ref}`;
} catch {
// optional footer detail
}
}
function render(data) {
document.getElementById("design-link").href = data.design_reference;
loadDesignRefLabel();
const header = document.getElementById("page-header");
header.lede = `Period ${data.period}. Ledger-backed view of infrastructure spend, member payments, and remaining budget. Customer cost-pass-through billing is not active.`;
setBadge(document.getElementById("liquidity-badge"), data.snapshot.liquidity_status);
renderHero(data);
renderMetrics(data);
renderBudget(data);
renderChart(data.history);
renderInfra(data);
@@ -174,5 +198,5 @@ function render(data) {
loadDashboard()
.then(render)
.catch((error) => {
document.body.innerHTML = `<pre style="padding:24px;color:#fb7185">${error}</pre>`;
document.body.innerHTML = `<main class="wn-main"><wn-banner variant="error" title="Load failed">${error}</wn-banner></main>`;
});

View File

@@ -3,71 +3,95 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Coulomb Economic Observatory</title>
<title>Coulomb · Economic Observatory</title>
<link rel="stylesheet" href="/ui/vendor/whynot-design/colors_and_type.css" />
<link rel="stylesheet" href="/ui/vendor/whynot-design/components.css" />
<link rel="stylesheet" href="/ui/styles.css" />
<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="/ui/vendor/whynot-design/index.js"></script>
</head>
<body>
<div class="app">
<header class="topbar">
<div>
<p class="eyebrow">Adaptive Pricing · Coulomb Social MVP</p>
<h1>Economic Observatory</h1>
</div>
<div class="topbar-actions">
<label>
Period
<select id="period-select"></select>
</label>
<span class="badge" id="liquidity-badge"></span>
</div>
</header>
<wn-top-nav brand="adaptive-pricing" slug="coulomb · observatory">
<div slot="right" class="obs-period" id="period-control">
<wn-eyebrow>Period</wn-eyebrow>
<wn-select id="period-select"></wn-select>
</div>
<wn-tag slot="right" id="liquidity-badge"></wn-tag>
</wn-top-nav>
<section class="hero-grid" id="hero-grid"></section>
<main class="wn-main obs-main">
<wn-page-header
eyebrow="Economic Observatory · MVP"
title="Operator liquidity"
lede="Ledger-backed view of infrastructure spend, member payments, and remaining budget. Customer cost-pass-through billing is not active."
id="page-header"
></wn-page-header>
<section class="panel budget-panel">
<div class="panel-head">
<h2>Liquidity &amp; Budget</h2>
<p id="budget-caption">Operator liquidity pool</p>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Snapshot</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="budget-bar">
<div class="budget-fill" id="budget-fill"></div>
</div>
<div class="budget-stats" id="budget-stats"></div>
<div class="obs-metric-grid" id="metric-grid"></div>
</section>
<div class="split-grid">
<section class="panel">
<div class="panel-head">
<h2>Monthly Net Liquidity</h2>
<p>Member net payments minus infrastructure</p>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Liquidity &amp; budget</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<wn-banner variant="info" title="Budget position" id="budget-banner">
<p id="budget-caption"></p>
</wn-banner>
<div class="obs-budget-meter" aria-hidden="true">
<div class="obs-budget-meter__fill" id="budget-fill"></div>
</div>
<div class="obs-field-sheet" id="budget-stats"></div>
</section>
<div class="obs-split">
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Monthly net liquidity</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="chart" id="liquidity-chart"></div>
<p class="lead obs-section-note">Member net payments minus infrastructure, by period.</p>
<div class="obs-liquidity-list" id="liquidity-chart"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Infrastructure Stack</h2>
<p>Domains, hosting, and Stripe reference</p>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Infrastructure stack</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<p class="lead obs-section-note">Domains, hosting, and Stripe reference rates.</p>
<div id="infra-stack"></div>
</section>
</div>
<section class="panel">
<div class="panel-head">
<h2>Monthly Ledger</h2>
<p>Computed from expense and payment record tables</p>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Monthly ledger</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="table-wrap">
<table>
<p class="lead obs-section-note">Computed from expense and payment record tables.</p>
<div class="obs-table-wrap">
<table class="wn-table--native obs-table">
<thead>
<tr>
<th>Period</th>
<th>Members</th>
<th>Gross</th>
<th>Infrastructure</th>
<th>Processing</th>
<th>Net liquidity</th>
<th class="obs-num">Gross</th>
<th class="obs-num">Infrastructure</th>
<th class="obs-num">Processing</th>
<th class="obs-num">Net liquidity</th>
</tr>
</thead>
<tbody id="history-body"></tbody>
@@ -75,12 +99,13 @@
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Pricing Model Registry</h2>
<section class="obs-section">
<div class="obs-section__head">
<wn-eyebrow>Pricing model registry</wn-eyebrow>
<div class="obs-section__rule"></div>
</div>
<div class="table-wrap">
<table>
<div class="obs-table-wrap">
<table class="wn-table--native obs-table">
<thead>
<tr>
<th>ID</th>
@@ -94,14 +119,17 @@
</div>
</section>
<footer class="footer">
<p>
Design reference:
<footer class="obs-footer">
<p class="small">
Design:
<a id="design-link" href="#" target="_blank" rel="noreferrer">Claude design share</a>
· Totals computed programmatically from ledgers · Customer cost-pass-through billing not active
·
<span class="mono" id="design-ref-label">whynot-design</span>
· totals computed programmatically from ledgers
</p>
</footer>
</div>
</main>
<script src="/ui/app.js"></script>
</body>
</html>

View File

@@ -1,309 +1,196 @@
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2f;
--panel-border: #24304d;
--text: #e8edf8;
--muted: #93a0bf;
--accent: #5eead4;
--accent-soft: rgba(94, 234, 212, 0.12);
--warn: #fb7185;
--warn-soft: rgba(251, 113, 133, 0.14);
--ok: #86efac;
--ok-soft: rgba(134, 239, 172, 0.14);
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
font-family: "Segoe UI", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
/* Observatory layout — extends whynot-design (Layer 1). No gradients, no shadows on cards. */
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(94, 234, 212, 0.08), transparent 28%),
radial-gradient(circle at top right, rgba(96, 165, 250, 0.08), transparent 24%),
var(--bg);
color: var(--text);
background: var(--paper);
}
.app {
max-width: 1200px;
.obs-main {
max-width: 1080px;
margin: 0 auto;
padding: 28px 20px 48px;
padding: var(--sp-6) var(--sp-5) var(--sp-9);
}
.topbar,
.panel,
.hero-card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 18px;
box-shadow: var(--shadow);
}
.topbar {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: center;
padding: 22px 24px;
margin-bottom: 20px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--muted);
font-size: 0.82rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
font-weight: 650;
}
.topbar-actions {
display: flex;
gap: 14px;
align-items: end;
}
label {
.obs-period {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 0.82rem;
min-width: 132px;
}
select {
background: #0d1426;
color: var(--text);
border: 1px solid var(--panel-border);
border-radius: 10px;
padding: 10px 12px;
min-width: 140px;
wn-top-nav [slot="right"] {
display: flex;
align-items: end;
gap: var(--sp-3);
}
.badge {
padding: 10px 14px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
background: var(--warn-soft);
color: var(--warn);
.obs-section {
margin-bottom: var(--sp-7);
}
.badge.ok {
background: var(--ok-soft);
color: var(--ok);
.obs-section__head {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.hero-grid {
.obs-section__rule {
flex: 1;
border-top: 1px solid var(--border-soft);
}
.obs-section-note {
margin: 0 0 var(--sp-4);
max-width: 60ch;
}
.obs-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 20px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--sp-3);
}
.hero-card {
padding: 18px 18px 16px;
.obs-metric__value {
font: 500 28px/1.1 var(--ff-sans);
letter-spacing: -0.02em;
color: var(--fg-1);
font-variant-numeric: tabular-nums;
}
.hero-card .label {
color: var(--muted);
font-size: 0.82rem;
margin-bottom: 8px;
.obs-budget-meter {
height: 8px;
border: 1px solid var(--border);
background: var(--paper-2);
margin-bottom: var(--sp-4);
}
.hero-card .value {
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -0.03em;
}
.hero-card .sub {
margin-top: 8px;
color: var(--muted);
font-size: 0.85rem;
}
.panel {
padding: 20px 22px;
margin-bottom: 20px;
}
.panel-head {
margin-bottom: 16px;
}
.panel-head p {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.split-grid {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 20px;
}
.budget-bar {
height: 14px;
border-radius: 999px;
background: #0d1426;
overflow: hidden;
margin-bottom: 14px;
}
.budget-fill {
.obs-budget-meter__fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent), #60a5fa);
transition: width 0.35s ease;
background: var(--ink);
transition: width 0.25s ease;
}
.budget-stats {
.obs-field-sheet {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--paper);
padding: 0 var(--sp-4);
}
.obs-split {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-columns: 1.35fr 1fr;
gap: var(--sp-6);
margin-bottom: var(--sp-2);
}
.stat {
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
.obs-liquidity-list {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--paper);
padding: 0 var(--sp-4);
}
.stat .k {
color: var(--muted);
font-size: 0.8rem;
}
.stat .v {
margin-top: 6px;
font-size: 1.1rem;
font-weight: 650;
}
.chart {
.obs-liquidity-row {
display: grid;
gap: 10px;
}
.chart-row {
display: grid;
grid-template-columns: 72px 1fr 72px;
gap: 10px;
grid-template-columns: 72px 1fr 88px;
gap: var(--sp-3);
align-items: center;
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border-soft);
}
.chart-label,
.chart-value {
font-size: 0.82rem;
color: var(--muted);
.obs-liquidity-row:last-child {
border-bottom: 0;
}
.chart-value {
text-align: right;
.obs-liquidity-row__period {
font: 400 12px var(--ff-mono);
color: var(--fg-3);
}
.chart-bar-wrap {
height: 28px;
background: #0d1426;
border-radius: 8px;
overflow: hidden;
position: relative;
}
.chart-bar {
position: absolute;
top: 0;
bottom: 0;
border-radius: 8px;
}
.chart-bar.neg {
left: 50%;
background: linear-gradient(90deg, transparent, var(--warn));
}
.chart-bar.pos {
right: 50%;
background: linear-gradient(270deg, transparent, var(--ok));
}
.infra-list {
display: grid;
gap: 10px;
}
.infra-item {
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.infra-item strong {
display: block;
margin-bottom: 4px;
}
.infra-item span {
color: var(--muted);
font-size: 0.88rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 11px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
font-size: 0.92rem;
}
th {
color: var(--muted);
font-weight: 600;
}
td.num,
th.num {
.obs-liquidity-row__value {
font: 500 12px var(--ff-mono);
color: var(--fg-1);
text-align: right;
font-variant-numeric: tabular-nums;
}
.footer {
color: var(--muted);
font-size: 0.86rem;
.obs-liquidity-row__value--neg {
color: var(--fg-2);
}
.footer a {
color: var(--accent);
.obs-liquidity-row__bar-wrap {
height: 10px;
border: 1px solid var(--border-soft);
background: var(--paper-2);
position: relative;
}
.obs-liquidity-row__bar {
position: absolute;
top: 0;
bottom: 0;
background: var(--ink);
}
.obs-liquidity-row__bar--neg {
right: 50%;
background: var(--ink-4);
}
.obs-liquidity-row__bar--pos {
left: 50%;
background: var(--ink);
}
.obs-infra-list {
border: 1px solid var(--border);
border-radius: var(--r-2);
background: var(--paper);
padding: 0 var(--sp-4);
}
.obs-table-wrap {
border: 1px solid var(--border);
border-radius: var(--r-2);
overflow-x: auto;
background: var(--paper);
}
.obs-table {
width: 100%;
margin: 0;
}
.obs-num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.obs-footer {
padding-top: var(--sp-5);
border-top: 1px solid var(--border);
}
.obs-footer a {
color: var(--fg-1);
text-decoration: underline;
text-underline-offset: 2px;
}
wn-banner {
display: block;
margin-bottom: var(--sp-4);
}
@media (max-width: 900px) {
.split-grid {
.obs-split {
grid-template-columns: 1fr;
}
.topbar {
flex-direction: column;
align-items: flex-start;
wn-top-nav [slot="right"] {
width: 100%;
justify-content: space-between;
}
}

View File

@@ -0,0 +1 @@
9b9f3728937ca308966de9c62accdb00c8cf5b0e

View File

@@ -0,0 +1,273 @@
/* ============================================================
WhyNot Design System — Colors & Type
------------------------------------------------------------
Neutral, mostly black/white. Color is used SPARINGLY — only
one warm accent (annotation yellow) borrowed from the LEGO
brick in the logo. The system favours light grey wireframe
artefacts over heavy fills.
============================================================ */
/* ---------- Webfonts (Google Fonts, see /fonts for offline) ---------- */
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;1,400&display=swap");
:root {
/* ---------- Base palette: neutrals ---------- */
--ink: #0A0A0A; /* near-black, the only "fill" most of the time */
--ink-2: #1F1F1F;
--ink-3: #5C5C5C;
--ink-4: #8A8A8A;
--ink-5: #B5B5B3; /* placeholder text, wireframe labels */
--line: #E5E5E2; /* default 1px wireframe rule */
--line-strong: #C9C9C5; /* dividers between sections */
--line-soft: #F0F0EC; /* hairline within a card */
--paper: #FFFFFF; /* canvas */
--paper-2: #FAFAF7; /* sheet, dim canvas */
--paper-3: #F4F4EF; /* recessed surface, code block bg */
/* ---------- Foreground / background semantic ---------- */
--fg-1: var(--ink);
--fg-2: var(--ink-3);
--fg-3: var(--ink-4);
--fg-mute: var(--ink-5);
--fg-on-dark: #FAFAF7;
--bg-1: var(--paper);
--bg-2: var(--paper-2);
--bg-3: var(--paper-3);
--bg-invert: var(--ink);
--border: var(--line);
--border-strong: var(--line-strong);
--border-soft: var(--line-soft);
/* ---------- The single accent: annotation yellow ---------- */
/* Lifted from the LEGO brick. Used as highlighter, "draft"
stamp, signal-marker. Never as a button fill. */
--hi: #FFE14A;
--hi-2: #FFD400;
--hi-ink: #1A1500; /* text on yellow */
/* ---------- Status (for prototype lifecycle, signal strength) ---------- */
/* Kept deliberately desaturated so they read as labels, not UI. */
--status-raw: #B5B5B3; /* S0 — no signal */
--status-weak: #8A8A8A; /* S1 — weak signal */
--status-medium: #5C5C5C; /* S2 — medium signal */
--status-strong: #0A0A0A; /* S3 — strong signal */
--status-commercial: #FFD400; /* S4 — commercial */
/* ---------- Type families ---------- */
--ff-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
--ff-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
--ff-serif: "IBM Plex Serif", "Iowan Old Style", Georgia, serif;
/* ---------- Type scale (modular, ~1.2) ---------- */
--fs-xs: 11px;
--fs-sm: 13px;
--fs-base: 15px;
--fs-md: 17px;
--fs-lg: 20px;
--fs-xl: 24px;
--fs-2xl: 32px;
--fs-3xl: 44px;
--fs-4xl: 64px;
--fs-5xl: 96px;
--lh-tight: 1.05;
--lh-snug: 1.25;
--lh-base: 1.5;
--lh-loose: 1.7;
--tr-tight: -0.02em;
--tr-snug: -0.01em;
--tr-base: 0em;
--tr-mono: 0.02em;
--tr-label: 0.08em; /* uppercase eyebrow labels */
/* ---------- Spacing (4px base) ---------- */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-7: 48px;
--sp-8: 64px;
--sp-9: 96px;
--sp-10: 128px;
/* ---------- Radii — small, mostly square ---------- */
--r-0: 0px;
--r-1: 2px;
--r-2: 4px;
--r-3: 8px;
--r-pill: 999px;
/* ---------- Elevation — almost none. This is a wireframe system. ---------- */
--shadow-0: none;
--shadow-1: 0 1px 0 var(--line);
--shadow-2: 0 1px 0 var(--line-strong);
--shadow-3: 0 4px 12px -6px rgba(10,10,10,0.10);
}
/* ============================================================
Semantic element styles
============================================================ */
html {
font-family: var(--ff-sans);
font-size: var(--fs-base);
line-height: var(--lh-base);
color: var(--fg-1);
background: var(--bg-1);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
margin: 0;
font-feature-settings: "ss01", "cv11";
text-wrap: pretty;
}
/* ---------- Headings ---------- */
h1, .h1 {
font: 600 var(--fs-3xl)/var(--lh-tight) var(--ff-sans);
letter-spacing: var(--tr-tight);
margin: 0 0 var(--sp-5);
color: var(--fg-1);
}
h2, .h2 {
font: 500 var(--fs-2xl)/var(--lh-snug) var(--ff-sans);
letter-spacing: var(--tr-snug);
margin: 0 0 var(--sp-4);
}
h3, .h3 {
font: 500 var(--fs-xl)/var(--lh-snug) var(--ff-sans);
letter-spacing: var(--tr-snug);
margin: 0 0 var(--sp-3);
}
h4, .h4 {
font: 500 var(--fs-lg)/var(--lh-snug) var(--ff-sans);
margin: 0 0 var(--sp-2);
}
h5, .h5 {
font: 500 var(--fs-md)/var(--lh-snug) var(--ff-sans);
margin: 0 0 var(--sp-2);
}
/* ---------- Display (for hero / title slides) ---------- */
.display-1 {
font: 300 var(--fs-5xl)/0.95 var(--ff-sans);
letter-spacing: -0.035em;
color: var(--fg-1);
}
.display-2 {
font: 400 var(--fs-4xl)/1.0 var(--ff-sans);
letter-spacing: var(--tr-tight);
}
/* ---------- Body ---------- */
p {
margin: 0 0 var(--sp-4);
line-height: var(--lh-base);
color: var(--fg-1);
}
.lead {
font-size: var(--fs-md);
line-height: 1.55;
color: var(--fg-2);
}
small, .small {
font-size: var(--fs-sm);
color: var(--fg-2);
}
/* ---------- Eyebrow / uppercase labels (very common in this system) ---------- */
.eyebrow,
.label {
font: 500 var(--fs-xs)/1.2 var(--ff-mono);
letter-spacing: var(--tr-label);
text-transform: uppercase;
color: var(--fg-3);
}
/* ---------- Code / mono ---------- */
code, kbd, samp, pre, .mono {
font-family: var(--ff-mono);
font-size: 0.92em;
letter-spacing: var(--tr-mono);
}
code {
background: var(--bg-3);
padding: 1px 6px;
border-radius: var(--r-1);
color: var(--ink-2);
}
pre {
background: var(--bg-3);
border: 1px solid var(--border);
padding: var(--sp-4);
overflow-x: auto;
border-radius: var(--r-2);
font-size: var(--fs-sm);
line-height: var(--lh-snug);
}
pre code { background: none; padding: 0; }
/* ---------- Editorial serif moments ---------- */
.serif { font-family: var(--ff-serif); }
.serif-quote {
font: 400 italic var(--fs-xl)/1.4 var(--ff-serif);
color: var(--fg-2);
}
/* ---------- Links ---------- */
a {
color: var(--fg-1);
text-decoration: underline;
text-decoration-color: var(--border-strong);
text-underline-offset: 3px;
text-decoration-thickness: 1px;
transition: text-decoration-color 120ms ease, color 120ms ease;
}
a:hover {
text-decoration-color: var(--fg-1);
}
/* ---------- HR ---------- */
hr {
border: 0;
border-top: 1px solid var(--border);
margin: var(--sp-5) 0;
}
/* ---------- Highlighter (the one place yellow appears in body copy) ---------- */
mark, .mark {
background: var(--hi);
color: var(--hi-ink);
padding: 0 2px;
}
/* ---------- Tables (used in templates) ---------- */
table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
th, td {
text-align: left;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
}
th {
font-weight: 500;
color: var(--fg-2);
font-family: var(--ff-mono);
font-size: var(--fs-xs);
letter-spacing: var(--tr-label);
text-transform: uppercase;
}
/* ---------- Selection ---------- */
::selection { background: var(--hi); color: var(--hi-ink); }

View 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 (S0S4) */
.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; }

View 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 (S0S4) */
.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;
}

View 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 };

View 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);
}

View 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);
}

View 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"],
};

View 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);
}

View File

@@ -0,0 +1,35 @@
/* =============================================================
* @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";
* ============================================================= */
import { defineAtoms } from "./elements/atoms.js";
import { defineForm } from "./elements/form.js";
import { defineLayout } from "./elements/layout.js";
import { defineChrome } from "./elements/chrome.js";
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";

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"ink": { "value": "#0A0A0A", "type": "color", "comment": "Near-black. The only fill most of the time." },
"ink-2": { "value": "#1F1F1F", "type": "color" },
"ink-3": { "value": "#5C5C5C", "type": "color" },
"ink-4": { "value": "#8A8A8A", "type": "color" },
"ink-5": { "value": "#B5B5B3", "type": "color", "comment": "Placeholder text, wireframe labels." },
"line": { "value": "#E5E5E2", "type": "color", "comment": "Default 1px wireframe rule." },
"line-strong": { "value": "#C9C9C5", "type": "color" },
"line-soft": { "value": "#F0F0EC", "type": "color" },
"paper": { "value": "#FFFFFF", "type": "color" },
"paper-2": { "value": "#FAFAF7", "type": "color" },
"paper-3": { "value": "#F4F4EF", "type": "color" },
"hi": { "value": "#FFE14A", "type": "color", "comment": "Annotation yellow. Highlighter only, never a button fill." },
"hi-2": { "value": "#FFD400", "type": "color" },
"hi-ink": { "value": "#1A1500", "type": "color", "comment": "Text on yellow." },
"status-raw": { "value": "#B5B5B3", "type": "color", "comment": "S0 — no signal" },
"status-weak": { "value": "#8A8A8A", "type": "color", "comment": "S1 — weak signal" },
"status-medium": { "value": "#5C5C5C", "type": "color", "comment": "S2 — medium signal" },
"status-strong": { "value": "#0A0A0A", "type": "color", "comment": "S3 — strong signal" },
"status-commercial": { "value": "#FFD400", "type": "color", "comment": "S4 — commercial" }
}

View File

@@ -0,0 +1,6 @@
{
"comment": "Manifest pointing at the three token files. Source-of-truth for any future Style Dictionary build.",
"colors": "./colors.json",
"type": "./type.json",
"spacing": "./spacing.json"
}

View File

@@ -0,0 +1,28 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"spacing": {
"1": { "value": "4px", "type": "dimension" },
"2": { "value": "8px", "type": "dimension" },
"3": { "value": "12px", "type": "dimension" },
"4": { "value": "16px", "type": "dimension" },
"5": { "value": "24px", "type": "dimension" },
"6": { "value": "32px", "type": "dimension" },
"7": { "value": "48px", "type": "dimension" },
"8": { "value": "64px", "type": "dimension" },
"9": { "value": "96px", "type": "dimension" },
"10": { "value": "128px", "type": "dimension" }
},
"radius": {
"0": { "value": "0px", "type": "dimension" },
"1": { "value": "2px", "type": "dimension" },
"2": { "value": "4px", "type": "dimension" },
"3": { "value": "8px", "type": "dimension" },
"pill": { "value": "999px", "type": "dimension" }
},
"shadow": {
"0": { "value": "none", "type": "shadow" },
"1": { "value": "0 1px 0 #E5E5E2", "type": "shadow" },
"2": { "value": "0 1px 0 #C9C9C5", "type": "shadow" },
"3": { "value": "0 4px 12px -6px rgba(10,10,10,0.10)", "type": "shadow", "comment": "Floating elements only." }
}
}

View File

@@ -0,0 +1,33 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"family": {
"sans": { "value": "\"IBM Plex Sans\", ui-sans-serif, system-ui, sans-serif", "type": "fontFamily" },
"mono": { "value": "\"IBM Plex Mono\", ui-monospace, \"SF Mono\", Menlo, monospace", "type": "fontFamily" },
"serif": { "value": "\"IBM Plex Serif\", \"Iowan Old Style\", Georgia, serif", "type": "fontFamily" }
},
"size": {
"xs": { "value": "11px", "type": "dimension" },
"sm": { "value": "13px", "type": "dimension" },
"base": { "value": "15px", "type": "dimension" },
"md": { "value": "17px", "type": "dimension" },
"lg": { "value": "20px", "type": "dimension" },
"xl": { "value": "24px", "type": "dimension" },
"2xl": { "value": "32px", "type": "dimension" },
"3xl": { "value": "44px", "type": "dimension" },
"4xl": { "value": "64px", "type": "dimension" },
"5xl": { "value": "96px", "type": "dimension" }
},
"lineHeight": {
"tight": { "value": 1.05, "type": "number" },
"snug": { "value": 1.25, "type": "number" },
"base": { "value": 1.5, "type": "number" },
"loose": { "value": 1.7, "type": "number" }
},
"tracking": {
"tight": { "value": "-0.02em", "type": "dimension" },
"snug": { "value": "-0.01em", "type": "dimension" },
"base": { "value": "0em", "type": "dimension" },
"mono": { "value": "0.02em", "type": "dimension" },
"label": { "value": "0.08em", "type": "dimension", "comment": "Uppercase eyebrow labels." }
}
}