Repository navigation
feat(map): brand-colour the map markers and cluster paints - #353
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
sahajatlas-design | 9db2698 | Nov 05 2026, 04:42 AM |
sydevs-bot
left a comment
There was a problem hiding this comment.
2 questions for the reviewer, one per thread.
🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
|
🤖 no session running recent dispatchesWritten by the sydevs dispatcher. Sessions never edit this comment. |
|
Smoke is red for a cause outside this branch, so nothing was pushed. The publish step answers "You cannot upload a new version of a Worker that does not yet exist" — Window: CI run 551 green at 19:10Z, run 552 (this PR) red at 20:05Z, run 553 red at 20:21Z. Already filed with the probe table and both candidate causes: #356. It needs the Cloudflare dashboard, so no commit here fixes it. Changing 🤖 Written by the sydevs autonomous loop — see what this is Generated by Claude Code |
The brand palette themed the React UI and stopped at the map, so a maroon-branded tenant still got teal pins. `BrandTheme` now publishes the palette it painted from to a store beside it, and the map derives its own hexes from the same roles — the variables it writes are unreadable to an SVG `fill` or a Mapbox paint, and a role that fell back to the default writes no variable at all. `palette.ts` gains three hex accessors, so `colord` stays in the one file that knows the ladder. `stepHex` and `onHex` answer null for a seed that is absent, invalid or achromatic — the refusal `setRole` already makes by writing nothing — so each caller keeps its own built-in default instead of painting a grey ramp over everything. The pin and cluster fill from `primary` step 9, the selected pair from `contrast` step 9, each cluster ring from its own fill at +21 L / -16 S. Fallback is per role, so a tenant that configured `color1` alone keeps the stock selected accent. An unseeded role keeps the `markers.ts` constants verbatim rather than deriving from DEFAULT_SEEDS, whose step 9 is 16pp darker and 16 degrees bluer than today's pin in light mode and lighter again in dark, while today's pin is identical in both. `markers.test.ts` freezes the four stock SVGs byte for byte, taken off main and compared in both modes. Every decode cache is keyed by the id plus its colours, and a live map is repainted with `map.updateImage` — a second `addImage` on a live id makes Mapbox fire an `error` event instead. The synchronous warm path inside `styleimagemissing` is unchanged, so the new style's first frame still has its pins. `clusterLayer` and `boundsLayer` become factories, memoised on the palette and the mode in `Map.tsx`, and `LAYER_IDS` holds the ids a factory can no longer be asked for. `INTERACTIVE_LAYER_IDS` replaces an array literal that `<ReactMapGL>` was handed fresh on every render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjZbsYaRHjC5XTuXizf
`BrandTheme.publish.test.tsx` pins the Map.tsx wiring in source, as `Widget.theme-root.test.tsx` and `MapFrame.test.tsx` pin their own, so it reads a repo file through `repo-source` and takes the next slot in the widest per-edit selection — which `select-unit-specs.test.ts` held at 18 with no headroom. Timed the hook rather than dropping the spec, which is what docs/testing.md says to do. Medians of three on 4 cores: 5.5s for the widest edit (`src/test-support/repo-source.ts`, now 19 specs) and 6.2s for `src/config/i18n.ts` (12). Both well under the 12-second budget, and the pair has swapped places again, so the comment keeps saying width is a proxy and not a time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjZbsYaRHjC5XTuXizf
A tenant's pins changing colour is host-observable, so CHANGELOG.md and docs/embedding.md both say so — the two documents an embedding site reads. Neither the CSP table nor the Permissions Policy table moves: no origin is added and nothing new is requested. docs/rules/mapbox.md's markers and layers sections carried the registration mechanism and the "define every layer in layers.ts" rule. Both now carry colours: which role feeds which image, that an unseeded role keeps its constants, that a cache key is an id plus its colours, that a repaint is `updateImage`, and that two layers are factories whose result `Map.tsx` memoises. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjZbsYaRHjC5XTuXizf
The one behavioural fix: `markerColors` returns the shared
DEFAULT_MARKER_COLORS object when no role has a usable seed, instead of an
equal-but-new one. `Map.tsx` memoises it and the registration effect
depends on it, so a new identity cost an unbranded embed a re-subscription
and four rebuilt SVGs on every light/dark flip — work that, before this
branch, a flip did not do here at all. Asserted by identity, not equality.
A queued decode no longer re-uploads art already on the map. Mapbox
re-fires `styleimagemissing` per missing id per tile batch, so several
repeats await one promise; each resolution called `place`, and after the
first they were `updateImage` of the same bitmap. `current(id, key)` is
now re-asked after the await, which is the guard `add` itself uses.
`registered` moves inside `registerMarkerImages`. It records what one
subscription put on the map, and the colours are fixed for a
subscription's life, so page-global was the wrong scope — and the
staleness it had to tolerate goes with it.
One `SOLID_STEP` in `palette.ts`, beside the existing `BACKGROUND_STEP`,
replaces a private copy in each of the two map modules, in two spellings,
that nothing pinned together. `markers.ts` no longer imports `Step`.
`hexOf` hands the channel string to colord rather than parsing it:
`channel()` emits the CSS space-separated HSL syntax, so a hand-rolled
split was a second, unpinned copy of that format.
`shiftHex`'s three fields are required. One caller passes all three, so
the defaults were only ever exercised by a test of themselves. Its comment
now states the precondition: it sits below the ladder, with no mode, no
`isBrandSeed` and no saturation cap.
A cluster bubble's `ring` is required in the type, so `markerImages` drops
two unreachable `?? fill` branches. The caches key on `${id}|${svg}` — the
SVG string is the art, so a key cannot miss a difference, and the second
id-to-key Map and its dead guard go.
`layers.test.ts` gains the invariant the two modules only imply: the count
is asserted legible against the bubble `markerColors` actually fills, by
contrast ratio rather than by the two expressions matching, so a change to
either side that broke the agreement fails. Branded only — the stock pair
is `#FFFFFF` on `#37BB92` at 2.4:1, inherited from the Studio sprite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mg9rjZbsYaRHjC5XTuXizf
`BrandTheme` paints `getThemeRoot()`, which is `<html>` in Ladle because nothing calls `setThemeRoot`, and it publishes to a module store. Neither is scoped to one story and neither is undone on unmount, so opening the branded-markers story left the whole session maroon — including the `Mapbox` story the new one asks the reviewer to compare it against, which is the single thing that story exists for. A module-private wrapper clears both on unmount. `applyPalette` with no roles removes exactly the variables it manages. The story's description also now says what the config means: both roles are the same maroon, so every marker carries the brand, and the selected pin therefore matches an unselected one, since the two differ only by fill. A tenant wanting the selection to stand out sets `color3` away from `color1`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjZbsYaRHjC5XTuXizf
docs/rules/mapbox.md told the next author to "add a new marker to MARKER_IMAGES". This branch replaced that constant, so the one doc saying where to add a marker sent them to a grep with no hits. It now names the four places a new marker needs: the id, its stock colours, a case in `markerColors`, and a row in `markerImages`. The per-edit measurement note said `i18n.ts` had been "the faster of the two at seventeen". It was the slower there and the faster at eighteen; the flip being recorded is against eighteen, not seventeen. Both copies of the sentence, in docs/testing.md and in the pin's own comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjZbsYaRHjC5XTuXizf
The rule listed six stores in `src/config/store.ts` and said nothing about a store co-located with its one consumer, so `usePaddingState` had been undocumented and `useBrandPalette` would have been the second unexplained exception. Both are named now, with why each belongs beside its consumer rather than in `store.ts`. `useBrandPalette`'s entry carries the one rule a reader cannot re-derive: it must answer with a single object while nothing is seeded, because the map memoises on its identity and re-registers its marker images when that changes. The `paths:` list gains `brand-palette.ts`, so a session editing that store loads this rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjZbsYaRHjC5XTuXizf
276d664 to
85ff6b2
Compare
sydevs-bot
left a comment
There was a problem hiding this comment.
🧐 Adversarial review
Scope is tight. Every piece is named by #17, and both joined changes are joined by the rule. The ladder turned up no named alternative: SDF with icon-color cannot draw either image — the pin has a white centre dot, the cluster two fills at two alphas — and colord's lighten/desaturate cannot hold shiftHex's maxLightness clamp. Contracts hold: no new origin, attribute or capability; layer ids pinned as the strings they were.
Two things deserve your eye. BrandTheme now publishes state it never retracts, and the one consumer that unmounts it already clears the store by hand. And "is this seed usable" is now decided twice.
Both ## Preview links answer 404 — Atlas preview is neutral, so no app preview exists (#356). The body presents them as live and does not say so.
Findings
- should-fix — the published palette is never cleared on unmount (
BrandTheme.tsx:56) - should-fix — the host guide says "two colours" without saying which two of three (
docs/embedding.md:244) - consider — the usable-seed verdict is decided twice (
palette.ts:202) - consider — the legibility assertion measures the opaque fill, not the rendered disc (
layers.test.ts:98)
What was checked
#17 against the diff, file by file. Read whole on the branch: markers.ts, layers.ts, palette.ts, BrandTheme.tsx, brand-palette.ts, layers.test.ts, the stories, Map.tsx's hunks. Traced contrast and secondary across src/: all three roles reach buttons, which is what makes that line ambiguous. The count's text-color and the disc's fill both read primary step 9, so partial seeding cannot disagree, and only clusterLayer draws text. Unseeded, markerColors returns one shared object, so a theme flip re-runs no registration. Both previews fetched. Profile read.
🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
`applyPalette`'s paint is released by the wrapper's callback ref. The publication had no such owner, so every consumer that unmounts `BrandTheme` had to clear the module store by hand — the branded Ladle story did, and the next one would have copied it. The retraction sits in the unmount-only effect, not in the publish effect's cleanup. That effect keys on the palette and the mode, so a theme flip would empty the store and refill it within one commit, and a branded embed would re-register its markers in the stock colors in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjXMiSgjCg27bP8UVQL
|
Four review findings answered: three adopted, one rebutted. Pushed f622b7c.
Both question threads stay open. They wait on you. Body refreshed: the Lean gate green. Full gate not re-run. 🤖 Written by the sydevs autonomous loop — see what this is Generated by Claude Code |
The reviewer asked which of the two transports is the more robust and the cheaper to maintain, having read both. This is the answer. A module singleton outlives the component that resolved it, so keeping the published palette in step with the paint needed an explicit retraction on unmount, plus the rule that it must not live in the publish effect's own cleanup — a flip would empty the store and refill it inside one commit, and re-register a branded embed's markers in the stock colors between. That rule was carried in three places and had already been got wrong once, in the Ladle story's hand clear. A provider ends the class: the value's lifetime is the painter's, by construction. Runtime behaviour is unchanged. The map re-renders on exactly the changes the store's selector fired on, because both read the same memoised object. What the identity rule still protects is the memo in `Map.tsx`, so the context default is one module object — `NO_BRAND_PALETTE`, which every unbranded embed, story and spec reads — and the spec pins both halves. `src/config/` resolves `mode`, `responsive` and `routing` this way already, so this is the house pattern rather than a new one. The spec drops its retraction case, gains the two identity cases, and is renamed for what it now covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjV6wdtJi8vE5Yg48VD
|
Both question threads answered. Pushed ea2d976.
Merged Lean gate green: 1929 tests, 160 files. Full gate not re-run — the swap moves no module into or out of the eager graph. Body refreshed: its outline, flow and Detail all described the store. 🤖 Written by the sydevs autonomous loop — see what this is Generated by Claude Code |
CHANGELOG.md conflicted: both sides added a first entry under the same `### Changed` heading. Both are kept, newest first — #17's brand-colour entry above #343's captcha `frame-src` one — which is the order the section already follows. The two entries' CSP claims are each scoped to their own change and do not contradict: #17 adds no directive, #343 adds `frame-src`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mg9rjZNBHJFuSUi4nddoeV
|
Both sides added a first entry under No source overlap. Lean gate green on the merge: 160 files, 1895 tests. The body's 1929 predates #355, which rewrote the turnstile specs. 🤖 Written by the sydevs autonomous loop — see what this is Generated by Claude Code |
) On a live map a palette change left every pin and cluster in its old colours. mapbox-gl 3.34.1's updateImage only queues an icon-atlas patch, and a frame that re-evaluates the style first discards it. A palette change restarts the cluster count's text-color transition, which is exactly that frame. Removing the image and adding it again makes Mapbox re-lay out the symbol tiles instead, once per palette change. Verified in headless Chromium against the live feed, across the three Ladle presets in both modes, with no image errors in the console. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Ladle decorator now provides BrandPaletteContext with the chosen preset, as BrandTheme does in the app, so the Brand palette control recolours the map's markers on every story. The separate branded Mapbox story is gone. Each preset is one module object, because the map memoises its marker colours on the palette's identity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
primary/contraststep 9, per mode.docs/rules/i18n-and-state.mdgainsusePaddingState, which was already undocumented, and the brand palette's own section.Change outline
The map could not read the palette:
BrandThemepainted CSS variables, and an SVGfilland a Mapbox paint take a literal colour.BrandThemenow hands the roles it painted from down the tree as context, and the map derives its own hexes from the same values.The transport changed in the review round. A zustand store was built first, as the ticket's § 1 named; the reviewer asked which transport is more robust and cheaper to maintain, and the answer was context (ea2d976) — see
## Notes for reviewer.Flow
BrandTheme({ palette, apiKey }) src/config/theme/BrandTheme.tsx resolved = prop role ?? client record role ?? built-in default applyPalette(themeRoot, resolved, mode) # CSS variables, unchanged + <BrandPaletteContext.Provider value={resolved}> # src/config/theme/brand-palette.ts … Mapbox() src/components/organisms/Mapbox/Map.tsx + palette = useBrandPalette() # NO_BRAND_PALETTE with no provider above + colors = useMemo(markerColors(palette, theme)) # memoised: Layer force-renders on styledata + clusters = useMemo(clusterLayer(palette, theme)) + bounds = useMemo(boundsLayer(palette, theme)) - useEffect(registerMarkerImages(mapbox), [mapbox]) + useEffect(registerMarkerImages(mapbox, colors), [mapbox, colors])Behaviour — how a marker's colours are chosen
One pass per role. A role with no usable seed derives nothing, and an embed with no seed at all gets the same object every time, so the effect above never re-runs for it.
Behaviour — registration, now that art can change
addused to return onmap.hasImage(id), so a palette change registered nothing. It now compares art, and the eager pass is what repaints a live map — nothing reloads the style, so nothing re-firesstyleimagemissing.Types
Contract —
layers.tsTwo of the seven layers carry a colour paint, so those two became factories. The ids moved out of the layer objects, because a factory has none until it is called.
Contract — what a host can observe
docs/embedding.md§ What you configure in the CMS instead, andCHANGELOG.md[Unreleased]:The transport change is invisible to a host: same colours, same markers, same tables.
Preview
⚠ Neither link resolves yet. The app Worker's preview publish fails repo-wide (#356), so
Atlas previewisneutraland no preview of this branch exists. These are the addresses to use once that is fixed, not links to follow now.Both show the unbranded case, which is the criterion worth checking on a deploy: nothing passes a brand prop and no preview tenant has colours set, so the pins must be exactly today's teal and orange.
Data: not seeded. This repo has no seed command and the preview reads the live Atlas API, so an empty page there is the API's content, not this branch.
The branded case is on neither preview. No embed passes a brand prop, so the only live source of a palette is a client record's colours — see
## Manual verification.Test results
.claude/skills/pr-prep/check.sh— lint, typecheck,test:run(1929 tests, 160 files). ⚠ This line said 1933 before the first review round. Re-measured at 85ff6b2 the tree was at 1927, so that figure was simply wrong — read it off a run, never off this line.--full, at 85ff6b2: production build, CSS-scoping and source-map asserts,pnpm size. No budget moved:standalone324.6/330 KiB,embed326.3/330,loader2.8/3.5.colordwas already eager throughBrandTheme. Not re-run since. The two review rounds added five lines toBrandTheme.tsxand then swapped its store for a provider, which moves no module into or out of the eager graph —zustandis still eager throughstore.ts, and a context adds no import.markers.test.ts(11),layers.test.ts(11),BrandTheme.palette.test.tsx(5), plus four cases inpalette.test.tscomment-lint.mjs --base origin/main): clean across 12 changed filespnpm typecheck✓, eslint on the changed files ✓, andpnpm test:runonsrc/components/organisms/Mapbox,src/config/themeand the twoscripts/specs ✓ (114). The lean gate was not re-run locally; CI runs it.shrimataji.org, back towemeditate.com,sahajayoga.org, then dark mode under both brands and back to light: every switch recoloured the pins and bubbles at once, the stock preset restored teal and orange with white counts, and the console carried no image error or warning.Manual verification
VITE_MAPBOX_ACCESSTOKENin.env.local, runpnpm ladle, open Organisms → Mapbox (?story=organisms--mapbox), and pick a preset in the Brand palette control:shrimataji.orgturns pins and cluster bubbles maroon with white counts,sahajayoga.orgolive, andwemeditate.comrestores the stock teal and orange. Neither brand preset setscontrast, so the selected pin stays stock orange under both — what a tenant withoutcolor3gets.⚠ Ladle sets no SahajCloud API key (only
Widget.tsxdoes), so the events feed answers 403 and the map draws no markers at all unlessatlasAuth.apiKeyis set locally. That predates this branch.Image "<id>" could not be loadedand noerrorevent in the console. This is the path the review round's fix is for: withupdateImage, the pins kept their old colours.LAYER_IDS, so one pass on a deploy is worth it once fix(build): the sahajatlas Worker is gone, so no PR gets a preview #356 lifts.docs/rules/mapbox.md§ Checking map behavior in a browser has the recipe, including reading.playwright-mcp/console-*.logfor the absence ofImage "<id>" could not be loadedacross repeated theme toggles.Notes for reviewer
Both question threads are answered, and nothing is waiting on you.
Since the review: the branded story is gone, and the palette control drives the map. Ladle's decorator now provides
BrandPaletteContextwith the chosen preset, asBrandThemedoes in the app (9db2698). Wiring it exposed a real defect in this branch: a palette change on a live map never repainted the pins, so the repaint is now remove-then-add (67c0c96) — see Why remove-then-add in Detail.Where the resolved palette should travel — you asked for a recommendation on robustness and maintenance rather than a letter. Context, built in ea2d976. A module singleton outlives the component that resolved it, so the store needed an explicit retraction on unmount plus the rule that it must not sit in the publish effect's own cleanup — a rule carried in three documents, and already got wrong once in the Ladle story's hand clear. A provider ends the class: the value's lifetime is the painter's, by construction.
src/config/resolvesmode,responsiveandroutingthe same way. Two things that argument does not claim: runtime behaviour is identical, since the store's selector fired on exactly the changes the context value does; and the per-edit ceiling stays 19, because the wiring pin still reads a repo file. Recorded as a review line on feat(map): brand-colour the map markers and cluster paints #17, since its § 1 names the store. Reverting is one commit.A tenant with
color1 ≈ color3— A, as you said. Nothing changed.The adversarial review's four findings are answered. Three adopted — the palette is retracted on unmount (363ba09, now moot: the provider does it), both host-facing docs name which two colours reach the map (b047671, f622b7c), and the cluster-count legibility bound records that 4.5 is the opaque figure (6326acd). One rebutted: "the usable-seed verdict is decided twice" rests on a premise
palette.tsdisproves —isBrandSeedis one module-private predicate, andsetRole,stepHexandonHexall call it, so the verdict is re-computed rather than re-derived and there is no second decision to drift from. Each thread carries the detail.Review findings I dismissed, with reasons:
markerColorsproduced, rather than fromprimary's ramp. Done literally it changes the stock label:#FFFFFFon the Studio art's#37BB92measures 2.4:1, while black measures about 8.7:1, so the count would flip to black on every unbranded embed. Keeping white means alabelfield inside the type that describes the SVG art. Instead the invariant is now asserted:layers.test.tschecks the count's contrast against the fillmarkerColorsactually returns, which fails if either side changes source.boundsLayersolayers.tsstops knowing about the palette. An acceptance criterion requires that line to follow the palette.ringOfmode-aware, as step 10 is. The ring is a halo, always lighter than its fill, not a hover tone that reverses toward the canvas — and the ticket fixed the transform for both pairs and both modes. Worth knowing as a drift risk: a future ladder re-tune moves the UI and leaves the rings behind, andmarkers.test.tspins them byte for byte, so the drift would be frozen in rather than caught.LAYER_IDSto the three ids read from outside the module. Having all seven in one place is the point, and the five per-layer assertions are not tautological against the defect they guard — they fail the moment a layer stops reading fromLAYER_IDS.markerColorsintoregisterMarkerImagesand drop the memo. The ticket specifies the colours as both the argument and the effect dependency, and a function taking the data it uses is the more testable seam.buildScalebyseed|mode. It is rebuilt four or five times per palette or theme change, which the reviewer measured as tens of microseconds, two to four times a session. That is the ladder's "no machinery against an unmeasured harm" case, so the redundancy is named here and the call left to you.boundsmemo whileDEBUG_BOUNDARYisfalse. Same magnitude, and it costs a nullable memo plus a guard in the JSX.One measurement worth having, which I judged out of scope rather than fixing: the pin's white centre dot does not follow the palette. On the stock teal it is 2.28:1; the worst branded case in dark mode is 1.86:1 (
#A1C3D7) or 1.88:1 (#1E6C71), since step 9 floors lightness at 60 there. The dot is decorative rather than informational and the stock art was already in that range, so this is a small worsening of an existing figure, not a new class of problem.Security review: skipped. No changed path matches
securityReview.triggerPattern, and the diff adds no line matching its content pattern — re-checked for both review rounds.package.jsonandpnpm-lock.yamlappear in this branch's history only through the merge oforigin/main, whose own content was reviewed there. The review pass was/simplifyplus/code-review high— nopr-review-toolkitplugin is installed in this session.Detail
Why a context and not a store, in full. Both carry the same value to the same consumer and re-render it on the same changes, so the choice is about what each costs to keep correct. A zustand store is a module singleton: its value outlives the
BrandThemethat resolved it, so the two are kept in step by hand — a retraction on unmount, which must not live in the publish effect's own cleanup, because that effect keys on the palette and the mode and would empty the store and refill it inside one commit on every theme flip, re-registering a branded embed's four marker images in the stock colours in between. That reasoning was correct and it was load-bearing in three documents, which is the tell: the provider needs none of it, because unmounting the provider is what removes the value. What the context does not change: the identity rule still matters, sinceMap.tsxmemoises on the value, soBrandTheme'suseMemoholds it for a tenant andNO_BRAND_PALETTEholds it for the default; and the source-text wiring pin still reads a repo file, so the per-edit ceiling stays at 19.Why neither one reads
getComputedStyle. Reading the variables back would need the map to grow its own change signal, and a role that fell back to the built-in default writes no variable at all — so the map could not tell "default" from "unset".Why an unseeded role must not derive.
DEFAULT_SEEDS.primaryis#1E6C71; its step 9 ishsl(184 58% 28%)in light andhsl(184 58% 60%)in dark, against today's pin at#21C1A2=hsl(168 71% 44%)in both. Deriving the default would recolour every unbranded embed and make it mode-dependent.applyPalettealready makes the same promise for the CSS variables.How byte-identity was established, not assumed.
origin/main'smarkers.tswas copied aside with itsMARKER_IMAGESexported, and a throwaway spec compared all four images against the new builder in both modes and for a#000000palette. All equal. The four strings are now frozen inmarkers.test.ts, and the scratch files were deleted.Each new assertion was watched failing.
markerColorswith the default seed substituted → the four stock-art cases go red; the early return removed → both identity cases; the cluster fill switched tocontrast→ the legibility case;onHex/stepHexwith their fallbacks hard-coded and oneLAYER_IDSvalue misspelled → threelayers.test.tscases;isBrandSeeddropped fromstepHexandmaxLightnessignored inshiftHex→ twopalette.test.tscases.BrandTheme.palette.test.tsx's four cases were watched against four separate defects: the provider replaced by a fragment → the paint-and-value case;DEFAULT_SEEDS.primarysubstituted intoresolved→ the no-default case; theuseMemodropped → the stable-identity case; the context default built as{ ...NO_BRAND_PALETTE }→ the unbranded-subtree case.Why the caches key on the SVG string. The art is what a cache entry holds, and for one id the SVG differs only by its fills, so the string is "this id plus its colours" — with no second structure mapping ids to keys, and no way for a key to miss a difference. Bounded at one entry per image per mode, eight per embed.
Why
boundsLayertakes step 9 rather than a border step. It is the brand solid, the same value the pins use, and step 9 is the one step clamped to stay visible on both basemaps — light caps its lightness, dark floors it. The#888fallback reads on both for the same reason.Why remove-then-add and not
updateImage.updateImagewas the first version, and it does not work on a live map. In mapbox-gl 3.34.1 it only records the id for an icon-atlas patch, andstyle.update()clears that record before the tiles apply it whenever the style is dirty. A palette change also moves the cluster count'stext-color, whose paint transition keeps the style dirty, so the patch was discarded and the pins kept their old colours. Only a browser could show this, and the first round had no Mapbox token.removeImagemakes Mapbox re-lay out the symbol tiles instead, once per palette change. The remove comes first because a secondaddImageon a live id fires Mapbox'serrorevent rather than throwing — the signaldocs/rules/mapbox.mdtells a reviewer to watch for.The cluster-count legibility bound is the opaque figure, and says so. The disc renders at
opacity="0.914", so what a reader sees is that fill composited over the basemap.foregroundForcrosses between black and white at 4.58:1, so a seed landing near the crossover clears 4.5 in the spec and reads nearer 4.2 on screen. Blending properly needs a basemap colour the node lane has no business asserting, so the gap is recorded rather than closed.INTERACTIVE_LAYER_IDSwas an array literal in the JSX, so<ReactMapGL>was handed a fresh array every render. It is one module constant now.A branded embed's first switch into a mode decodes that mode's four images, so those pins land a tick after the new style's first frame rather than on it. An unseeded role derives nothing, so its art is identical in both modes and stays warm exactly as before. Marked
shortcut:inmarkers.ts, with a note to measure before pre-warming: warming the counterpart mode costs four rasterisations and four texture uploads at boot for a mode the visitor may never ask for.The per-edit ceiling.
docs/testing.mdsays to time the hook and ratchet rather than drop a spec. Medians of three on 4 cores: 5.5 s for the widest edit (src/test-support/repo-source.ts, now 19 specs) and 6.2 s forsrc/config/i18n.ts(12), against a 12 s budget. The pair has swapped places again, which the comment now records.Out of scope, deliberately: per-tenant basemap styles, basemap tinting, SDF assets and
icon-color.MAP_STYLESis untouched. The card-hover highlight gets no dedicated image — it reuses the selected pair, so it follows the brand for free, andlayers.ts's comment saying one "can land with #17" is corrected rather than left promising one.Closes #17
🤖 Generated with Claude Code
https://claude.ai/code/session_01Mg9rjV6wdtJi8vE5Yg48VD