C1a: the affinity-block and doxa-region palettes move to library/colors/

First palette migration from SPEC-LIBRARY-CURATION.md. Source only; studio rebuilds
(rule 5B — library/ plus four other owners' plugins).

WHY THIS ONE FIRST: the duplication was not idle. doxa-research-map's
affinity-block.js carried A018 'Tibetan Peoples' and A019 'Himalayan Peoples' —
live on the pray-tools API — with colours measured to WCAG 1.4.11's 3.0:1 non-text
floor (A013 5.28 unchanged, A018 7.69, A019 3.53; sibling separations 2.61/1.46/1.79).
country-posters' copy covered only A001–A017, so 134 rows (6.4% of 2,107) fell to the
grey fallback #95a5a6 — the "bunch of grey colours" report. exp-shepherd confirmed
that code path headlessly. So the correct copy wins with no judgement call, and the
move FIXES country-posters rather than changing it.

FOUR CONSUMERS, NOT THE TWO THE SPEC COUNTED, and finding that is the sharpest
argument for the migration: research-dashboard/index.js and dashboard-plus-grid/index.js
each imported `../doxa-research-map/src/colors/affinity-block.js` — a cross-plugin
RELATIVE import into another plugin's PRIVATE folder. Both now import @map/colors/.
Only the build found this; no gate names cross-plugin reach.

THE GLOB STAYS in both plugins' index.js, deliberately, and is documented there:
resource.js is still plugin-local pending C1b, so deleting the glob would unregister
it and colour those pins by the soft fallback — the same silent wrong-colour class
being fixed here. Deleting the two STALE FILES is what stops the stale copy winning.

THE REJECTED-CANDIDATE NOTE MOVED ABOVE `export const PALETTE`, and this is a real
constraint rather than tidying: check:one-palette slices from that export to END OF
FILE and matches every hex, despite its own comment claiming it reads "only the
PALETTE export". #AF7AC5 was rejected for A019 at 2.95 — under the accessibility
floor — and mentioned below the export it would have been DEMANDED of every consumer
bundle. The note keeps the reasoning without creating the obligation; the gate bug
itself is reported separately since internal/ is not mine to edit.

The legend needed no change and that is by design: useAffinityBlockLegendData resolves
via getColorStrategy(COLOR_MODES.AFFINITY_BLOCK) — the same strategy the pins use — and
reads labels from the data, so map and legend gain A018/A019 together. Its comments
claiming the strategy lives in a plugin folder were corrected; they now describe where
it actually is.

GATES: check:one-palette PASS — 8 strategies, 16 bundle/palette pairs (baselined at 6
and 12 before the move, so a red would have been mine). check:library-index PASS after
regenerating the derived menu. check:no-free-identifiers PASS. All four consumer
bundles build; their artifacts reverted for studio.
This commit is contained in:
Doxa Maps 2026-09-26 08:09:18 +00:00
parent 7fbad5499c
commit 6263c250ec
10 changed files with 48 additions and 311 deletions

View file

@ -178,6 +178,10 @@ and a change here can break other maps.
`import … from '@map/utils/embedBase.js'`
- **geoUtils** — geoUtils.js — Geographic / GeoJSON Utility Functions
`import … from '@map/utils/geoUtils.js'`
- **html** — html.js — the two helpers every HTML string built in library/ must use (CRITIC-3, 2026-09-26).
`import … from '@map/utils/html.js'`
- **loadPinnedUmd** — loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
`import … from '@map/utils/loadPinnedUmd.js'`
- **viewportScrim** — viewportScrim.js — make a position:fixed full-screen SCRIM actually cover the VIEWPORT,
`import … from '@map/utils/viewportScrim.js'`
@ -192,6 +196,8 @@ and a change here can break other maps.
### Constants (never-change values)
- **cdn** — cdn.js — EVERY third-party script and stylesheet a plugin loads at runtime, pinned to an EXACT version
`import … from '@map/constants/cdn.js'`
- **mapDefaults** — mapDefaults.js — Default map initialization settings
`import … from '@map/constants/mapDefaults.js'`
- **posterDefaults** — posterDefaults.js — default margins, bleed, slot sizes, and typography

View file

@ -42,6 +42,14 @@ export const AFFINITY_BLOCK_NAMES = {
}
/** Primary mapping: ROP1 Code → Color */
// ★ REJECTED CANDIDATE, RECORDED ABOVE THE PALETTE DELIBERATELY: #AF7AC5 was considered for A019
// and scored 2.95 against the Material Light surface, just under WCAG 1.4.11's 3.0:1 non-text floor.
// It lives here rather than beside A019 because check:one-palette slices from `export const PALETTE`
// to END OF FILE and matches every hex it finds -- so a rejected colour mentioned BELOW the export
// would be demanded of every consumer bundle, and the gate would fail on a colour we refused on
// accessibility grounds. Keeping the note above the export preserves the reasoning without
// creating that obligation. (The gate's own comment says it reads "only the PALETTE export"; its
// implementation does not, which is reported separately.)
export const PALETTE = {
'A001': '#E74C3C', // Arab World - Red/Orange
'A002': '#3498DB', // East Asian Peoples - Blue
@ -68,7 +76,6 @@ export const PALETTE = {
// A013 #8E44AD 5.28 (unchanged) A018 #6C3483 7.69 A019 #A569BD 3.53
// Sibling separation so the three stay tellable apart: A018-vs-A019 2.61,
// A013-vs-A018 1.46, A013-vs-A019 1.79.
// REJECTED #AF7AC5 for A019 — scored 2.95, just under the 3.0 floor.
'A018': '#6C3483', // Tibetan Peoples - deep purple (A013 family, darker)
'A019': '#A569BD' // Himalayan Peoples - light purple (A013 family, lighter)
}

View file

@ -42,9 +42,12 @@ import { computed } from 'vue'
// Single source of truth — pins paint with this same palette, keyed by ROP1 code.
// Resolving the SAME strategy the pins use keeps legend ↔ map in lock-step (was a
// separate name-keyed palette causing pin/legend drift). affinity-block is a per-map
// (research-only) strategy that lives in plugin-designs/doxa-research-map/
// src/colors/affinity-block.js and is resolved via the registry (registered by
// the research bundle's local src/colors glob). Read LAZILY (at call time
// strategy, which NOW LIVES IN library/colors/affinity-block.js (C1): it was private to
// plugin-designs/doxa-research-map/ and duplicated in country-posters, where the stale copy
// covered only A001-A017 and sent 134 Tibetan/Himalayan rows to the grey fallback.
// It is resolved via the registry, which now discovers it from library/colors/ for EVERY
// bundle rather than from one plugin's local glob -- that is the whole point of the move.
// Read LAZILY (at call time
// inside blocColor — this composable is only invoked by research surfaces) so
// registration has already run, and GUARDED on propertyKey — getColorStrategy
// silently falls back to language-family for unregistered modes, and that palette

View file

@ -48,6 +48,13 @@ const profileModules = import.meta.glob('./*.vue')
// Keys are auto-derived from filenames and merged OVER the shared library set.
// This bundle inlines its own copy of the registry (IIFE build), so registering
// here affects THIS bundle only.
// ★ THIS GLOB NOW COVERS resource.js ONLY. doxa-region.js and affinity-block.js moved to
// library/colors/ (C1), where _registry.js discovers them for every bundle, so registering them
// here would be re-declaring what the shared set already has.
// THE GLOB STAYS rather than being deleted: resource.js is still private pending C1b (its palette
// has 8 values and the two dashboards ship 6, which needs its owner's answer before it moves).
// Removing the glob now would unregister resource and colour those pins by the soft fallback --
// the same class of silent wrong-colour bug this migration is fixing.
registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true }))
// ─── Mapbox RTL text plugin — load ONCE before any map instance ──────────────

View file

@ -1,164 +0,0 @@
/**
* Affinity Block Color Strategy
*
* Colors pins based on their ROP1 code (A001, A002, etc.).
* Uses ROP1 code as the primary key for consistency across data sources.
*
* ARCHITECTURE DECISION:
* - Use ROP1 codes (A001, A002, etc.) as the PRIMARY KEY
* - Store mappings from ROP1 → Name and ROP1 → Color
* - Avoids issues with typos in affinity bloc names
*/
export const PROPERTY_KEY = 'affinityBlock' // This is the ROP1 code
const DEFAULT_COLOR = '#95a5a6'
/** Primary mapping: ROP1 Code → Affinity Block Name */
export const AFFINITY_BLOCK_NAMES = {
'A001': 'Arab World',
'A002': 'East Asian Peoples',
'A003': 'Eurasian Peoples',
'A004': 'Horn of Africa Peoples',
'A005': 'Jewish',
'A006': 'Malay Peoples',
'A007': 'Persian-Median Peoples',
'A008': 'South Asian Peoples',
'A009': 'North American Peoples',
'A010': 'Pacific Islanders',
'A011': 'Southeast Asian Peoples',
'A012': 'Sub-Saharan Peoples',
'A013': 'Tibetan-Himalayan Peoples',
'A014': 'Turkic Peoples',
'A015': 'Latin-Caribbean Americans',
'A017': 'Deaf'
}
/** Primary mapping: ROP1 Code → Color */
export const PALETTE = {
'A001': '#E74C3C', // Arab World - Red/Orange
'A002': '#3498DB', // East Asian Peoples - Blue
'A003': '#2ECC71', // Eurasian Peoples - Green
'A004': '#F39C12', // Horn of Africa Peoples - Orange
'A005': '#9B59B6', // Jewish - Purple
'A006': '#1ABC9C', // Malay Peoples - Teal
'A007': '#E67E22', // Persian-Median Peoples - Dark orange
'A008': '#34495E', // South Asian Peoples - Dark blue-grey
'A009': '#95A5A6', // North American Peoples - Grey
'A010': '#5DADE2', // Pacific Islanders - Light blue
'A011': '#16A085', // Southeast Asian Peoples - Sea green
'A012': '#C0392B', // Sub-Saharan Peoples - Deep red
'A013': '#8E44AD', // Tibetan-Himalayan Peoples - Purple
'A014': '#D35400', // Turkic Peoples - Burnt orange
'A015': '#27AE60', // Latin-Caribbean Americans - Forest green
'A017': '#000000' // Deaf - Black (universal accessibility)
}
export const AFFINITY_BLOCK_COLORS = PALETTE
/** Reverse lookup: Name → ROP1 Code */
export const AFFINITY_BLOCK_NAME_TO_CODE = Object.entries(AFFINITY_BLOCK_NAMES).reduce((acc, [code, name]) => {
acc[name] = code
return acc
}, {})
/** Legacy: Name-based color lookup (for backward compatibility) */
export const AFFINITY_BLOCK_COLORS_BY_NAME = Object.entries(AFFINITY_BLOCK_NAMES).reduce((acc, [code, name]) => {
acc[name] = AFFINITY_BLOCK_COLORS[code]
return acc
}, {})
export const NAMES = AFFINITY_BLOCK_NAMES
export function getAffinityBlockName(code) {
return AFFINITY_BLOCK_NAMES[code] || 'Unknown'
}
export function getAffinityBlockColorByCode(code) {
return AFFINITY_BLOCK_COLORS[code] || DEFAULT_COLOR
}
/**
* Get color for an affinity block (supports both ROP1 code and name).
*/
export function getAffinityBlockColor(blockNameOrCode) {
if (!blockNameOrCode) return DEFAULT_COLOR
if (typeof blockNameOrCode === 'string' && /^A\d{3}$/.test(blockNameOrCode)) {
return AFFINITY_BLOCK_COLORS[blockNameOrCode] || DEFAULT_COLOR
}
return AFFINITY_BLOCK_COLORS_BY_NAME[blockNameOrCode] || DEFAULT_COLOR
}
export function getAffinityBlocksArray() {
return Object.entries(AFFINITY_BLOCK_NAMES).map(([code, name]) => ({
code,
name,
color: AFFINITY_BLOCK_COLORS[code]
}))
}
export function getAffinityBlockData(code) {
return {
code,
name: AFFINITY_BLOCK_NAMES[code] || 'Unknown',
color: AFFINITY_BLOCK_COLORS[code] || DEFAULT_COLOR
}
}
/**
* Strategy-aligned getter — uniform name across all per-strategy modules.
* Expects properties.affinityBlock to contain ROP1 code (e.g., "A007").
*/
export function getColor(properties) {
const rop1Code = properties.affinityBlock || properties._raw?.ROP1
return getAffinityBlockColorByCode(rop1Code)
}
/**
* Get display name for a feature.
*/
export function getName(properties) {
const rop1Code = properties.affinityBlock || properties._raw?.ROP1
return getAffinityBlockName(rop1Code)
}
/**
* Build Mapbox color expression for affinity blocks.
* Uses ROP1 code (A001, A002, etc.) as the match key.
*/
export function applyColor() {
const colorExpression = ['match', ['get', PROPERTY_KEY]]
Object.entries(AFFINITY_BLOCK_COLORS).forEach(([code, color]) => {
colorExpression.push(code, color)
})
colorExpression.push(DEFAULT_COLOR)
return colorExpression
}
export const buildColorExpression = applyColor
// Backward-compatibility aliases
export const affinityBlockCodeToName = AFFINITY_BLOCK_NAMES
export const affinityBlockCodeToColor = AFFINITY_BLOCK_COLORS
export const affinityBlockNameToCode = AFFINITY_BLOCK_NAME_TO_CODE
export const affinityBlockColors = AFFINITY_BLOCK_COLORS_BY_NAME
export const affinityBlockCodeLookup = AFFINITY_BLOCK_NAMES
export default {
name: 'Affinity Block',
propertyKey: PROPERTY_KEY,
palette: PALETTE,
colors: PALETTE,
names: NAMES,
getColor,
applyColor,
buildColorExpression,
getName,
getAffinityBlockName,
getAffinityBlockColor,
getAffinityBlockColorByCode,
getAffinityBlocksArray,
getAffinityBlockData
}

View file

@ -15,8 +15,13 @@ import { createPinia } from 'pinia'
import { createAppI18n } from '@map/i18n/index.js'
import { registerStrategies } from '@map/colors/_registry.js'
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
import affinityBlock from '../doxa-research-map/src/colors/affinity-block.js'
import doxaRegion from '../doxa-research-map/src/colors/doxa-region.js'
// ★ WAS A CROSS-PLUGIN RELATIVE IMPORT into another plugin's PRIVATE folder
// ('../doxa-research-map/src/colors/...'). Both modules now live in library/colors/ (C1), so this
// bundle no longer depends on doxa-research-map's internal layout -- which is the coupling the
// library exists to remove, and the reason this migration had four consumers rather than the two
// the spec counted.
import affinityBlock from '@map/colors/affinity-block.js'
import doxaRegion from '@map/colors/doxa-region.js'
import DashboardPlusGrid from './dashboard-plus-grid.vue'
// ─── THE SAME BYPASS AS THE i18n BUG BELOW, SECOND LEG (card BUILD-MAP-STYLE-...) ────────────

View file

@ -35,6 +35,13 @@ const profileModules = import.meta.glob('./*.vue')
// copy of the registry (IIFE build), so registering here affects THIS bundle only.
// This bundle ships THREE research-only strategies: affinity-block.js,
// doxa-region.js, resource.js (shared modes like religion stay in the library).
// ★ THIS GLOB NOW COVERS resource.js ONLY. doxa-region.js and affinity-block.js moved to
// library/colors/ (C1), where _registry.js discovers them for every bundle, so registering them
// here would be re-declaring what the shared set already has.
// THE GLOB STAYS rather than being deleted: resource.js is still private pending C1b (its palette
// has 8 values and the two dashboards ship 6, which needs its owner's answer before it moves).
// Removing the glob now would unregister resource and colour those pins by the soft fallback --
// the same class of silent wrong-colour bug this migration is fixing.
registerStrategies(import.meta.glob('./src/colors/*.js', { eager: true }))
// ─── Mapbox RTL text plugin — load ONCE before any map instance ──────────────

View file

@ -1,139 +0,0 @@
/**
* DOXA Region Color Strategy
*
* Colors pins based on their DOXA / WAGF region.
*
* Drift note (vs simple-map):
* - simple-map's 'No WAGF Region/Bloc' = '#95a5a6' (grey),
* research = '#1a1a2e' (deep navy). Research wins.
*
* generateColorFromString fallback comes from utils/geoUtils.js — sister
* subagent 1D owns that file. Cross-folder dep flagged.
*/
import { generateColorFromString } from '@map/utils/geoUtils.js'
export const PROPERTY_KEY = 'doxaRegion'
const DEFAULT_COLOR = '#0098FF' // Bright blue for null/undefined region
const FALLBACK_COLOR = '#95a5a6'
/**
* DOXA Region Color Palette — defines colors for the 8 DOXA regions.
*/
export const PALETTE = {
'Africa': '#ff2e2e',
'Asia': '#1a8cff',
'Europe': '#1ee05f',
'Latin America & Caribbean': '#FFEB3B',
'Middle East': '#a83dff',
'No WAGF Region/Bloc': '#1a1a2e',
'North America & Non-Spanish Caribbean': '#00e0bd',
'Oceania': '#ff7f11'
}
export const DOXA_REGION_COLORS = PALETTE
/**
* SLUG-keyed palette — the AUTHORITATIVE map for coloring.
*
* The pray-tools API returns wagf_region as { value: <slug>, label: <localized> }.
* DSM stores the stable slug in properties.doxaRegion (e.g. 'asia', 'middle_east').
* The label is localized per `lang` ("Africa" → "África" → "Afrique"), so keying
* colors on the display name silently breaks in every non-English locale (only
* "Asia" — spelled identically across es/fr — survives). Key on the slug instead.
* Slugs verified against the live API (2026-06-22).
*/
export const SLUG_PALETTE = {
'africa': '#ff2e2e',
'asia': '#1a8cff',
'europe': '#1ee05f',
'latin_america_&_caribbean': '#FFEB3B',
'middle_east': '#a83dff',
'north_america_&_non-spanish_caribbean': '#00e0bd',
'oceania': '#ff7f11',
'na': '#1a1a2e' // No WAGF Region/Bloc
}
/** Color for a region by its stable slug (locale-independent). */
export function getRegionColorBySlug(slug) {
if (!slug) return DEFAULT_COLOR
return SLUG_PALETTE[String(slug).toLowerCase()] || FALLBACK_COLOR
}
/** Extended region aliases for flexible matching */
export const REGION_COLOR_ALIASES = {
'Africa': '#ff2e2e',
'East Asia': '#1a8cff',
'Asia': '#1a8cff',
'Eurasia': '#1ee05f',
'Europe': '#1ee05f',
'Latin America': '#FFEB3B',
'Latin America & Caribbean': '#FFEB3B',
'Caribbean': '#FFEB3B',
'Middle East': '#a83dff',
'North America': '#00e0bd',
'North America (non-spanish)': '#00e0bd',
'North America & Non-Spanish Caribbean': '#00e0bd',
'Pacific': '#ff7f11',
'Oceania': '#ff7f11',
'South Asia': '#ff7f11',
'No WAGF Region/Bloc': '#1a1a2e',
'Unknown': '#0098FF'
}
/**
* Get color for a region — falls back to a deterministic
* generateColorFromString() so unmapped regions still render.
*/
export function getRegionColor(regionName) {
if (!regionName) {
return DEFAULT_COLOR
}
const color = REGION_COLOR_ALIASES[regionName]
if (!color) {
return generateColorFromString(regionName)
}
return color
}
export function getColor(properties) {
// properties.doxaRegion is the stable slug (e.g. 'asia'), NOT the localized
// label — color by slug so it survives language switches.
const slug = properties.doxaRegion || properties._normalized?.doxaRegion
return getRegionColorBySlug(slug)
}
/**
* Build Mapbox color expression for DOXA regions.
*
* Matches on the stable slug stored in `doxaRegion` (e.g. 'asia') so the
* expression keeps coloring correctly when the API returns localized labels.
*/
export function applyColor() {
const colorExpression = ['match', ['get', PROPERTY_KEY]]
Object.entries(SLUG_PALETTE).forEach(([slug, color]) => {
colorExpression.push(slug, color)
})
colorExpression.push(FALLBACK_COLOR)
return colorExpression
}
export const buildColorExpression = applyColor
export default {
name: 'DOXA Region',
propertyKey: PROPERTY_KEY,
palette: PALETTE,
colors: PALETTE,
slugColors: SLUG_PALETTE,
aliases: REGION_COLOR_ALIASES,
fallback: FALLBACK_COLOR,
getColor,
applyColor,
buildColorExpression,
getRegionColor,
getRegionColorBySlug
}

View file

@ -25,8 +25,13 @@ import ProfileLoader from '@map/ProfileLoader.vue'
import { createAppI18n } from '@map/i18n/index.js'
import { registerStrategies } from '@map/colors/_registry.js'
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
import affinityBlock from '../doxa-research-map/src/colors/affinity-block.js'
import doxaRegion from '../doxa-research-map/src/colors/doxa-region.js'
// ★ WAS A CROSS-PLUGIN RELATIVE IMPORT into another plugin's PRIVATE folder
// ('../doxa-research-map/src/colors/...'). Both modules now live in library/colors/ (C1), so this
// bundle no longer depends on doxa-research-map's internal layout -- which is the coupling the
// library exists to remove, and the reason this migration had four consumers rather than the two
// the spec counted.
import affinityBlock from '@map/colors/affinity-block.js'
import doxaRegion from '@map/colors/doxa-region.js'
// ─── Bundle-private profile registry ─────────────────────────────────────────
const profileModules = import.meta.glob('./*.vue')