Imported from buriedsignals/splash (
skills/palette/SKILL.md). Install upstream withnpx skills add buriedsignals/splash --skill palette. Copyright stays with the author.
palette — propose the colours, measure them, let the journalist decide
Overview
NEWSROOM.md has carried brandColor and ground since preflight was written, preflight.mjs
validates them, and nothing threaded them into a render. Every beat named its colours as hex
literals in its own source, with a // from NEWSROOM.md comment beside them — an instruction to
copy by eye. A newsroom's identity was collected and then never used.
This skill closes that. It does four things and refuses a fifth:
- Proposes.
proposePalettereturns the options — the newsroom's house colours, and the subject's own convention when one applies — each carrying where its values came from, why it is being offered, and what it measured. ANEWSROOM.mdmay record SEVERAL accents (brandColoris the primary,accentslists the rest), and each one becomes its own scored option: a house palette is rarely one colour, and a longer one must not become a way past the contrast floor. - Measures. Every option is scored against
NON_TEXT_CONTRAST_MIN— every recorded accent, not only the first. A failing option is shown as failing, with the nearest passing variant offered beside it, never swapped in, andrecommendedonly ever names an option that PASSED: a newsroom whose primary accent misses the floor gets the first of its own further accents that clears it, and nothing at all when none does. - Reads the answer back — and measures it again.
readPalettewalks up from a beat's own directory looking forPALETTE.md, so one recorded decision at a story root serves every beat under it.parsePalettethen measures every recorded accent against the recorded ground and REFUSES one under 3:1, naming the ratio, the criterion and the nearest colour that clears it. That second measurement is not redundancy for its own sake: measured on 2026-08-10, aPALETTE.mdrecording#FFFF00on white rendered a clean PNG with the beat's whole number in it, because the floor lived only inside the proposal — and aPALETTE.mdcan be written by hand, copied from another story, or produced bynewsroom-charter, which measures a newsroom's own site. - Carries more than one accent, when the newsroom has more than one.
accents:lists the further house colours besideaccent:, same shapeNEWSROOM.mduses. A beat drawing several series takes them in order throughseriesInksand derives further ones — shades of a recorded accent, each clearing the mark floor and reading apart from the others — instead of falling back to the furniture grey, which is what a three-series beat did until this landed.
The fifth thing — writing a colour anywhere — it does not do. There is no write path in this
skill, not a commented-out one, not a flag. PALETTE.md is authored from the journalist's answer,
the same way NEWSROOM.md is.
When to use
-
Before the first beat of a story is rendered, once
NEWSROOM.mdis resolved (valid or declined). The proposal is a question the journalist answers once per story — and the same movement decides the typeface (scripts/typeface.mjs), derived when a font file can be fetched for the newsroom's recorded face and asked when it cannot. -
When a single beat needs to leave the story's decision — a renewables beat inside a story about something else. Drop a
PALETTE.mdbeside that beat;readPalettefinds the nearest one first. -
Not to pick a categorical set, a sequential scale or a diverging scale. This skill proposes one accent on one ground. The rest are decisions with constraints a single-accent lookup has no business making — see
references/subject-conventions.md, last section.A choropleth's ramp has an owner, and it is not a second question here (issue #60). The ramp is DERIVED from this file's answer: the map craft skill's own geometry core (
map-beat/assets/geo.ts:dataRampEnd,sequentialRamp) walks the recordedaccenttoward the pole itsgroundis not, andassertRampReadsmeasures the result at render — neighbouring classes at least 0.02 relative luminance apart, the top class at 3:1 against the ground. The journalist's decision is the accent; the ramp has no separate provenance because it has no separate choice. How many classes and where the breaks fall is a fact about the data, andanalystproposes it (profile.mjs,classBreaksOf) for the journalist to confirm. Doctrine rule 8 (doctrine/references/geo-discipline.md) says what the ramp may and may not carry. -
Not to derive a newsroom's charter from scratch. That is
newsroom-charter, which measures the newsroom's own website; this skill starts from the result.
The one gotcha that will waste your day (read first)
The accent floor is 3:1, and raising it to 4.5:1 is the mistake that looks like rigour. Those
are two different WCAG success criteria for two different things. 4.5:1 is SC 1.4.3, and it governs
text — which is already handled, on any ground, by deriveFurniture escalating ink and
muted until they clear it. 3:1 is SC 1.4.11 Non-text Contrast, and it governs the visual
information that identifies a graphical object: the line, the bar, the highlighted circle. The
accent carries no text. Holding it to a text threshold rejects perfectly legible house colours for
failing a criterion they were never subject to — and the newsroom whose brand just got rejected has
no way to learn that the wrong rule was applied.
The second half of the same trap is the mid-grey band, and the honest version of it is narrower
than it sounds. Swept over 4352 grounds, adjustToContrast returns null zero times at 3:1 and
zero times at 4.5:1; the first null appears at 5:1. The hardest ground found is #747474, where
the far pole lands at 3.0000809:1 — tight, and still a pass, because towards switches poles at
luminance 0.18 precisely so both sides clear. The null branch exists for a caller who raises
min, not for a ground that defeats the default. What the band does defeat is reasoning: on
#808080 the obvious "luminance > 0.5 means use black" rule picks white at 3.95:1 over black at
5.32:1, which is why deriveFurniture measures both poles instead. Full numbers in
references/contrast-floors.md.
Architecture
| Layer | File | Role |
|---|---|---|
| Colour maths | scripts/colour.mjs |
contrast, adjustToContrast, assertLegible, parsePalette, readPalette — ONE file, carried verbatim from chart-beat/scripts/colour.mjs (held by splash/test/carried-copies.test.ts), so the proposal and the render measure with the same function |
| Conventions | scripts/palette.mjs |
SUBJECT_CONVENTIONS, matchConvention — a deliberately short table, one entry per association a reader already holds |
| Scoring | scripts/palette.mjs |
NON_TEXT_CONTRAST_MIN, adjustToContrast — the floor, and the remedy shown beside a failure rather than substituted for it |
| Proposal | scripts/palette.mjs |
proposePalette({newsroom, subject}) — the options, each with provenance, reasoning and measured contrast |
| Renderer | scripts/format-proposal.mjs |
formatProposal(proposal) — the question the journalist actually reads and answers |
| Typeface | scripts/typeface.mjs |
typefaceDecision({newsroom}) — paletteDecision's shape for the face: every recorded face asked whether a render could set it — familyResolves, which is the trunk's own "is there a font FILE", not a question about the machine's font library — derived when the first resolves, asked when it does not; formatTypefaceProposal, formatTypeface write the question and the TYPEFACE.md every render-still.mjs reads |
| Reader | scripts/palette.mjs |
readPalette(dir, {stopAt}), parsePalette — reads the recorded answer back, throws naming every directory searched, and refuses an accent under the mark floor |
| Refusal | scripts/palette.mjs |
assertLegible(colour, against, {role}) — one of mark (3:1, SC 1.4.11), text (4.5:1, SC 1.4.3) or largeText (3:1, the same criterion's relaxation). The caller names the role rather than the number, because the two floors coincide at 3:1 and mean different things |
| Series inks | chart-beat/scripts/render-still.mjs |
seriesInks(palette, count) — the recorded accents first, then shades derived from them; never the furniture grey, and a throw rather than a default when it runs out |
How it works (the shape)
-
The subject option comes FIRST, when a convention applies.
matchConventiontests the subject line the journalist wrote against a short table. It returns the single match, and nothing when several match: a story about coal replacing hydro is not two accents, it is an editorial choice, and returning the first table row would make that choice by table order, invisibly. A convention the reader already holds — blue for water, green for renewables — is doing work the legend would otherwise have to do, for THIS chart, which is why it leads. -
The house option comes second, and it is what leads when no convention applies. A
NEWSROOM.mdwith bothbrandColorandgroundproduces it. A malformed hex in either field throws — a newsroom charter is validated input, and quietly ignoring a broken value there would put a default into a published chart. -
Both options are scored. Contrast is measured accent-against-ground and compared to
NON_TEXT_CONTRAST_MIN. A failing option keeps its place in the list, marked failing, withadjustToContrast's nearest passing variant attached as aremedy— ornull, honestly, when the ground leaves no room on either side. -
The SUBJECT option is recommended when it exists and passes; the house option second. An earlier draft did the reverse, on the reasoning that a convention is a reason to depart from the newsroom's identity; the owner's ruling inverts it. Neither is ever applied over the journalist's head, and the recommendation never falls to a failing option. And when NO convention applies, the proposal says so.
noConventionReasoncarries that sentence andformatProposalprints it — because four conventions ship, so "none applies" is the common case, and a one-option proposal with no explanation reads as a tool with nothing to say rather than as a subject with no convention. -
formatProposalrenders the question, always ending in a real ask, always carrying the escape branch — including when there is exactly one option, and including when every option fails. A proposal that cannot be refused is not a proposal, and the case where refusing matters most is the case where the house colours themselves do not measure up. -
The answer is recorded by hand in
PALETTE.md(assets/PALETTE.example.mdis the shape), withorigin:naming who chose —newsroom,subjectorjournalist. -
A render that reads it never defaults, and the reach is measured, not claimed.
readPalettewalks from the beat's own directory up tostopAt, and a search that finds nothing throws, naming every directory it looked in. A render that fell back to black-on-white would publish in a colour nobody chose, and it would look deliberate.Measured by
scripts/palette-reach.mjs, re-run rather than typed — this table has drifted from the tree twice already, which is why it is now a script's output:population reads a recorded palette names nothing else of its own craft-skill seed runners 12 of 12 — render-still.mjscopies carryingreadPalette9 of 22 (the 13 proof/map copies reach the shared one through#shared/…)— beats under proof/76 of 76 58 of 76 The seed row is the one that decided the trend: until 2026-08-10 it read 0 of 12, and eleven runners named
#FFFFFF/#0B7A75as literals — the defect this whole skill exists to remove, sitting inside the files a new beat is copied from.splash/test/seed-reads-a-recorded-palette.test.tswalks for those runners and keeps the row at 12.The beat row went from 21 to 76 the same day, format by format. The second column is the honest half: 18 beats still name a colour of their own beyond the two contrast poles, and every one of them is a map — the basemap's own water and land paint baked into its plate, the no-data grey, and the territory cycle the three flow beats hold in
geo-flow.ts. Whether a journalist should be able to change those is a judgement about what a basemap IS, so the script reports them rather than scoring them. -
The reach is proved on the PIXELS, not on the source.
scripts/two-palette-proof.mjsrenders every beat twice under two recorded answers that share a ground — so all the furniture is byte-identical and every pixel that moves is the accent's — and counts what moved. That is the measurement a static scan cannot make: the walking guard above proves a runner MENTIONSreadPalette, and a decoy call beside a laundered literal defeats it (the audit mutated it and watched it stay green). The mutation for the pixel proof is in its own header, with its RED.
Quick start
import { proposePalette } from "./scripts/palette.mjs";
import { formatProposal } from "./scripts/format-proposal.mjs";
console.log(formatProposal(proposePalette({
newsroom: { name: "Heidi.news", brandColor: "#0B7A75", ground: "#FFFFFF" },
subject: "La part du solaire dans le mix électrique suisse",
})));
# Colours for this beat
PROPOSED, not applied. Nothing is rendered in these colours until you answer.
Subject read as: *La part du solaire dans le mix électrique suisse*
## The options
**1. the renewable generation convention** — **recommended**
- Ground `#FFFFFF`, accent `#1B7F4B`.
- Where from: references/subject-conventions.md — renewables; ground kept from NEWSROOM.md
- Why: Green reads as renewable generation before the legend is read. …
- Measured: **5.02:1** accent against ground — clears the 3:1 floor.
**2. Heidi.news's house colours**
- Ground `#FFFFFF`, accent `#0B7A75`.
- Where from: NEWSROOM.md — brandColor: #0B7A75, ground: #FFFFFF
- Why: The chart reads as this newsroom's, beside everything else it publishes. …
- Measured: **5.18:1** accent against ground — clears the 3:1 floor.
## Your answer
- **1** — the renewable generation convention
- **2** — Heidi.news's house colours
- **Something else — give me the two hex codes and I will use those.**
(Real output, trimmed at the … marks only. #0B7A75 is the value NEWSROOM.example.md documents.)
Then, once the journalist has answered and PALETTE.md is recorded beside the story, a beat reads
it instead of naming hexes:
import { readPalette } from "#shared/chart-beat/render-still.mjs";
const { ground, accent } = readPalette(import.meta.dirname, { stopAt: process.cwd() });
readPalette is vendored into render-still.mjs alongside deriveFurniture — a beat already
imports that module to render at all, and a second import path for two colours would be one more
thing to get wrong. The copies are guarded against drift by helper-parity.test.ts.
Tuning knobs
| Want | Knob | Where |
|---|---|---|
| The contrast floor an accent must clear against its ground before it is recommended | 3 |
NON_TEXT_CONTRAST_MIN, scripts/palette.mjs |
How finely the remedy walks the accent toward the far pole (steps, so 1/50 per step) |
50 |
adjustToContrast, scripts/palette.mjs |
| How many conventions may match a subject before none is offered | 1 |
matchConvention, scripts/palette.mjs |
| Which subject conventions exist at all, and their accents | 4 entries |
SUBJECT_CONVENTIONS, scripts/palette.mjs |
Files
scripts/palette.mjs—contrast,NON_TEXT_CONTRAST_MIN,TEXT_CONTRAST_MIN,LARGE_TEXT_CONTRAST_MIN,adjustToContrast,assertLegible,SUBJECT_CONVENTIONS,matchConvention,proposePalette,readPaletteandparsePalette. No write path.scripts/format-proposal.mjs—formatProposal, the markdown the journalist reads and answers.scripts/typeface.mjs—typefaceDecision,proposeTypeface,familyResolves,formatTypefaceProposal,formatTypeface: the typeface half of movement ⑨ (issue #57). A face there is no font file for is refused, never substituted, and installing it changes nothing — every render draws withloadSystemFonts: falsefrom filesscripts/typefaces.mjsfetches;origin: defaultrecords the fallback as a choice, because nobody chose it.scripts/typefaces.mjs— carried verbatim fromshared/design-base/typefaces.mjs(held bysplash/test/carried-copies.test.ts), so the proposal and the render ask the catalogue through the same function.assets/PALETTE.example.md— the recorded-answer shape:ground,accent, the optionalaccentslist,origin.references/subject-conventions.md— the evidence behind each convention, why the table is short, and why a multi-match returns nothing.references/contrast-floors.md— why 3:1 and not 4.5:1, why a failing option is still shown, and the mid-grey band where nothing passes.test/palette.test.ts— the proposal, the conventions, the scoring, the remedy, and the reader's refusal to default.test/format-proposal.test.ts— the rendered question, in each of its three shapes.../../scripts/palette-reach.mjs— how far the recorded answer reaches, counted per format and per beat, with what each beat still names of its own.../../scripts/two-palette-proof.mjs— the same question answered on the rendered pixels: every beat drawn twice under two palettes, with the count of beats whose data ink actually moved.