Skip to content

feat(themes): add a sequential scale; fix the magnitude ramps (Phase 2a) - #4611

Open
Yeraze wants to merge 1 commit into
mainfrom
feature/semantic-tokens-phase2
Open

feat(themes): add a sequential scale; fix the magnitude ramps (Phase 2a)#4611
Yeraze wants to merge 1 commit into
mainfrom
feature/semantic-tokens-phase2

Conversation

@Yeraze

@Yeraze Yeraze commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

Folds in the widget-ramp bug, as asked. Investigating it corrected my own framing of it: only one of the three widgets is a hop ramp.

widget encodes was now
HopDistributionWidget hop count private 7-hue list shared getHopColor(hop, overlayColors.hopColors)
DistanceDistributionWidget histogram position same private list --seq-N
HopDistanceHeatmapWidget cell density surface→blue→sapphire→teal --seq-N

The hop one was a genuine bug: its private list disagreed with the map — 2 hops rendered blue there and teal here — and silently ignored overlayColors.hopColors, the gradient the user can configure and the map honours.

The other two are magnitude, not hops. Pointing them at hopColors would have mislabelled distance buckets as hop counts. Pointing them at --chart-N would have repeated the exact category error Phase 1 fixed.

The third kind of colour

A role says what a colour means. A categorical slot only has to differ from its neighbours. A sequential step has to read as ordered — step 4 must look like "more" than step 2. Categorical hues cannot do that job, which is why both ramps reached past the role layer into raw --ctp-* for hues no role provided.

--seq-1..5 are built with color-mix from the accent rather than picked from the palette, which buys three things the palette can't:

  • monotonic by construction — 20/40/60/80/100%
  • cannot collide — the failure that made --chart-6 and --chart-8 render identically in Nord is structurally impossible here
  • theme-responsive without five more entries in every theme block

Mixed toward --color-surface rather than transparent, so low steps stay legible instead of dissolving into the background.

Browser-verified, not assumed

Across mocha / latte / nord / gruvbox-dark / high-contrast-light:

mocha                monotonic=true  distinct=5/5  minStep=0.098
latte                monotonic=true  distinct=5/5  minStep=0.087
nord                 monotonic=true  distinct=5/5  minStep=0.082
gruvbox-dark         monotonic=true  distinct=5/5  minStep=0.085
high-contrast-light  monotonic=true  distinct=5/5  minStep=0.124

Luminance direction inverts between dark and light themes, which is correct — the ramp runs surface → accent.

Also fixed: the heatmap legend drew a fourth swatch (teal) that no cell used in themes where teal and sapphire coincide. It now mirrors getCellStyle's bands exactly.

A collection-time break worth naming

Importing SettingsContext into HopDistributionWidget broke that widget's test suite — the real context pulls in initReactI18next, which the file's partial react-i18next mock doesn't provide. Mocked useSettings there instead of widening the i18n mock, since the test has no interest in settings beyond the gradient.

It only surfaced because the JSON reporter reported success: false with failed: 0 — a suite-level collection failure with zero failing assertions. The pass/fail test count alone would have looked clean.

Test plan

  • npx tsc --noEmit — 0 errors
  • npm run lint:ci — clean
  • Full npx vitest run: success=true, 13,693 passed, 0 failed, 0 failed suites
  • Six new guards: contiguous scale, every step derives from the accent, monotonic percentages, borrows no --chart-N, magnitude widgets use --seq- and no raw palette, hop widget routes through the shared scale
  • Each guard verified to fail against its own regression (broke monotonicity and reintroduced raw --ctp-teal; exactly the two intended tests failed)
  • Deployed and confirmed --seq-* present in the served bundle before browser-measuring
  • Data volumes unchanged (3 before, 3 after)

Scope

Phase 2a. The bulk --ctp-* migration (62 occurrences, 20 files) and the never-defined --ctp-*-rgb overlays follow in 2b — kept separate because this one is behavioural and that one is mechanical.


Generated by Claude Code

https://claude.ai/code/session_01EtJnjbUgYwJfNU6XXACbFf

Folds in the widget-ramp bug. Investigating it corrected my own framing:
only ONE of the three widgets is a hop ramp.

- HopDistributionWidget IS hops, and hardcoded a private 7-colour list
  that disagreed with the map (2 hops was blue there, teal here) and
  ignored `overlayColors.hopColors` — the gradient the user can actually
  configure and the map honours. Now calls the shared
  `getHopColor(hop, overlayColors.hopColors)`.

- DistanceDistributionWidget indexes by position in a distance histogram
  and HopDistanceHeatmapWidget by cell density. Those are MAGNITUDE, not
  hops. Pointing them at hopColors would have mislabelled distance
  buckets as hop counts; pointing them at --chart-N would have repeated
  the category error Phase 1 fixed.

So this adds the third kind of colour the file was missing. A role says
what a colour MEANS; a categorical slot only has to differ from its
neighbours; a sequential step has to READ AS ORDERED.

--seq-1..5 are built with color-mix from the accent rather than picked
from the palette, which buys what the palette cannot: monotonic by
construction, incapable of colliding (the failure that made --chart-6 and
--chart-8 identical in Nord), and theme-responsive without five more
entries in every theme block. Mixed toward --color-surface, not
transparent, so low steps stay legible.

Browser-verified across mocha/latte/nord/gruvbox-dark/high-contrast-light:
all five steps resolve, all five distinct, luminance monotonic in every
theme with a 0.08-0.12 minimum step. Direction inverts between dark and
light themes, which is correct — the ramp runs surface -> accent.

The heatmap legend now mirrors getCellStyle's bands exactly; it previously
drew a fourth swatch (teal) that no cell used in themes where teal and
sapphire coincide.

Six guards added, each verified to fail against its regression.

Also: importing SettingsContext into HopDistributionWidget broke that
widget's test suite at collection time — the real context pulls in
`initReactI18next`, which the file's partial react-i18next mock does not
provide. Mocked `useSettings` there instead of widening the i18n mock;
the test has no interest in settings beyond the gradient. Caught only
because the JSON reporter reports `success:false` with `failed:0` for a
suite-level failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtJnjbUgYwJfNU6XXACbFf
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(themes): add a sequential scale; fix the magnitude ramps (Phase 2a)

Tasks:

  • Gather context and understand the PR
  • Read changed files
  • Review code quality and best practices
  • Check for potential bugs
  • Review test coverage
  • Post final review

Overall this is a well-reasoned, cleanly executed change. The framing in the PR description (three distinct color semantics: role / categorical / sequential) is exactly right and the implementation follows through. A few observations:

Bug: Wrong import path for getHopColor in HopDistributionWidget.tsx

HopDistributionWidget.tsx:13 imports from '../utils/mapIcons', which is the deprecated re-export shim. The CLAUDE.md notes this file is "kept as a transparent re-export shim so existing importers … are unaffected", but new code is supposed to import from '../components/map/markerIcons.js' directly. More importantly, getHopColor isn't actually re-exported from mapIcons.tsmarkerIcons.ts; it lives in roleGlyphSvg.ts and is explicitly re-exported by markerIcons.ts. The chain works today because markerIcons.ts re-exports it, but importing the hop color function through a deprecated map-icon shim is semantically confusing. Consider importing from '../../utils/roleGlyphSvg.js' (the canonical home) or at least from '../components/map/markerIcons.js'.

Minor: --seq-4 defined but never used in HopDistanceHeatmapWidget

getCellStyle in HopDistanceHeatmapWidget.tsx:152-166 uses --seq-1, --seq-2, --seq-3, and --seq-5 — skipping --seq-4. The comment in the PR acknowledges that the "fourth swatch" was dropped from the legend, but --seq-4 still exists in App.css and the test verifies monotonicity over all 5 steps. There's no bug here per se, but it may be worth a short inline comment explaining why the 4-band design maps 0.25/0.5/0.75/1.0 intensity to steps 1/2/3/5 instead of 1/2/3/4 (presumably to make the top band visually jump further from step 3). As-is, a reader will wonder if --seq-4 was accidentally omitted.

Minor: getBarColor re-created on every render in DistanceDistributionWidget

getBarColor at DistanceDistributionWidget.tsx:131-136 is declared as a plain const function at module render time (inside the component body, before return). It closes over SEQ_STEPS (a local const 5) and nothing else, so it's stable, but it's recreated on every render. This isn't a performance problem in practice for this widget, but wrapping it in useCallback or hoisting it outside the component (since it has no closure over props/state) would be cleaner. The HopDistributionWidget pattern at line 93-96 has the same minor issue with getBarColor.

Test coverage: no assertion on bar colors

The new HopDistributionWidget.test.tsx correctly mocks useSettings to supply a known gradient, but none of the existing or new tests assert the rendered bar color against the mocked gradient. The test for "shows correct hop buckets" verifies the count and labels but doesn't check that, say, the 0-hop bar uses overlayColors.hopColors.local. That would catch any regression where the component stops reading the gradient. Low-priority since the test file's stated purpose is rendering/counts, and the semanticTokens.test.ts guard for the sequential widgets is already solid, but worth noting.

semanticTokens.test.tsrootBlock() regex is fragile

rootBlock() at semanticTokens.test.ts:34 matches the first :root { … } block via:

const m = appCss.match(/:root\s*\{([\s\S]*?)\n\}/);

This lazy [\s\S]*? stops at the first \n} in the file. If a nested rule or a comment inside the :root block ever contains a bare } on its own line, the match would truncate early and silently miss tokens. The current App.css doesn't have that, but it's a subtle maintenance trap. A more robust approach would scan for balanced braces, or assert after the test that rootBlock() captured all expected tokens. Not blocking, but flagging for when the :root block grows.

Well done

  • The color-mix approach for monotonic sequential steps is elegant: it sidesteps the palette-aliasing problem that caused --chart-6/--chart-8 to collide in Nord and Gruvbox without requiring per-theme entries.
  • The test that verifies each guard fails against its own regression is exactly the right methodology — the PR description explicitly calls this out.
  • The success: false, failed: 0 collection-failure catch is a genuinely useful operational note that belongs in team lore.
  • Legend/cell parity fix for the heatmap is a real bug fix: a legend swatch for a color band that never appears in any cell is misleading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant