@sema-agent/server 1.196.0 → 1.198.0

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.
Files changed (38) hide show
  1. package/dist/config.d.ts.map +1 -1
  2. package/dist/config.js +10 -1
  3. package/dist/config.js.map +1 -1
  4. package/dist/http/server.js +1 -0
  5. package/dist/http/server.js.map +1 -1
  6. package/dist/main.js +24 -5
  7. package/dist/main.js.map +1 -1
  8. package/dist/plugins/remote-scratchpad.d.ts +8 -0
  9. package/dist/plugins/remote-scratchpad.d.ts.map +1 -0
  10. package/dist/plugins/remote-scratchpad.js +56 -0
  11. package/dist/plugins/remote-scratchpad.js.map +1 -0
  12. package/dist/task-cwd.d.ts +8 -0
  13. package/dist/task-cwd.d.ts.map +1 -1
  14. package/dist/task-cwd.js +14 -0
  15. package/dist/task-cwd.js.map +1 -1
  16. package/package.json +2 -1
  17. package/skills/code-review.md +28 -0
  18. package/skills/commit-push-pr.md +77 -0
  19. package/skills/dataviz/SKILL.md +112 -0
  20. package/skills/dataviz/references/anti-patterns.md +119 -0
  21. package/skills/dataviz/references/choosing-a-form.md +57 -0
  22. package/skills/dataviz/references/color-formula.md +113 -0
  23. package/skills/dataviz/references/components.md +39 -0
  24. package/skills/dataviz/references/interaction.md +60 -0
  25. package/skills/dataviz/references/marks-and-anatomy.md +97 -0
  26. package/skills/dataviz/references/palette.md +149 -0
  27. package/skills/dataviz/scripts/validate_palette.js +262 -0
  28. package/skills/find-skills.md +148 -0
  29. package/skills/init.md +28 -0
  30. package/skills/keybindings-help.md +294 -0
  31. package/skills/loop.md +50 -0
  32. package/skills/run-skill-generator.md +493 -0
  33. package/skills/run.md +148 -0
  34. package/skills/schedule.md +48 -0
  35. package/skills/security-review.md +181 -0
  36. package/skills/simplify.md +64 -0
  37. package/skills/update-config.md +93 -0
  38. package/skills/verify.md +334 -0
@@ -0,0 +1,60 @@
1
+ # Interaction — tooltips & filters
2
+
3
+ An HTML chart is interactive by default — the hover layer is part of the deliverable,
4
+ not an upgrade. Omitting it is the exception (a bare stat tile), never the default.
5
+ Design it with the same care as the static render.
6
+
7
+ ## Tooltips & hover
8
+
9
+ Tooltips **enhance, they never gate**: every value a tooltip shows is also reachable
10
+ without it, through direct labels or the table view. Same details on keyboard focus
11
+ as on hover.
12
+
13
+ - **The crosshair finds the X.** A vertical hairline tracks the pointer and snaps to
14
+ the nearest data position. Readers aim at a date, never at a 2px line.
15
+ - **On bars and cells, the mark is the hit target.** No crosshair — each bar, segment,
16
+ dot, or heat-cell carries its own `pointermove`/`focus` tooltip showing category and
17
+ value, and the hovered mark lifts (slight lighten or outline) so the reader sees it respond.
18
+ - **One tooltip, every series.** The readout lists every series at that X — the
19
+ pointer never has to land on a line or a fill to get a value.
20
+ - **Labels are untrusted data — use `textContent`.** Series and category names
21
+ often come from CSV headers, tool output, or API responses. Insert them into
22
+ tooltip/legend/table DOM with `textContent` or `createTextNode`, never via
23
+ `innerHTML` string concatenation.
24
+ - **Values lead, labels follow.** In the tooltip the value is the Strong,
25
+ high-contrast element and the series name is secondary — the legend's hierarchy
26
+ inverted, because here the reader has the series and wants the number.
27
+ - **Line keys, not boxes.** Tooltip rows key their series with a short stroke of the
28
+ series color; at tooltip density a filled box is data-weight ink doing a label's
29
+ job. (Legends still mirror the mark: rect for bars/areas, line for lines.)
30
+ - **The hit target is bigger than the mark.** A mark's hover/focus area includes its
31
+ 2px surface gap and then some — never only the painted pixels. An 8px scatter dot is a
32
+ pinpoint nobody hits reliably; give each point a transparent hit area of at least
33
+ **24px**, or — for dense scatter — a nearest-point / Voronoi layer so the pointer only
34
+ has to be *closest*, not dead-center. (The crosshair already does this for the X on
35
+ line and bar charts; scatter and bubble need the per-point version.)
36
+ - **A value pushed off its mark lives in the tooltip.** When a label won't fit inside a
37
+ small bar (see `marks-and-anatomy.md`), that bar's hit area carries the value on hover
38
+ and focus — the tooltip is its overflow home, and the table view keeps it reachable
39
+ without hovering at all.
40
+
41
+ ## Filters & time ranges
42
+
43
+ Every monitoring dashboard needs the same controls. These are **standard UI, not
44
+ chart marks** — build them with ordinary HTML form controls styled to match the
45
+ chart chrome. Dataviz only adds composition rules:
46
+
47
+ - **One row, above the charts.** Filters sit in a single left-aligned row above the
48
+ content they scope — never inside a chart card, never per-chart. If one chart needs
49
+ its own range, it's a different dashboard.
50
+ - **Date range first.** It's the filter every reader reaches for; presets (today,
51
+ last 7 / 30 / 90 days) before a custom range.
52
+ - **Filters scope everything below them.** Every chart, stat, and table re-renders
53
+ against the same slice, so the numbers always agree.
54
+ - **Refetch keeps the frame.** While data reloads, charts hold their previous render
55
+ at reduced opacity — no skeleton, no layout jump, no flash.
56
+
57
+ A good date picker lists presets as rows (nobody fights a calendar grid for "last 30
58
+ days"), marks selection with a 16px bold check, keeps hover a ghost wash so it never
59
+ competes with selection, and tucks the custom range behind a hairline in the footer.
60
+ (See `palette.md` for the reference spec.)
@@ -0,0 +1,97 @@
1
+ # Marks & anatomy
2
+
3
+ The quiet, considered look is a few fixed specs plus two pieces of negative space.
4
+ The data is the only thing allowed to be loud.
5
+
6
+ ## Mark specs (fixed across every chart)
7
+
8
+ | Mark | Spec |
9
+ |---|---|
10
+ | Bar / column | **≤ 24px thick** (cap it — never fill the slot; let the band's leftover be air); **4px rounded data-end, square at the baseline**; grows from a single baseline |
11
+ | Line | **2px**, round join/cap |
12
+ | Marker / end-dot | **≥ 8px** (r ≥ 4), filled with the series color |
13
+ | Area fill | the series hue at **~10% opacity** (a wash, never a saturated block) |
14
+ | Gridlines / axes | one-step-off-surface gray, **hairline (1px), solid** (never dashed), recessive |
15
+
16
+ ## The two spacers (white doing the separating)
17
+
18
+ - **Surface gap.** A **2px gap** in the surface color separates touching marks — every
19
+ segment of a stacked bar, and every adjacent (touching) bar, the same width. Keep it
20
+ one consistent width across a stack; neighbors one step apart read distinct because of
21
+ the gap, not a stroke drawn around them.
22
+ - **Surface ring.** Dots and end-markers carry a **2px ring in the surface color**,
23
+ so they stay legible where they cross a line or overlap each other. The ring is part
24
+ of the mark's hover/hit target, not just spacing — see `interaction.md` (small dots
25
+ are easy to under-size for hover).
26
+
27
+ Never draw a border around a mark to separate it. The gap and the ring are the
28
+ mechanism; a stroke adds data-weight ink that isn't data.
29
+
30
+ ## Labels & legend
31
+
32
+ A **legend is always present for two or more series** — the dependable identity
33
+ channel; never make the reader rely on color-matching alone. Direct labels then ride
34
+ the marks to *supplement* it. **A single series needs no legend box**: there is only
35
+ one color, so the chart's title or subtitle already says what is plotted. A box with
36
+ one swatch restates the title and costs space.
37
+
38
+ - **Label selectively — never a number on every point.** A value beside every dot or
39
+ segment is chaos and goes unread. Label the endpoint, the extreme, or the one series
40
+ the story is about; let the axis, the legend, and the tooltip/table carry the rest.
41
+ Direct labels work *because* they are sparing — flood the chart and they stop working.
42
+ - **Direct labels before gridlines; gridlines before a second axis.**
43
+ - **A label that won't fit doesn't get clipped — measure first.** Only place a label
44
+ *inside* a bar or stacked segment when the rendered text fits with comfortable
45
+ padding on both sides. If it doesn't fit: for a whole bar/column, move the label
46
+ outside the bar end (or to the tooltip if there's no room outside either); for an
47
+ *interior* stacked segment (which has no free end),
48
+ skip the inline label and let the legend + tooltip carry it. Either way the value
49
+ stays in the table view, so nothing is gated. Never use `overflow: hidden` on the
50
+ segment to "solve" it — that crops the first/last characters and is worse than no
51
+ label. Text never overflows or is clipped by its own mark.
52
+ - Bars → value at the tip. Columns → value on the cap. Lines → value at the end.
53
+ - Y-axis ticks: round to clean numbers (0 / 1,000 / 2,000), thousands-comma'd; they
54
+ carry the values you didn't directly label, so keep them unless every value is labeled.
55
+ - **Text never wears the data color.** Marks — bars, lines, dots, area fills — carry
56
+ the series color; labels, values, legends, and axis text use **text tokens**
57
+ (primary / secondary / muted). A light categorical hue (yellow, aqua) is illegible
58
+ as text on the surface. Identity comes from the colored mark *beside* the text — a
59
+ dot, a short line-key, a swatch — never from coloring the text itself. A label set
60
+ *inside* a colored fill (a stacked segment, a map tile) is the one exception: pick
61
+ white or ink by the fill's luminance so it always clears contrast.
62
+ - **When end-labels collide, don't stack them.** Direct end-labels work when series
63
+ separate at the right edge. When lines converge, nudging labels apart vertically
64
+ detaches them from their lines and reads as noise — instead use **leader lines**
65
+ (a thin connector from label to line-end), facet into **small multiples**, or fall
66
+ back to the legend + tooltip. Past ~4 converging series, small multiples is usually right.
67
+
68
+ ## Figures — when the form is a number
69
+
70
+ - **Stat tile** contract: `label` (sentence case, no trailing colon) · `value` (Sans
71
+ semibold, auto-compact: 1,284 / 12.9K / $4.2M) · `delta` (optional; signed,
72
+ vs a named period; color = direction × whether up is good) · `trend` (optional;
73
+ 12-point sparkline in the de-emphasis hue, current period in the accent).
74
+ - **Meter:** the fill carries severity (accent → warning → danger); the unfilled
75
+ track is a **lighter step of the same ramp** (blue-on-blue, etc.) so state reads
76
+ across the whole bar.
77
+ - **Hero figure.** The single number a dashboard leads with, ≥48px, in the same
78
+ sans as everything else (never a display or serif face — it reads as off-brand
79
+ decoration). Exactly one per view.
80
+ - **Proportional figures for big numbers; tabular only in columns.** A large
81
+ standalone value (hero figure, stat-tile value) uses the font's default
82
+ proportional figures — `tabular-nums` gives every digit the width of a `0`, so a
83
+ number like `121` looks loose at display sizes. Reserve
84
+ `font-variant-numeric: tabular-nums` for columns of numbers that must align
85
+ vertically (table rows, axis ticks).
86
+
87
+ ## Texture — the backup channel (opt-in)
88
+
89
+ Where hue fails — full-severity CVD, grayscale print, `forced-colors` — texture
90
+ carries identity. One directional hand-drawn fill, used at **45° and its 135° mirror
91
+ only** (never horizontal/vertical — those read as gridlines/bars). Inked tone-on-tone
92
+ (a step from the fill's own ramp), equal loudness across slots. On value scales the
93
+ texture is *ordered* (rotation steps with magnitude; arm angle carries the diverging
94
+ sign) so it never misstates the value. Triggered by an accessibility setting, print,
95
+ or `forced-colors` — never on by default. (See `palette.md`.)
96
+
97
+ ---
@@ -0,0 +1,149 @@
1
+ # Reference palette
2
+
3
+ This is the **reference instance** of the data-viz method: every parameter the
4
+ method needs, filled in with a validated default palette. The rest of the skill
5
+ is system-agnostic — **to target your brand, substitute this file's values** and
6
+ re-run the validator. Nothing else changes.
7
+
8
+ ## How to use these values
9
+
10
+ Everything below is plain hex. In an HTML chart, **define the slots you use as
11
+ CSS custom properties in a local `<style>` block** at the top of the file, then
12
+ reference them by role throughout — so the light/dark values swap in one place,
13
+ and the chart body is written against roles rather than raw hex:
14
+
15
+ ```css
16
+ .viz-root {
17
+ --surface-1: #fcfcfb; /* chart surface */
18
+ --text-primary: #0b0b0b;
19
+ --text-secondary: #52514e;
20
+ --series-1: #2a78d6; /* categorical slot 1 */
21
+ /* …only the roles this chart uses */
22
+ }
23
+ @media (prefers-color-scheme: dark) {
24
+ .viz-root {
25
+ --surface-1: #1a1a19;
26
+ --text-primary: #ffffff;
27
+ --text-secondary: #c3c2b7;
28
+ --series-1: #3987e5;
29
+ }
30
+ }
31
+ ```
32
+
33
+ ## Categorical palette
34
+
35
+ Both modes are selected. The dark column is the same eight hues stepped for the
36
+ dark surface, not a separate palette:
37
+
38
+ | Slot | Hue | Light | Dark |
39
+ |------|-----|-------|------|
40
+ | 1 | blue | `#2a78d6` | `#3987e5` |
41
+ | 2 | aqua | `#1baf7a` | `#199e70` |
42
+ | 3 | yellow | `#eda100` | `#c98500` |
43
+ | 4 | green | `#008300` | `#008300` |
44
+ | 5 | violet | `#4a3aa7` | `#9085e9` |
45
+ | 6 | red | `#e34948` | `#e66767` |
46
+ | 7 | magenta | `#e87ba4` | `#d55181` |
47
+ | 8 | orange | `#eb6834` | `#d95926` |
48
+
49
+ Light-mode worst adjacent CVD ΔE is 24.2 — well clear of the ≥12 target. Three
50
+ light-mode slots (aqua, yellow, magenta) sit below 3:1 contrast on the light
51
+ surface: the **relief rule** applies (ship visible direct labels or the table
52
+ view). The dark steps were chosen for the dark band (OKLCH L ≈ 0.48–0.67, ≥ 3:1
53
+ on the dark surface) and validated as a set — worst adjacent ΔE 10.3, the floor
54
+ band, so four-plus series lean on direct labels or texture in dark mode too.
55
+
56
+ The slot **ordering** is the CVD-safety mechanism, not cosmetic — it was derived
57
+ by enumerating orderings and picking the one that maximizes the minimum adjacent
58
+ ΔE (see `color-formula.md` § Themes). When you swap in your brand's hues, do the
59
+ same: run the validator on candidate orderings and keep the best.
60
+
61
+ ## Sequential hue
62
+
63
+ Default single hue: **blue**, light→dark. When two sequential contexts appear at
64
+ once, the second takes the next categorical slot's hue (aqua), each as its own
65
+ one-hue ramp.
66
+
67
+ | step | hex | step | hex | step | hex | step | hex |
68
+ |---|---|---|---|---|---|---|---|
69
+ | 100 | `#cde2fb` | 250 | `#86b6ef` | 400 | `#3987e5` | 550 | `#1c5cab` |
70
+ | 150 | `#b7d3f6` | 300 | `#6da7ec` | 450 | `#2a78d6` | 600 | `#184f95` |
71
+ | 200 | `#9ec5f4` | 350 | `#5598e7` | 500 | `#256abf` | 650 | `#104281` |
72
+ | | | | | | | 700 | `#0d366b` |
73
+
74
+ The full 100→700 range is for **sequential** encoding (continuous magnitude —
75
+ heatmaps, choropleths) where the lightest step means "near zero" and is allowed
76
+ to recede toward the surface. For an **ordinal** ramp (discrete ordered marks —
77
+ funnel stages, tiers — validated with `--ordinal`), the step nearest the surface
78
+ must still clear 2:1: on light, start no lighter than **step 250** (`#86b6ef`,
79
+ 2.06:1); on dark, go no darker than **step 600** (`#184f95`, 2.15:1).
80
+
81
+ ## Diverging pair
82
+
83
+ **blue ↔ red** — warm/cool poles that read as opposite. Neutral midpoint is gray
84
+ (light `#f0efec`, dark `#383835`). Equal step count per arm. (blue↔aqua was
85
+ rejected — both cool, the midpoint doesn't read as "nothing".)
86
+
87
+ ## Status palette (fixed — never themed)
88
+
89
+ | role | hex | light-surface contrast | dark-surface contrast |
90
+ |---|---|---|---|
91
+ | good | `#0ca30c` | 3.27 | 5.19 |
92
+ | warning | `#fab219` | 1.79 | 9.49 |
93
+ | serious | `#ec835a` | 2.57 | 6.60 |
94
+ | critical | `#d03b3b` | 4.68 | 3.62 |
95
+
96
+ Dark: same four steps — all clear 3:1 on the dark surface (`#1a1a19`) and remain
97
+ distinct from the dark categorical slots. On the light surface, warning and
98
+ serious are sub-3:1 by design; the **icon + label** pairing is the mitigation, so
99
+ a status color never carries meaning alone. These steps are deliberately distinct
100
+ from the categorical slots so a status color never impersonates a series.
101
+
102
+ ## Texture fill (the accessibility channel)
103
+
104
+ One hand-drawn **"Lines"** fill, used at **45° and its 135° mirror only**. Inked
105
+ tone-on-tone (a darker step of the fill's own ramp). On value scales it is
106
+ *ordered* (rotation steps with magnitude; arm angle carries the diverging sign).
107
+ Triggered by the accessibility setting, print, or `forced-colors` — never
108
+ decorative, never on by default.
109
+
110
+ ## Surfaces (for the validator)
111
+
112
+ - Light chart surface: `#fcfcfb`
113
+ - Dark chart surface: `#1a1a19`
114
+
115
+ These are the validator's built-in defaults. **When you swap in your own
116
+ palette, re-run against your own surfaces:**
117
+ `--surface <your-light> --mode light` and `--surface <your-dark> --mode dark` —
118
+ contrast and band results are only meaningful against the surface the chart
119
+ actually renders on.
120
+
121
+ ## Chart chrome & ink
122
+
123
+ | Role | Light | Dark |
124
+ |---|---|---|
125
+ | Chart surface | `#fcfcfb` | `#1a1a19` |
126
+ | Page plane | `#f9f9f7` | `#0d0d0d` |
127
+ | Primary ink | `#0b0b0b` | `#ffffff` |
128
+ | Secondary ink | `#52514e` | `#c3c2b7` |
129
+ | Muted (axis/labels) | `#898781` | `#898781` |
130
+ | Gridline (hairline) | `#e1e0d9` | `#2c2c2a` |
131
+ | Baseline / axis | `#c3c2b7` | `#383835` |
132
+ | Delta ↑ good (success text) | `#006300` | `#0ca30c` |
133
+ | Border (hairline ring) | `rgba(11,11,11,0.10)` | `rgba(255,255,255,0.10)` |
134
+
135
+ ## Filter controls
136
+
137
+ Filters are standard UI, not chart components — the chart layer only adds the
138
+ composition rules in `interaction.md`. A date-range control is a list of preset
139
+ rows (today, last 7/30/90 days, month-to-date) with selection marked by a 16px
140
+ bold check, hover as a ghost wash, and custom range behind a hairline in the
141
+ footer. Dimension filters are a standard combobox.
142
+
143
+ ## Typeface & figures
144
+
145
+ Everything — including the hero figure — stays in the system sans: `system-ui,
146
+ -apple-system, "Segoe UI", sans-serif`. No display or serif face anywhere. Large
147
+ standalone numbers (hero figure, stat-tile values) use the default proportional
148
+ figures; reserve `font-variant-numeric: tabular-nums` for columns that must align
149
+ vertically (table rows, axis ticks). Substitute your brand's UI sans here.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Validate a categorical chart palette against the computable data-viz checks.
3
+ *
4
+ * Design-system-agnostic: feed it ANY palette's hex values plus the mode and
5
+ * surface, and it computes — never eyeballs — the four checks that can be
6
+ * measured from color alone:
7
+ *
8
+ * 2. Lightness band — OKLCH L within the mode's band
9
+ * 3. Chroma floor — OKLCH C >= floor (below it a hue reads as gray)
10
+ * 4. CVD separation — Machado-2009 ΔE between slots (protan/deutan/tritan);
11
+ * adjacent pairs by default, pairs:"all" for scatter/bubble/maps
12
+ * 5. Contrast vs surface — WCAG ratio of each mark against the chart surface
13
+ *
14
+ * Checks 1 (fixed hue order) and 6 (values are from the documented palette) are
15
+ * structural rules the skill enforces, not measurable from hexes alone.
16
+ *
17
+ * Usage (node):
18
+ * node validate_palette.js "#2a78d6,#1baf7a,#eda100,#008300,#4a3aa7,#e34948,#e87ba4,#eb6834" --mode light
19
+ * node validate_palette.js "#256abf,#199e70,..." --mode dark --surface "#1a1a19"
20
+ * node validate_palette.js "#cde2fb,#9ec5f4,#6da7ec,#3987e5,#256abf" --ordinal
21
+ *
22
+ * Usage (browser — as a module script):
23
+ * <body data-palette="#2a78d6,#1baf7a,..." data-mode="light">
24
+ * <script type="module" src="validate_palette.js"></script>
25
+ * → logs a console.table of the report and console.warn on any FAIL.
26
+ *
27
+ * Exit code 0 unless a check hard-FAILs; 1 on any FAIL. WARN bands do not fail:
28
+ * adjacent CVD in the 8–12 floor band, and contrast in the sub-3:1 relief band,
29
+ * are reported as WARNs and still exit 0 (each is legal only with mandatory
30
+ * secondary encoding: direct labels, gaps, or texture).
31
+ */
32
+
33
+ // ── thresholds ────────────────────────────────────────────────────────────────
34
+ const BAND = { light: [0.43, 0.77], dark: [0.48, 0.67] }; // OKLCH L
35
+ const CHROMA_FLOOR = 0.10; // OKLCH C
36
+ const CVD_TARGET = 12.0, CVD_FLOOR = 8.0; // CIE76 ΔE on adjacent pairs
37
+ const CONTRAST_MIN = 3.0; // WCAG vs surface
38
+ const DEFAULT_SURFACE = { light: "#fcfcfb", dark: "#1a1a19" };
39
+ const ORDINAL_MIN_DL = 0.06; // min OKLCH ΔL between adjacent steps
40
+ const ORDINAL_LIGHT_FLOOR = 2.0; // lightest step: WCAG contrast vs surface
41
+
42
+ // Machado, Oliveira & Fernandes (2009) CVD transforms at severity 1.0 (linear RGB).
43
+ const MACHADO = {
44
+ protan: [[0.152286, 1.052583, -0.204868],
45
+ [0.114503, 0.786281, 0.099216],
46
+ [-0.003882, -0.048116, 1.051998]],
47
+ deutan: [[0.367322, 0.860646, -0.227968],
48
+ [0.280085, 0.672501, 0.047413],
49
+ [-0.011820, 0.042940, 0.968881]],
50
+ tritan: [[1.255528, -0.076749, -0.178779],
51
+ [-0.078411, 0.930809, 0.147602],
52
+ [0.004733, 0.691367, 0.303900]],
53
+ };
54
+
55
+ // ── color conversions ──────────────────────────────────────────────────────────
56
+ const hex2srgb = (h) => { h = h.trim().replace(/^#/, ""); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16) / 255); };
57
+ const s2lin = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
58
+ const lin2s = (c) => { c = Math.max(0, Math.min(1, c)); return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; };
59
+ const lin = (h) => hex2srgb(h).map(s2lin);
60
+ const relLum = (h) => { const [r, g, b] = lin(h); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
61
+ export const contrast = (a, b) => { const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
62
+
63
+ function oklab(h) {
64
+ const [r, g, b] = lin(h);
65
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
66
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
67
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
68
+ return [
69
+ 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, // L
70
+ 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, // a
71
+ 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, // b
72
+ ];
73
+ }
74
+ const oklch = (h) => { const [L, a, b] = oklab(h); return [L, Math.hypot(a, b)]; };
75
+ const okhue = (h) => { const [, a, b] = oklab(h); return ((Math.atan2(b, a) * 180 / Math.PI) % 360 + 360) % 360; };
76
+
77
+ // CIELAB (D65) for ΔE
78
+ function lin2lab(r, g, b) {
79
+ const X = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
80
+ const Y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b;
81
+ const Z = 0.0193339 * r + 0.1191920 * g + 0.9503041 * b;
82
+ const f = (t) => t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
83
+ const [fx, fy, fz] = [f(X / 0.95047), f(Y / 1.0), f(Z / 1.08883)];
84
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
85
+ }
86
+ function simulate(h, kind) {
87
+ const [r, g, b] = lin(h), M = MACHADO[kind];
88
+ const clamp = (c) => Math.max(0, Math.min(1, c));
89
+ return [
90
+ clamp(M[0][0] * r + M[0][1] * g + M[0][2] * b),
91
+ clamp(M[1][0] * r + M[1][1] * g + M[1][2] * b),
92
+ clamp(M[2][0] * r + M[2][1] * g + M[2][2] * b),
93
+ ];
94
+ }
95
+ function deltaE(h1, h2, kind) {
96
+ const a = lin2lab(...(kind ? simulate(h1, kind) : lin(h1)));
97
+ const b = lin2lab(...(kind ? simulate(h2, kind) : lin(h2)));
98
+ return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
99
+ }
100
+
101
+ // ── checks ─────────────────────────────────────────────────────────────────────
102
+ export function validate(palette, { mode = "light", surface, pairs = "adjacent" } = {}) {
103
+ surface ??= DEFAULT_SURFACE[mode];
104
+ const [lo, hi] = BAND[mode];
105
+ const report = [];
106
+ let ok = true;
107
+
108
+ // 2. lightness band
109
+ const offband = palette.filter(c => { const L = oklch(c)[0]; return L < lo || L > hi; })
110
+ .map(c => [c, +oklch(c)[0].toFixed(3)]);
111
+ if (offband.length) ok = false;
112
+ report.push(["Lightness band", !offband.length,
113
+ offband.length ? `outside band: ${JSON.stringify(offband)}` : `all ${palette.length} inside L ${lo}–${hi}`]);
114
+
115
+ // 3. chroma floor
116
+ const lowc = palette.filter(c => oklch(c)[1] < CHROMA_FLOOR).map(c => [c, +oklch(c)[1].toFixed(3)]);
117
+ if (lowc.length) ok = false;
118
+ report.push(["Chroma floor", !lowc.length,
119
+ lowc.length ? `below floor (reads gray): ${JSON.stringify(lowc)}` : `all ${palette.length} >= ${CHROMA_FLOOR}`]);
120
+
121
+ // 4. CVD separation — adjacent for stacks/bars/lines; ALL pairs for scatter/bubble/maps/small-multiples
122
+ const n = palette.length;
123
+ const pairlist = pairs === "all"
124
+ ? Array.from({ length: n }, (_, i) => Array.from({ length: n - i - 1 }, (_, k) => [i, i + 1 + k])).flat()
125
+ : Array.from({ length: n - 1 }, (_, i) => [i, i + 1]);
126
+ const label = pairs === "all" ? "all-pairs" : "adjacent";
127
+ let worst = null;
128
+ for (const kind of ["protan", "deutan"]) {
129
+ for (const [i, j] of pairlist) {
130
+ const d = deltaE(palette[i], palette[j], kind);
131
+ if (worst === null || d < worst[0]) worst = [d, kind, palette[i], palette[j]];
132
+ }
133
+ }
134
+ const tri = pairlist.length ? Math.min(...pairlist.map(([i, j]) => deltaE(palette[i], palette[j], "tritan"))) : 99;
135
+ const nor = pairlist.length ? Math.min(...pairlist.map(([i, j]) => deltaE(palette[i], palette[j]))) : 99;
136
+ const wd = worst ? worst[0] : 99;
137
+ const cvdState = wd >= CVD_TARGET ? "pass" : wd >= CVD_FLOOR ? "floor" : "fail";
138
+ if (cvdState === "fail") ok = false;
139
+ report.push(["CVD separation", cvdState,
140
+ worst ? `worst ${label} ${worst[3]}↔${worst[2]} ΔE ${wd.toFixed(1)} (${worst[1]}) · tritan ${tri.toFixed(1)} · normal ${nor.toFixed(1)}` : "n/a"]);
141
+
142
+ // 5. contrast vs surface — sub-3:1 is a documented conditional relax (visible labels / table view), not a hard fail
143
+ const low = palette.filter(c => contrast(c, surface) < CONTRAST_MIN).map(c => [c, +contrast(c, surface).toFixed(2)]);
144
+ report.push(["Contrast vs surface", low.length ? "relief" : "pass",
145
+ low.length ? `below ${CONTRAST_MIN}:1 — relief required (visible labels or table view): ${JSON.stringify(low)}`
146
+ : `all ${palette.length} >= ${CONTRAST_MIN}:1`]);
147
+
148
+ return { report, ok };
149
+ }
150
+
151
+ export function validateOrdinal(palette, { mode = "light", surface } = {}) {
152
+ /* Ordered categories (funnel stages, size tiers, time buckets rendered as
153
+ discrete marks) take a one-hue ramp, not categorical hues. The categorical
154
+ checks FAIL a correct ramp by design (it spans the lightness band; light
155
+ steps drop below the chroma floor). The ordinal checks instead verify the
156
+ ramp reads *as a ramp*: one hue, monotone lightness with visible gaps
157
+ between steps, and a lightest step that still clears the surface. */
158
+ surface ??= DEFAULT_SURFACE[mode];
159
+ const report = [];
160
+ let ok = true;
161
+ const Ls = palette.map(c => oklch(c)[0]);
162
+
163
+ // Monotone lightness — sorted by L must match input order (or its reverse).
164
+ const order = [...Ls.keys()].sort((a, b) => Ls[a] - Ls[b]);
165
+ const fwd = order.every((v, i) => v === i);
166
+ const rev = order.every((v, i) => v === Ls.length - 1 - i);
167
+ const mono = fwd || rev;
168
+ if (!mono) ok = false;
169
+ report.push(["Lightness monotone", mono,
170
+ mono ? "steps read light→dark" : `out of order — L values ${JSON.stringify(Ls.map(l => +l.toFixed(3)))}`]);
171
+
172
+ // Adjacent ΔL — each step must be visibly distinct from its neighbour.
173
+ const gaps = Ls.slice(1).map((l, i) => Math.abs(l - Ls[i]));
174
+ const thin = gaps.map((g, i) => [palette[i], palette[i + 1], +g.toFixed(3)]).filter(([, , g]) => g < ORDINAL_MIN_DL);
175
+ if (thin.length) ok = false;
176
+ report.push(["Adjacent ΔL", !thin.length,
177
+ thin.length ? `steps too close: ${JSON.stringify(thin)}` : `all gaps >= ${ORDINAL_MIN_DL}`]);
178
+
179
+ // Lightest step vs surface — the pale end must still read as a mark.
180
+ const byL = [...palette].sort((a, b) => oklch(a)[0] - oklch(b)[0]);
181
+ const lightest = mode === "light" ? byL[byL.length - 1] : byL[0];
182
+ const cr = contrast(lightest, surface);
183
+ if (cr < ORDINAL_LIGHT_FLOOR) ok = false;
184
+ report.push(["Light-end contrast", cr >= ORDINAL_LIGHT_FLOOR,
185
+ `${lightest} at ${cr.toFixed(2)}:1 vs surface` + (cr >= ORDINAL_LIGHT_FLOOR ? "" : ` — below ${ORDINAL_LIGHT_FLOOR}:1 floor`)]);
186
+
187
+ // Single hue — an ordinal ramp is one hue; a hue jump means it's categorical.
188
+ const hues = palette.map(okhue);
189
+ let spread = hues.length ? Math.max(...hues) - Math.min(...hues) : 0;
190
+ if (spread > 180) spread = 360 - spread;
191
+ const oneHue = spread <= 40;
192
+ if (!oneHue) ok = false;
193
+ report.push(["Single hue", oneHue,
194
+ `hue spread ${spread.toFixed(0)}°` + (oneHue ? "" : " — >40°, not a one-hue ramp")]);
195
+
196
+ return { report, ok };
197
+ }
198
+
199
+ // ── entrypoints ────────────────────────────────────────────────────────────────
200
+ const GLYPH = { true: "PASS", false: "FAIL", pass: "PASS", floor: "WARN", fail: "FAIL", relief: "WARN" };
201
+
202
+ function printReport({ report, ok }, { mode, surface, ordinal, n }) {
203
+ const kind = ordinal ? "ordinal ramp" : "categorical";
204
+ console.log(`\nPalette (${mode}, surface ${surface}, ${kind}): ${n} slots`);
205
+ for (const [name, state, detail] of report) {
206
+ console.log(` [${(GLYPH[state] ?? state).padEnd(4)}] ${name.padEnd(22)} ${detail}`);
207
+ }
208
+ if (ordinal) {
209
+ console.log(`\n → ${ok ? "ALL CHECKS PASS" : "FAILED — fix the marked checks"}`
210
+ + " (ordinal: one hue, monotone L, visible step gaps, light end clears surface)");
211
+ } else {
212
+ console.log(`\n → ${ok ? "ALL CHECKS PASS" : "FAILED — fix the marked checks"}`
213
+ + " (CVD in the 8–12 floor band is legal ONLY with secondary encoding: direct labels, gaps, or texture)");
214
+ console.log(" scope: categorical palettes only. For a lone status/text color check WCAG"
215
+ + " text contrast; for a sequential ramp, lightness monotonicity.\n");
216
+ }
217
+ }
218
+
219
+ // Node CLI
220
+ if (typeof process !== "undefined" && process.argv && process.argv[1] && process.argv[1].endsWith("validate_palette.js")) {
221
+ const args = process.argv.slice(2);
222
+ const VALUE_FLAGS = new Set(["--mode", "--surface", "--pairs"]);
223
+ const CHOICES = { mode: ["light", "dark"], pairs: ["adjacent", "all"] };
224
+ const opts = {}; let positional = null;
225
+ for (let i = 0; i < args.length; i++) {
226
+ let a = args[i], val;
227
+ const eq = a.indexOf("="); if (eq > 0) { val = a.slice(eq + 1); a = a.slice(0, eq); }
228
+ if (VALUE_FLAGS.has(a)) { opts[a.slice(2)] = val ?? args[++i]; }
229
+ else if (a === "--ordinal") { opts.ordinal = true; }
230
+ else if (a.startsWith("--")) { console.error(`unknown flag: ${a}`); process.exit(2); }
231
+ else if (positional === null) { positional = a; }
232
+ else { console.error(`unexpected extra positional: ${a}`); process.exit(2); }
233
+ }
234
+ for (const [k, allowed] of Object.entries(CHOICES)) {
235
+ if (opts[k] != null && !allowed.includes(opts[k])) {
236
+ console.error(`--${k} must be one of: ${allowed.join(", ")} (got ${JSON.stringify(opts[k])})`); process.exit(2);
237
+ }
238
+ }
239
+ const palette = (positional || "").split(",").map(s => s.trim()).filter(Boolean);
240
+ if (!palette.length) { console.error("usage: node validate_palette.js \"#hex,#hex,...\" [--mode light|dark] [--surface #hex] [--pairs adjacent|all] [--ordinal]"); process.exit(2); }
241
+ const mode = opts.mode || "light";
242
+ const surface = opts.surface || DEFAULT_SURFACE[mode];
243
+ const pairs = opts.pairs || "adjacent";
244
+ const result = opts.ordinal ? validateOrdinal(palette, { mode, surface }) : validate(palette, { mode, surface, pairs });
245
+ printReport(result, { mode, surface, ordinal: !!opts.ordinal, n: palette.length });
246
+ process.exit(result.ok ? 0 : 1);
247
+ }
248
+
249
+ // Browser auto-run (as a <script type="module">). Fires whenever the page has a
250
+ // data-palette attribute on <body>; omit it to import the module without auto-running.
251
+ if (typeof document !== "undefined") {
252
+ const b = document.body;
253
+ if (b?.dataset.palette) {
254
+ const palette = b.dataset.palette.split(",").map(s => s.trim()).filter(Boolean);
255
+ const mode = b.dataset.mode || "light";
256
+ const surface = b.dataset.surface || DEFAULT_SURFACE[mode];
257
+ const ordinal = "ordinal" in b.dataset;
258
+ const result = ordinal ? validateOrdinal(palette, { mode, surface }) : validate(palette, { mode, surface, pairs: b.dataset.pairs || "adjacent" });
259
+ console.table(result.report.map(([name, state, detail]) => ({ check: name, result: GLYPH[state] ?? state, detail })));
260
+ if (!result.ok) console.warn("validate_palette: FAILED — fix the marked checks");
261
+ }
262
+ }