Skip to content

feat(map): brand-colour the map markers and cluster paints - #353

Merged
sydevs-bot merged 17 commits into
mainfrom
claude/feat-17-brand-map-markers
Nov 5, 2026
Merged

sydevs-bot merged 17 commits into
mainfrom
claude/feat-17-brand-map-markers

Conversation

@sydevs-bot

@sydevs-bot sydevs-bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • A tenant's brand colours now reach the map: pins, cluster bubbles and cluster counts derive from primary/contrast step 9, per mode.
  • An unseeded role keeps the stock marker art byte for byte, so every unbranded embed is unchanged.
  • Joined: the per-edit spec-selection ceiling moves 18 → 19, because the new wiring pin reads a repo file.
  • Joined: docs/rules/i18n-and-state.md gains usePaddingState, which was already undocumented, and the brand palette's own section.

Change outline

The map could not read the palette: BrandTheme painted CSS variables, and an SVG fill and a Mapbox paint take a literal colour. BrandTheme now 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.

 markerColors(palette, mode)                        src/components/organisms/Mapbox/markers.ts
+  primary  = stepHex(palette.primary, mode, SOLID_STEP)   # null: absent, invalid or achromatic
+  contrast = stepHex(palette.contrast, mode, SOLID_STEP)
+  neither → return DEFAULT_MARKER_COLORS             # ⚠ the same object, not an equal one
+  sy-point           → primary  ?? POINT_FILL
+  sy-point-selected  → contrast ?? POINT_SELECTED_FILL
+  sy-cluster         → primary  + ring(primary)  ?? CLUSTER_FILL + CLUSTER_RING
+  sy-cluster-selected→ contrast + ring(contrast) ?? CLUSTER_SELECTED_FILL + CLUSTER_SELECTED_RING
+    ring(fill) = shiftHex(fill, +21 L, -16 S, L ≤ 92)

Behaviour — registration, now that art can change

add used to return on map.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-fires styleimagemissing.

 registerMarkerImages(map, colors)                  src/components/organisms/Mapbox/markers.ts
+  images     = markerImages(colors)                 # per call
+  registered = new Map()                            # per SUBSCRIPTION, not per page
+  current(id, key) = registered.get(id) === key AND map.hasImage(id)
   add(id)
-    skip if failed.has(id) or map.hasImage(id)
+    skip if failed.has(key) or current(id, key)      # key = `<id>|<the SVG itself>`
     decoded.get(key) → place(id, key, image)         # SYNCHRONOUS, inside styleimagemissing
-    else decode, then add unless cancelled or already present
+    else decode, then place unless cancelled or current(id, key)
+  place(id, key, image)
+    map.hasImage(id) && map.removeImage(id)          # a 2nd addImage fires Mapbox's error event
+    map.addImage(id, image, { pixelRatio: 2 })       # ⚠ not updateImage — see Detail
   decoded / decoding / failed                        # keyed by the art, not by the id

Types

 src/config/theme/palette.ts
+ SOLID_STEP: Step                                   # beside the existing BACKGROUND_STEP
+ stepHex(seedHex, mode, step): string | null        # null = the refusal setRole already makes
+ onHex(seedHex, mode): string | null
+ shiftHex(hex, { lightness, saturation, maxLightness }): string   # ⚠ below the ladder
 src/config/theme/brand-palette.ts
+ NO_BRAND_PALETTE: PaletteRoles                     # ⚠ one module object — the map memoises on it
+ BrandPaletteContext: Context<PaletteRoles>
+ useBrandPalette(): PaletteRoles
 src/components/organisms/Mapbox/markers.ts
- MarkerFill = { fill: string; ring?: string }
+ PinColors     = { fill: string }
+ ClusterColors = { fill: string; ring: string }     # a bubble always has both
+ MarkerColors  = Readonly<{ 'sy-point': PinColors; …; 'sy-cluster': ClusterColors; … }>
+ DEFAULT_MARKER_COLORS: MarkerColors                # the stock art, verbatim

Contract — layers.ts

Two 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.

-export const clusterLayer: Props = { id: 'clusters', …, paint: { 'text-color': '#FFFFFF' } }
+export const clusterLayer = (palette, mode): Props =>
+  ({ id: LAYER_IDS.cluster, …, paint: { 'text-color': onHex(palette.primary, mode) ?? '#FFFFFF' } })
-export const boundsLayer: LayerProps = { id: 'selected-bounds', …, paint: { 'line-color': '#888' } }
+export const boundsLayer = (palette, mode): LayerProps =>
+  ({ id: LAYER_IDS.bounds, …, paint: { 'line-color': stepHex(palette.primary, mode, SOLID_STEP) ?? '#888' } })
+export const LAYER_IDS = { cluster: 'clusters', unclusteredPoint: 'unclustered-point', … }
+export const INTERACTIVE_LAYER_IDS = [LAYER_IDS.cluster, LAYER_IDS.unclusteredPoint]

Contract — what a host can observe

docs/embedding.md § What you configure in the CMS instead, and CHANGELOG.md [Unreleased]:

+ Your brand colours reach the map, not only the panels: pins, cluster bubbles and counts.
+ The FIRST of your three colours fills them; the THIRD fills the selected pin. The second does not reach the map.
+ A record with the colours unset keeps the widget's own teal and orange, map included.
+ No new origin, no new attribute, no new capability — the CSP and Permissions Policy tables are unchanged.

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 preview is neutral and 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.

  • The world map — clusters, with white counts on the stock teal bubbles
  • A country's events — teal pins; clicking one gives the orange selected pin, and hovering a result card the translucent highlight

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

  • Lean gate: ✓ .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 gate: ✓ --full, at 85ff6b2: production build, CSS-scoping and source-map asserts, pnpm size. No budget moved: standalone 324.6/330 KiB, embed 326.3/330, loader 2.8/3.5. colord was already eager through BrandTheme. Not re-run since. The two review rounds added five lines to BrandTheme.tsx and then swapped its store for a provider, which moves no module into or out of the eager graph — zustand is still eager through store.ts, and a context adds no import.
  • New specs: markers.test.ts (11), layers.test.ts (11), BrandTheme.palette.test.tsx (5), plus four cases in palette.test.ts
  • Every new assertion was watched failing against the defect it is for — see Detail
  • Comment lint (comment-lint.mjs --base origin/main): clean across 12 changed files
  • Since the review round (67c0c96, 9db2698): pnpm typecheck ✓, eslint on the changed files ✓, and pnpm test:run on src/components/organisms/Mapbox, src/config/theme and the two scripts/ specs ✓ (114). The lean gate was not re-run locally; CI runs it.
  • Browser: ✓ headless Chromium against the live feed, at 9db2698. shrimataji.org, back to wemeditate.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

  1. The branded case, in Ladle. With VITE_MAPBOX_ACCESSTOKEN in .env.local, run pnpm ladle, open Organisms → Mapbox (?story=organisms--mapbox), and pick a preset in the Brand palette control: shrimataji.org turns pins and cluster bubbles maroon with white counts, sahajayoga.org olive, and wemeditate.com restores the stock teal and orange. Neither brand preset sets contrast, so the selected pin stays stock orange under both — what a tenant without color3 gets.
    ⚠ Ladle sets no SahajCloud API key (only Widget.tsx does), so the events feed answers 403 and the map draws no markers at all unless atlasAuth.apiKey is set locally. That predates this branch.
  2. A light/dark flip should keep pins on screen and move the brand colour with the mode — step 9 floors its lightness in dark, so a dark-seeded brand gets lighter rather than vanishing into the basemap.
  3. Switching presets on a live map should recolour every pin and bubble at once, with no Image "<id>" could not be loaded and no error event in the console. This is the path the review round's fix is for: with updateImage, the pins kept their old colours.
  4. Clustering, cluster expansion, pin navigation, selection and the card-hover highlight are unchanged code paths that now read 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-*.log for the absence of Image "<id>" could not be loaded across 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 BrandPaletteContext with the chosen preset, as BrandTheme does 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/ resolves mode, responsive and routing the 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.ts disproves — isBrandSeed is one module-private predicate, and setRole, stepHex and onHex all 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:

  • Derive the cluster count's colour from the fill markerColors produced, rather than from primary's ramp. Done literally it changes the stock label: #FFFFFF on the Studio art's #37BB92 measures 2.4:1, while black measures about 8.7:1, so the count would flip to black on every unbranded embed. Keeping white means a label field inside the type that describes the SVG art. Instead the invariant is now asserted: layers.test.ts checks the count's contrast against the fill markerColors actually returns, which fails if either side changes source.
  • Drop brand colour from boundsLayer so layers.ts stops knowing about the palette. An acceptance criterion requires that line to follow the palette.
  • Make ringOf mode-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, and markers.test.ts pins them byte for byte, so the drift would be frozen in rather than caught.
  • Narrow LAYER_IDS to 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 from LAYER_IDS.
  • Fold markerColors into registerMarkerImages and 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.
  • Cache buildScale by seed|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.
  • Skip the bounds memo while DEBUG_BOUNDARY is false. 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.json and pnpm-lock.yaml appear in this branch's history only through the merge of origin/main, whose own content was reviewed there. The review pass was /simplify plus /code-review high — no pr-review-toolkit plugin 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 BrandTheme that 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, since Map.tsx memoises on the value, so BrandTheme's useMemo holds it for a tenant and NO_BRAND_PALETTE holds 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.primary is #1E6C71; its step 9 is hsl(184 58% 28%) in light and hsl(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. applyPalette already makes the same promise for the CSS variables.

How byte-identity was established, not assumed. origin/main's markers.ts was copied aside with its MARKER_IMAGES exported, and a throwaway spec compared all four images against the new builder in both modes and for a #000000 palette. All equal. The four strings are now frozen in markers.test.ts, and the scratch files were deleted.

Each new assertion was watched failing. markerColors with the default seed substituted → the four stock-art cases go red; the early return removed → both identity cases; the cluster fill switched to contrast → the legibility case; onHex/stepHex with their fallbacks hard-coded and one LAYER_IDS value misspelled → three layers.test.ts cases; isBrandSeed dropped from stepHex and maxLightness ignored in shiftHex → two palette.test.ts cases. 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.primary substituted into resolved → the no-default case; the useMemo dropped → 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 boundsLayer takes 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 #888 fallback reads on both for the same reason.

Why remove-then-add and not updateImage. updateImage was 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, and style.update() clears that record before the tiles apply it whenever the style is dirty. A palette change also moves the cluster count's text-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. removeImage makes Mapbox re-lay out the symbol tiles instead, once per palette change. The remove comes first because a second addImage on a live id fires Mapbox's error event rather than throwing — the signal docs/rules/mapbox.md tells 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. foregroundFor crosses 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_IDS was 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: in markers.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.md says 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 for src/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_STYLES is untouched. The card-hover highlight gets no dedicated image — it reuses the selected pair, so it follows the brand for free, and layers.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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
sahajatlas-design 9db2698 Nov 05 2026, 04:42 AM

@sydevs-bot sydevs-bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 questions for the reviewer, one per thread.

🤖 Written by the sydevs autonomous loop — see what this is


Generated by Claude Code

Comment thread src/config/theme/brand-palette.ts Resolved
@sydevs-bot

sydevs-bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

🤖 no session running
CI fix 1

recent dispatches
  • 22:20Z resolve-conflicts · attempt 1 · session · done
  • 22:20Z resolve-conflicts · attempt 1 · session
  • 21:12Z address-review · attempt 1 · session · done
  • 21:12Z address-review · attempt 1 · session
  • 02:23Z address-review · attempt 1 · session · done

Written by the sydevs dispatcher. Sessions never edit this comment.

@sydevs-bot sydevs-bot moved this from Pending to Working in SYDevelopers Technical Roadmap Nov 1, 2026
@sydevs-bot

Copy link
Copy Markdown
Contributor Author

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" — wrangler versions upload --config wrangler.jsonc cannot see the sahajatlas Worker. This PR touches no deploy config, and #355 fails identically at the same step, so it is repo-wide.

Window: CI run 551 green at 19:10Z, run 552 (this PR) red at 20:05Z, run 553 red at 20:21Z. sahajatlas.com/ still answers 200 with our own _headers.

Already filed with the probe table and both candidate causes: #356. It needs the Cloudflare dashboard, so no commit here fixes it. Changing versions upload to deploy would make every PR deploy to production, so that was not done.

🤖 Written by the sydevs autonomous loop — see what this is


Generated by Claude Code

@sydevs-bot sydevs-bot removed the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 1, 2026
@sydevs-bot sydevs-bot moved this from Working to Awaiting in SYDevelopers Technical Roadmap Nov 1, 2026
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
@Ardnived
Ardnived force-pushed the claude/feat-17-brand-map-markers branch from 276d664 to 85ff6b2 Compare November 2, 2026 02:11
@sydevs-bot sydevs-bot moved this from Awaiting to Pending in SYDevelopers Technical Roadmap Nov 2, 2026
@sydevs-bot sydevs-bot added the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Pending to Working in SYDevelopers Technical Roadmap Nov 2, 2026

@sydevs-bot sydevs-bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧐 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

Comment thread src/config/theme/BrandTheme.tsx Resolved
Comment thread docs/embedding.md Resolved
Comment thread src/config/theme/palette.ts Resolved
@sydevs-bot sydevs-bot removed the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Working to Pending in SYDevelopers Technical Roadmap Nov 2, 2026
@sydevs-bot sydevs-bot added the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Pending to Working in SYDevelopers Technical Roadmap Nov 2, 2026
`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
@sydevs-bot

Copy link
Copy Markdown
Contributor Author

Four review findings answered: three adopted, one rebutted. Pushed f622b7c.

Both question threads stay open. They wait on you.

Body refreshed: the ## Preview links do not resolve (#356), and its test count was wrong — 1927 at 85ff6b2, 1928 now.

Lean gate green. Full gate not re-run.

🤖 Written by the sydevs autonomous loop — see what this is


Generated by Claude Code

@sydevs-bot sydevs-bot removed the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot
sydevs-bot marked this pull request as ready for review November 2, 2026 02:39
@sydevs-bot
sydevs-bot requested a review from Ardnived November 2, 2026 02:39
@sydevs-bot
sydevs-bot enabled auto-merge November 2, 2026 02:39
@sydevs-bot sydevs-bot moved this from Working to Awaiting in SYDevelopers Technical Roadmap Nov 2, 2026
@sydevs-bot sydevs-bot added the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Awaiting to Working in SYDevelopers Technical Roadmap Nov 2, 2026
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
@sydevs-bot

Copy link
Copy Markdown
Contributor Author

Both question threads answered. Pushed ea2d976.

  • Where the palette travels — you asked for a recommendation, not a letter. Context. A module singleton outlives the component that resolved it, so the store needed a retraction on unmount plus the rule that it must not sit in the publish effect's cleanup. Three documents carried that rule and the story had got it wrong once. mode, responsive and routing already resolve this way. Runtime behaviour is identical and the ceiling stays 19. Recorded as a review line on feat(map): brand-colour the map markers and cluster paints #17, whose § 1 names the store. Revert is one commit.
  • color1 near color3 — A. Nothing changed.

Merged origin/main in. Clean.

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

@sydevs-bot sydevs-bot removed the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 2, 2026
@sydevs-bot sydevs-bot moved this from Working to Awaiting in SYDevelopers Technical Roadmap Nov 2, 2026
@sydevs-bot sydevs-bot added the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 3, 2026
@sydevs-bot sydevs-bot moved this from Awaiting to Working in SYDevelopers Technical Roadmap Nov 3, 2026
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
@sydevs-bot

Copy link
Copy Markdown
Contributor Author

CHANGELOG.md was the only conflict, and it resolved the one way it could.

Both sides added a first entry under ### Changed. Both are kept, newest first: #17's brand-colour entry above #343's captcha frame-src one. That is the order the section already follows. The two CSP claims do not contradict — each is scoped to its own change. #17 adds no directive. #343 adds frame-src.

No source overlap. main moved by one commit, #355 (turnstile). All three overlapping files are docs. docs/embedding.md and docs/testing.md auto-merged with both sides' additions intact.

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

@sydevs-bot sydevs-bot removed the bot:working Lock: a cloud session is running on this item. Removed when it ends. label Nov 3, 2026
@sydevs-bot sydevs-bot moved this from Working to Awaiting in SYDevelopers Technical Roadmap Nov 3, 2026
Ardnived and others added 2 commits November 4, 2026 21:41
)

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>
@sydevs-bot sydevs-bot moved this from Awaiting to Pending in SYDevelopers Technical Roadmap Nov 5, 2026
@sydevs-bot
sydevs-bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit ff32c07 Nov 5, 2026
17 checks passed
@sydevs-bot
sydevs-bot deleted the claude/feat-17-brand-map-markers branch November 5, 2026 04:49
@sydevs-bot sydevs-bot moved this from Pending to Done in SYDevelopers Technical Roadmap Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

feat(map): brand-colour the map markers and cluster paints

3 participants