trykeyline 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/plugin.json +22 -0
- package/plugins/keyline/skills/keyline/SKILL.md +47 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +16 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +97 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# `/keyline init`
|
|
2
|
+
|
|
3
|
+
Fit Keyline to this project: find out once what it calls things, who it's for and where it already breaks the laws, and write that down in `DESIGN.md` at the project root.
|
|
4
|
+
|
|
5
|
+
**Read:** `system/guide.md`, then `system/roles.md`, `system/presets.md` and `system/design-md.md`. `system/detection.md` when the scan leaves you unsure where a stack keeps its tokens.
|
|
6
|
+
|
|
7
|
+
**Steps:** follow `system/guide.md` from step 1 to step 5 — scan, map by usage, measure contrast, choose the preset from the reader, ask at most four questions, write the file. Before writing `## Open issues`, read the law headings (the `### N.` lines) of each domain guide you cite, so every citation names the right law: `grep -n '^### [0-9]' <keyline>/domains/*/guide.md`. Once the preset is chosen, read every guide's `## Dials` for its column, since `## Decisions` records each dial the project sets differently: `awk 'FNR==1{p=0; print "== " FILENAME} /^## /{p = /^## Dials/} p' <keyline>/domains/*/guide.md`.
|
|
8
|
+
|
|
9
|
+
- **Preset `glass`:** the values come from the tables in each `glass/<domain>/guide.md`, under the project's existing names wherever it has them, not from the whole folder. There is no `glass/patterns/`; patterns take the dials of the preset nearest the reader.
|
|
10
|
+
- **`DESIGN.md` already exists:** this is `/keyline sync`, not a rewrite. Say so and run that instead.
|
|
11
|
+
- **A new project with no interface yet:** there is nothing to scan. Ask what the product is and who reads it, propose a preset, and write `## Profile` and each domain's section with the preset's defaults from those `## Dials`, marked as defaults. Generate tokens only if asked (`system/guide.md` §6).
|
|
12
|
+
- **Run for some domains only** (another command found `DESIGN.md` missing): write `## Profile` and those domains' sections, and leave the rest for a full run.
|
|
13
|
+
|
|
14
|
+
**Report**, briefly: the preset and the evidence for it, how many roles mapped cleanly, which names you proposed, and the three open issues that matter most. Then the next step — usually `/keyline critique` on the main screen, or `/keyline polish` if the user wants changes.
|
|
15
|
+
|
|
16
|
+
Generating tokens is a separate request.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# `/keyline layout`
|
|
2
|
+
|
|
3
|
+
**Guide:** `domains/layout/guide.md` · **`DESIGN.md` section:** `## Layout` · **Procedure:** `commands/refine.md` · **Alias:** `spacing`
|
|
4
|
+
|
|
5
|
+
Lets the layout carry the hierarchy: one reading measure every template shares, spacing that groups (a heading closer to what it introduces than to what came before), radii that nest, line weights by job, one z-index ladder, breakpoints named for what changes, a column grid where unlike blocks sit side by side, targets big enough to hit, and boxes only where something is genuinely a separate object.
|
|
6
|
+
|
|
7
|
+
Typical finds: equal margins above and below a heading; every section in a card, cards in cards, a bento grid of unlike things; a radius inside a radius that doesn't subtract the padding; `z-index: 9999`; a hover-only control with no touch path; a 16px icon button with no padding to hit; content that unmounts when collapsed. Deeper references: `measure-and-rhythm.md`, `radius-lines-and-layers.md`, `forks-gating-and-disclosures.md`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# `/keyline motion`
|
|
2
|
+
|
|
3
|
+
**Guide:** `domains/motion/guide.md` · **`DESIGN.md` section:** `## Motion` · **Procedure:** `commands/refine.md` · **Alias:** `animate`
|
|
4
|
+
|
|
5
|
+
Makes every animation explain a change: durations scaled to how much of the screen moves, one standard curve, a colour curve that never overshoots, springs named by character, closing faster than opening, shared elements that travel, reduced motion as a real alternative, content never hidden behind an animation that might not run, and scroll work that stays off the main thread.
|
|
6
|
+
|
|
7
|
+
Typical finds: `transition: all`; a dozen durations where three would do; a menu that closes as slowly as it opens; reveal-on-scroll that leaves content invisible without JavaScript; hover effects that fire on touch; animating `top` and `height` instead of transforms. Check every change with reduced motion on. Deeper references: `ladder-and-curves.md`, `patterns.md`, `guards.md`, `performance.md`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# `/keyline patterns`
|
|
2
|
+
|
|
3
|
+
**Guide:** `domains/patterns/guide.md` · **`DESIGN.md` section:** `## Patterns` · **Procedure:** `commands/refine.md` · **Alias:** `copy`
|
|
4
|
+
|
|
5
|
+
How the product behaves and what it says: short, sentence-case labels and verb buttons; a row's second line naming type and origin; one treatment for destructive actions, with confirmation scaled to the loss; an AI agent's work shown step by step with approvals inline; keyboard-first dense tools; optimistic changes that roll back; dashboards, forms and sign-in that ask for no more than they need; actions that answer at once and say when they're slow; inputs that forgive format; flows that show how near the end is and end on a designed screen; on landing pages and articles, elements that do what their form promises. A pattern kept in one place and broken in another is the finding.
|
|
6
|
+
|
|
7
|
+
Typical finds: "Click here to submit your form"; Title Case generated from keys; "OK" on a delete dialog; three different confirm treatments; an agent that goes silent while it works; a dashboard of stat tiles that answer nothing; a Save button that sits silent for two seconds; a phone field that rejects spaces; a wizard that ends on "Success". Feedback rounds follow the guide's `## Iterating on feedback`. Deeper references: `copy.md`, `actions-and-forms.md`, `agents-and-live-states.md`, `collections-and-dashboards.md`, `iteration.md`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# `/keyline polish`
|
|
2
|
+
|
|
3
|
+
All six refining commands on one target, in order, each as its own reviewable pass. The finishing pass before something ships, and the default when the user says "make this better".
|
|
4
|
+
|
|
5
|
+
**Order:** `type` → `color` → `layout` → `depth` → `motion` → `patterns`. Each builds on the one before: type settles the roles colour gives tiers to; spacing groups type that has stopped moving; surfaces sit on finished spacing and colour; motion animates surfaces that exist; words are checked last, in the product as it now stands. If a `patterns` change alters a label's length enough to move the layout, recheck that spot.
|
|
6
|
+
|
|
7
|
+
**Steps:**
|
|
8
|
+
|
|
9
|
+
1. Read `DESIGN.md`. If it's missing, run `/keyline init` first and say so — polish without a profile is guessing.
|
|
10
|
+
2. Agree the target: one screen, flow or component. "The whole app" is several polishes; offer to start with the screen readers see most.
|
|
11
|
+
3. Run `commands/refine.md` once per domain, in order, reading each domain's guide when its turn comes. Keep each pass to its domain.
|
|
12
|
+
4. **Settle.** Later passes create and change elements the earlier ones never saw — the depth pass turns a callout into a note, and nothing has given the note its type role. Re-run each earlier domain's checklist (`commands/refine.md` step 7) on the elements later passes created or changed, and fix within that domain.
|
|
13
|
+
5. Render the result beside the starting point.
|
|
14
|
+
|
|
15
|
+
Polish improves the screen it's given; it doesn't move navigation or add features (`commands/refine.md`, the limits).
|
|
16
|
+
|
|
17
|
+
**Report**, per domain in order: what changed and why, in a line or two each — a domain with nothing to fix says so — then what the settling pass changed. Then what was measured, what went into `DESIGN.md`, and anything left for later with its command.
|
|
18
|
+
|
|
19
|
+
If the user wants to review as you go, stop after each domain and show it before starting the next.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# How a refining command works
|
|
2
|
+
|
|
3
|
+
`type`, `color`, `layout`, `depth`, `motion` and `patterns` share one procedure. Each command's own file names its guide and what it covers; this is the rest.
|
|
4
|
+
|
|
5
|
+
1. **Read the parts of the domain's `guide.md` a pass uses**, not the whole file: `## Laws`, `## Dials` (the preset's targets, which `DESIGN.md`'s values are judged against), `## Auditing existing code`, `## Writing back to DESIGN.md`, and `## Checklist` if it has one — `awk '/^## /{p = /^## (Laws|Dials|Auditing|Writing back|Checklist)/} p' <keyline>/domains/<domain>/guide.md`. Add `## Building it for a project` only when adding a role or proposing a set for a domain with no tokens (patterns has none; its nearest is `## Iterating on feedback`), read the same way with its heading in the pattern. Then the references its laws point to, for the parts the target touches. Under preset `glass`, read `glass/<domain>/guide.md` too (there is no `glass/patterns/`), and take from its `assets/` only the files that guide's own `## Assets` list names for what you're changing.
|
|
6
|
+
2. **Read the domain's section of `DESIGN.md`** and every `## Decisions` entry it covers. A decision beats a dial; never "fix" a decided value back.
|
|
7
|
+
3. **Find the problems first.** Run `node <keyline>/scripts/check.mjs <target> --root <project root> --rule <domain>` for what can be checked mechanically, then the guide's `## Auditing existing code` on the target only, and list what breaks a law before changing anything. With no target, take the screen the conversation is about; if there isn't one, ask which, or offer the whole project as a longer pass.
|
|
8
|
+
4. **Fix in the project's names**, at the smallest scope that removes the cause — a token over twenty call sites, a shared component over its instances. Add a role only where the guide's `## Building it for a project` says one is missing, in the project's naming style, marked `proposed` in `DESIGN.md`. Where the project has no tokens for the domain at all — no type tokens, no motion tokens — propose a full set in the project's style, each marked `proposed`, and fix against it.
|
|
9
|
+
5. **A fix that belongs to an earlier domain** in polish order but only became visible in this pass — the note this pass made from a callout now needs its type role — is made here, and reported as that domain's. Anything else another domain owns is reported with its command.
|
|
10
|
+
6. **Sweep** every route the changed rule reaches and report what moved: `awk '/^## /{p = /^## The sweep/} p' <keyline>/domains/patterns/iteration.md`.
|
|
11
|
+
7. **Check what you changed** against the domain's checklist — each law's bold first sentence, plus anything left under `## Checklist` — and run the detector again on the target: nothing you touched should still be flagged.
|
|
12
|
+
|
|
13
|
+
**Three limits hold in every pass:**
|
|
14
|
+
|
|
15
|
+
- **The brief wins over the preset.** An element the product or its brief requires stays, even where a dial or a law would cut it — a dashboard's activity feed, a top bar the product has. The pass changes how it's built, not whether it exists.
|
|
16
|
+
- **Improving is not restructuring.** Improving an existing screen doesn't move its navigation or add features — a collapsible icon rail, a theme toggle, a new view. Those go under `## Open issues` unless the user asks for them.
|
|
17
|
+
- **Improving keeps what the design already chose** (`SKILL.md`). Where a preset's default and the existing design disagree on dividers, corners or line weight, `## Refining an existing design` in the layout guide decides; read it when the pass touches layout or depth: `awk '/^## /{p = /^## Refining an existing design/} p' <keyline>/domains/layout/guide.md`.
|
|
18
|
+
|
|
19
|
+
**Report:** what was wrong, as a reader would feel it; what changed, by token or component, with the law; what was measured (sizes, ratios, durations); and anything noticed outside the domain, with its command.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# `/keyline sync`
|
|
2
|
+
|
|
3
|
+
Bring `DESIGN.md` back in line with code that has moved since it was written. An audit of the file, not a rewrite of it.
|
|
4
|
+
|
|
5
|
+
**Read:** `system/design-md.md` for the format.
|
|
6
|
+
|
|
7
|
+
**Steps:**
|
|
8
|
+
|
|
9
|
+
1. Rescan with `node <keyline>/scripts/scan.mjs . --ignore <prose and example dirs>`, using the same ignores as last time if `DESIGN.md` records them.
|
|
10
|
+
2. Compare against every table in `DESIGN.md`: values that changed, tokens that appeared or disappeared, roles now done by a different token, a theme or area that's new.
|
|
11
|
+
3. Re-measure every text tier with `<keyline>/scripts/contrast.mjs --ladder`, per theme, against its worst surface.
|
|
12
|
+
4. Walk `## Open issues`: strike what's fixed (with the evidence that it is), keep what isn't, add what's new.
|
|
13
|
+
5. Propose the edits as a short diff and apply them. Every name the user chose and every `## Decisions` entry survives; a decision the code now contradicts is reported, never silently dropped.
|
|
14
|
+
|
|
15
|
+
**Report:** what drifted, what's newly fixed, what's newly broken, and anything in `## Decisions` the code no longer follows.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# `/keyline type`
|
|
2
|
+
|
|
3
|
+
**Guide:** `domains/type/guide.md` · **`DESIGN.md` section:** `## Type` · **Procedure:** `commands/refine.md`
|
|
4
|
+
|
|
5
|
+
Makes the hierarchy readable at a glance: every size belongs to a role, nothing outranks the heading it sits under, weights each have one job, leading and tracking are corrected for size, emphasis uses axes the system already has, numbers that align are tabular, and fonts load without moving the text.
|
|
6
|
+
|
|
7
|
+
Typical finds: a template opening a step above the rest; a lead larger than its heading; five weights where two would do; uppercase eyebrows over every section; body copy tracked; prices in proportional figures; imported Markdown on the browser's own ladder. Deeper references: `ladders.md`, `display-tier.md`, `prose-and-emphasis.md`, `numerals-and-loading.md`, `type-audit.md` (the sweep after a type change).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# UX laws
|
|
2
|
+
|
|
3
|
+
The named laws behind what a reader feels, and where Keyline holds each one. `critique` and `audit` cite a name beside a finding only where it explains why the reader feels the problem: the names are reasons, not a checklist. In the table, "step 3 above" is `critique`'s step 3: reading the screen in order.
|
|
4
|
+
|
|
5
|
+
| UX law | The reader… | Where Keyline holds it |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Aesthetic-usability | forgives a polished screen, and overlooks its problems in testing | Here: judge the laws, not the polish — a beautiful screen still gets every check |
|
|
8
|
+
| Hick's law | takes longer to choose as choices grow | patterns law 8 |
|
|
9
|
+
| Fitts's law | hits big, near targets faster | layout law 13 |
|
|
10
|
+
| Jakob's law | expects this product to work like the others they use | patterns law 14 |
|
|
11
|
+
| Miller's law | holds only a few chunks at once | patterns law 8 |
|
|
12
|
+
| Doherty threshold | keeps pace with responses under ≈ 400ms | patterns law 15; motion law 3 |
|
|
13
|
+
| Proximity | groups what sits close | layout law 2 |
|
|
14
|
+
| Similarity | groups what looks alike | patterns law 3; layout law 3 (one style per device) |
|
|
15
|
+
| Common region | groups what shares a boundary | layout law 3 — the quietest boundary that holds, a card last |
|
|
16
|
+
| Visual hierarchy | reads in the order size, weight, colour and position set | type laws 3–4; layout law 2; step 3 above |
|
|
17
|
+
| Progressive disclosure | copes when detail arrives on request | patterns law 8; layout law 11 |
|
|
18
|
+
| Recognition over recall | finds seeing easier than remembering | patterns law 16; patterns law 7 (shortcuts shown where the action lives) |
|
|
19
|
+
| Von Restorff | remembers the one thing that differs | colour law 7; motion law 9; one primary button (patterns law 13) |
|
|
20
|
+
| Peak-end rule | remembers the best or worst moment and the end | patterns law 17 |
|
|
21
|
+
| Goal-gradient | speeds up near the goal | patterns law 17 |
|
|
22
|
+
| Serial position | remembers the first and last items | patterns law 3; primary destinations first in the shell (layout dials) |
|
|
23
|
+
| Tesler's law | shouldn't carry complexity the product can | patterns law 16 |
|
|
24
|
+
| Postel's law | types things in many forms | patterns law 16 |
|
|
25
|
+
| Occam's razor | is served by the fewest parts that do the job | motion law 1; patterns law 12; `commands/refine.md` |
|
|
26
|
+
| Feedback loops | needs to see every action land | patterns law 15 |
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Deriving a ramp
|
|
2
|
+
|
|
3
|
+
From a preset and a brand hue (or none) to every role, per theme, every ratio measured. Dials change the inputs, not the method.
|
|
4
|
+
|
|
5
|
+
## Work in OKLCH
|
|
6
|
+
|
|
7
|
+
Pick values in `oklch(L C h)`, not hex or HSL: lightness is perceptual, so greys 0.02 apart look equally different anywhere on the ramp, and hue holds as lightness changes — a teal stays teal as it darkens, where HSL drifts. Browsers and `contrast.mjs` take `oklch()`. Where the stack needs hex (some design tools, email, older build tooling), keep the OKLCH source in a comment beside it, so a value can move without re-deriving the rest.
|
|
8
|
+
|
|
9
|
+
## The three inputs
|
|
10
|
+
|
|
11
|
+
1. **The neutral hue and chroma.** No hue and chroma 0 for pure greys; otherwise the brand hue at the preset's neutral chroma.
|
|
12
|
+
2. **The worst surface for each tier.** List the surfaces each text tier really lands on — tertiary text in a table meets hover and selected rows; an article caption, the page and a subtle band — and solve against the worst.
|
|
13
|
+
3. **The text targets.** One ratio per tier, from the table, adjusted so adjacent tiers keep the preset's minimum step.
|
|
14
|
+
|
|
15
|
+
| Preset | Tiers, top to bottom (target ratio on the worst surface) | Minimum step |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `editorial` | 15 · 8 · 6.5 · 5.5 · 4.6 | 1.15× |
|
|
18
|
+
| `product` | 15 · 7.5 · 4.8 | 1.4× |
|
|
19
|
+
| `expressive` | 15 · 8 · 6 · 4.6 | 1.25× |
|
|
20
|
+
|
|
21
|
+
Drop unused middle tiers; never the floor.
|
|
22
|
+
|
|
23
|
+
## Solving a tier's lightness
|
|
24
|
+
|
|
25
|
+
For a surface of relative luminance `Yₛ` and a target ratio `R`, the text's luminance `Y` is:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
text darker than the surface (light theme): Y = (Yₛ + 0.05) / R − 0.05
|
|
29
|
+
text lighter than the surface (dark theme): Y = R × (Yₛ + 0.05) − 0.05
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For a near-neutral colour, OKLCH lightness is very nearly the cube root of luminance, so `L ≈ ∛Y` and `Yₛ ≈ Lₛ³` — a one-line starting value; verify it, nudging by 0.005 until the check passes:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
node <keyline>/scripts/contrast.mjs --ladder "oklch(0.955 0.008 195)" \
|
|
36
|
+
"oklch(0.22 0.008 195)" "oklch(0.41 0.008 195)" "oklch(0.52 0.008 195)"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Worked: a tertiary target of 4.8 on a light hover row at `L 0.955` (`Yₛ ≈ 0.871`) gives `Y = 0.921 / 4.8 − 0.05 = 0.142`, so `L ≈ 0.52`. On a dark hover row at `L 0.24` (`Yₛ ≈ 0.014`), `Y = 4.8 × 0.064 − 0.05 = 0.256`, so `L ≈ 0.635`.
|
|
40
|
+
|
|
41
|
+
For accents and status foregrounds `∛Y` is only a first guess: at high chroma luminance drifts from lightness, most in blues and violets (`oklch(0.55 0.25 290)` matches a grey near L 0.52). Let the script decide.
|
|
42
|
+
|
|
43
|
+
## Spacing surfaces and lines
|
|
44
|
+
|
|
45
|
+
Space surfaces by contrast against the page, not equal lightness steps, so the ratios read as the same steps in both themes.
|
|
46
|
+
|
|
47
|
+
| Role | Ratio against `surface.page` | Light L (from 1.0) | Dark L (from 0.17) |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `surface.raised` | 1.03–1.08 | 0.985 | 0.21 |
|
|
50
|
+
| `surface.subtle` | 1.05–1.08 | 0.975 | 0.20 |
|
|
51
|
+
| `surface.hover` | 1.12–1.16 | 0.955 | 0.24 |
|
|
52
|
+
| `surface.selected` | 1.12–1.22, plus accent chroma | 0.95 | 0.25 |
|
|
53
|
+
| `surface.pressed` | 1.2–1.27 | 0.935 | 0.27 |
|
|
54
|
+
| `border.default` | 1.25–1.45 | 0.90 | 0.30 |
|
|
55
|
+
| `border.hover` · `mark` | 1.8–2.1 | 0.80 | 0.40 |
|
|
56
|
+
| `border.control` (an input's edge) | ≥ 3 on every surface an input sits on | 0.62 | 0.52 |
|
|
57
|
+
|
|
58
|
+
Steps land about 0.02 apart in L in light, 0.03 in dark. Light `border.default` may sit lighter than `surface.pressed` if borders should recede further (law 1). Light `surface.raised` may be the page colour with a border, or white on a grey page — either way round, so long as every level steps the same way; dark must be lighter than the page.
|
|
59
|
+
|
|
60
|
+
## Deriving the accent from a brand hue
|
|
61
|
+
|
|
62
|
+
The brand hue is given; derive the rest per theme. A logo's colour is where the hue comes from, not the accent: tuned for a mark seen once, at full strength it reads too bright across a working screen. Keep its hue and tone chroma and lightness down until white text clears 4.5:1.
|
|
63
|
+
|
|
64
|
+
1. **`accent` in light.** The highest in-gamut chroma at a lightness where `text.on-accent` clears 4.5:1 — with white text, usually L 0.45–0.55. If the script reports clipping, lower the chroma, not the lightness.
|
|
65
|
+
2. **`accent` in dark.** Either one value, L roughly 0.72–0.78 with chroma trimmed a little, with `text.on-accent` flipped to the dark `surface.page`; or, where the brand keeps white text on its fill, two: the fill near its light-theme lightness, and a lighter `accent.ink` at 4.5:1 on the dark page for links, icons and brand text, since no single value clears both. Measure each as a new colour.
|
|
66
|
+
3. **Its hover.** 0.05 in L away from the page — darker in light, lighter in dark: more contrast in both.
|
|
67
|
+
4. **`surface.selected`.** For a selected row in a dense table, the accent hue at the `surface.selected` lightness, chroma about 0.03. For the current nav item or tab under product and expressive, the stronger tint colour law 7 defines, carrying `accent.ink`. Re-measure the *lowest* text tier on each: this fails most often, because a tint costs a little luminance.
|
|
68
|
+
5. **`focus`.** The accent, if it clears 3:1 on every surface including `surface.selected`, and on the page: `outline-offset` leaves a gap of page colour, so a ring round an accent button is seen against the page.
|
|
69
|
+
|
|
70
|
+
**When the brand colour can't be text.** Yellows, limes and light cyans have no lightness at usable chroma where white text clears 4.5:1, and fail as text on white. Keep them for fills and marks with dark `text.on-accent`, and derive a darker token for anything text-sized — `text.link`, if links are coloured. Name both for their jobs; neither is "brand-yellow".
|
|
71
|
+
|
|
72
|
+
## Mapping an existing numbered ramp
|
|
73
|
+
|
|
74
|
+
A project with `gray-50…950` or `blue-100…900` keeps its names; each step gets the role most of its usage does (border, caption, hover). Three findings are common:
|
|
75
|
+
|
|
76
|
+
- **One step doing two jobs**, usually a border grey also used for meta text; record it, since it can rarely pass both 3:1 and 4.5:1.
|
|
77
|
+
- **One job at several steps**, usually borders at three adjacent greys; the majority step becomes the role, the rest are strays.
|
|
78
|
+
- **No step that works as a text tier** between secondary and the floor; the fix is a new token picked by the method above, not a borrowed step.
|
|
79
|
+
|
|
80
|
+
## The translucent set
|
|
81
|
+
|
|
82
|
+
Starting values; measure each over the lightest and darkest surface it can sit on (`contrast.mjs` composites the alpha first):
|
|
83
|
+
|
|
84
|
+
| Role | Light | Dark |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| a layered hover wash | `oklch(0 0 0 / 0.04)` | `oklch(1 0 0 / 0.05)` |
|
|
87
|
+
| `border.layered` | `oklch(0 0 0 / 0.08)` | `oklch(1 0 0 / 0.1)` |
|
|
88
|
+
| `border.layered`, hovered | `oklch(0 0 0 / 0.14)` | `oklch(1 0 0 / 0.16)` |
|
|
89
|
+
|
|
90
|
+
Tint these toward the neutral hue if the solids are tinted; a pure-black hairline beside tinted greys reads as dirt.
|
|
91
|
+
|
|
92
|
+
## Worked example: Harbour
|
|
93
|
+
|
|
94
|
+
A fictional clinic-scheduling product: `product` preset, teal brand hue at OKLCH h 195, neutral chroma 0.008. Tertiary text sits in table rows, so the worst surface is `surface.hover`; every ratio is measured with `contrast.mjs`. Core blocks, then the full set:
|
|
95
|
+
|
|
96
|
+
```css
|
|
97
|
+
:root {
|
|
98
|
+
color-scheme: light;
|
|
99
|
+
--surface-page: oklch(1 0 0);
|
|
100
|
+
--surface-hover: oklch(0.955 0.008 195);
|
|
101
|
+
--border-default: oklch(0.9 0.008 195);
|
|
102
|
+
--text-primary: oklch(0.22 0.008 195);
|
|
103
|
+
--text-secondary: oklch(0.41 0.008 195);
|
|
104
|
+
--text-tertiary: oklch(0.52 0.008 195);
|
|
105
|
+
--accent: oklch(0.5 0.08 195); /* white text 5.77:1 */
|
|
106
|
+
}
|
|
107
|
+
:root[data-theme="dark"] {
|
|
108
|
+
color-scheme: dark;
|
|
109
|
+
--surface-page: oklch(0.17 0.008 195);
|
|
110
|
+
--surface-hover: oklch(0.24 0.008 195);
|
|
111
|
+
--border-default: oklch(0.3 0.008 195);
|
|
112
|
+
--text-primary: oklch(0.92 0.008 195);
|
|
113
|
+
--text-secondary: oklch(0.73 0.008 195);
|
|
114
|
+
--text-tertiary: oklch(0.64 0.008 195);
|
|
115
|
+
--accent: oklch(0.75 0.1 195); /* re-picked; dark page text on it 8.94:1 */
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Role | Light | Dark | Measured |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| `surface.page` | `oklch(1 0 0)` `#ffffff` | `oklch(0.17 0.008 195)` `#0b1110` | — |
|
|
122
|
+
| `surface.subtle` | `oklch(0.975 0.008 195)` | `oklch(0.2 0.008 195)` | — |
|
|
123
|
+
| `surface.raised` | `oklch(0.985 0.008 195)` | `oklch(0.21 0.008 195)` | — |
|
|
124
|
+
| `surface.hover` | `oklch(0.955 0.008 195)` | `oklch(0.24 0.008 195)` | — |
|
|
125
|
+
| `surface.selected` | `oklch(0.95 0.03 195)` | `oklch(0.25 0.03 195)` | — |
|
|
126
|
+
| `surface.pressed` | `oklch(0.935 0.008 195)` | `oklch(0.27 0.008 195)` | — |
|
|
127
|
+
| `border.default` | `oklch(0.9 0.008 195)` | `oklch(0.3 0.008 195)` | decorative |
|
|
128
|
+
| `border.control` | `oklch(0.62 0.008 195)` | `oklch(0.52 0.008 195)` | 3.38 / 3.22 on the worst surface an input sits on |
|
|
129
|
+
| `mark` | `oklch(0.8 0.008 195)` | `oklch(0.4 0.008 195)` | decorative |
|
|
130
|
+
| `text.primary` | `oklch(0.22 0.008 195)` | `oklch(0.92 0.008 195)` | 15.19:1 / 12.99:1 on hover |
|
|
131
|
+
| `text.secondary` | `oklch(0.41 0.008 195)` | `oklch(0.73 0.008 195)` | 7.74:1 / 6.88:1 on hover |
|
|
132
|
+
| `text.tertiary` | `oklch(0.52 0.008 195)` | `oklch(0.64 0.008 195)` | 4.83:1 / 4.90:1 on hover |
|
|
133
|
+
| `accent` · `focus` | `oklch(0.5 0.08 195)` | `oklch(0.75 0.1 195)` | focus 5.07:1 (light) on hover; 5.04 / 7.42 on selected |
|
|
134
|
+
| `accent`, hovered | `oklch(0.45 0.075 195)` | `oklch(0.8 0.1 195)` | — |
|
|
135
|
+
| `text.on-accent` | `oklch(1 0 0)` | `oklch(0.17 0.008 195)` | 5.77 / 8.94 |
|
|
136
|
+
| `surface.inverse` | = `text.primary` | = `text.primary` | — |
|
|
137
|
+
| `text.inverse` | = `surface.page` | = `surface.page` | 17.27 / 15.11 |
|
|
138
|
+
|
|
139
|
+
What the measurements found:
|
|
140
|
+
|
|
141
|
+
- **The first dark `surface.selected` failed:** at `oklch(0.27 0.035 195)` tertiary text on a selected row was 4.44:1; `oklch(0.25 0.03 195)` gives 4.73:1. The page check would never have shown it.
|
|
142
|
+
- **`surface.pressed` fails tertiary text in dark** (4.48:1). A press is momentary, so Harbour excludes it from the tertiary check and records that under `## Decisions`, so the next audit doesn't rediscover it as a bug.
|
|
143
|
+
|
|
144
|
+
The ladder steps, 15.19 → 7.74 → 4.83 in light (2.0×, 1.6×) and 12.99 → 6.88 → 4.90 in dark (1.9×, 1.4×), both clear the `product` minimum of 1.4×.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Colour and Theming
|
|
2
|
+
|
|
3
|
+
A near-monochrome reading site, a dense product with a full status palette and a brand page spending colour broadly differ in how much colour they spend, not in how it is organised: **a colour token encodes a job, not a lightness.** `border.default` is not "a bit darker than the hover surface", it is *the colour an edge is*. Indexed by intent, picking a colour means naming a role, a reviewer can catch a mistake in the diff, and a second theme is a re-tint of the same names rather than a second palette.
|
|
4
|
+
|
|
5
|
+
## Before you start
|
|
6
|
+
|
|
7
|
+
Reads `DESIGN.md` `## Colour`.
|
|
8
|
+
|
|
9
|
+
## Laws
|
|
10
|
+
|
|
11
|
+
### 1. A token is named for its job, not its lightness
|
|
12
|
+
|
|
13
|
+
**Every colour in the interface is a token, and every token answers one question — "what colour is a border?", "what colour is a pressed surface?" — never a position on a lightness scale.**
|
|
14
|
+
|
|
15
|
+
Indexed by lightness (`gray-100` lightest, `gray-900` darkest), every pick is a judgement — "a border, so… 300? 400?" — and six months later there are four border greys. Indexed by intent, the question has one answer, and a border at the pressed-surface step is visibly wrong in review. Brand hues too: `indigo-50` to `indigo-900` must still say which step is `accent`, `accent` hovered and `surface.selected`; `bg-indigo-600` here and `bg-indigo-700` there on one button means the ramp does no work. Map steps to roles; components reference the roles.
|
|
16
|
+
|
|
17
|
+
- **Intent can beat monotonicity.** A border sits *beside* content and must stay quieter than a pressed surface, which sits *under* it, so in light the border step may legitimately be lighter — in the Glass preset, `gray-400` (border) is lighter than `gray-300` (pressed).
|
|
18
|
+
- **"I need something between two steps" is a misdiagnosis.** The element has a different job from the one assumed; if no role fits, it's a new role, named and recorded — not an inline hex.
|
|
19
|
+
|
|
20
|
+
The allowed literals:
|
|
21
|
+
|
|
22
|
+
- **Illustration.** Colours inside an image, SVG artwork, or an HTML or SVG product mock or diagram marked `role="img"`. Text in it that a reader is meant to read still clears 4.5:1 against what is behind it.
|
|
23
|
+
- **Third-party marks.** Logos and provider marks — Google's, Slack's, a calendar provider's dot — keep their own colours. They are marks, not interface hues, and count against neither the one accent nor the exceptions (law 7).
|
|
24
|
+
- **Multi-layer values** built once as a shared constant (law 10).
|
|
25
|
+
|
|
26
|
+
### 2. Text tiers are their own tokens, never border steps
|
|
27
|
+
|
|
28
|
+
**A text colour is picked for text contrast, never a mid-ramp step borrowed from the border and fill range.**
|
|
29
|
+
|
|
30
|
+
Text needs 4.5:1 against its surface; a border or icon a user must see to operate a control, 3:1; a decorative edge, only visibility. One value rarely serves both, least in a *light* theme, where mid steps are light tints on white: a timestamp borrowing a border-hover step can sit at 1.66:1.
|
|
31
|
+
|
|
32
|
+
Dark mode hides this — the same indices sit far lighter on near-black, so every mid step passes, and a system reviewed in dark ships a light theme whose body copy fails. So `text.primary`, `text.secondary`, `text.tertiary` and any tiers between are separate tokens. Icons take their label's tier or one below — an icon has more visual mass than text at the same value — and both advance together on hover. Decorative marks with no text (bullets, dots, inactive rail markers) are `mark`, stay on the ramp and aren't held to text contrast; kept faint, a list doesn't read as a column of dark dots.
|
|
33
|
+
|
|
34
|
+
### 3. The ladder clears the floor and strictly descends, measured where it's worst
|
|
35
|
+
|
|
36
|
+
**Every text tier clears 4.5:1 against the worst surface it actually lands on, in the worst theme, and each tier is measurably below the one above it.**
|
|
37
|
+
|
|
38
|
+
- **Computed, not estimated.** Use `<keyline>/scripts/contrast.mjs` (`--ladder <surface> <tier> <tier> …`, top tier first), which composites translucent colours first — the number the reader sees. Where the surface itself is translucent — a wash over a surface, a glass header over an image — `--over <backdrop>` composites it over the backdrop before the text is measured: `node <keyline>/scripts/contrast.mjs --over "#0b1220" --ladder "rgba(250,250,250,0.82)" "#16161a" "#4b4b52"`.
|
|
39
|
+
- **The worst surface, not the page.** Tertiary text may pass on `surface.page` and fail on `surface.hover`, `surface.subtle` or `surface.selected`; measure on the surface leaving least contrast — the darkest in light, the lightest in dark.
|
|
40
|
+
- **Strictly descending, with a step you can see.** Above 4.5:1 the tiers crowd into a narrow band, and it's easy to pass WCAG and lose the hierarchy — tiers at 5.1 and 4.9 are one tier with two names. `--ladder` flags a tier not below the one above; the minimum *size* of the step is a dial.
|
|
41
|
+
|
|
42
|
+
### 4. Anything on an unknown backdrop takes a translucent colour
|
|
43
|
+
|
|
44
|
+
**A border, divider, hairline or hover wash that can sit over anything but a known opaque surface uses a translucent value, and that translucent set inverts between themes.**
|
|
45
|
+
|
|
46
|
+
A solid border on a translucent panel ignores what's behind it, and over a photograph or a scrolled page reads as a frame pasted on top; a translucent one — "a little more contrast than whatever is here" — stays right over anything. That is black at low alpha in light and *white* in dark, because a dark-mode edge is lighter than its surface and a black overlay would disappear. Built once, with the same role names in both themes, it needs no per-component theme branching.
|
|
47
|
+
|
|
48
|
+
`border.layered` is the canonical role; project roles under it are fine. Translucent *material* tints are colours and live here (as runtime values, law 10); blur, edge, shadow and fallback surface belong to the depth guide.
|
|
49
|
+
|
|
50
|
+
### 5. Focus is findable on every surface
|
|
51
|
+
|
|
52
|
+
**The focus colour clears 3:1 against every surface it can land on, is drawn with a gap of page colour between it and the element, and nothing it sits next to shares it.**
|
|
53
|
+
|
|
54
|
+
```css
|
|
55
|
+
:focus-visible {
|
|
56
|
+
outline: 2px solid var(--focus);
|
|
57
|
+
outline-offset: 2px;
|
|
58
|
+
}
|
|
59
|
+
a:focus-visible { outline-offset: 3px; } /* ragged inline edges need more room */
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`:focus-visible`, never `:focus`, so a click leaves no ring — except a skip link, which must appear on keyboard focus regardless of heuristics. The offset makes the ring an object, not a border; `outline`, not `box-shadow`, because forced-colours mode removes shadows and keeps outlines. `outline: none` without a replacement is a bug under every preset.
|
|
63
|
+
|
|
64
|
+
Focus may share a token with `accent` but may not sit beside it: an accent ring round an accent button on an accent-tinted row is invisible exactly where it's needed. If the accent is everywhere, the ring gets a different colour or a double ring (page-colour inner, focus-colour outer). Status colours keep out of the focus hue for the same reason.
|
|
65
|
+
|
|
66
|
+
A dark band on a light-only page — a testimonial strip, a closing call to action — is a local theme: `focus` is overridden inside it, like its surfaces and text, and measured against the band.
|
|
67
|
+
|
|
68
|
+
### 6. Colour never carries a signal alone
|
|
69
|
+
|
|
70
|
+
**Every state or status expressed in colour is also expressed in something that survives without colour — an icon, a word, a weight, a rule, a marker, a position.**
|
|
71
|
+
|
|
72
|
+
About one man in twelve has some colour-vision deficiency, and every reader loses hue in sunlight, greyscale screenshots, cheap projectors; a red border as the only sign of an error tells them nothing. Status is an icon plus a sentence, with colour reinforcing both: **shape carries the state, colour confirms it.** A status glyph whose shape moves with the work, a ring that fills until it is a check, reads in greyscale; so do priority bars. The case people miss is state *within* a neutral system: once every tier clears 4.5:1 only two or three greys are readable, too close to mean "selected" or "current", so a nav item marked active only by being one tier darker needs a rule, a marker, a fill or a weight as well.
|
|
73
|
+
|
|
74
|
+
A status palette is a set of **pairs**, each a foreground at 4.5:1 on the page and its own tint plus a tint that reads as a surface, not a highlighter, re-picked per theme.
|
|
75
|
+
|
|
76
|
+
→ Status pairs with a worked set, the glyph shapes, and the exception test in full: `domains/color/status-and-exceptions.md`
|
|
77
|
+
|
|
78
|
+
### 7. Colour is spent structurally
|
|
79
|
+
|
|
80
|
+
**Every hue has one job, written down. Each chromatic spend beyond the accent and the status palette is named for what it's for, with a reason that can't spread to another element or another job, and contained so that only the mark takes the colour.**
|
|
81
|
+
|
|
82
|
+
Colour is attention, spent because of what the element *is*, never as decoration. "It's important", "it's new" or "it's the brand colour" can spread, and becomes a palette within two components, because every author thinks their element qualifies; a reason naming a category of one can't. Only the mark takes the colour, never the text inside. Record each as `exception.<what-it's-for>` in `DESIGN.md`, a family as `exception.<question>.<member>`, with its reason; how many a system holds is a dial. First ask whether weight, position and space can do the job. Most can. That test is for colour being *added*.
|
|
83
|
+
|
|
84
|
+
**Colour the design already has stays.** When improving an existing interface, its brand colour stays wherever it does a job: the selected nav item, the primary data series, a progress or usage bar, a brand mark, the gradient every avatar shares. Each of those is the owner's decision and part of how the product is recognised. Turning it grey is a regression that no law asks for. Colour comes out only when it breaks a law, and the change cites that law: text under its contrast floor, a second hue competing with the primary action, colour carrying a state on its own, or body text dyed for emphasis. Even then, the first fix is the shade (re-pick it until it passes) rather than taking the colour away. Muting the brand for calm is a dial for a new system, not a fix for an existing one.
|
|
85
|
+
|
|
86
|
+
**One hue acts.** The accent's jobs — primary action, focus, "your turn" — share one hue, and it marks one action per view. A second highlight hue beside it splits one meaning in two; whatever wanted it goes to a status pair, a neutral marker or weight.
|
|
87
|
+
|
|
88
|
+
**Where the reader *is* takes the accent as a tint, not a fill.** Under product and expressive, the current nav item sits on `surface.selected`: the accent at about 25% over the surface it sits on, with the icon and label in `accent.ink`. Measure that label at 4.5:1 on the tint in each theme, and deepen the ink, not the tint, until it passes. The tint marks a place and the solid fill marks the action, so the selected item and the primary button never read as two calls to act. A page tab marks its current tab with layout law 3's underline instead, in `text.primary`. **This is the one definition of current**: the type guide adds weight to it, the motion guide moves it, neither re-picks it. Editorial and glass keep the current item neutral (a marker, a rule or weight), because a reading page has little chrome and the accent there belongs to focus.
|
|
89
|
+
|
|
90
|
+
**"Too grey" is answered with jobs, not decoration**: one hue family per question the reader can't answer at a glance — what, how, where, who — and no hue crosses into another's job. Whether done takes success colour is a dial; a chart's primary series takes the accent at full strength; code takes syntax highlighting under every preset, counted against no limit.
|
|
91
|
+
|
|
92
|
+
→ The exception test and containment, hue families, figures, done states, charts, live and AI marks, destructive rows: `domains/color/status-and-exceptions.md`
|
|
93
|
+
|
|
94
|
+
### 8. A theme is a re-pick, not a reuse
|
|
95
|
+
|
|
96
|
+
**Every coloured value — accent, focus, status pair, exception, each hue in a family, every stop of every gradient — is chosen afresh for each theme and measured there.**
|
|
97
|
+
|
|
98
|
+
- **Accents.** A hue at 4.5:1 on white has far less on near-black; reused, it dims the ring and links. Re-pick its lightness (usually trimming chroma) and re-check `text.on-accent`, which often flips from light to dark. A dark fill that keeps white text is too dark to be text on the dark page; no one value is both, so dark adds a lighter `accent.ink` for links, icons and brand text.
|
|
99
|
+
- **Small marks, against every ground.** Area colours shared across themes fell to 1.8:1 on a light frame. A mark the reader needs clears 3:1 on every ground it sits on — panel, frame, hover row — in each theme.
|
|
100
|
+
- **Gradients move every stop.** The darkest stop can sit a few points above a dark page and vanish down half its length; lightening only that stop compresses the ramp until it isn't one gradient. Move every stop by roughly the same amount.
|
|
101
|
+
- **Near-black, not black.** Dark `surface.page` sits a little above `#000`: pure black gives shadows nothing to sit on and glares against light text. Light `text.primary` is near-black too: pure black on white halates at small sizes.
|
|
102
|
+
- **"Raised" is ordered, and in dark it is lighter** (depth law 5). Dark fills are tinted, not darkened, so they don't punch a hole in the page.
|
|
103
|
+
- **Not a mechanical inversion,** which puts every gap in the wrong place. Space each theme's surfaces by contrast against *its own* page, so hover and pressed read as the same steps in both, with a large jump between the last edge step and the lowest text tier.
|
|
104
|
+
|
|
105
|
+
### 9. One mechanism sets the theme, before first paint
|
|
106
|
+
|
|
107
|
+
**When the user can choose a theme, an attribute or class on the root element is the mechanism — not the `prefers-color-scheme` media query — and it is set by a blocking inline script before the stylesheet applies.**
|
|
108
|
+
|
|
109
|
+
A toggle and a media query are two sources of truth, and once a user has chosen, the OS shouldn't win; the media query may seed the first visit, not be the switch. (Without a toggle it is the only source, and fine.)
|
|
110
|
+
|
|
111
|
+
The script is inline in `<head>`, blocking, before the stylesheet, and in `try/catch` because storage throws in some privacy modes. Skip any of the four and dark-mode users get a white flash on every load, the most noticeable bug a themed site can have. Under SSR, render the attribute from a cookie. Declare `color-scheme` per theme, or scrollbars, form controls and autofill stay light on a black page.
|
|
112
|
+
|
|
113
|
+
### 10. Values that change by context are runtime; the vocabulary is build-time
|
|
114
|
+
|
|
115
|
+
**A value that has to change with theme, user preference or container is a runtime custom property on the root; the fixed vocabulary may be build-time tokens. Anything that isn't a single colour — a gradient, a shadow stack — is runtime regardless.**
|
|
116
|
+
|
|
117
|
+
A build-time token (a Sass variable, an inlined JS constant, a static theme object) can't be re-declared by a media query, so `prefers-reduced-transparency`, `prefers-contrast: more` and the toggle can't reach it; and no token system has a slot for `linear-gradient()` or a four-layer `box-shadow`. A preference block re-declaring theme values at the same specificity goes **after** every theme block and names every theme selector.
|
|
118
|
+
|
|
119
|
+
→ The head script, persistence, two or three states, the crossfade, theme islands, runtime vs build-time per stack, preference overrides: `domains/color/theming-mechanics.md`
|
|
120
|
+
|
|
121
|
+
## Dials
|
|
122
|
+
|
|
123
|
+
| Dial | editorial | product | expressive |
|
|
124
|
+
|---|---|---|---|
|
|
125
|
+
| Palette | Near-monochrome neutral | Near-monochrome neutral plus a status palette | Brand palette used broadly, each hue placed by rule |
|
|
126
|
+
| Neutral chroma (OKLCH C) | 0–0.01 | 0–0.015, toward brand hue | 0–0.03, toward brand hue |
|
|
127
|
+
| What the accent does | Focus, and nothing else | Primary action, focus, selection, the primary data series | Brand surfaces, action, focus, selection |
|
|
128
|
+
| Brand colour already in the design | Kept where it has a job | Kept where it has a job | Kept where it has a job |
|
|
129
|
+
| Chromatic exceptions beyond accent and status | ≤ 1 | None by default; a hue family per question the reader can't answer at a glance (what, where, who), plus one-member exceptions — each listed | ≤ 4, each named |
|
|
130
|
+
| Hues that call for action | 1, the accent | 1, the accent | Brand hues, each placed by rule |
|
|
131
|
+
| Completed states | Uncoloured | `status.success` where completion is the news; uncoloured in a sequence passed through | Uncoloured, or per brand, sequences included, counted among the exceptions |
|
|
132
|
+
| Current location | Uncoloured: a marker, rule or weight | `surface.selected` (law 7) with icon and label in `accent.ink` | As product, or per brand |
|
|
133
|
+
| Link colour | Text colour, underlined | `accent` | `accent` or a brand hue, underlined in prose |
|
|
134
|
+
| Text tiers, `text.primary` to `text.tertiary` | 3–5 | 3 | 3–4 |
|
|
135
|
+
| Minimum step between adjacent tiers (ratio of ratios) | 1.15× | 1.4× | 1.25× |
|
|
136
|
+
| `text.tertiary` target on its worst surface | ≥ 4.6:1 | ≥ 4.8:1 | ≥ 4.6:1 |
|
|
137
|
+
| Status palette | Only if something reports state | `danger`, `warning`, `success`, `info` — required | As needed |
|
|
138
|
+
| Theme control | Light and dark, following the device; a toggle optional, two-state if added | Light and dark, following the device; a toggle optional, light, dark and system if added | One theme allowed; if two, following the device, a toggle optional, two-state if added |
|
|
139
|
+
| Dark `surface.page` lightness (OKLCH L) | 0.14–0.18 | 0.17–0.21 | 0.12–0.18 |
|
|
140
|
+
|
|
141
|
+
**Palette and neutral chroma.** Near-monochrome makes the accent and the one exception loud by default, but every state must then be carried by something other than colour (law 6). Tinted greys tie to the brand, but past about 0.015 chroma they read as colour, and a tinted `text.tertiary` beside a status foreground can look like a status.
|
|
142
|
+
|
|
143
|
+
**Exceptions and done.** An editorial page's content is prose, so its colour can be the one offer, and nothing on it is a task to finish. A product holds many kinds of record in many places by many people and is scanned, not read; in grey alone every row must be read to be sorted, which is usually what "too grey" means, and its boards exist to show what got done. So it starts from the status palette alone and adds a family, or colours done, when a question can't be answered at a glance, never because a screen looks plain.
|
|
144
|
+
|
|
145
|
+
**What the accent does.** Restricted to focus, colour *is* where the keyboard is. On primary actions it shows a working user the next step — worth it in a product, at the price of law 5's care for the ring beside accent buttons.
|
|
146
|
+
|
|
147
|
+
**Links.** Text-coloured links keep a reading page calm and leave colour for focus, but must be underlined because colour isn't carrying them. Accent links scan faster in a product, where links are navigation, not citation.
|
|
148
|
+
|
|
149
|
+
**Tiers, step and tertiary target.** More tiers give prose finer emphasis, but each must clear the floor, so every extra one shrinks the steps; a light theme rarely fits more than five. A product has fewer, more distinct tiers, told apart at a glance across a dense table, and a wider margin above 4.5:1 to absorb the surfaces the floor wasn't measured on (hover, selected, zebra rows).
|
|
150
|
+
|
|
151
|
+
**Theme control.** Following the device needs no control and no head script (law 9). A toggle, where one is wanted: two states are predictable; "system" changes the page when the OS crosses sunset, surprising someone who chose from the page's menu. Three follow the OS for those who want it, and cost a live listener.
|
|
152
|
+
|
|
153
|
+
**Dark page lightness.** Lower is more dramatic but leaves less room below the raised steps; a product's dark theme stacks panels, so its page sits higher.
|
|
154
|
+
|
|
155
|
+
## Building it for a project
|
|
156
|
+
|
|
157
|
+
1. **List the jobs.** From `roles.md`, keep the colour roles this project has and strike the rest — no dark theme, no dark column; no status, no status rows. Add project roles under their nearest parent.
|
|
158
|
+
2. **Choose the neutral:** a hue (the brand's, or none) and a chroma from the dial, in OKLCH.
|
|
159
|
+
3. **Fix the ends, per theme:** `surface.page` and `text.primary` near-white and near-black in light, the dial's dark lightness and a light grey in dark.
|
|
160
|
+
4. **Place surfaces and lines** (`surface.raised`, `surface.hover`, `surface.pressed`, `surface.subtle`, `border.default`, `border.hover`) as small steps down from the page in light (or `surface.raised` a step up, white on a grey page), up in dark, spaced by contrast against it; what must be seen (an input's edge, an icon, the focus ring) clears 3:1.
|
|
161
|
+
5. **Solve the text tiers from contrast targets** (tertiary at the dial's target, the rest at least the minimum step apart) on each tier's worst surface; verify per theme with `contrast.mjs --ladder`.
|
|
162
|
+
6. **Derive the accent from the brand hue**, per theme: `text.on-accent` at 4.5:1 and `focus` at 3:1 on every surface; then its hover and the `surface.selected` tint (law 7; translucent, so it re-tints with the theme), re-measuring the lowest text tier on it too.
|
|
163
|
+
7. **Status pairs**, if the preset needs them; **exceptions and hue families**, each passing law 7, every mark at 3:1 on its grounds.
|
|
164
|
+
8. **The translucent set**: black at low alpha in light, white in dark, for `border.layered` and any layered washes.
|
|
165
|
+
9. **Write it** as custom properties, one block per theme, with the framework's tokens pointing at them (law 10), and record the ratios in `DESIGN.md`.
|
|
166
|
+
|
|
167
|
+
→ The contrast formula, surface spacing, the brand ramp, per-preset targets, and *Harbour*, a worked `product` example with a teal hue, measured on its worst surfaces: `domains/color/deriving-a-ramp.md`
|
|
168
|
+
|
|
169
|
+
## Auditing existing code
|
|
170
|
+
|
|
171
|
+
Collect evidence, then measure. The greps assume CSS, Tailwind or CSS-in-JS; adapt them.
|
|
172
|
+
|
|
173
|
+
| Look for | Grep for | Law |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| Colour literals outside token files and artwork | `#[0-9a-fA-F]{3,8}\b`, `rgba?\(`, `hsla?\(`, `oklch\(` | 1 |
|
|
176
|
+
| The same job at several values | every value used for borders; for captions and meta | 1 |
|
|
177
|
+
| Mid-ramp steps used as text | `text-(gray\|neutral\|slate\|zinc)-(300\|400\|500)`, or the project's equivalent | 2 |
|
|
178
|
+
| Icons on a text-primary tier by default | icons with the top text colour at rest | 2 |
|
|
179
|
+
| Solid borders on translucent surfaces | `backdrop-filter` elements, then their border values | 4 |
|
|
180
|
+
| Lost focus | `outline:\s*(none\|0)`, `:focus\b` without `-visible`, `ring-0` | 5 |
|
|
181
|
+
| Colour-only status | error or success styling with no icon or message; nav active by colour alone | 6 |
|
|
182
|
+
| Uncounted colour | each hue outside accent, focus and status, and its job; third-party marks and `role="img"` illustration aside | 7 |
|
|
183
|
+
| Greyed brand (when comparing to the original) | a brand colour or gradient the original spent on selection, a data series, a meter, a mark or avatars, now grey or muted with no law cited | 7 |
|
|
184
|
+
| A second action hue; done coloured against the dial | two hues on buttons, links or "needs you" markers; success colour on passed steps, checks or the current nav item | 7 |
|
|
185
|
+
| A hue doing two jobs | a type or area hue on state or text; danger red on a live or recording state; status colour on a plain count or a zero | 7 |
|
|
186
|
+
| Reused accents and half-moved gradients | some gradient stops re-declared per theme, not all; single-value accents | 8 |
|
|
187
|
+
| Faint marks | type, area and person hues shared across themes; each measured at `--min 3` on panel, frame and hover | 8 |
|
|
188
|
+
| Pure black | `#000\b`, `#000000`, `black` on surfaces | 8 |
|
|
189
|
+
| Two theme mechanisms | `prefers-color-scheme` in CSS alongside a toggle; missing `color-scheme` | 9 |
|
|
190
|
+
| Flash of wrong theme | theme set in a component effect, not an inline head script | 9 |
|
|
191
|
+
| Compiled context values | build-time tints or gradients a preference query must replace | 10 |
|
|
192
|
+
|
|
193
|
+
Then measure, in every theme: each text tier on its worst surface (`contrast.mjs --ladder`), the focus colour at `--min 3` on every surface it can land on, each status foreground on the page and its tint.
|
|
194
|
+
|
|
195
|
+
## Writing back to DESIGN.md
|
|
196
|
+
|
|
197
|
+
- **The ratios.** Every text tier and the focus colour, per theme, with the surface measured on; re-measure when a value in `## Colour` changes.
|
|
198
|
+
- **New roles.** Any you added — a missing `surface.selected`, a tier between secondary and tertiary — in the project's naming style, marked `proposed` until accepted.
|
|
199
|
+
- **Exceptions.** Each `exception.<name>` under **Exceptions**: what it's for, and why that reason can't spread; a family with its question, its members and the marks it may take.
|
|
200
|
+
|
|
201
|
+
## Checklist
|
|
202
|
+
|
|
203
|
+
- [ ] Control edges and icons that must be seen clear 3:1.
|
|
204
|
+
- [ ] Each `status.*` is a fg/bg pair per theme, fg at 4.5:1 on the page and its tint.
|
|
205
|
+
- [ ] Dark `surface.page` and light `text.primary` are near-black, not `#000`; `color-scheme` is declared in every theme.
|
|
206
|
+
- [ ] Every brand colour and gradient the original spent on a job is still there, or the law that removed it is named.
|
|
207
|
+
- [ ] Product and expressive: the current nav item's label clears 4.5:1 in `accent.ink` on its tint; the primary chart series is the accent at full strength.
|