The region card becomes a shared component the plugin consumes

The Driver's ask, and his negotiating point with the DX-life author depends on it
being true: the components cloned from their /regions page must be REUSABLE,
PARAMETERIZED components in the shared library, not private to one plugin. He named
the failure mode himself — someone opens the regions plugin folder and finds the
components are not reusable after all. I checked before building, and for the UI he
named he was right: only the DATA and LOGIC had been promoted.

So library/components/regions/RegionSectionCard.vue takes title, summary, countries
and footerLabel, emits what was clicked, and knows nothing about where regions come
from. The plugin version fetched its own data and owned the layout, which is exactly
what made it unusable anywhere else.

THE DESIGN TRAVELS WITH IT. The card carries the region-card, countries-grid and
country-link CSS lifted unchanged — including the RTL chevron rules, where a
"forward" chevron must mirror in Arabic while a disclosure caret must not, and where
[dir="rtl"] .region-card__icon at 0-2-0 would otherwise beat the 0-1-0 --open
modifier. A consumer gets the look, not a lookalike. It needs dir on an ancestor for
that mirroring, which is documented in the file as a requirement.

★ THE CARD OWNS THE DISCLOSURE ROW, after the first cut got it wrong. I put the
"View country posters" toggle in the consumer's slot — and immediately had to copy
the footer-link styling and the rotating caret back into the plugin, re-creating the
duplication this extraction exists to remove. A disclosure inside a card is a card
feature; only the revealed CONTENT is the consumer's, so PosterWall stays in the
slot with its fourteen props and does not become every consumer's API.

★ THE CHEVRON IS INLINE, NOT A <use href="#id">. The plugin declared the symbol once
and every card referenced it. A shadow-root id is scoped to that root, so the same
card mounted elsewhere would reference a symbol that does not exist — and an
unresolved <use> renders NOTHING, silently. The plugin's own comment warns about
that class for external hrefs; a cross-root id fails the same way.

check:regions-uses-library is registered and asserts BOTH halves: the plugin imports
from @map/ and renders the component, AND no longer holds the markup or the CSS it
delegated. A reusability claim only a human can audit is one that rots; this one
fails the day someone re-inlines a copy. Two negative controls keep it honest — the
plugin still uses its own .rl-root classes, and the other tab is asserted NOT to use
the card yet, so the check describes what is extracted rather than what I hope for.

Two of my own mechanical edits produced valid JS and broken content on the way here,
both caught by checking rather than by the parser: a line-numbered splice that
dropped PosterWall's closing /> (fixed by locating boundaries by CONTENT), and a
line-filtered CSS deletion that left 9 orphaned declarations and braces at 12/21
(fixed by removing rules brace-aware, then re-validating the concatenated stylesheet
rather than trusting check:sfc-parse, which only proves the JS parses).
This commit is contained in:
Doxa Maps 2026-09-27 02:38:53 +00:00
parent 3f4ad2d85c
commit cf8cc929c8
5 changed files with 356 additions and 233 deletions

View file

@ -84,6 +84,8 @@ and a change here can break other maps.
`import … from '@map/components/poster/slots/TitleSlot.vue'`
- **QaCorner** — QaCorner.vue — the embeddable QA corner widget (bottom-right of any map).
`import … from '@map/components/qa/QaCorner.vue'`
- **RegionSectionCard** — RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
`import … from '@map/components/regions/RegionSectionCard.vue'`
- **FilterDropdown** — FilterDropdown.vue — SHARED (@map/components/search/FilterDropdown.vue).
`import … from '@map/components/search/FilterDropdown.vue'`
- **SearchBar** — SearchBar.vue — SHARED (@map/components/search/SearchBar.vue).

View file

@ -0,0 +1,275 @@
<script setup>
/**
* RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
* and a footer link. The DX-life /regions card, made reusable.
*
* WHY THIS IS IN library/. The Driver, verbatim: "let's say we want to reuse that on future plugins.
* We make it a reusable component, parameterized — you can set the region titles wherever you want
* them to be, fill in the countries to whatever you want them to be." The design is the point as much
* as the markup: the 156 lines of CSS below are lifted UNCHANGED from the regions plugin, so a caller
* gets the look, not a lookalike.
*
* ★ IT TAKES DATA, NOT AN API. The plugin version fetched its own regions and owned the layout, which
* is what made it unusable anywhere else. This takes `title`, `summary` and `countries` and emits what
* was clicked. Nothing here knows where regions come from.
*
* ★ THE CHEVRON IS INLINE ON PURPOSE, and this is the one thing a mover has to get right. The plugin
* declared <symbol id="chevron-up"> ONCE in its own template and every card referenced it with
* <use href="#chevron-up">. A shadow-root id is scoped to that root, so the same card mounted anywhere
* else would reference a symbol that does not exist — and an unresolved <use> renders NOTHING, with no
* error. The chevron would simply be missing. The plugin's own comment warns about exactly this class
* for external hrefs ("would 404 SILENTLY and the chevron would simply vanish"); a cross-root id
* reference fails the same way. So the path travels with the component.
*
* ★ RTL NEEDS AN ANCESTOR CARRYING dir, and that is a REQUIREMENT ON THE CONSUMER. The rules below
* include [dir="rtl"] .region-card__icon, ported because a chevron meaning "forward" must mirror in
* Arabic while a disclosure caret meaning "open/closed" must not — and the rtl --open rule is
* load-bearing, since [dir="rtl"] .region-card__icon is specificity 0-2-0 and would otherwise beat the
* 0-1-0 --open modifier and freeze the caret. The regions plugin satisfies this because .rl-root
* carries :dir itself. A new consumer must set dir on an ancestor inside its own shadow root, or the
* mirroring silently will not happen.
*
* ★ EXTRA CONTENT IS A SLOT, NOT A PROP. The regions plugin shows a country-poster wall under each
* card, configured with fourteen PosterWall props. Baking that in would have dragged one plugin's
* feature into every consumer's API. The `extra` slot keeps it where it belongs.
*/
import { useShadowStyles } from '../../composables/useShadowStyles.js'
const props = defineProps({
/** The region's display name. Already localized by the caller. */
title: { type: String, default: '' },
/** The line under the title, e.g. "12 countries · 480 groups". Caller-formatted. */
summary: { type: String, default: '' },
/** [{ name, count, slug }] — `count` is optional and rendered in parentheses when present. */
countries: { type: Array, default: () => [] },
/** The footer link's text, e.g. "View the region dashboard". Omit it to hide the footer. */
footerLabel: { type: String, default: '' },
/**
* OPTIONAL DISCLOSURE ROW, and the card owns it deliberately. The regions plugin needed a
* "View country posters" toggle under each card. The first cut put that row in the consumer's slot
* — which immediately meant copying this component's footer-link look and its rotating caret back
* into the plugin, i.e. re-creating the duplication this extraction exists to remove. A disclosure
* inside a card is a CARD feature; only the revealed CONTENT belongs to the consumer.
* Omit the label to hide the row entirely.
*/
disclosureLabel: { type: String, default: '' },
disclosureOpen: { type: Boolean, default: false },
/** id for aria-controls, so the button and the revealed region are really associated. */
disclosureId: { type: String, default: '' },
})
const emit = defineEmits(['select', 'select-country', 'toggle-disclosure'])
useShadowStyles(
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
// the dark header band supplies its own edge, unlike our old grey-outlined card.
'.region-card{ flex:0 0 auto; border-radius:var(--dx-radius); overflow:hidden; background:var(--dx-elevated); }' +
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) -- the clamp
// is KEPT as a clamp (var(--spacing-md) / var(--spacing-2xl) are the resolved ends), not frozen at its desktop maximum.
// gap = --spacing-2xs (7.5px), on --color-surface-brand.
'.region-card__header{ display:flex; flex-direction:column; gap:var(--spacing-2xs);' +
' padding:var(--spacing-md) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl));' +
' background:var(--dx-header-bg); color:var(--dx-header-text); }' +
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) = clamp(var(--font-size-xl),_,var(--font-size-3xl)).
// A phone gets the var(--font-size-xl) FLOOR, as theirs does; it only reaches var(--font-size-3xl) past ~1192px of width.
// DX-life's heading font (Bebas Neue -- a bold condensed display face), regular weight (the
// face itself carries the boldness), all-caps to match the source screenshot exactly.
// Bebas Neue itself is DX-life's own self-hosted font file (doxa.life/assets/fonts/BebasNeue/),
// not served with CORS -- cross-origin @font-face silently fails without it, so it cannot be
// loaded here without shipping a font asset this repo doesn't own. Approximated with the
// standard condensed-display-face fallback stack (bold weight + font-stretch:condensed + tight
// tracking), since Bebas Neue's glyphs read as heavy/compressed even at its nominal "400" weight.
// getComputedStyle() on the live card title reads weight 400 / line-height 1.0 (47.68px on a
// 47.69px font-size) / letter-spacing normal / text-transform none -- Bebas Neue's own glyphs
// are what read as bold+condensed+caps-like, DX-life applies NO transform on top of the font.
// Our fallback stack has no true Bebas-Neue substitute, so font-stretch:condensed stands in for
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
// exactly rather than compounding extra "boldness" the source doesn't have.
'.region-card__title{ margin:0; font-size:clamp(var(--font-size-xl), 4cqw, var(--font-size-3xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; letter-spacing:normal; }' +
// Their link is an <a class="region-card__link | with-icon">, so it carries their `a` rule's
// font-weight: var(--font-weight-medium) = 500 (_globals.scss:132). Ours is a <button>
// because this element has no router, and `font:inherit` gave it 400 — MEASURED against
// their live /regions. Same class of miss as the dashboard back-link.
// The icon spacing is theirs too: .with-icon is inline-flex with align-items:baseline and NO
// gap (composition/_icon.scss:12), and the 1rem separation comes from
// `.with-icon .icon.right { margin-inline-start: 1rem }` — 10px at their 10px root. We had
// the same 10px via `gap`, which looks identical but means their rule would not apply, so
// the mechanism is now theirs: margin on the icon, gap left alone.
// ★ text-align:START, not left. Theirs is a NuxtLink, an inline <a> that shrink-wraps to its
// text; ours must be a <button> because this element changes the embed's subject in place instead
// of navigating a route. A button's UA default is text-align:center, so the port overrode it -- but
// it overrode it with the PHYSICAL `left`, which is identical to `start` in English and its
// opposite in Arabic. MEASURED on live /ar/regions vs ours at dir=rtl: same 246.9px row and the
// same 102.7px of text, their label flush to the row's RIGHT edge (gap 0px, the RTL reading start)
// and ours flush LEFT -- every label stranded 144.2px on the wrong side. Invisible in English,
// which is exactly why it survived a 37-property card diff and an 11-property bullet diff.
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:start;' +
' cursor:pointer; color:inherit; font:inherit; font-weight:500;' +
' display:inline-flex; align-items:baseline; }' +
'.region-card__link:hover{ color:#92b195; }' +
'.region-card__link:focus-visible{ outline:2px solid #fff; outline-offset:2px; }' +
// .region-card__icon: DX-life's own <svg class="icon | rotate-90 right"><use href="arrow-
// chevron.svg#chevron-up"></use></svg> -- a REAL chevron path (viewBox 0 0 489.67 289.877),
// not a text glyph. Ported verbatim (fill:currentColor tracks the surrounding text colour,
// same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same
// as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page.
// ★ 1rem WAS THE ONE UNIT IN THIS PORT THAT INHERITS THE HOST PAGE'S ROOT, and it was wrong on
// our own leaf, not only in an embed. `rem` resolves against the DOCUMENT root wherever the
// element lives — which is the whole reason dxlife-tokens.js resolves their scale to px — and
// their root is 10px (html{font-size: var(--font-size-base)}, $font-size-base: 10px). Ours
// inherits whatever the host sets: 16px by default, so this gap rendered 16px against their
// 10px, and on a host with the common html{font-size:62.5%} reset it would render 10px by
// accident. Pinned to the value their root produces, like every other token here.
// No diff caught it: the list diff's `link` spec reads colour, decoration, weight, display and
// alignItems — no margin — so the icon's own spacing was never a measured property. It is now.
// (Credit where due: the class of bug came to me as a flock relay from another agent's sweep.)
'.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg);' +
' margin-inline-start:10px; }' +
// .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band.
'.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' +
// ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44:
// h1,h2,h3 { [lang="ru-RU"] & { font-family: var(--font-family-cyrillic); letter-spacing: -0.02em } }
// and --font-family-cyrillic is Oswald. The switch is not decoration: BEBAS NEUE HAS NO CYRILLIC
// GLYPHS, so a Russian heading in it leaves the family entirely and lands on whatever condensed
// system face the browser picks. We ship a `ru` locale and had neither the rule nor the font.
// ONLY the headings, exactly as theirs — their .region-stat__value is a <p> and keeps Bebas even in
// Russian, which is their behaviour and so is ours.
// BOTH TAG FORMS, deliberately: their site tags the document ru-RU while our i18n locale is `ru`,
// and a host page embedding this element may set either. A selector that matched only one would
// work on their site and fail on ours, or the reverse, depending on who set the attribute.
'[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,' +
'[lang="ru"] .region-card__title, [lang="ru-RU"] .region-card__title{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl)
'.region-card__body{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-xl); }' +
// .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl
// (10px / var(--spacing-xl)). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
// page's global `ul` reset (bullets kept, not list-style:none), so the whole grid is indented
// one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly.
// Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list
// bullet has somewhere to sit without stealing width from the text column.
// padding-INLINE-start, as theirs is (1.8em). The physical `padding-left:35.2px` it replaces
// put the indent on the wrong side in Arabic, where the bullets hang off the right edge.
'.countries-grid{ list-style:none; margin:0; padding-inline-start:1.8em; display:grid;' +
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px var(--spacing-xl); }' +
// Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's
// <NuxtLink> text does -- a flex row with align-items:baseline pinned the bullet to the first
// line only and stranded the (count) span beside the second line on wrap. font-weight:500
// matches getComputedStyle() on the live country links (theirs is medium, not regular).
// Their country entries are bare <NuxtLink>s inside .countries-grid li — no class of their
// own — so they take their `a` rule: color var(--color-brand-light) = $color-green-400 =
// #4e594f, font-weight medium. MEASURED on their live /regions: rgb(78, 89, 79). Ours used
// var(--dx-text), which resolves to rgb(42, 51, 42) — the body ink, not their link ink.
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0;' +
' text-align:start; cursor:pointer; color:#4e594f; font:inherit; font-size:var(--font-size-md);' +
' font-weight:500; width:100%; position:relative; }' +
// inset-inline-start, NOT left: the bullet hangs off the text's starting edge, which is the
// RIGHT edge in Arabic. With `left:-16px` it stayed on the physical left in RTL — measured
// at locale=ar, computed right was 256.938px instead of the -16px near-edge overhang.
// ★ THEIR BULLET, COPIED — not a dot of my own. base/_globals.scss:117-131 paints the list
// marker with a 16x16 data-URI SVG circle in #92b195 sized to 1.5em, hung off the text's start
// edge by margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9. Their
// .countries-grid comment is explicit that keeping it is deliberate ("Keeps the site's list
// bullets (global ul padding + ::before dot)").
// I had re-created it as a 6px border-radius:50% square, and MEASURED against live it was wrong
// in 9 of 11 properties: 6px vs 29.2969px box, solid fill vs a transparent box carrying the
// image, top 11.72px vs 5.86px, and positioned with inset-inline-start:-16px instead of their
// negative margin. Not a near-miss — a different bullet.
// The rule is a double-quoted JS string because the data URI contains single quotes.
".country-link::before{ content:''; position:absolute; top:0.3em; width:1.5em; height:1.5em;" +
" line-height:0.9; margin-inline-start:-1.3em; background-repeat:no-repeat;" +
" background-image:url(\"data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='%2392b195' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='7' stroke='%2392b195' stroke-width='1.5'/%3E%3C/svg%3E\"); }" +
// Their RTL variant. It is a separate rule here rather than a nested [dir="rtl"] & because this
// CSS lives in a shadow root, where a [dir] selector on an outer ancestor does not reach in.
'[dir="rtl"] .country-link::before{ margin-inline-start:-2em; }' +
'.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' +
'.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' +
'.country-link__count{ color:var(--dx-muted); }' +
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (var(--spacing-lg)),
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~var(--font-size-md) at
// this font-size) was leaking through because only margin-top was ever set -- pin margin:0
// first, then apply margin-top, so the bottom UA default can't survive the cascade.
// The toggle row and the wall it opens. The caret rotates rather than swapping glyphs,
// which is what makes open/closed legible at a glance.
'.region-card__posters-toggle{ margin-top:0; }' +
'.region-card__icon--open{ transform:rotate(180deg); }' +
// RTL MIRRORING, ported from their utilities/_shapes.scss:
// .rotate-90 { transform:rotate(90deg); [dir="rtl"] & { transform:rotate(-90deg); } }
// Their .rotate-180 has NO rtl variant, and that distinction is the whole rule: a chevron that
// means "forward" must mirror, a disclosure caret that means "open/closed" must not. MEASURED
// on live doxa.life: .region-card__link svg is matrix(0,1,-1,0,0,0) at /regions and
// matrix(0,-1,1,0,0,0) at /ar/regions — ours stayed at +90deg in Arabic, so the chevron sat on
// the correct side pointing the WRONG WAY. Found by looking at the Arabic screenshot.
// The selector works inside our shadow root because .rl-root carries :dir itself.
'[dir="rtl"] .region-card__icon{ transform:rotate(-90deg); }' +
// ...and this one is load-bearing: [dir="rtl"] .region-card__icon is specificity 0-2-0, which
// would otherwise beat the 0-1-0 --open modifier and freeze the poster caret in Arabic.
'[dir="rtl"] .region-card__icon--open{ transform:rotate(180deg); }' +
'.region-card__posters{ margin-top:13.3px; }' +
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:var(--spacing-lg);' +
' padding:0 clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-lg); }' +
'.region-card__footer-link{ appearance:none; background:none; border:0; padding:0; margin:0;' +
' cursor:pointer; font:inherit; font-size:var(--font-size-md); font-weight:500; color:var(--dx-link);' +
' display:inline-flex; align-items:baseline; gap:10px; text-decoration:underline; text-decoration-thickness:2px; }' +
'.region-card__footer-link:hover{ color:var(--dx-header-bg); }' +
'.region-card__footer-link:focus-visible{ outline:2px solid var(--dx-link); outline-offset:2px; border-radius:4px; }',
'regions-list'
, 'region-section-card')
</script>
<template>
<div class="region-card">
<div class="region-card__header">
<h2 class="region-card__title">
<button type="button" class="region-card__link" @click="emit('select')">
{{ title }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</h2>
<p v-if="summary" class="region-card__summary">{{ summary }}</p>
</div>
<div class="region-card__body">
<ul class="countries-grid">
<li v-for="c in countries" :key="c.slug || c.name">
<button type="button" class="country-link" @click="emit('select-country', c)">
{{ c.name }}<span v-if="c.count != null" class="country-link__count"> ({{ c.count }})</span>
</button>
</li>
</ul>
</div>
<p v-if="footerLabel" class="region-card__footer">
<button type="button" class="region-card__footer-link" @click="emit('select')">
{{ footerLabel }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</p>
<p v-if="disclosureLabel" class="region-card__footer region-card__posters-toggle">
<button
type="button"
class="region-card__footer-link"
:aria-expanded="disclosureOpen ? 'true' : 'false'"
:aria-controls="disclosureId || undefined"
@click="emit('toggle-disclosure')"
>
{{ disclosureLabel }}
<svg class="region-card__icon" :class="{ 'region-card__icon--open': disclosureOpen }" viewBox="0 0 489.67 289.877" aria-hidden="true">
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-10.323,10.323-10.323,27.056,0,37.379,10.323,10.323,27.056,10.323,37.379,0l161.068-161.068,161.068,161.068c10.323,10.323,27.056,10.323,37.379,0,10.277-10.324,10.277-27.011-.238-37.333Z"/>
</svg>
</button>
</p>
<!-- Only the revealed CONTENT is the consumer's. The regions plugin puts its PosterWall here. -->
<div v-if="disclosureOpen" :id="disclosureId || undefined" class="region-card__posters">
<slot name="extra" />
</div>
</div>
</template>

View file

@ -194,6 +194,7 @@
"check:script-flags": "node internal/check-script-flags-reachable.mjs",
"check:settled": "node internal/settled-tree-gate.mjs",
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
"check:regions-uses-library": "node plugin-designs/regions-dashboard/scripts/check-regions-uses-library.mjs",
"check:religion-label-not-value": "node plugin-designs/regions-dashboard/scripts/check-religion-label-not-value.mjs",
"check:share-policy": "node internal/check-share-policy.mjs",
"check:shared-reached": "node internal/check-shared-component-reached-bundles.mjs",
@ -231,7 +232,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:regions-uses-library",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",

View file

@ -65,6 +65,7 @@ import { DXLIFE_TOKENS } from './dxlife-tokens.js'
// labour country-poster-wall.vue states in its own header ("this profile supplies only its
// data and taxonomy").
import PosterWall from '@map/components/poster/PosterWall.vue'
import RegionSectionCard from '@map/components/regions/RegionSectionCard.vue'
import posterData from '@map/data/country-posters.json'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { onBeforeUnmount } from 'vue'
@ -426,74 +427,6 @@ useShadowStyles(
'.rl-summary{ margin:0; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status{ padding:var(--spacing-xl); text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
'.rl-status.rl-failed{ font-weight:600; }' +
// .region-card: DX-life's --border-radius-xlg (35px), overflow hidden, white body, no border --
// the dark header band supplies its own edge, unlike our old grey-outlined card.
'.region-card{ flex:0 0 auto; border-radius:var(--dx-radius); overflow:hidden; background:var(--dx-elevated); }' +
// .region-card__header: padding = --spacing-md / clamp(--spacing-md,4vw,--spacing-2xl) -- the clamp
// is KEPT as a clamp (var(--spacing-md) / var(--spacing-2xl) are the resolved ends), not frozen at its desktop maximum.
// gap = --spacing-2xs (7.5px), on --color-surface-brand.
'.region-card__header{ display:flex; flex-direction:column; gap:var(--spacing-2xs);' +
' padding:var(--spacing-md) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl));' +
' background:var(--dx-header-bg); color:var(--dx-header-text); }' +
// .region-card__title: font-size clamp(--font-size-xl,4vw,--font-size-3xl) = clamp(var(--font-size-xl),_,var(--font-size-3xl)).
// A phone gets the var(--font-size-xl) FLOOR, as theirs does; it only reaches var(--font-size-3xl) past ~1192px of width.
// DX-life's heading font (Bebas Neue -- a bold condensed display face), regular weight (the
// face itself carries the boldness), all-caps to match the source screenshot exactly.
// Bebas Neue itself is DX-life's own self-hosted font file (doxa.life/assets/fonts/BebasNeue/),
// not served with CORS -- cross-origin @font-face silently fails without it, so it cannot be
// loaded here without shipping a font asset this repo doesn't own. Approximated with the
// standard condensed-display-face fallback stack (bold weight + font-stretch:condensed + tight
// tracking), since Bebas Neue's glyphs read as heavy/compressed even at its nominal "400" weight.
// getComputedStyle() on the live card title reads weight 400 / line-height 1.0 (47.68px on a
// 47.69px font-size) / letter-spacing normal / text-transform none -- Bebas Neue's own glyphs
// are what read as bold+condensed+caps-like, DX-life applies NO transform on top of the font.
// Our fallback stack has no true Bebas-Neue substitute, so font-stretch:condensed stands in for
// the face's natural narrowness, but weight/leading/tracking/case now match the measured values
// exactly rather than compounding extra "boldness" the source doesn't have.
'.region-card__title{ margin:0; font-size:clamp(var(--font-size-xl), 4cqw, var(--font-size-3xl)); line-height:1;' +
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' font-weight:400; letter-spacing:normal; }' +
// Their link is an <a class="region-card__link | with-icon">, so it carries their `a` rule's
// font-weight: var(--font-weight-medium) = 500 (_globals.scss:132). Ours is a <button>
// because this element has no router, and `font:inherit` gave it 400 — MEASURED against
// their live /regions. Same class of miss as the dashboard back-link.
// The icon spacing is theirs too: .with-icon is inline-flex with align-items:baseline and NO
// gap (composition/_icon.scss:12), and the 1rem separation comes from
// `.with-icon .icon.right { margin-inline-start: 1rem }` — 10px at their 10px root. We had
// the same 10px via `gap`, which looks identical but means their rule would not apply, so
// the mechanism is now theirs: margin on the icon, gap left alone.
// ★ text-align:START, not left. Theirs is a NuxtLink, an inline <a> that shrink-wraps to its
// text; ours must be a <button> because this element changes the embed's subject in place instead
// of navigating a route. A button's UA default is text-align:center, so the port overrode it -- but
// it overrode it with the PHYSICAL `left`, which is identical to `start` in English and its
// opposite in Arabic. MEASURED on live /ar/regions vs ours at dir=rtl: same 246.9px row and the
// same 102.7px of text, their label flush to the row's RIGHT edge (gap 0px, the RTL reading start)
// and ours flush LEFT -- every label stranded 144.2px on the wrong side. Invisible in English,
// which is exactly why it survived a 37-property card diff and an 11-property bullet diff.
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:start;' +
' cursor:pointer; color:inherit; font:inherit; font-weight:500;' +
' display:inline-flex; align-items:baseline; }' +
'.region-card__link:hover{ color:#92b195; }' +
'.region-card__link:focus-visible{ outline:2px solid #fff; outline-offset:2px; }' +
// .region-card__icon: DX-life's own <svg class="icon | rotate-90 right"><use href="arrow-
// chevron.svg#chevron-up"></use></svg> -- a REAL chevron path (viewBox 0 0 489.67 289.877),
// not a text glyph. Ported verbatim (fill:currentColor tracks the surrounding text colour,
// same as their `.icon{ fill:currentColor }` utility) and rotated 90deg to point right, same
// as their `rotate-90` class. getComputedStyle measured it at 33.38px square on the live page.
// ★ 1rem WAS THE ONE UNIT IN THIS PORT THAT INHERITS THE HOST PAGE'S ROOT, and it was wrong on
// our own leaf, not only in an embed. `rem` resolves against the DOCUMENT root wherever the
// element lives — which is the whole reason dxlife-tokens.js resolves their scale to px — and
// their root is 10px (html{font-size: var(--font-size-base)}, $font-size-base: 10px). Ours
// inherits whatever the host sets: 16px by default, so this gap rendered 16px against their
// 10px, and on a host with the common html{font-size:62.5%} reset it would render 10px by
// accident. Pinned to the value their root produces, like every other token here.
// No diff caught it: the list diff's `link` spec reads colour, decoration, weight, display and
// alignItems — no margin — so the icon's own spacing was never a measured property. It is now.
// (Credit where due: the class of bug came to me as a flock relay from another agent's sweep.)
'.region-card__icon{ width:0.7em; height:0.7em; flex:none; fill:currentColor; transform:rotate(90deg);' +
' margin-inline-start:10px; }' +
// .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band.
'.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' +
// ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44:
// h1,h2,h3 { [lang="ru-RU"] & { font-family: var(--font-family-cyrillic); letter-spacing: -0.02em } }
// and --font-family-cyrillic is Oswald. The switch is not decoration: BEBAS NEUE HAS NO CYRILLIC
@ -505,81 +438,6 @@ useShadowStyles(
// and a host page embedding this element may set either. A selector that matched only one would
// work on their site and fail on ours, or the reverse, depending on who set the attribute.
'[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,' +
'[lang="ru"] .region-card__title, [lang="ru-RU"] .region-card__title{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
' letter-spacing:-0.02em; }' +
// .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl)
'.region-card__body{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-xl); }' +
// .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl
// (10px / var(--spacing-xl)). getComputedStyle on the live grid shows padding-left:35.16px -- that's the
// page's global `ul` reset (bullets kept, not list-style:none), so the whole grid is indented
// one list-item's worth even though DX-life's countries-grid a rule doesn't set it directly.
// Reproduced with list-style:none + the same 35.16px indent kept explicitly so a real list
// bullet has somewhere to sit without stealing width from the text column.
// padding-INLINE-start, as theirs is (1.8em). The physical `padding-left:35.2px` it replaces
// put the indent on the wrong side in Arabic, where the bullets hang off the right edge.
'.countries-grid{ list-style:none; margin:0; padding-inline-start:1.8em; display:grid;' +
' grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:10px var(--spacing-xl); }' +
// Plain inline text (not a flex row) so multi-line country names wrap the same way DX-life's
// <NuxtLink> text does -- a flex row with align-items:baseline pinned the bullet to the first
// line only and stranded the (count) span beside the second line on wrap. font-weight:500
// matches getComputedStyle() on the live country links (theirs is medium, not regular).
// Their country entries are bare <NuxtLink>s inside .countries-grid li — no class of their
// own — so they take their `a` rule: color var(--color-brand-light) = $color-green-400 =
// #4e594f, font-weight medium. MEASURED on their live /regions: rgb(78, 89, 79). Ours used
// var(--dx-text), which resolves to rgb(42, 51, 42) — the body ink, not their link ink.
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0;' +
' text-align:start; cursor:pointer; color:#4e594f; font:inherit; font-size:var(--font-size-md);' +
' font-weight:500; width:100%; position:relative; }' +
// inset-inline-start, NOT left: the bullet hangs off the text's starting edge, which is the
// RIGHT edge in Arabic. With `left:-16px` it stayed on the physical left in RTL — measured
// at locale=ar, computed right was 256.938px instead of the -16px near-edge overhang.
// ★ THEIR BULLET, COPIED — not a dot of my own. base/_globals.scss:117-131 paints the list
// marker with a 16x16 data-URI SVG circle in #92b195 sized to 1.5em, hung off the text's start
// edge by margin-inline-start:-1.3em (-2em in RTL), top .3em, line-height .9. Their
// .countries-grid comment is explicit that keeping it is deliberate ("Keeps the site's list
// bullets (global ul padding + ::before dot)").
// I had re-created it as a 6px border-radius:50% square, and MEASURED against live it was wrong
// in 9 of 11 properties: 6px vs 29.2969px box, solid fill vs a transparent box carrying the
// image, top 11.72px vs 5.86px, and positioned with inset-inline-start:-16px instead of their
// negative margin. Not a near-miss — a different bullet.
// The rule is a double-quoted JS string because the data URI contains single quotes.
".country-link::before{ content:''; position:absolute; top:0.3em; width:1.5em; height:1.5em;" +
" line-height:0.9; margin-inline-start:-1.3em; background-repeat:no-repeat;" +
" background-image:url(\"data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='%2392b195' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='7' stroke='%2392b195' stroke-width='1.5'/%3E%3C/svg%3E\"); }" +
// Their RTL variant. It is a separate rule here rather than a nested [dir="rtl"] & because this
// CSS lives in a shadow root, where a [dir] selector on an outer ancestor does not reach in.
'[dir="rtl"] .country-link::before{ margin-inline-start:-2em; }' +
'.country-link:hover{ text-decoration:underline; text-decoration-color:#92b195; }' +
'.country-link:focus-visible{ outline:2px solid #92b195; outline-offset:2px; border-radius:4px; }' +
'.country-link__count{ color:var(--dx-muted); }' +
// .region-card__footer: DX-life's exact rule -- flex, flex-end, margin-top --spacing-lg (var(--spacing-lg)),
// NO margin-bottom. This element is a <p>; its user-agent default margin-block-end (~var(--font-size-md) at
// this font-size) was leaking through because only margin-top was ever set -- pin margin:0
// first, then apply margin-top, so the bottom UA default can't survive the cascade.
// The toggle row and the wall it opens. The caret rotates rather than swapping glyphs,
// which is what makes open/closed legible at a glance.
'.region-card__posters-toggle{ margin-top:0; }' +
'.region-card__icon--open{ transform:rotate(180deg); }' +
// RTL MIRRORING, ported from their utilities/_shapes.scss:
// .rotate-90 { transform:rotate(90deg); [dir="rtl"] & { transform:rotate(-90deg); } }
// Their .rotate-180 has NO rtl variant, and that distinction is the whole rule: a chevron that
// means "forward" must mirror, a disclosure caret that means "open/closed" must not. MEASURED
// on live doxa.life: .region-card__link svg is matrix(0,1,-1,0,0,0) at /regions and
// matrix(0,-1,1,0,0,0) at /ar/regions — ours stayed at +90deg in Arabic, so the chevron sat on
// the correct side pointing the WRONG WAY. Found by looking at the Arabic screenshot.
// The selector works inside our shadow root because .rl-root carries :dir itself.
'[dir="rtl"] .region-card__icon{ transform:rotate(-90deg); }' +
// ...and this one is load-bearing: [dir="rtl"] .region-card__icon is specificity 0-2-0, which
// would otherwise beat the 0-1-0 --open modifier and freeze the poster caret in Arabic.
'[dir="rtl"] .region-card__icon--open{ transform:rotate(180deg); }' +
'.region-card__posters{ margin-top:13.3px; }' +
'.region-card__footer{ display:flex; justify-content:flex-end; margin:0; margin-top:var(--spacing-lg);' +
' padding:0 clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-lg); }' +
'.region-card__footer-link{ appearance:none; background:none; border:0; padding:0; margin:0;' +
' cursor:pointer; font:inherit; font-size:var(--font-size-md); font-weight:500; color:var(--dx-link);' +
' display:inline-flex; align-items:baseline; gap:10px; text-decoration:underline; text-decoration-thickness:2px; }' +
'.region-card__footer-link:hover{ color:var(--dx-header-bg); }' +
'.region-card__footer-link:focus-visible{ outline:2px solid var(--dx-link); outline-offset:2px; border-radius:4px; }',
'regions-list'
)
</script>
@ -625,96 +483,21 @@ useShadowStyles(
<div v-else-if="!regions.length" class="rl-status">{{ t('No regions found.') }}</div>
<template v-else>
<div
<RegionSectionCard
v-for="region in regions"
:key="region.slug"
class="region-card"
:title="region.label || 'Other'"
:summary="summaryLine(region)"
:countries="region.countries"
:footer-label="dashboardLink(region)"
:disclosure-label="postersOpen(region) ? t('Hide country posters') : t('View country posters')"
:disclosure-open="postersOpen(region)"
:disclosure-id="`posters-${region.slug}`"
@toggle-disclosure="togglePosters(region)"
@select="selectRegion(region)"
@select-country="(c) => selectCountry(c, region)"
>
<div class="region-card__header">
<h2 class="region-card__title">
<button
type="button"
class="region-card__link"
@click="selectRegion(region)"
>
{{ region.label || 'Other' }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<use href="#chevron-up" />
</svg>
</button>
</h2>
<p class="region-card__summary">{{ summaryLine(region) }}</p>
</div>
<div class="region-card__body">
<ul class="countries-grid">
<li v-for="c in region.countries" :key="c.slug">
<button
type="button"
class="country-link"
@click="selectCountry(c, region)"
>
{{ c.name }} <span class="country-link__count">({{ c.count }})</span>
</button>
</li>
</ul>
</div>
<p class="region-card__footer">
<button
type="button"
class="region-card__footer-link"
@click="selectRegion(region)"
>
{{ dashboardLink(region) }}
<svg class="region-card__icon" viewBox="0 0 489.67 289.877" aria-hidden="true">
<use href="#chevron-up" />
</svg>
</button>
</p>
<!-- THE POSTER TOGGLE — Driver 2026-09-24: "a toggle or carrot added to the bottom
of the region section that says view country posters, which will let you view
country posters for each of the countries in the regions". The caret is the
same SVG the header and footer links already use, rotated when open, so the
control reads as part of this card rather than bolted on. aria-expanded and
aria-controls are what make it a real disclosure for a screen reader. -->
<p class="region-card__footer region-card__posters-toggle">
<button
type="button"
class="region-card__footer-link"
:aria-expanded="postersOpen(region) ? 'true' : 'false'"
:aria-controls="`posters-${region.slug}`"
@click="togglePosters(region)"
>
{{ postersOpen(region) ? t('Hide country posters') : t('View country posters') }}
<svg
class="region-card__icon"
:class="{ 'region-card__icon--open': postersOpen(region) }"
viewBox="0 0 489.67 289.877"
aria-hidden="true"
>
<use href="#chevron-up" />
</svg>
</button>
</p>
<!-- The wall itself: the SHARED component, given only this region's countries. No
second level of collapsing (we are already inside one region) and no grouping,
hence posterGroupBy returning ''. -->
<div v-if="postersOpen(region)" :id="`posters-${region.slug}`" class="region-card__posters">
<!-- poster-action="print" — PRINT IN PLACE, not download (Driver card 2026-09-24).
PosterWall's posterAction prop defaults to 'download'; UPG-100 passes "print"
and the print UX here must be identical. deliverPoster() branches on that one
prop and routes BOTH the shipped-.webp and the browser-rasterised path through
openPrintFrame, so this is configuration — not a second implementation. -->
<!-- ★ TWO PER ROW, AND A KNOB. The Driver, verbatim: "i need two posters per row on
the regiosn posters and witha setitn got icnreat eh number of poswers per row".
The wall's own default is an auto-fill grid at minmax(210px,1fr), which lays out
five across a region card; two is a deliberate, larger card, and the control lets
a reader put it back.
Both props are OPT-IN on the shared component, so country-posters and
my-upg-100-list keep the auto-fill they have always had.
The comment sits ABOVE the tag because an HTML comment inside an attribute list is
a parse error — which is how the first attempt at this failed. -->
<template #extra>
<PosterWall
:items="postersFor(region)"
:group-by="posterGroupBy"
@ -736,8 +519,8 @@ useShadowStyles(
poster-action="print"
prayer-list-soon-message="Coming soon — a prayer list will be generated for each country, naming every unengaged people group within it."
/>
</div>
</div>
</template>
</RegionSectionCard>
</template>
</div>
</div>

View file

@ -0,0 +1,62 @@
#!/usr/bin/env node
/* check-regions-uses-library.mjs — `bun run check:regions-uses-library`. Source-only, headless.
*
* THE DRIVER'S POINT, and the reason this check exists rather than a note: his negotiating position
* with the DX-life author is that cloning their /regions page into this bundler MADE ITS COMPONENTS
* REUSABLE for every future map plugin. He named the failure mode himself — "a load bearing assumption
* that could be proven wrong by going into the private plugin folder for the Regions plugin and seeing
* that actually the components are not reusable".
*
* So this asserts the thing that can be checked by opening the folder: the regions plugin must USE the
* shared components, and must NOT hold its own copy of what it uses. A claim about reusability that
* only a human can audit is a claim that rots; this one fails on the day someone re-inlines a copy.
*/
import { readFileSync, existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
let fails = 0
const ok = (c, m) => { console.log(` ${c ? 'ok ' : 'FAIL'} - ${m}`); if (!c) fails++ }
const read = (p) => readFileSync(resolve(ROOT, p), 'utf8')
// area, library file, the plugin file that must consume it, the classes the library file now owns
const EXTRACTED = [
{
lib: 'library/components/regions/RegionSectionCard.vue',
consumer: 'plugin-designs/regions-dashboard/regions-list.vue',
tag: 'RegionSectionCard',
owns: ['region-card', 'countries-grid', 'country-link'],
},
]
for (const e of EXTRACTED) {
const name = e.lib.split('/').pop()
ok(existsSync(resolve(ROOT, e.lib)), `${name} exists in library/`)
const plugin = read(e.consumer)
ok(plugin.includes(`@map/components/regions/${name}`), `the plugin IMPORTS it from @map/, not a local path`)
ok(new RegExp(`<${e.tag}[\\s>]`).test(plugin), `and actually RENDERS <${e.tag}>`)
// the half that makes it real: the plugin must no longer own what it delegated.
const tpl = plugin.slice(plugin.indexOf('<template>'))
for (const cls of e.owns) {
ok(!new RegExp(`class="[^"]*\\b${cls}\\b`).test(tpl),
`the plugin's own template no longer uses .${cls} — the library component owns that markup`)
ok(!new RegExp(`'\\.${cls}[{ ]`).test(plugin),
`and no longer ships CSS for .${cls} — one copy, in the library component`)
}
// ...and the library component must actually carry the design, or "reusable" means markup only.
const lib = read(e.lib)
for (const cls of e.owns) {
ok(new RegExp(`'\\.${cls}[{ ]`).test(lib), `${name} DOES carry .${cls} styling, so a consumer gets the look`)
}
}
// NEG CONTROLS: each assertion must be able to fail.
ok(/class="[^"]*\brl-root\b/.test(read('plugin-designs/regions-dashboard/regions-list.vue')),
'NEG CONTROL: the plugin still uses its OWN classes (.rl-root), so the "no longer uses" checks above are not vacuously true')
ok(!/RegionSectionCard/.test(read('plugin-designs/regions-dashboard/region-dashboards.vue')),
'CONTROL: the OTHER tab does not use the card yet — this check is about what is extracted, not a wish')
console.log(fails ? `\nREGIONS-USES-LIBRARY: ${fails} FAIL(s)` : '\nREGIONS-USES-LIBRARY: the plugin consumes the shared component and keeps no copy of it')
process.exit(fails ? 1 : 0)