Seeded claude design
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled

This commit is contained in:
2026-05-23 16:34:14 +02:00
commit 9419f166ce
32 changed files with 2553 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
// =============================================================
// Atoms — Eyebrow, Tag, Button, StageDot, Stamp, IconBtn
// =============================================================
function Eyebrow({ children, style }) {
return (
<span style={{
font: '500 11px/1.2 var(--ff-mono)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--fg-3)',
...style,
}}>{children}</span>
);
}
function Tag({ children, active, draft, style }) {
const base = {
font: '500 10px/1 var(--ff-mono)',
letterSpacing: '0.1em',
textTransform: 'uppercase',
padding: '5px 10px',
borderRadius: 'var(--r-pill)',
border: '1px solid var(--border)',
color: 'var(--fg-2)',
background: 'var(--paper)',
display: 'inline-block',
};
if (active) Object.assign(base, { background: 'var(--ink)', color: 'var(--paper)', borderColor: 'var(--ink)' });
if (draft) Object.assign(base, { background: 'var(--hi)', color: 'var(--hi-ink)', borderColor: 'transparent' });
return <span style={{ ...base, ...style }}>{children}</span>;
}
function Button({ children, variant = 'secondary', onClick, style, icon }) {
const base = {
font: '500 13px var(--ff-sans)',
letterSpacing: '-0.005em',
padding: '9px 14px',
borderRadius: 'var(--r-2)',
border: '1px solid var(--border)',
background: 'var(--paper)',
color: 'var(--ink)',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
whiteSpace: 'nowrap',
transition: 'background 120ms ease, border-color 120ms ease',
};
if (variant === 'primary') Object.assign(base, { background: 'var(--ink)', color: 'var(--paper)', borderColor: 'var(--ink)' });
if (variant === 'ghost') Object.assign(base, { background: 'transparent', borderColor: 'transparent', padding: '7px 10px' });
return (
<button onClick={onClick} style={{ ...base, ...style }}>
{icon && <i data-lucide={icon} style={{ width: 14, height: 14, strokeWidth: 1.5 }}></i>}
{children}
</button>
);
}
const STAGE_COLORS = {
S0: '#B5B5B3', S1: '#8A8A8A', S2: '#5C5C5C', S3: '#0A0A0A', S4: '#FFD400',
};
function StageDot({ level = 'S2', label, style }) {
return (
<span style={{
font: '500 10px/1 var(--ff-mono)',
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'var(--fg-2)',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
...style,
}}>
<span style={{ width: 8, height: 8, borderRadius: 999, background: STAGE_COLORS[level] }}></span>
{label || level}
</span>
);
}
function Stamp({ children, style }) {
return (
<span style={{
display: 'inline-block',
background: 'var(--hi)',
color: 'var(--hi-ink)',
padding: '5px 10px 3px',
font: '500 10px/1 var(--ff-mono)',
letterSpacing: '0.12em',
textTransform: 'uppercase',
transform: 'rotate(-1.5deg)',
...style,
}}>{children}</span>
);
}
function Icon({ name, size = 16, style }) {
return <i data-lucide={name} style={{ width: size, height: size, strokeWidth: 1.5, ...style }}></i>;
}
Object.assign(window, { Eyebrow, Tag, Button, StageDot, Stamp, Icon, STAGE_COLORS });

View File

@@ -0,0 +1,165 @@
// =============================================================
// Chrome — TopNav, Sidebar, PageHeader, PipelineStrip
// =============================================================
function TopNav({ onNew }) {
return (
<nav style={{
height: 56,
background: 'rgba(255,255,255,0.92)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
gap: 28,
padding: '0 24px',
position: 'sticky',
top: 0,
zIndex: 10,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src="../../assets/whynot-logo.png" alt="" style={{ width: 22, height: 22 }} />
<span style={{ font: '500 14px var(--ff-sans)' }}>whynot</span>
<span style={{ font: '400 12px var(--ff-mono)', color: 'var(--fg-3)', letterSpacing: '0.04em' }}>/ control</span>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
font: '400 12px var(--ff-mono)',
color: 'var(--fg-3)',
border: '1px solid var(--border)',
padding: '6px 10px',
borderRadius: 'var(--r-1)',
display: 'flex', alignItems: 'center', gap: 10,
minWidth: 240,
}}>
<Icon name="search" size={14} />
<span>Search ideas, prototypes, signals</span>
<span style={{ marginLeft: 'auto', padding: '1px 5px', border: '1px solid var(--border)', borderRadius: 2, fontSize: 10 }}> K</span>
</div>
<Button variant="primary" icon="plus" onClick={onNew}>New idea</Button>
</div>
</nav>
);
}
const NAV_ITEMS = [
{ key: 'inbox', label: 'Inbox', icon: 'inbox', count: 7 },
{ key: 'prototypes', label: 'Prototypes', icon: 'flask-conical', count: 4 },
{ key: 'signals', label: 'Signals', icon: 'activity', count: 12 },
{ key: 'betas', label: 'Betas', icon: 'users', count: 1 },
{ key: 'decisions', label: 'Decisions', icon: 'check-square', count: 3 },
];
const DOC_ITEMS = [
{ key: 'intent', label: 'INTENT.md' },
{ key: 'scope', label: 'SCOPE.md' },
{ key: 'operating', label: 'OPERATING_MODEL.md' },
{ key: 'pipeline', label: 'PROTOTYPE_PIPELINE.md' },
{ key: 'agent', label: 'AGENT_RULES.md' },
];
function Sidebar({ current, onNav }) {
const itemStyle = (active) => ({
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
borderRadius: 4,
color: active ? 'var(--fg-1)' : 'var(--fg-2)',
background: active ? 'var(--paper)' : 'transparent',
boxShadow: active ? '0 0 0 1px var(--border) inset' : 'none',
font: '500 13px var(--ff-sans)',
cursor: 'pointer',
textDecoration: 'none',
transition: 'background 120ms ease, color 120ms ease',
});
return (
<aside style={{
width: 240, flex: 'none',
background: 'var(--paper-2)',
borderRight: '1px solid var(--border)',
padding: '24px 16px',
display: 'flex', flexDirection: 'column', gap: 24,
height: 'calc(100vh - 56px)',
position: 'sticky', top: 56,
overflowY: 'auto',
}}>
<div>
<Eyebrow style={{ paddingLeft: 12, marginBottom: 8, display: 'block' }}>Work</Eyebrow>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{NAV_ITEMS.map(item => (
<a key={item.key} onClick={() => onNav(item.key)} style={itemStyle(current === item.key)}>
<Icon name={item.icon} size={16} />
<span>{item.label}</span>
<span style={{ marginLeft: 'auto', font: '400 11px var(--ff-mono)', color: 'var(--fg-3)' }}>{item.count}</span>
</a>
))}
</div>
</div>
<div>
<Eyebrow style={{ paddingLeft: 12, marginBottom: 8, display: 'block' }}>Control docs</Eyebrow>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{DOC_ITEMS.map(item => (
<a key={item.key} onClick={() => onNav('doc:' + item.key)} style={{ ...itemStyle(current === 'doc:' + item.key), font: '400 12px var(--ff-mono)' }}>
<Icon name="file-text" size={14} />
<span>{item.label}</span>
</a>
))}
</div>
</div>
<div style={{ marginTop: 'auto', paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 12px' }}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--hi-2)' }}></span>
<span style={{ font: '500 11px var(--ff-mono)', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--fg-2)' }}>A1 · Incubating</span>
</div>
</div>
</aside>
);
}
function PageHeader({ eyebrow, title, lede, actions }) {
return (
<header style={{ marginBottom: 32, display: 'flex', flexDirection: 'column', gap: 8 }}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 24 }}>
<h1 style={{ font: '500 32px/1.15 var(--ff-sans)', letterSpacing: '-0.015em', margin: 0, flex: 1 }}>{title}</h1>
{actions && <div style={{ display: 'flex', gap: 8 }}>{actions}</div>}
</div>
{lede && <p style={{ font: '400 16px/1.55 var(--ff-sans)', color: 'var(--fg-2)', margin: 0, maxWidth: '60ch' }}>{lede}</p>}
</header>
);
}
function PipelineStrip({ activeIdx = 3 }) {
const stages = [
{ num: 'Stage 0', name: 'Raw idea', meta: 'inbox/' },
{ num: 'Stage 1', name: 'Triage', meta: '2026-02-12' },
{ num: 'Stage 2', name: 'Prototype card', meta: 'prototypes/' },
{ num: 'Stage 3', name: 'Experiment', meta: 'ends 2026-04-01' },
{ num: 'Stage 4', name: 'Signal review', meta: '— pending' },
];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 0, position: 'relative', margin: '0 0 32px' }}>
{stages.map((s, i) => {
const state = i < activeIdx ? 'done' : i === activeIdx ? 'active' : 'pending';
const topColor = state === 'done' ? 'var(--ink)' : state === 'active' ? 'var(--hi-2)' : 'var(--border)';
return (
<div key={i} style={{
padding: '10px 12px 14px',
borderTop: `2px solid ${topColor}`,
display: 'flex', flexDirection: 'column', gap: 4,
position: 'relative',
}}>
<span style={{ font: '500 10px/1 var(--ff-mono)', letterSpacing: '0.1em', textTransform: 'uppercase', color: state === 'pending' ? 'var(--fg-3)' : 'var(--fg-1)' }}>{s.num}</span>
<span style={{ font: '500 14px/1.25 var(--ff-sans)', color: state === 'pending' ? 'var(--fg-3)' : 'var(--fg-1)' }}>{s.name}</span>
<span style={{ font: '400 11px/1.35 var(--ff-mono)', color: 'var(--fg-3)' }}>{s.meta}</span>
{i > 0 && (
<span style={{ position: 'absolute', top: -8, right: -7, font: '400 14px var(--ff-mono)', color: state === 'pending' ? 'var(--ink-5)' : 'var(--ink)' }}></span>
)}
</div>
);
})}
</div>
);
}
Object.assign(window, { TopNav, Sidebar, PageHeader, PipelineStrip, NAV_ITEMS, DOC_ITEMS });

View File

@@ -0,0 +1,102 @@
// =============================================================
// Document viewer — renders one of the control docs
// =============================================================
const DOC_CONTENT = {
intent: {
title: 'INTENT.md',
eyebrow: 'whynot-control · control document',
sections: [
{ h: 'Purpose', p: 'whynot-control exists to serve as the control repository for the whynot organisation: a prototype, feedback, and market-signal space for discovering the weird and the useful.' },
{ h: 'Primary utility', list: [
'capture unusual but potentially useful ideas;',
'distinguish curiosity from commitment;',
'shape rough ideas into testable prototypes;',
'collect early feedback and market signals;',
'run closed beta concepts in a controlled way;',
'identify which ideas should move toward Helix, Coulomb, Sloppers, Plenitude, Binky, or Tegwick;',
'prevent premature productisation.',
]},
{ h: 'Operating principle', quote: 'A prototype is a question made tangible. The purpose of a prototype is not to prove that an idea is brilliant. The purpose is to learn what is actually useful, desirable, feasible, or irrelevant.' },
],
},
scope: {
title: 'SCOPE.md',
eyebrow: 'whynot-control · control document',
sections: [
{ h: 'Current reality', p: 'whynot-control is the control repository for organising prototype exploration and early market-signal capture.' },
{ h: 'In scope', list: ['Prototype idea capture.', 'Prototype classification.', 'Early user feedback notes.', 'Market-signal tracking.', 'Closed beta planning.', 'Experiment records.', 'Promotion recommendations.', 'Agent-assisted drafting and analysis.'] },
{ h: 'Out of scope', list: ['Production implementation.', 'Long-term product maintenance.', 'Payment processing.', 'Legal investment documentation.', 'Public launch operations.', 'Binding financial, legal, or tax conclusions.'] },
{ h: 'Scope guardrail', quote: 'whynot-control explores and validates. It does not absorb all product development.' },
],
},
operating: {
title: 'OPERATING_MODEL.md',
eyebrow: 'whynot-control · control document',
sections: [
{ h: 'Core rules', list: [
'Prototypes are questions. Each prototype should express a question about usefulness, desirability, feasibility, or willingness to pay.',
'Signal beats enthusiasm. An idea should not be promoted only because it is exciting.',
'Low-cost learning first. Prefer sketches, mockups, demos, landing pages, conversations.',
'Closed beta before broad launch.',
'Promotion requires criteria.',
]},
{ h: 'Burnout guardrail', quote: 'A prototype can be interesting and still be parked. whynot exists to reduce uncertainty, not to create more obligations.' },
],
},
pipeline: {
title: 'PROTOTYPE_PIPELINE.md',
eyebrow: 'whynot-control · control document',
sections: [
{ h: 'Stage 0 — Raw capture', p: 'Capture ideas without judging them immediately. Located in inbox/. Done when the idea is saved and no longer needs to be held in memory.' },
{ h: 'Stage 1 — Triage', p: 'Decide whether an idea deserves a prototype card. Outcomes: create card, park, merge, reject.' },
{ h: 'Stage 2 — Prototype card', p: 'Turn the idea into a structured prototype candidate. Located in prototypes/.' },
{ h: 'Stage 3 — Experiment', p: 'Test the idea with minimal cost: concept note, landing page, clickable mockup, CLI/demo script, Wizard-of-Oz, manual concierge test, closed conversation, private beta.' },
{ h: 'Stage 4 — Signal review', p: 'Evaluate what was learned. Interest, usefulness, retention, referral, payment, contribution, strategic fit.' },
{ h: 'Stage 5 — Decision', p: 'Park, iterate, promote, reject, or merge. Promotion requires an explicit record in DECISIONS.md.' },
],
},
agent: {
title: 'AGENT_RULES.md',
eyebrow: 'whynot-control · control document',
sections: [
{ h: 'General principle', p: 'Agents may help clarify, structure, draft, compare, and analyse prototype ideas. They must not silently turn experiments into product commitments.' },
{ h: 'Allowed', list: ['draft prototype cards', 'classify ideas by lifecycle stage', 'propose smallest useful tests', 'summarise feedback', 'compare prototype candidates', 'improve wording and structure'] },
{ h: 'Forbidden', list: ['create artificial urgency', 'treat all prototype ideas as products', 'infer willingness to pay without evidence', 'present weak signals as strong validation', 'create legal, financial, or investment commitments'] },
{ h: 'Preferred output style', quote: 'Agent outputs should be concise, evidence-oriented, explicit about uncertainty, and careful to separate idea, hypothesis, signal, and decision.' },
],
},
};
function DocView({ docKey }) {
const doc = DOC_CONTENT[docKey];
if (!doc) return <div>Doc not found.</div>;
return (
<article style={{ maxWidth: 680 }}>
<Eyebrow>{doc.eyebrow}</Eyebrow>
<h1 style={{ font: '600 36px/1.1 var(--ff-mono)', letterSpacing: '-0.01em', margin: '12px 0 28px' }}>{doc.title}</h1>
{doc.sections.map((s, i) => (
<section key={i} style={{ marginBottom: 36 }}>
<h2 style={{ font: '500 22px/1.25 var(--ff-sans)', letterSpacing: '-0.005em', margin: '0 0 14px' }}>{s.h}</h2>
{s.p && <p style={{ margin: 0, font: '400 15px/1.65 var(--ff-sans)', color: 'var(--fg-1)' }}>{s.p}</p>}
{s.list && (
<ul style={{ margin: 0, paddingLeft: 18, color: 'var(--fg-1)', font: '400 15px/1.7 var(--ff-sans)' }}>
{s.list.map((li, j) => <li key={j} style={{ marginBottom: 6 }}>{li}</li>)}
</ul>
)}
{s.quote && (
<blockquote style={{ margin: 0, paddingLeft: 16, borderLeft: '1px solid var(--border-strong)' }}>
<p style={{ margin: 0, font: '400 italic 17px/1.55 var(--ff-serif)', color: 'var(--fg-2)' }}>{s.quote}</p>
</blockquote>
)}
</section>
))}
<div style={{ marginTop: 48, padding: '14px 0', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', font: '400 11px var(--ff-mono)', color: 'var(--fg-3)' }}>
<span>whynot-control / {doc.title}</span>
<span>A1 · Incubating · 2026</span>
</div>
</article>
);
}
Object.assign(window, { DocView, DOC_CONTENT });

View File

@@ -0,0 +1,31 @@
# whynot-control UI kit
A click-through high-fidelity recreation of the `whynot-control` repository — rendered not as a folder of Markdown files, but as the lightweight web application it implies.
This kit demonstrates the WhyNot Design System applied to its primary use case: a prototype-and-signal control surface. Everything here is **cosmetic** — there's no backend, no persistence, no real router. Each screen is a working visual artefact you can drop into a design review.
## Screens
| Screen | Source doc(s) | Component |
|---|---|---|
| Inbox | `inbox/` | `Inbox.jsx` |
| Prototypes (index) | `prototypes/` + `PROTOTYPE_PIPELINE.md` | `PrototypesIndex.jsx` |
| Prototype (detail) | `templates/prototype-card.md` + `example-prototype-card.md` | `PrototypeDetail.jsx` |
| Signals | `signals/` + `MARKET_SIGNAL.md` | `SignalsIndex.jsx` |
| Document viewer | `INTENT.md`, `OPERATING_MODEL.md` | `DocView.jsx` |
## Components
- `TopNav.jsx` — sticky 56px hairline top bar (search + new-idea action).
- `Sidebar.jsx` — left rail with org slug, repo nav, activation indicator.
- `PrototypeCard.jsx` — the card from `preview/comp-prototype-card.html`, factored.
- `PipelineStrip.jsx` — the 5-stage progress strip from `preview/comp-pipeline.html`.
- `SignalRow.jsx` — one row in the signals table.
- `Tag.jsx`, `Eyebrow.jsx`, `Button.jsx`, `StageDot.jsx`, `Stamp.jsx` — atoms used everywhere.
## Conventions
- All components are flat function components, no hooks beyond `useState` for screen routing.
- Components export themselves onto `window` so each `<script type="text/babel">` file can find them.
- Style objects are inline or scoped (e.g. `cardStyles`, `navStyles`) to avoid name collisions.
- Icons are Lucide via CDN, rendered as `<i data-lucide="…">` and hydrated by `lucide.createIcons()`.

View File

@@ -0,0 +1,282 @@
// =============================================================
// Screens — Inbox, PrototypesIndex, PrototypeDetail, SignalsIndex, DocView, BetasIndex, DecisionsIndex
// =============================================================
function Inbox({ onCapture }) {
const [draft, setDraft] = React.useState('');
return (
<div>
<PageHeader
eyebrow="whynot-control / inbox"
title="Inbox"
lede="Temporary capture for rough ideas, weird observations, user comments, market hints, and product fragments. Capture is not commitment."
/>
<div style={{
border: '1px solid var(--border)',
borderRadius: 'var(--r-2)',
padding: 16,
background: 'var(--paper)',
marginBottom: 28,
display: 'flex', flexDirection: 'column', gap: 10,
}}>
<Eyebrow>Capture</Eyebrow>
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder="An idea, an observation, a fragment. No filter, no judgement, no commitment."
style={{
font: '400 14px/1.5 var(--ff-sans)',
border: 'none', outline: 'none', resize: 'none',
minHeight: 64, padding: 0, background: 'transparent', color: 'var(--fg-1)',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ font: '400 11px var(--ff-mono)', color: 'var(--fg-3)', marginRight: 'auto' }}>
to capture · stored in <code className="mono">inbox/</code>
</span>
<Button variant="ghost" onClick={() => setDraft('')}>Discard</Button>
<Button variant="primary" icon="inbox" onClick={() => { onCapture && onCapture(draft); setDraft(''); }}>Capture</Button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<Eyebrow>Recent · 7</Eyebrow>
<div style={{ flex: 1, borderTop: '1px solid var(--border-soft)' }}></div>
<span style={{ font: '400 11px var(--ff-mono)', color: 'var(--fg-3)' }}> newest first</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{INBOX.map(item => (
<div key={item.id} style={{
display: 'grid',
gridTemplateColumns: '140px 1fr 100px',
gap: 20,
padding: '14px 4px',
borderBottom: '1px solid var(--border-soft)',
alignItems: 'flex-start',
}}>
<span style={{ font: '400 11px var(--ff-mono)', color: 'var(--fg-3)', paddingTop: 2 }}>{item.ts}</span>
<p style={{ margin: 0, font: '400 14px/1.5 var(--ff-sans)', color: 'var(--fg-1)' }}>{item.text}</p>
<span style={{ font: '500 10px/1 var(--ff-mono)', letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--fg-3)', textAlign: 'right', paddingTop: 4 }}>{item.from}</span>
</div>
))}
</div>
</div>
);
}
function PrototypeListCard({ p, onOpen }) {
const [hover, setHover] = React.useState(false);
return (
<article
onClick={() => onOpen(p.id)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
background: 'var(--paper)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-2)',
padding: '20px 22px',
display: 'flex',
flexDirection: 'column',
gap: 10,
position: 'relative',
cursor: 'pointer',
transition: 'border-color 120ms ease',
borderColor: hover ? 'var(--ink)' : 'var(--border)',
}}>
{hover && <span style={{ position: 'absolute', left: -1, top: -1, bottom: -1, width: 2, background: 'var(--ink)', borderRadius: '2px 0 0 2px' }}></span>}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Eyebrow style={{ color: hover ? 'var(--fg-1)' : 'var(--fg-3)' }}>{p.id} · Prototype</Eyebrow>
<StageDot level={p.signal} label={p.stageLabel} />
</div>
<h3 style={{ font: '500 17px/1.35 var(--ff-sans)', margin: '4px 0 8px', color: 'var(--fg-1)' }}>{p.pitch}</h3>
<div style={{ display: 'grid', gridTemplateColumns: '110px 1fr', gap: '6px 12px', fontSize: 13, color: 'var(--fg-1)' }}>
<span style={{ font: '500 11px/1.5 var(--ff-mono)', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--fg-3)' }}>Learning q.</span>
<span style={{ lineHeight: 1.45 }}>{p.learning}</span>
<span style={{ font: '500 11px/1.5 var(--ff-mono)', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--fg-3)' }}>Smallest test</span>
<span style={{ lineHeight: 1.45 }}>{p.test}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: 12, marginTop: 4, borderTop: '1px solid var(--border-soft)', font: '500 11px var(--ff-mono)', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--fg-3)' }}>
<span> {p.target}</span>
<span>{p.signal} signal</span>
</div>
</article>
);
}
function PrototypesIndex({ onOpen }) {
const [filter, setFilter] = React.useState('All');
const filters = ['All', 'Experiment', 'Signal review', 'Parked'];
const list = filter === 'All' ? PROTOTYPES : PROTOTYPES.filter(p => p.stageLabel === filter);
return (
<div>
<PageHeader
eyebrow="whynot-control / prototypes"
title="Prototypes"
lede="Structured prototype cards. A prototype card defines a learning question and the smallest useful test."
actions={<Button variant="primary" icon="plus">New prototype</Button>}
/>
<div style={{ display: 'flex', gap: 8, marginBottom: 24, alignItems: 'center' }}>
{filters.map(f => (
<Tag key={f} active={filter === f} style={{ cursor: 'pointer' }} >
<span onClick={() => setFilter(f)}>{f}</span>
</Tag>
))}
<span style={{ marginLeft: 'auto', font: '400 11px var(--ff-mono)', color: 'var(--fg-3)' }}>{list.length} of {PROTOTYPES.length}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
{list.map(p => <PrototypeListCard key={p.id} p={p} onOpen={onOpen} />)}
</div>
</div>
);
}
function PrototypeDetail({ id, onBack }) {
const p = PROTOTYPES.find(p => p.id === id) || PROTOTYPES[0];
const stageIdx = { 'parked': 0, 'experiment': 3, 'signal': 4, 'experiment-active': 3 }[p.stage] ?? 3;
return (
<div>
<a onClick={onBack} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, font: '400 12px var(--ff-mono)', color: 'var(--fg-2)', textDecoration: 'none', marginBottom: 18, cursor: 'pointer' }}>
<Icon name="arrow-left" size={14} /> Back to prototypes
</a>
<PageHeader
eyebrow={`${p.id} · Prototype`}
title={p.pitch}
actions={
<React.Fragment>
<Button variant="secondary" icon="archive">Park</Button>
<Button variant="primary" icon="arrow-right">Promote {p.target}</Button>
</React.Fragment>
}
/>
<PipelineStrip activeIdx={stageIdx} />
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 32 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
<Field label="Learning question" value={p.learning} />
<Field label="Smallest useful test" value={p.test} />
<Field label="Expected signal" value="At least one person asks for a concrete next step, gives specific use-case feedback, or identifies a realistic context where the idea would matter." />
<Field label="Risks" value={p.risks} />
</div>
<aside style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<SidebarField label="Stage" value={<Tag active>{p.stageLabel}</Tag>} />
<SidebarField label="Signal" value={<StageDot level={p.signal} />} />
<SidebarField label="Target" value={<code className="mono"> {p.target}</code>} />
<SidebarField label="Audience" value="Potential early users, collaborators, or customers." />
<SidebarField label="Agentic suitability" value="Agents may help turn rough notes into a sharper prototype card." />
<div style={{ marginTop: 6, border: '1px dashed var(--border-strong)', borderRadius: 4, padding: 14 }}>
<Eyebrow style={{ display: 'block', marginBottom: 8 }}>Caveat</Eyebrow>
<p style={{ margin: 0, font: '400 13px/1.55 var(--ff-sans)', color: 'var(--fg-2)' }}>
A prototype can be interesting and still be parked. <code className="mono">whynot</code> exists to reduce uncertainty, not create more obligations.
</p>
</div>
</aside>
</div>
</div>
);
}
function Field({ label, value }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Eyebrow>{label}</Eyebrow>
<p style={{ margin: 0, font: '400 15px/1.55 var(--ff-sans)', color: 'var(--fg-1)' }}>{value}</p>
</div>
);
}
function SidebarField({ label, value }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Eyebrow>{label}</Eyebrow>
<div style={{ font: '400 13px/1.5 var(--ff-sans)', color: 'var(--fg-1)' }}>{value}</div>
</div>
);
}
function SignalsIndex() {
return (
<div>
<PageHeader
eyebrow="whynot-control / signals"
title="Signals"
lede="Market-signal and feedback records. A signal is evidence. Record what happened, who did it, and how strong the evidence is."
actions={<Button variant="primary" icon="plus">Record signal</Button>}
/>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<thead>
<tr>
<th style={{ width: 80 }}>ID</th>
<th style={{ width: 90 }}>Prototype</th>
<th style={{ width: 72 }}>Level</th>
<th>What happened</th>
<th style={{ width: 110 }}>Source</th>
<th style={{ width: 90 }}>Date</th>
</tr>
</thead>
<tbody>
{SIGNALS.map(s => (
<tr key={s.id}>
<td><code className="mono">{s.id}</code></td>
<td><code className="mono">{s.proto}</code></td>
<td><StageDot level={s.level} /></td>
<td style={{ color: 'var(--fg-1)', fontSize: 13, lineHeight: 1.5 }}>{s.what}</td>
<td style={{ font: '400 12px var(--ff-mono)', color: 'var(--fg-2)' }}>{s.source}</td>
<td style={{ font: '400 12px var(--ff-mono)', color: 'var(--fg-3)' }}>{s.date}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function BetasIndex() {
return (
<div>
<PageHeader
eyebrow="whynot-control / betas"
title="Betas"
lede="Closed beta plans and beta review notes. A beta should have a clear learning question, entry criteria, and exit outcome."
/>
<div style={{
border: '1px dashed var(--border-strong)',
padding: 32,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
textAlign: 'center',
color: 'var(--fg-3)',
borderRadius: 4,
}}>
<Icon name="users" size={20} />
<div style={{ font: '500 14px var(--ff-sans)', color: 'var(--fg-2)' }}>One beta plan in draft.</div>
<div style={{ font: '400 12px var(--ff-mono)', color: 'var(--fg-3)' }}>WNO-021 · Concierge triage · pending Binky approval</div>
<a href="#" style={{ font: '500 12px var(--ff-mono)', color: 'var(--fg-1)', marginTop: 4 }}>Open draft </a>
</div>
</div>
);
}
function DecisionsIndex() {
const decisions = [
{ id: 'DEC-001', title: 'Shorten organisation name from whywhynot to whynot.', status: 'Accepted', date: '2026-01-08' },
{ id: 'DEC-002', title: 'Maintain A1 Incubating until first prototype candidates review.', status: 'Open', date: '—' },
{ id: 'DEC-003', title: 'Initial promotion targets: Helix, Coulomb, Sloppers, Plenitude, Binky, Tegwick.', status: 'Open', date: '—' },
];
return (
<div>
<PageHeader eyebrow="whynot-control / decisions" title="Decisions" lede="A promotion record is required before any prototype moves to Helix, Coulomb, Sloppers, Plenitude, Binky, or Tegwick." />
<div style={{ display: 'flex', flexDirection: 'column' }}>
{decisions.map(d => (
<div key={d.id} style={{ display: 'grid', gridTemplateColumns: '90px 1fr 130px 100px', gap: 20, alignItems: 'baseline', padding: '16px 4px', borderBottom: '1px solid var(--border-soft)' }}>
<code className="mono" style={{ background: 'none', padding: 0, color: 'var(--fg-1)' }}>{d.id}</code>
<span style={{ font: '500 15px var(--ff-sans)', color: 'var(--fg-1)' }}>{d.title}</span>
<Tag active={d.status === 'Accepted'} draft={d.status === 'Open'}>{d.status}</Tag>
<span style={{ font: '400 12px var(--ff-mono)', color: 'var(--fg-3)', textAlign: 'right' }}>{d.date}</span>
</div>
))}
</div>
</div>
);
}
Object.assign(window, { Inbox, PrototypesIndex, PrototypeDetail, SignalsIndex, BetasIndex, DecisionsIndex, Field, SidebarField, PrototypeListCard });

View File

@@ -0,0 +1,71 @@
// =============================================================
// Sample data — prototypes, signals, inbox items
// =============================================================
const PROTOTYPES = [
{
id: 'WNO-014',
pitch: 'A field-notebook for catching weird ideas before they evaporate.',
learning: 'Do people return to capture more than once?',
test: 'One-page landing + email capture, 14 days.',
target: 'Coulomb',
stage: 'experiment',
stageLabel: 'Experiment',
signal: 'S1',
risks: 'Confused with note-taking apps.',
},
{
id: 'WNO-017',
pitch: 'A LEGO-brick mood board for engineers who don\u2019t think in mood boards.',
learning: 'Will engineers attach metaphors to their tickets?',
test: 'Slack bot, three teams, two weeks.',
target: 'Helix',
stage: 'signal',
stageLabel: 'Signal review',
signal: 'S3',
risks: 'Cute but unused after a week.',
},
{
id: 'WNO-021',
pitch: 'Concierge-style \u201cprototype triage\u201d for indie hackers.',
learning: 'Will three founders pay for a one-hour triage call?',
test: 'Offer beta · 3 calls · listed price.',
target: 'Plenitude',
stage: 'experiment',
stageLabel: 'Experiment',
signal: 'S2',
risks: 'Time-cost outruns signal value.',
},
{
id: 'WNO-024',
pitch: 'A relevant-#CoronaPolitics timeline, re-released with one editor.',
learning: 'Is there residual demand five years on?',
test: 'Static preview page, 30 days, count returns.',
target: 'None yet',
stage: 'parked',
stageLabel: 'Parked',
signal: 'S0',
risks: 'Topical relevance has clearly faded.',
},
];
const INBOX = [
{ id: 1, ts: '2026-03-02 14:21', text: 'Idea: \u201csubway map\u201d view of the prototype pipeline. People understand transit maps; they don\u2019t understand kanban boards.', from: 'Tegwick' },
{ id: 2, ts: '2026-03-01 09:08', text: 'Weird observation from yesterday\u2019s call: three founders independently asked for \u201csomething to capture the half-formed stuff\u201d.', from: 'note-to-self' },
{ id: 3, ts: '2026-02-28 23:55', text: 'Could the LEGO-brick metaphor extend to a public \u201cbuild log\u201d format? One brick = one decision.', from: 'Tegwick' },
{ id: 4, ts: '2026-02-27 11:34', text: 'Park idea: realtime sentiment dashboard for prototype landing pages. Probably worse than reading the comments.', from: 'note-to-self' },
{ id: 5, ts: '2026-02-26 17:02', text: 'Conversation with R. about closed-beta etiquette. Useful: pre-write the exit email before the beta opens.', from: 'note-to-self' },
{ id: 6, ts: '2026-02-25 08:12', text: 'fuerindifferenz shirts: residual interest from old whywhynot.de page. Could a yearly drop work?', from: 'Tegwick' },
{ id: 7, ts: '2026-02-24 15:40', text: 'Tiny idea: a \u201creject log\u201d that publishes the ideas you said no to, with one-sentence reasons.', from: 'note-to-self' },
];
const SIGNALS = [
{ id: 'SIG-031', proto: 'WNO-017', level: 'S3', what: 'Two teams shipped public README sections labelled \u201cbrick: scope\u201d after using the bot for a week.', source: 'usage log', date: '2026-03-04' },
{ id: 'SIG-030', proto: 'WNO-017', level: 'S2', what: 'Three engineers DM\u2019d asking for an export-to-Notion option.', source: 'Slack', date: '2026-03-03' },
{ id: 'SIG-029', proto: 'WNO-014', level: 'S1', what: 'Landing page: 34 visits, 7 emails, 0 returns in week 1.', source: 'Plausible', date: '2026-03-01' },
{ id: 'SIG-028', proto: 'WNO-021', level: 'S2', what: 'First triage call booked at listed price; second declined on price.', source: 'Stripe / email', date: '2026-02-28' },
{ id: 'SIG-027', proto: 'WNO-021', level: 'S1', what: '\u201cInteresting but I\u2019d want a free first one\u201d \u00d72.', source: 'interview', date: '2026-02-26' },
{ id: 'SIG-026', proto: 'WNO-024', level: 'S0', what: 'Static preview: 12 visits in 30 days, 0 returns.', source: 'Plausible', date: '2026-02-24' },
];
Object.assign(window, { PROTOTYPES, INBOX, SIGNALS });

View File

@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>whynot · control</title>
<link rel="icon" href="../../assets/whynot-logo.png">
<link rel="stylesheet" href="../../colors_and_type.css">
<style>
html, body { background: var(--paper); }
body { min-height: 100vh; }
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
.main { padding: 40px 48px 80px; max-width: 1180px; }
/* Lucide icons inherit currentColor */
[data-lucide] { stroke-width: 1.5; }
/* Cleanup: button reset */
button { font-family: inherit; }
button:active { transform: none; }
a { cursor: pointer; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="Atoms.jsx"></script>
<script type="text/babel" src="Chrome.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="Screens.jsx"></script>
<script type="text/babel" src="DocView.jsx"></script>
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
const [route, setRoute] = useState('prototypes'); // inbox | prototypes | signals | betas | decisions | proto:<id> | doc:<key>
useEffect(() => {
// re-hydrate lucide icons whenever the route changes
if (window.lucide) window.lucide.createIcons();
}, [route]);
const onNav = (key) => setRoute(key);
const onOpen = (id) => setRoute('proto:' + id);
const onBack = () => setRoute('prototypes');
let screen;
if (route === 'inbox') screen = <Inbox onCapture={() => {}} />;
else if (route === 'prototypes') screen = <PrototypesIndex onOpen={onOpen} />;
else if (route.startsWith('proto:')) screen = <PrototypeDetail id={route.slice(6)} onBack={onBack} />;
else if (route === 'signals') screen = <SignalsIndex />;
else if (route === 'betas') screen = <BetasIndex />;
else if (route === 'decisions') screen = <DecisionsIndex />;
else if (route.startsWith('doc:')) screen = <DocView docKey={route.slice(4)} />;
else screen = <Inbox />;
const sidebarKey = route.startsWith('proto:') ? 'prototypes' : route;
return (
<React.Fragment>
<TopNav onNew={() => setRoute('inbox')} />
<div className="app">
<Sidebar current={sidebarKey} onNav={onNav} />
<main className="main">{screen}</main>
</div>
</React.Fragment>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>