Seeded claude design
This commit is contained in:
70
.gitea/workflows/ci.yml
Normal file
70
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: pnpm/action-setup@v3
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify CHANGELOG has an entry for this PR
|
||||
if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'no-changelog')
|
||||
run: node ./scripts/check-changelog.mjs
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Visual regression
|
||||
run: pnpm test:visual
|
||||
|
||||
- name: Upload diff artefacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
release:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# When a private registry is in use, uncomment:
|
||||
# - name: Publish to registry
|
||||
# run: pnpm publish --no-git-checks
|
||||
# env:
|
||||
# NPM_CONFIG_REGISTRY: https://gitea.example.com/api/packages/whynot/npm/
|
||||
# NPM_AUTH_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }}
|
||||
|
||||
- name: Extract release notes for this tag
|
||||
id: notes
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
node ./scripts/extract-release-notes.mjs "$TAG" > release-notes.md
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
- name: Create Gitea/GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.notes.outputs.tag }}
|
||||
body_path: release-notes.md
|
||||
70
.github/workflows/ci.yml
vendored
Normal file
70
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 }
|
||||
- uses: pnpm/action-setup@v3
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify CHANGELOG has an entry for this PR
|
||||
if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'no-changelog')
|
||||
run: node ./scripts/check-changelog.mjs
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Visual regression
|
||||
run: pnpm test:visual
|
||||
|
||||
- name: Upload diff artefacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
release:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
needs: check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# When a private registry is in use, uncomment:
|
||||
# - name: Publish to registry
|
||||
# run: pnpm publish --no-git-checks
|
||||
# env:
|
||||
# NPM_CONFIG_REGISTRY: https://gitea.example.com/api/packages/whynot/npm/
|
||||
# NPM_AUTH_TOKEN: ${{ secrets.GITEA_NPM_TOKEN }}
|
||||
|
||||
- name: Extract release notes for this tag
|
||||
id: notes
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
node ./scripts/extract-release-notes.mjs "$TAG" > release-notes.md
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
- name: Create Gitea/GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.notes.outputs.tag }}
|
||||
body_path: release-notes.md
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
# Playwright
|
||||
playwright-report
|
||||
test-results
|
||||
/tests/visual/**/__diff__
|
||||
|
||||
# Editor
|
||||
.vscode
|
||||
.idea
|
||||
5
.npmrc
Normal file
5
.npmrc
Normal file
@@ -0,0 +1,5 @@
|
||||
# When ready to publish to Gitea Packages, uncomment and set NPM_AUTH_TOKEN
|
||||
# in your shell or CI secrets.
|
||||
#
|
||||
# @whynot:registry=https://gitea.example.com/api/packages/whynot/npm/
|
||||
# //gitea.example.com/api/packages/whynot/npm/:_authToken=${NPM_AUTH_TOKEN}
|
||||
53
BOOTSTRAP.md
Normal file
53
BOOTSTRAP.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Bootstrap: prime `whynot-design` from this seed
|
||||
|
||||
You're holding a zip — `whynot-design-seed.zip` — that contains a complete first commit for the `whynot-design` repository.
|
||||
|
||||
## Step-by-step
|
||||
|
||||
```sh
|
||||
# 1. Unzip into your fresh whynot-design clone.
|
||||
cd /path/to/whynot-design
|
||||
unzip ~/Downloads/whynot-design-seed.zip
|
||||
mv whynot-design-seed/* whynot-design-seed/.[!.]* .
|
||||
rmdir whynot-design-seed
|
||||
|
||||
# 2. Sanity-check the tree.
|
||||
ls -la
|
||||
# Expect: README.md DesignSystemIntroduction.md SKILL.md CONTRIBUTING.md
|
||||
# CHANGELOG.md package.json src/ tokens/ assets/ examples/
|
||||
# .gitea/ .github/ scripts/ tests/
|
||||
|
||||
# 3. First commit.
|
||||
git add -A
|
||||
git commit -m "feat: seed whynot-design from atelier — v0.1.0"
|
||||
git tag v0.1.0
|
||||
git push origin main --tags
|
||||
|
||||
# 4. Verify the example renders.
|
||||
pnpm install
|
||||
pnpm example
|
||||
# Open http://localhost:3000 — should show the whynot-control kit.
|
||||
|
||||
# 5. (Optional) Generate Playwright baselines locally.
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
pnpm test:visual:update
|
||||
git add tests/visual/__screenshots__
|
||||
git commit -m "test: add initial visual-regression baselines"
|
||||
git push
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The `git+ssh` URL in `package.json` (`gitea.example.com/whynot/whynot-design.git`) is a placeholder. Replace with your actual Gitea host.
|
||||
- The same goes for `.npmrc` and the registry URL in `.gitea/workflows/ci.yml` (commented out — uncomment when you stand up a Gitea Packages registry).
|
||||
- `.gitea/workflows/ci.yml` and `.github/workflows/ci.yml` are identical. Keep whichever your forge uses and delete the other.
|
||||
|
||||
## After bootstrap
|
||||
|
||||
1. Record the bootstrap as `DEC-004` in `whynot-control/DECISIONS.md`.
|
||||
2. Mention `whynot-design` in `whynot-control/SCOPE.md` as a sibling repository (out of scope for `whynot-control`, in scope for the org).
|
||||
3. Add `@whynot/design` as a dependency in your first consuming prototype to close the loop.
|
||||
|
||||
## You can delete this file after bootstrap
|
||||
|
||||
It's only useful for the first push.
|
||||
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `@whynot/design` are recorded here. Hand-edited until release cadence makes it painful.
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning rules: see [`DesignSystemIntroduction.md` §5](./DesignSystemIntroduction.md#5-versioning-discipline).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_Nothing yet. Add entries above the next `[vX.Y.Z]` block as PRs land._
|
||||
|
||||
## [0.1.0] — 2026-05-23
|
||||
|
||||
**Initial seed.** Established `whynot-design` as the implementation surface for the `whynot` visual language. Decision recorded in `whynot-control/DECISIONS.md` as DEC-004 (pending).
|
||||
|
||||
### Added
|
||||
|
||||
- `src/styles/colors_and_type.css` — full token set + semantic element styles.
|
||||
- `tokens/` — source-of-truth JSON tokens for colours, type, spacing, radii, shadows.
|
||||
- `src/components/Atoms.jsx` — `Eyebrow`, `Tag`, `Button`, `StageDot`, `Stamp`, `Icon`.
|
||||
- `src/components/Chrome.jsx` — `TopNav`, `Sidebar`, `PageHeader`, `PipelineStrip`.
|
||||
- `examples/whynot-control/` — click-through UI kit recreating the `whynot-control` surface (inbox, prototypes, prototype detail, signals, betas, decisions, control-doc viewer).
|
||||
- `assets/whynot-logo.png` — LEGO-brick + `?!` mark.
|
||||
- `SKILL.md` — Agent Skill manifest, cross-compatible with Claude Code.
|
||||
- `README.md` — full design language.
|
||||
- `DesignSystemIntroduction.md` — integration guide.
|
||||
- `CONTRIBUTING.md` — contribution flow + house rules.
|
||||
|
||||
### Known caveats
|
||||
|
||||
- IBM Plex is loaded from Google Fonts. Drop `.woff2` files into `fonts/` and swap to a local `@font-face` for offline use.
|
||||
- No build step — consumers import JSX directly. Promote to a build when the system has >30 components or needs non-React consumers.
|
||||
- Playwright visual regression covers `examples/whynot-control/index.html` only. Add per-component coverage when component count exceeds ~20.
|
||||
- `assets/whynot-logo.png` is raster. Re-draw as SVG before promoting to `1.0.0`.
|
||||
95
CONTRIBUTING.md
Normal file
95
CONTRIBUTING.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Contributing to `whynot-design`
|
||||
|
||||
Thanks for the curiosity. A few things before you open a PR.
|
||||
|
||||
## Where the language is decided
|
||||
|
||||
Visual decisions are made in the **Claude atelier project** (the `WhyNot Design System` template), not in this repo. This repo is the *implementation* — it ships what the atelier agreed.
|
||||
|
||||
If your change is purely visual (a new component, a token tweak, a new variant), do it in the atelier first. Get a designer or `whynot` lead to approve the variation. Then open a PR here with:
|
||||
|
||||
- A link to the atelier project / screenshot of the approved variation.
|
||||
- A one-sentence rationale, written in `whynot` voice. E.g. *"The hover bar reads as 3px at 14” preview sizes; 2px disappears."*
|
||||
|
||||
If your change is purely mechanical (CI, tooling, dependency bumps, doc typos), skip the atelier — just open the PR.
|
||||
|
||||
## House rules
|
||||
|
||||
These are enforced by review, not CI. Internalise them.
|
||||
|
||||
1. **Quiet voice.** Sentence case. No emoji (except `→` and `?!`). No hype. No marketing language.
|
||||
2. **No new colour without a fight.** The system is two neutrals and one accent. Adding a colour is a major decision. Add a *token* (e.g. status ramp extension) before adding a *colour*.
|
||||
3. **No gradients. Anywhere.**
|
||||
4. **No shadows on cards.** Elevation is intentionally near-zero. Popovers and modals only.
|
||||
5. **Square corners on big things.** 0–4 px radii for cards/sheets; 8 px for large modals; pill only for label capsules.
|
||||
6. **No hand-rolled SVG icons or emoji.** Lucide at 1.5 px stroke, or a placeholder.
|
||||
7. **Lowercase organisation name** (`whynot`) in body copy.
|
||||
|
||||
When in doubt, re-read `README.md` and `SKILL.md`. Both are authoritative.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
[atelier] [this repo] [consumers]
|
||||
|
||||
explore ──► PR: tokens / components / CSS ──► Renovate PR
|
||||
decide + CHANGELOG entry (auto, weekly)
|
||||
+ Playwright snapshot updated
|
||||
│ │
|
||||
▼ ▼
|
||||
merge → tag → release consumer CI
|
||||
(CI handles) + visual regression
|
||||
```
|
||||
|
||||
### Branches
|
||||
|
||||
- `main` is always releasable. Tag `vN.N.N` directly from `main`.
|
||||
- Feature branches: `feat/<short-slug>`. PRs squash-merge.
|
||||
|
||||
### Commits
|
||||
|
||||
Conventional Commits, lightly.
|
||||
|
||||
- `feat: add StageDot pulse variant`
|
||||
- `fix(button): nudge ghost padding to match secondary`
|
||||
- `tokens: tighten --tr-tight to -0.025em`
|
||||
- `docs: clarify Lucide stroke override`
|
||||
- `chore: bump @playwright/test`
|
||||
|
||||
The `tokens:` prefix is non-standard but useful — it marks PRs that touch `tokens/*.json` or `src/styles/colors_and_type.css` and trigger consumer visual-regression review.
|
||||
|
||||
## What CI checks
|
||||
|
||||
1. `pnpm install` succeeds.
|
||||
2. `CHANGELOG.md` has a new entry that mentions the PR (this is `scripts/check-changelog.mjs`). Skip with the label `no-changelog` for trivial doc-only PRs.
|
||||
3. `pnpm test:visual` — Playwright opens `examples/whynot-control/index.html` and compares against snapshots in `examples/whynot-control/__screenshots__/`.
|
||||
|
||||
If a snapshot intentionally needs to change, run `pnpm test:visual:update` locally, commit the new PNGs, and call it out in the PR description: *"Snapshot updated — see `Prototype.png`, hover bar is now 3 px."*
|
||||
|
||||
## Release process
|
||||
|
||||
After PR merge:
|
||||
|
||||
1. Bump `package.json` version following the [versioning rules in `DesignSystemIntroduction.md` §5](./DesignSystemIntroduction.md#5-versioning-discipline).
|
||||
2. Move the `## [Unreleased]` block in `CHANGELOG.md` under a new `## [vX.Y.Z] — YYYY-MM-DD` header.
|
||||
3. Tag: `git tag vX.Y.Z && git push --tags`.
|
||||
4. CI's release workflow attaches the CHANGELOG slice to the Gitea release. (If publishing to a registry, this is where `npm publish` runs.)
|
||||
5. Renovate picks up the new tag in consumer repos within ~24h. Or manually:
|
||||
|
||||
```sh
|
||||
cd ../whynot-prototype-WNO-022
|
||||
pnpm up @whynot/design
|
||||
```
|
||||
|
||||
## Promoting past 1.0.0
|
||||
|
||||
Stay in `0.x.x` until something built with this system is in production for >30 days with at least S2 signal. Promotion to `1.0.0` must be recorded in `whynot-control/DECISIONS.md`, same as promotion to Helix, Coulomb, etc.
|
||||
|
||||
## What we don't accept
|
||||
|
||||
- PRs that add a colour palette without a corresponding atelier decision.
|
||||
- PRs that import a UI library (Radix, Headless UI, shadcn) and re-export it. The system is small on purpose.
|
||||
- PRs that add a build step before the system has >20 components.
|
||||
- PRs that add storybook before the system has >2 contributors.
|
||||
|
||||
> A contribution can be interesting and still be parked. `whynot-design` exists to reduce visual uncertainty, not to absorb every good idea.
|
||||
232
DesignSystemIntroduction.md
Normal file
232
DesignSystemIntroduction.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Design System Introduction
|
||||
|
||||
> How `whynot-design` fits into the broader `whynot` workflow — from atelier exploration to production deploys.
|
||||
>
|
||||
> Audience: anyone (human or agent) about to add `whynot-design` as a dependency, or contribute changes back to it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mental model — three places, three jobs
|
||||
|
||||
`whynot-design` is one of three surfaces. Each has a different job. Don't confuse them.
|
||||
|
||||
| Place | Job | What lives there |
|
||||
|---|---|---|
|
||||
| **Claude atelier project** (`WhyNot Design System` template) | Explore, decide, mock | HTML cards, UI-kit JSX prototypes, the README that defines the rules. Source of truth for the *language* of the system. |
|
||||
| **`whynot-design` repo** (this one) | Distribute | A versioned, publishable package: CSS tokens, components, the logo bundle. Source of truth for the *artefact*. |
|
||||
| **`whynot-*` consuming repos** (apps, prototypes, marketing sites) | Use | `pnpm add @whynot/design`, import tokens + components, build the app. |
|
||||
|
||||
This mirrors the existing organisational logic. `whynot-control` is the *control* surface (intent, scope, decisions, governance). `whynot-design` is the *implementation* surface for the visual language. Same separation, same vocabulary.
|
||||
|
||||
> The Claude-Design template is the atelier for the *next* design exploration — mocking a new screen, a beta landing page, a signal-record view. It is **not** what production code consumes. Production consumes this repo.
|
||||
|
||||
---
|
||||
|
||||
## 2. What this repo contains
|
||||
|
||||
```
|
||||
whynot-design/
|
||||
├── README.md Quick start + manifest.
|
||||
├── DesignSystemIntroduction.md This file.
|
||||
├── SKILL.md Agent Skill manifest — also usable by Claude Code.
|
||||
├── CONTRIBUTING.md How to propose, review, and ship a change.
|
||||
├── CHANGELOG.md Hand-edited, one entry per release.
|
||||
├── package.json Name: @whynot/design.
|
||||
│
|
||||
├── tokens/ Source-of-truth design tokens (JSON).
|
||||
│ ├── colors.json
|
||||
│ ├── type.json
|
||||
│ └── spacing.json
|
||||
│
|
||||
├── src/
|
||||
│ ├── styles/
|
||||
│ │ └── colors_and_type.css Drop-in CSS variables + semantic element styles.
|
||||
│ ├── components/ JSX, consumed from source (no build step at A1).
|
||||
│ └── index.js Barrel export.
|
||||
│
|
||||
├── assets/ Logo, favicon-ready marks, future imagery.
|
||||
├── examples/
|
||||
│ └── whynot-control/ Working UI-kit recreation — also the visual-regression target.
|
||||
└── .gitea/workflows/
|
||||
└── ci.yml Lint + Playwright screenshot diff on PR.
|
||||
```
|
||||
|
||||
There is intentionally **no build step**. Consumers import the JSX and CSS directly. This keeps the A1 pipeline trivially debuggable. Add a build step when the system grows past ~30 components or needs to support non-React consumers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Integrating with a consuming codebase
|
||||
|
||||
### 3.1 The two viable distribution channels at A1
|
||||
|
||||
In order of effort:
|
||||
|
||||
**a) pnpm workspaces (recommended for now)** — put `whynot-design` and your consuming app in the same monorepo (or use `file:` / `link:` references). Zero registry, zero auth, instant updates.
|
||||
|
||||
```jsonc
|
||||
// consuming-app/package.json
|
||||
{
|
||||
"dependencies": {
|
||||
"@whynot/design": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**b) Install directly from Gitea** — no registry needed.
|
||||
|
||||
```sh
|
||||
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.3.1
|
||||
```
|
||||
|
||||
Pin to a tag, not `main`. Tag-pinning is the entire versioning discipline at A1.
|
||||
|
||||
When you outgrow either of these (a second team needs read access without repo cloning, or you want semver resolution), publish to **Gitea Packages** (supports npm protocol natively) or set up a private Verdaccio.
|
||||
|
||||
### 3.2 What a consumer imports
|
||||
|
||||
```jsx
|
||||
// At the root of the consuming app — once.
|
||||
import "@whynot/design/styles/colors_and_type.css";
|
||||
|
||||
// In any component file.
|
||||
import { Button, Tag, PrototypeCard, Eyebrow } from "@whynot/design";
|
||||
|
||||
export default function NewBetaPage() {
|
||||
return (
|
||||
<article>
|
||||
<Eyebrow>whynot · closed beta</Eyebrow>
|
||||
<h1>Concierge prototype triage</h1>
|
||||
<p className="lead">Five seats. Two weeks. One learning question.</p>
|
||||
<Button variant="primary">Request an invite</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Three rules of consumption:
|
||||
|
||||
1. **Import the CSS exactly once**, at the app's root.
|
||||
2. **Use CSS variables for any colour, type, or spacing decision**, not hex codes. If the token doesn't exist, that's a signal to extend the system — not to invent.
|
||||
3. **Don't restyle components by overriding their CSS.** If a component doesn't fit a use case, add a variant in `whynot-design` itself. The discipline that makes a design system valuable is the discipline of not forking it locally.
|
||||
|
||||
### 3.3 Bootstrapping a new consuming repo
|
||||
|
||||
```sh
|
||||
mkdir whynot-prototype-WNO-022
|
||||
cd whynot-prototype-WNO-022
|
||||
pnpm init
|
||||
pnpm add react react-dom
|
||||
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.1.0
|
||||
echo 'import "@whynot/design/styles/colors_and_type.css"' >> src/main.jsx
|
||||
```
|
||||
|
||||
That's the whole onboarding. If it takes longer than this, the design system is fighting you.
|
||||
|
||||
---
|
||||
|
||||
## 4. Propagation pipeline — five hops
|
||||
|
||||
The end-to-end flow for a single design change:
|
||||
|
||||
```
|
||||
[Claude atelier] [whynot-design] [Consuming repo] [Deploy]
|
||||
|
||||
explore variants ──► PR with token / ──► Renovate / pnpm ──► staging
|
||||
in the template component change up opens PR bumping
|
||||
user approves + Playwright diff @whynot/design │
|
||||
+ CHANGELOG entry ▼
|
||||
│ │ prod
|
||||
tag v0.3.1 CI runs visual
|
||||
CI publishes / regression on the
|
||||
attaches release consuming app
|
||||
asset │
|
||||
│ merge if green
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Hop-by-hop
|
||||
|
||||
1. **Atelier → `whynot-design` PR.** Someone (you, a designer, or an agent) takes a change agreed in the Claude project — "the prototype card's hover bar is 3px not 2px" — and opens a PR against `whynot-design`. The PR description quotes the atelier decision, e.g. *"Decided in atelier 2026-04-12: thicker hover bar reads better at 14” preview sizes."*
|
||||
|
||||
2. **`whynot-design` CI** runs:
|
||||
- Lint + (optional) typecheck.
|
||||
- **Visual regression** — Playwright screenshots `examples/whynot-control/index.html` and any other registered example, diffs against baselines. This is the single most valuable piece of automation; everything else is bookkeeping.
|
||||
- A `CHANGELOG.md` entry is required (enforce with a small CI check, or use `changesets` once you outgrow hand-editing).
|
||||
|
||||
3. **Merge → tag → publish.** On merge to `main`:
|
||||
- Bump `package.json` (patch for token tweaks, minor for new components, major for renames/removals).
|
||||
- Tag `v0.3.1`.
|
||||
- Push tag. CI uploads release notes from the CHANGELOG. (Once a registry is in use, this step also runs `npm publish`.)
|
||||
|
||||
4. **Consumer auto-PR.** Renovate or Dependabot watches `@whynot/design` and opens a PR in every consuming repo bumping the version. Renovate config groups these so all `whynot-*` prototypes update on the same schedule (e.g. weekly).
|
||||
|
||||
5. **Consumer CI + deploy.** The consuming repo's *own* CI runs *its* visual regression — does the new token change anything in *this* app's screens? If not, auto-merge (Renovate can do this for patch versions with green CI). If yes, a human reviews. Merge triggers the existing deploy to staging → prod.
|
||||
|
||||
The whole loop, warm, takes minutes. The key insight: **automation works only because every step has a deterministic check** — visual regression on the design-system side, visual regression on the consumer side, semver, changelogs. Skip those and the "pipeline" is a slow manual process with extra tools.
|
||||
|
||||
---
|
||||
|
||||
## 5. Versioning discipline
|
||||
|
||||
Strict semver, even at A1.
|
||||
|
||||
| Change | Bump |
|
||||
|---|---|
|
||||
| Token value tweak that doesn't visibly break any existing example | **patch** — `0.1.0 → 0.1.1` |
|
||||
| New component, new variant, new token | **minor** — `0.1.1 → 0.2.0` |
|
||||
| Removing / renaming a component, prop, or token; changing default behaviour | **major** — `0.2.0 → 1.0.0` |
|
||||
|
||||
Stay in `0.x.x` until something is in production. While in `0.x.x`, **minor bumps are allowed to break things** — that's the convention. This gives you permission to iterate without ceremony.
|
||||
|
||||
Promotion past `1.0.0` should appear in `whynot-control/DECISIONS.md`. Same rule as promotion to Helix or Coulomb: it's a deliberate act, not a release-script side-effect.
|
||||
|
||||
---
|
||||
|
||||
## 6. Where Claude fits
|
||||
|
||||
Two distinct roles, both useful:
|
||||
|
||||
- **The Claude atelier template** — used at hop 1. Designer (or you) opens a new project from the template, mocks variations, decides, hands off a PR description + diff to whoever (or whatever) writes the `whynot-design` PR. The atelier never publishes anything to production directly.
|
||||
|
||||
- **Claude Code with `SKILL.md`** — used at hop 1 *and* hop 5. The same SKILL file works in both contexts:
|
||||
- Pointed at `whynot-design`, Claude Code knows the rules and can write component PRs.
|
||||
- Pointed at a consuming repo, Claude Code knows the rules and can build screens that respect them.
|
||||
|
||||
That's why `SKILL.md` ships with this repo. Drop it into `.claude/skills/` of any consuming repo (or copy its contents into a project-level `CLAUDE.md`) and any agent operating in that repo will know the visual language.
|
||||
|
||||
---
|
||||
|
||||
## 7. Pragmatic A1 staging — don't build the whole pipeline yet
|
||||
|
||||
Right now, `whynot` is at A1 Incubating with one or two prototype apps in flight. Build the smallest pipeline that still has the right *shape*. Promote each piece only when a real signal demands it.
|
||||
|
||||
| Hop | A1 version | Promote to full when… |
|
||||
|---|---|---|
|
||||
| Atelier | Claude template, as-is. | Never — stays here. |
|
||||
| `whynot-design` repo | This seed. CSS + JSX consumed from source. No build step. | Second non-React consumer appears, or bundle size becomes a measurable problem. |
|
||||
| Distribution | pnpm workspace, or `git+ssh` install from tags. | An external collaborator needs read access without cloning. |
|
||||
| Visual regression | One Playwright test screenshotting `examples/whynot-control/index.html`. | The system has >20 components or >2 consuming apps. |
|
||||
| Dependency updates | Manual `pnpm up` once a week. | More than two consuming repos. |
|
||||
| Release notes | Hand-edited `CHANGELOG.md`. | More than two contributors, or releases become weekly. |
|
||||
| Component-level visual coverage | One screenshot of the whole UI kit. | The system has >20 components. |
|
||||
|
||||
This staging is exactly the *"low-cost learning first"* posture in `whynot-control/OPERATING_MODEL.md`. A design system with one consumer and one author does not need Chromatic. A design system with five consumers and three authors absolutely does. Don't pay the cost of the second one until you're in it.
|
||||
|
||||
---
|
||||
|
||||
## 8. First-week checklist
|
||||
|
||||
For whoever is bootstrapping this repo right now:
|
||||
|
||||
- [ ] Push the seed contents to `gitea.example.com/whynot/whynot-design`.
|
||||
- [ ] Tag `v0.1.0` immediately so consumers can pin.
|
||||
- [ ] Add the repo as a remote dependency in **one** consuming app and verify imports work.
|
||||
- [ ] Open one trivial PR against `whynot-design` (e.g. a CHANGELOG typo) to confirm CI passes end-to-end.
|
||||
- [ ] Record this bootstrap in `whynot-control/DECISIONS.md` as DEC-004 or similar — *"Established whynot-design as the implementation surface for the visual language."*
|
||||
- [ ] Update `whynot-control/SCOPE.md` to mention `whynot-design` in the out-of-scope list (it's a sibling, not absorbed scope).
|
||||
|
||||
That's it. Anything more is over-engineering for the current stage.
|
||||
|
||||
---
|
||||
|
||||
> A design system can be interesting and still be parked. `whynot-design` exists to reduce visual uncertainty across prototypes, not to create more obligations.
|
||||
288
README.md
Normal file
288
README.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# @whynot/design
|
||||
|
||||
The neutral, mostly-black-and-white visual language for **whynot** — Tegwick's prototype and market-signal organisation. Wireframe-leaning. Quiet. Built for artefacts that should look deliberately unfinished.
|
||||
|
||||
> A prototype is a question made tangible. — `whynot-control/INTENT.md`
|
||||
|
||||
This repository is the **implementation surface**. The *language* of the system — voice, casing, motifs, the reasoning behind each rule — lives in this README. The *artefact* — the CSS, the components, the assets — lives in `src/` and `assets/`.
|
||||
|
||||
If you're new here, read these in order:
|
||||
|
||||
1. **`DesignSystemIntroduction.md`** — how this repo relates to `whynot-control`, the Claude atelier, and consuming apps. Pipeline, versioning, propagation.
|
||||
2. **`SKILL.md`** — the Agent Skill manifest. Read this if you (or an agent) will be generating new artefacts in this style.
|
||||
3. **This README** — the full design language: tokens, components, content rules, iconography.
|
||||
4. **`CONTRIBUTING.md`** — how to propose, review, and ship a change.
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
# in a consuming repo
|
||||
pnpm add git+ssh://git@gitea.example.com/whynot/whynot-design.git#v0.1.0
|
||||
```
|
||||
|
||||
```jsx
|
||||
// at the app root, once
|
||||
import "@whynot/design/styles/colors_and_type.css";
|
||||
|
||||
// anywhere
|
||||
import { Button, Tag, Eyebrow, StageDot } from "@whynot/design";
|
||||
```
|
||||
|
||||
## What lives where
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `tokens/` | Source-of-truth design tokens as JSON. |
|
||||
| `src/styles/colors_and_type.css` | All CSS variables + semantic element styles. The single file every consumer imports. |
|
||||
| `src/components/` | React components (JSX, no build step). |
|
||||
| `src/index.js` | Barrel export. |
|
||||
| `assets/` | Logo, mark, future imagery. |
|
||||
| `examples/whynot-control/` | Live click-through UI kit. Also the Playwright visual-regression target. |
|
||||
|
||||
---
|
||||
|
||||
> The remainder of this README is the full design language — colour reasoning, type stack, content rules, iconography. It is identical to the language defined in the Claude atelier project and should stay in sync. Treat it as authoritative.
|
||||
|
||||
|
||||
## CONTENT FUNDAMENTALS
|
||||
|
||||
### Voice
|
||||
|
||||
The voice is **quiet, structured, evidence-oriented, and careful about overclaiming**. It comes directly out of the `AGENT_RULES.md` and `OPERATING_MODEL.md`:
|
||||
|
||||
> Agent outputs should be concise, evidence-oriented, explicit about uncertainty, and careful to separate idea, hypothesis, signal, and decision.
|
||||
>
|
||||
> — `whynot-control/AGENT_RULES.md`
|
||||
|
||||
The voice should sound like a careful field-notebook — not a startup landing page, not a product manifesto, not marketing copy.
|
||||
|
||||
### Casing
|
||||
|
||||
- **Sentence case** for headings, buttons, labels, links — *not* Title Case. ("Smallest useful test", not "Smallest Useful Test".)
|
||||
- **lowercase** for the organization name in body: `whynot`, never `WhyNot` or `WHYNOT`. The logo wordmark may use `WhyWhyNot` for legacy reasons.
|
||||
- **UPPERCASE** is reserved for short eyebrow labels (`PROTOTYPE`, `SIGNAL S2`, `STAGE`, `IN BETA`) — set in mono, with letterspacing.
|
||||
- **`code-case`** for repo names, doc names, folder names: `whynot-control`, `INTENT.md`, `inbox/`. Always in monospace.
|
||||
|
||||
### Person
|
||||
|
||||
**Third person, with the project as subject.** Avoid "we", avoid "you", avoid "I".
|
||||
|
||||
- ✅ "A prototype is a question made tangible."
|
||||
- ✅ "The repository helps the user capture unusual but potentially useful ideas."
|
||||
- ❌ "We help you discover weird ideas."
|
||||
- ❌ "You'll love how easy this is."
|
||||
|
||||
Direct second-person is reserved for *imperatives in a checklist* (e.g. "Read `INTENT.md`.").
|
||||
|
||||
### Tone
|
||||
|
||||
- **Curious, not enthusiastic.** "This may be worth a closer look" beats "🚀 huge if true!".
|
||||
- **Hedged, not promotional.** Use *may*, *could*, *seems*, *appears to*. Avoid *will*, *guaranteed*, *the best*.
|
||||
- **Distinguish idea / hypothesis / signal / decision.** Never collapse them.
|
||||
- **Lack of signal is also information.** Silence is a finding, not a failure.
|
||||
|
||||
### Phrasing patterns to imitate
|
||||
|
||||
Lifted from the existing control documents:
|
||||
|
||||
- "A prototype is a question made tangible."
|
||||
- "Signal beats enthusiasm."
|
||||
- "Signals are evidence, not vibes."
|
||||
- "Capture is not commitment."
|
||||
- "Low-cost learning first."
|
||||
- "A prototype can be interesting and still be parked."
|
||||
|
||||
The pattern is: **short declarative claim → small qualifier or counter-claim**. Two beats, no exclamation.
|
||||
|
||||
### Phrasing to avoid
|
||||
|
||||
- ❌ "Revolutionize…", "Reimagine…", "Unlock…", "Empower…"
|
||||
- ❌ "🚀", "✨", "🔥", any other hype emoji.
|
||||
- ❌ "We believe…", "We're on a mission to…"
|
||||
- ❌ "Beta — sign up now!" (use "Closed beta. Invitation only.")
|
||||
- ❌ Numbers without context ("10x faster"). Use signal-record format instead.
|
||||
|
||||
### Examples
|
||||
|
||||
| ❌ Avoid | ✅ Prefer |
|
||||
|---|---|
|
||||
| "Get started — it's free!" | "Inbox is open. Capture is not commitment." |
|
||||
| "Our amazing new prototype" | "Prototype `WNO-014`. Stage: experiment." |
|
||||
| "Users love it!" | "S2 — repeated interest, three concrete use-cases." |
|
||||
| "Coming soon — sign up!" | "Closed beta. Five seats. Ends 2026-04-01." |
|
||||
| "🎉 Launched!" | "Promoted to Helix on 2026-03-12. See `DECISIONS.md`." |
|
||||
|
||||
### Emoji & punctuation
|
||||
|
||||
- **No emoji** in body copy, headings, or UI.
|
||||
- **`?` and `!`** are the brand's punctuation — they appear in the logo and may appear, sparingly, in display headlines (`try($idea) until success;`, `why? why not!`).
|
||||
- **`→`** (U+2192) for "promotes to / goes to" links between stages. Not `->`.
|
||||
- **`—`** (em dash) for parenthetical, not `--`.
|
||||
|
||||
---
|
||||
|
||||
## VISUAL FOUNDATIONS
|
||||
|
||||
### Overall posture
|
||||
|
||||
The system reads like **engineering graph paper** — precise hairlines, lots of whitespace, monospace labels in margins, content blocks that look like fields in a form rather than cards in a feed. The aesthetic is closer to a Bauhaus wall-chart or a `man` page than to a SaaS dashboard.
|
||||
|
||||
### Color
|
||||
|
||||
- **Mostly black on white**, with a few flat greys.
|
||||
- **One accent only**: a warm yellow (`--hi: #FFE14A`) lifted from the LEGO brick in the logo. It appears as **highlighter / annotation / signal-marker** — never as a button fill, never as a hero background.
|
||||
- **No gradients.** Anywhere. Including subtle ones.
|
||||
- **No tinted whites** — `--paper` is a true `#FFFFFF`; `--paper-2` and `--paper-3` are barely-warm off-whites (`#FAFAF7`, `#F4F4EF`) reserved for sheets and recessed code blocks.
|
||||
- Status colors (S0–S4 signal strength) are **rendered as desaturated grey ramps**, not red/yellow/green. S4 ("commercial signal") is the only one that uses the yellow accent, because that's the threshold where a prototype actually matters commercially.
|
||||
|
||||
### Type
|
||||
|
||||
- **Family**: `IBM Plex Sans` for everything UI/body. `IBM Plex Mono` for labels, code, and stage markers. `IBM Plex Serif` for the occasional editorial pull-quote. (See font substitution note in *Fonts* below.)
|
||||
- **Weights**: 300 (display only), 400 (body), 500 (UI / headings), 600 (occasional emphasis). Never 700+ — too marketing.
|
||||
- **Tracking**: tight on display (`-0.035em`), neutral on body, **wide on uppercase labels** (`0.08em` — this is the one signature move).
|
||||
- **Eyebrows everywhere**: short uppercase mono labels above titles (`STAGE`, `SIGNAL`, `PROTOTYPE`). They are the system's main rhythmic element.
|
||||
|
||||
### Spacing
|
||||
|
||||
- **4px base unit**, exposed as `--sp-1` (4) through `--sp-10` (128).
|
||||
- Generous: a content block typically has `--sp-7` (48px) of internal padding. Lists separated by `--sp-5` (24px) minimum.
|
||||
- **Section breaks are big** (`--sp-9`, 96px). Reads like a printed report.
|
||||
|
||||
### Backgrounds
|
||||
|
||||
- **`--paper` (#FFFFFF) by default.** Period.
|
||||
- **`--paper-2`** for full-width "sheet" sections (e.g. between hero and content).
|
||||
- **`--paper-3`** for recessed surfaces only — code blocks, inline pre, inset cards.
|
||||
- **No images as backgrounds.** No hand-drawn illustrations. No repeating patterns. No textures. No noise. No grain. (Exception: a 1px hairline grid may be used on a literal "wireframe" mock; see `preview/grid-paper.html`.)
|
||||
|
||||
### Animation
|
||||
|
||||
- **Minimal.** This is a document system, not a product UI.
|
||||
- Transitions on hover only: `120ms ease` on `text-decoration-color` for links, `border-color` for inputs.
|
||||
- **No spring physics, no bounce, no fade-in-on-scroll.** Anything that draws attention is not in the spirit of "low-cost learning first".
|
||||
- Exception: the cursor-blink animation on a `<TerminalLine>` is permitted because it's a literal terminal motif.
|
||||
|
||||
### Hover & press states
|
||||
|
||||
- **Links** — underline color goes from `--border-strong` to `--fg-1` on hover.
|
||||
- **Buttons (primary, dark)** — `--ink` → `--ink-2` on hover. On press, no transform, just `--ink` (back to baseline).
|
||||
- **Buttons (secondary, outline)** — border `--border` → `--ink`. On press, background flashes `--bg-3`.
|
||||
- **Cards** — *do not have hover states.* They are documents, not interactive surfaces. Exception: prototype cards in an index list get a 1px black left border on hover.
|
||||
- **No scale transforms**, no shadow lifts, no glow effects.
|
||||
|
||||
### Borders
|
||||
|
||||
- **1px solid `--border` (#E5E5E2)** is the default. Used everywhere.
|
||||
- **`--border-strong` (#C9C9C5)** for section dividers and the outline of a primary block.
|
||||
- **Hairline `--border-soft` (#F0F0EC)** for internal rules within a card.
|
||||
- **No double borders, no inset borders, no dashed borders** except in one specific case: dashed `--border-strong` is used to indicate "placeholder / not yet defined" content (see Components / Empty State).
|
||||
|
||||
### Shadows
|
||||
|
||||
- **`--shadow-0`: none.** This is the default. Most cards and panels have no shadow.
|
||||
- **`--shadow-1`: `0 1px 0 var(--line)`** — a 1px bottom-line, used in place of bottom-border on sticky headers.
|
||||
- **`--shadow-2`: `0 1px 0 var(--line-strong)`** — slightly stronger version.
|
||||
- **`--shadow-3`** is reserved for *floating* elements only (a popover, a focus-trapped modal). Even then it's a soft 4-12px diffuse shadow at 10% opacity — never a "card lift" shadow.
|
||||
|
||||
### Protection gradients vs capsules
|
||||
|
||||
- **No protection gradients.** Backgrounds are solid; never overlay a gradient to "rescue" text from a busy background, because backgrounds are never busy.
|
||||
- **Capsules (pills with rounded ends)** are used only for `--label` and tag elements — never for buttons. Buttons are slightly rounded rectangles (`--r-2`, 4px).
|
||||
|
||||
### Layout rules
|
||||
|
||||
- **Single-column reading width** of ~640px for body, ~880px for documents with sidebars.
|
||||
- **Constant page padding** of `--sp-7` (48px) on desktop, `--sp-4` (16px) on mobile.
|
||||
- **Sticky element**: top navigation bar, height 56px, bottom hairline.
|
||||
- **Sidebar (where used)**: 256px fixed width, `--paper-2` background, no border on right (uses whitespace to separate from main).
|
||||
- **Grids** for structured data only — never as a "card wall". 12-column with 24px gutters.
|
||||
|
||||
### Transparency & blur
|
||||
|
||||
- **Almost never used.** No frosted glass. No backdrop-filter.
|
||||
- One permitted use: a `rgba(255,255,255,0.92)` on the sticky top nav so that scrolled content is faintly visible behind it. No blur.
|
||||
|
||||
### Imagery
|
||||
|
||||
- **Black & white only.** All photography (when used) is rendered with `filter: grayscale(1) contrast(0.95)` — slightly low-contrast, like a Risograph print.
|
||||
- **Aspect ratios**: 4:3 (preferred — feels like a document figure), 1:1 (for portraits / icons). Never 16:9 in body.
|
||||
- **No people-stock-photography.** Prefer objects, diagrams, or screenshots.
|
||||
- **No AI-generated imagery** unless explicitly labelled as such with a `[generated]` caption.
|
||||
|
||||
### Corner radii
|
||||
|
||||
- **`--r-1` (2px)** for inputs and tags.
|
||||
- **`--r-2` (4px)** for buttons and small cards.
|
||||
- **`--r-3` (8px)** for large cards and modals.
|
||||
- **`--r-pill` (999px)** for label capsules only.
|
||||
- **`--r-0` (0px / square)** is the default for documents, sheets, and any element wider than ~600px. Big things are square.
|
||||
|
||||
### Cards
|
||||
|
||||
A "card" in this system is **a bordered rectangle**, not a shadowed object floating off the page.
|
||||
|
||||
- 1px `--border` outline.
|
||||
- `--paper` background.
|
||||
- 4px or 8px radius depending on size.
|
||||
- **No shadow.**
|
||||
- Internal padding `--sp-5` (24px) minimum.
|
||||
- A monospace eyebrow at top-left + a stage label at top-right is the canonical card header.
|
||||
- On hover (when interactive): the top-left eyebrow tints to `--fg-1`, and a 2px black bar appears flush against the left edge. Nothing else moves.
|
||||
|
||||
---
|
||||
|
||||
## ICONOGRAPHY
|
||||
|
||||
The codebase did **not** ship an icon font, an icon sprite, or any SVG icons — `whynot-control` is a documents-only repo. The only visual asset is the LEGO-brick logo.
|
||||
|
||||
### Approach
|
||||
|
||||
- **Lucide icons via CDN** is the chosen icon set. It matches the system's stroke weight (1.5px), neutral geometry, and "wireframe artefact" feel better than Material, Heroicons, or Phosphor. Load with `<script src="https://unpkg.com/lucide@latest"></script>` and call `lucide.createIcons()`.
|
||||
- **Stroke weight is always 1.5px.** Override Lucide's default 2px via `stroke-width="1.5"` on each `<svg>` or via CSS.
|
||||
- **Color**: `currentColor`. Icons inherit from text. No two-tone, no fills.
|
||||
- **Size**: 16px (inline), 20px (button), 24px (heading-adjacent), 32px (feature). Never larger — large icons read as decoration, and decoration is not in the spirit.
|
||||
|
||||
### When to use icons
|
||||
|
||||
- In navigation labels, button labels, and inline status — **only when the icon adds parsing speed**. If the word reads faster than the icon, skip the icon.
|
||||
- In document margins to mark stage transitions (e.g. `→ Helix`).
|
||||
- **Not** as decorative chrome on cards.
|
||||
- **Not** as "feature icons" in a 3-up grid on a marketing page (this system has no marketing pages).
|
||||
|
||||
### Emoji
|
||||
|
||||
- **Never.** Emoji are not used anywhere — in copy, labels, alt text, or as fallback for missing icons.
|
||||
|
||||
### Unicode characters used as icons
|
||||
|
||||
These are allowed and preferred over raster icons in some contexts:
|
||||
|
||||
| Char | Used for |
|
||||
|---|---|
|
||||
| `→` | "promotes to" / pipeline arrow |
|
||||
| `←` | back / previous |
|
||||
| `·` | inline bullet separator (in nav, in meta lines) |
|
||||
| `—` | em dash, in metadata |
|
||||
| `?` `!` | the brand's signature punctuation, in display headlines only |
|
||||
| `§` | section marker, in long documents |
|
||||
|
||||
### The logo
|
||||
|
||||
- The primary mark is **the LEGO brick with `?` and `!` underneath**, in pure black & white. `assets/whynot-logo.png` (300×300 raster, transparent background).
|
||||
- For very small uses (favicon, footer mark), a **simplified mark** is recommended: just the `?!` pair in `IBM Plex Sans 600`, with the brick implied by a 2×4 dot grid above. See `preview/logo.html`.
|
||||
- The brick should **never be coloured** (no LEGO-red, LEGO-blue, etc). It is always black-outlined on white, or white-outlined on black.
|
||||
- Minimum size: 32px square. Below that, fall back to the `?!` wordmark.
|
||||
|
||||
---
|
||||
|
||||
## A note on font substitution
|
||||
|
||||
The control repo did not ship font files. **IBM Plex Sans / Mono / Serif** were chosen as a fresh pairing because:
|
||||
|
||||
- The "Plex" family was designed by IBM as an explicitly *neutral, technical-document* family — the same use-case as this system.
|
||||
- All three (sans, mono, serif) share metrics, so they mix cleanly in templates and tables.
|
||||
- They are openly licensed (SIL OFL) and available on Google Fonts.
|
||||
|
||||
Plex is currently loaded from Google Fonts (see top of `colors_and_type.css`). For offline use, drop the `.woff2` files into `fonts/` and swap the `@import` for a local `@font-face` block.
|
||||
|
||||
> **🟨 Substitution flagged**: there was no specified brand font; IBM Plex is a choice made here. If `whynot` later adopts a different brand font, replace `--ff-sans` / `--ff-mono` / `--ff-serif` in `colors_and_type.css` and everything downstream will follow.
|
||||
34
SKILL.md
Normal file
34
SKILL.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: whynot-design
|
||||
description: Use this skill to generate well-branded interfaces and assets for whynot — Tegwick's prototype-and-market-signal organisation that discovers the weird and the useful — either for production or throwaway prototypes, mocks, signal records, beta plans, and decision documents. Contains essential design guidelines, neutral colour system, IBM Plex type stack, the LEGO-brick logo, and a click-through UI kit recreating the whynot-control surface.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
Read the `README.md` file within this skill, and explore the other available files.
|
||||
|
||||
If creating visual artefacts (slides, mocks, throwaway prototypes, signal records, beta plans, etc.), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand.
|
||||
|
||||
If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artefacts *or* production code, depending on the need.
|
||||
|
||||
## Quick orientation
|
||||
|
||||
- **`README.md`** — full design system: company context, content rules (voice, casing, person, tone), visual foundations (color, type, spacing, animation, hover, borders, shadows, cards, imagery), and iconography.
|
||||
- **`colors_and_type.css`** — drop-in CSS with every CSS variable and semantic element style. Import this on every artefact.
|
||||
- **`assets/`** — `whynot-logo.png` (LEGO brick + ?!).
|
||||
- **`preview/`** — 23 reference cards (palettes, type specimens, components, motifs) you can read to understand any single concept in isolation.
|
||||
- **`ui_kits/whynot-control/`** — React kit recreating the `whynot-control` web app. Use these components when building prototype dashboards, signal records, decision documents.
|
||||
|
||||
## House rules (do not violate)
|
||||
|
||||
1. **Quiet voice.** No marketing language, no hype, no emoji. Sentence case everywhere. Distinguish idea / hypothesis / signal / decision.
|
||||
2. **Mostly black & white.** Color is one warm yellow accent (`--hi: #FFE14A`) used only as highlighter / draft stamp / S4 signal indicator. Never as a button fill, never as a hero background, never as a gradient.
|
||||
3. **No gradients. Anywhere.**
|
||||
4. **Wireframe vibes.** 1px hairlines, generous whitespace, monospace eyebrow labels in margins. The aesthetic is engineering graph-paper, not SaaS dashboard.
|
||||
5. **Square corners on big things.** 0–4px radii for cards/sheets; 8px reserved for large modals; pill only for tag capsules.
|
||||
6. **No shadows on cards.** Elevation is intentionally near-zero; only popovers get a soft 4–12px shadow.
|
||||
7. **No fake illustrations.** Use the logo, use Lucide icons at 1.5px stroke, use placeholders. Never hand-roll SVG icons or emoji.
|
||||
8. **Lowercase organisation name** in body: `whynot`. The logo wordmark may use `WhyWhyNot` for legacy reasons.
|
||||
|
||||
## When in doubt
|
||||
|
||||
Read `README.md` and the closest matching card in `preview/`. If still unsure, ask the user — `whynot` prefers explicit uncertainty over confident guesses.
|
||||
BIN
assets/whynot-logo-original.png
Normal file
BIN
assets/whynot-logo-original.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
assets/whynot-logo.png
Normal file
BIN
assets/whynot-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
102
examples/whynot-control/Atoms.jsx
Normal file
102
examples/whynot-control/Atoms.jsx
Normal 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 });
|
||||
165
examples/whynot-control/Chrome.jsx
Normal file
165
examples/whynot-control/Chrome.jsx
Normal 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 });
|
||||
102
examples/whynot-control/DocView.jsx
Normal file
102
examples/whynot-control/DocView.jsx
Normal 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 });
|
||||
31
examples/whynot-control/README.md
Normal file
31
examples/whynot-control/README.md
Normal 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()`.
|
||||
282
examples/whynot-control/Screens.jsx
Normal file
282
examples/whynot-control/Screens.jsx
Normal 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 });
|
||||
71
examples/whynot-control/data.jsx
Normal file
71
examples/whynot-control/data.jsx
Normal 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 });
|
||||
76
examples/whynot-control/index.html
Normal file
76
examples/whynot-control/index.html
Normal 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>
|
||||
55
package.json
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@whynot/design",
|
||||
"version": "0.1.0",
|
||||
"description": "The neutral, mostly-black-and-white visual language for whynot — prototypes, signal records, beta plans, decision documents, and any other deliberately-unfinished artefact.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.js",
|
||||
"module": "./src/index.js",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./styles/colors_and_type.css": "./src/styles/colors_and_type.css",
|
||||
"./styles": "./src/styles/colors_and_type.css",
|
||||
"./tokens": "./tokens/index.json",
|
||||
"./tokens/colors": "./tokens/colors.json",
|
||||
"./tokens/type": "./tokens/type.json",
|
||||
"./tokens/spacing": "./tokens/spacing.json",
|
||||
"./assets/*": "./assets/*",
|
||||
"./skill": "./SKILL.md"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"tokens",
|
||||
"assets",
|
||||
"SKILL.md",
|
||||
"DesignSystemIntroduction.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"scripts": {
|
||||
"check": "node ./scripts/check-changelog.mjs",
|
||||
"test:visual": "playwright test",
|
||||
"test:visual:update": "playwright test --update-snapshots",
|
||||
"example": "npx --yes serve examples/whynot-control"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": { "optional": true },
|
||||
"react-dom": { "optional": true }
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.45.0"
|
||||
},
|
||||
"keywords": ["whynot", "design-system", "wireframe", "prototype", "minimal"],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@gitea.example.com/whynot/whynot-design.git"
|
||||
},
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
30
playwright.config.mjs
Normal file
30
playwright.config.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/visual",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: 0,
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
use: {
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
],
|
||||
webServer: {
|
||||
command: "npx --yes serve -l 4321 examples/whynot-control",
|
||||
url: "http://localhost:4321",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
},
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
// Reasonable tolerance for font hinting / sub-pixel jitter.
|
||||
maxDiffPixelRatio: 0.005,
|
||||
},
|
||||
},
|
||||
});
|
||||
29
scripts/check-changelog.mjs
Normal file
29
scripts/check-changelog.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// Verify that CHANGELOG.md gained at least one entry under [Unreleased]
|
||||
// or that a [vX.Y.Z] block was added since main. Fails CI if not.
|
||||
//
|
||||
// Skip with the PR label `no-changelog` for trivial doc-only changes.
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const base = process.env.GITHUB_BASE_REF || "main";
|
||||
let diff;
|
||||
try {
|
||||
execSync(`git fetch --no-tags --depth=50 origin ${base}`, { stdio: "ignore" });
|
||||
diff = execSync(`git diff --unified=0 origin/${base}...HEAD -- CHANGELOG.md`).toString();
|
||||
} catch (err) {
|
||||
console.error("Could not diff CHANGELOG.md:", err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const additions = diff.split("\n").filter(l => l.startsWith("+") && !l.startsWith("+++"));
|
||||
const meaningful = additions.filter(l => l.replace(/^\+/, "").trim().length > 0 && !l.includes("Unreleased"));
|
||||
|
||||
if (meaningful.length === 0) {
|
||||
console.error("CHANGELOG.md has no new entries on this PR.");
|
||||
console.error("Either add an entry under [Unreleased], or label this PR `no-changelog`.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`OK — ${meaningful.length} CHANGELOG line(s) added.`);
|
||||
22
scripts/extract-release-notes.mjs
Normal file
22
scripts/extract-release-notes.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
// Print the CHANGELOG slice for a given tag to stdout.
|
||||
// Used by the release workflow to attach release notes.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const tag = process.argv[2];
|
||||
if (!tag) {
|
||||
console.error("Usage: extract-release-notes.mjs vX.Y.Z");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const version = tag.replace(/^v/, "");
|
||||
const changelog = readFileSync("CHANGELOG.md", "utf8");
|
||||
const re = new RegExp(`## \\[${version.replace(/\./g, "\\.")}\\][\\s\\S]*?(?=\\n## \\[|$)`, "m");
|
||||
const m = changelog.match(re);
|
||||
|
||||
if (!m) {
|
||||
console.error(`No CHANGELOG section found for [${version}].`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(m[0].trim() + "\n");
|
||||
102
src/components/Atoms.jsx
Normal file
102
src/components/Atoms.jsx
Normal 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 });
|
||||
165
src/components/Chrome.jsx
Normal file
165
src/components/Chrome.jsx
Normal 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 });
|
||||
18
src/index.js
Normal file
18
src/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// @whynot/design — barrel export.
|
||||
//
|
||||
// At A1 there is no build step: consumers import these JSX files directly.
|
||||
// Any modern bundler (Vite, Next.js, Webpack 5 with @babel/preset-react,
|
||||
// esbuild, Bun) handles JSX-in-.jsx out of the box.
|
||||
//
|
||||
// If you need to support a bundler that doesn't, fall back to either
|
||||
// (a) importing from `examples/whynot-control/` as inline <script type="text/babel">
|
||||
// or (b) adding a build step here when you next bump minor.
|
||||
|
||||
export * from "./components/Atoms.jsx";
|
||||
export * from "./components/Chrome.jsx";
|
||||
|
||||
// CSS is exported as a side-effect import:
|
||||
//
|
||||
// import "@whynot/design/styles/colors_and_type.css";
|
||||
//
|
||||
// Do this once, at the app root.
|
||||
273
src/styles/colors_and_type.css
Normal file
273
src/styles/colors_and_type.css
Normal 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); }
|
||||
48
tests/visual/ui-kit.spec.mjs
Normal file
48
tests/visual/ui-kit.spec.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Visual-regression baseline for the whynot-control UI kit.
|
||||
// Snapshots live next to this file under __screenshots__/.
|
||||
//
|
||||
// To update intentionally: pnpm test:visual:update
|
||||
|
||||
test.describe("whynot-control UI kit", () => {
|
||||
test("prototypes index", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
// Wait for Babel + Lucide to hydrate
|
||||
await page.waitForFunction(() => !!document.querySelector("aside"));
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveScreenshot("01-prototypes.png", { fullPage: true });
|
||||
});
|
||||
|
||||
test("inbox", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("aside a"));
|
||||
await page.click("aside a:has-text('Inbox')");
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page).toHaveScreenshot("02-inbox.png", { fullPage: true });
|
||||
});
|
||||
|
||||
test("signals", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("aside a"));
|
||||
await page.click("aside a:has-text('Signals')");
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page).toHaveScreenshot("03-signals.png", { fullPage: true });
|
||||
});
|
||||
|
||||
test("prototype detail", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("article"));
|
||||
await page.click("article:nth-of-type(1)");
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page).toHaveScreenshot("04-prototype-detail.png", { fullPage: true });
|
||||
});
|
||||
|
||||
test("control doc — INTENT.md", async ({ page }) => {
|
||||
await page.goto("/index.html");
|
||||
await page.waitForFunction(() => !!document.querySelector("aside a"));
|
||||
await page.click("aside a:has-text('INTENT.md')");
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page).toHaveScreenshot("05-doc-intent.png", { fullPage: true });
|
||||
});
|
||||
});
|
||||
22
tokens/colors.json
Normal file
22
tokens/colors.json
Normal 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" }
|
||||
}
|
||||
6
tokens/index.json
Normal file
6
tokens/index.json
Normal 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"
|
||||
}
|
||||
28
tokens/spacing.json
Normal file
28
tokens/spacing.json
Normal 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." }
|
||||
}
|
||||
}
|
||||
33
tokens/type.json
Normal file
33
tokens/type.json
Normal 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." }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user