The prayer-progress cards become shared components too
The three goal cards, the stat row and their bars were the regions
dashboard's own markup. They are library components now, and the plugin
renders them instead of holding a copy:
GoalCard title + figure + a goal line pinned to the card's bottom,
with a slot for whatever sits between
StatCard one figure and its caption
ProgressBar a single-value track, or a segmented one with a legend
useCardSurface the card surface both cards share, injected once per
root under a stable label instead of copied into each
The bar and its legend now read from ONE list of segments, so a width and
the number printed beside it come from the same figure and cannot drift
apart; before, each was computed separately. A segment's colour is a prop
carrying the same token its .rd-seg--full/--partial/--none class used to
set, so a shared bar needs no vocabulary about prayer and those three
rules are deleted rather than left unreachable.
ProgressBar carries its own reduced-motion guard. The plugin's reset is
scoped to .rd-root and would not have followed the component anywhere
else, which would have reintroduced the one-second sliding bar their own
site suppresses.
The rendered DOM is unchanged for all four instances, measured by
server-rendering the real components and comparing against the markup
that was deleted, including that the bar widths equal the percentOfSubject()
they replace.
check:regions-uses-library grows to cover all four, asserting both halves
again: the plugin imports and renders them, and no longer ships their
markup or CSS. The .rd-root strong rule STAYS in the plugin -- UupgsList
renders a <strong> in the same shadow root and depends on it, which a
file-scoped read would have called dead.
This commit is contained in:
parent
cf8cc929c8
commit
d96c105ca9
7 changed files with 540 additions and 158 deletions
|
|
@ -26,6 +26,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/components/ViewSelect.vue'`
|
||||
- **BarChart** — BarChart.vue — THE SHARED HORIZONTAL BAR CHART (hand-rolled SVG, zero dependencies).
|
||||
`import … from '@map/components/charts/BarChart.vue'`
|
||||
- **ProgressBar** — ProgressBar.vue — the DX-life progress bar: a single-value track, or a segmented one with a legend.
|
||||
`import … from '@map/components/charts/ProgressBar.vue'`
|
||||
- **PinHint** — PinHint.vue — the conversion coach-mark (bottom-center of any map).
|
||||
`import … from '@map/components/hints/PinHint.vue'`
|
||||
- **ApproxPinNote** — ApproxPinNote.vue — the ONE wording every map with people-group pins uses (APPROXPIN, 2026-09-26).
|
||||
|
|
@ -84,8 +86,12 @@ 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'`
|
||||
- **GoalCard** — GoalCard.vue — a headline figure with a goal line pinned to the bottom of the card.
|
||||
`import … from '@map/components/regions/GoalCard.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'`
|
||||
- **StatCard** — StatCard.vue — one figure with a caption, on the DX-life card surface.
|
||||
`import … from '@map/components/regions/StatCard.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).
|
||||
|
|
@ -117,6 +123,8 @@ and a change here can break other maps.
|
|||
`import … from '@map/composables/retryableAsyncComponent.js'`
|
||||
- **useAvatarGenerator** — Avatar Generator Composable
|
||||
`import … from '@map/composables/useAvatarGenerator.js'`
|
||||
- **useCardSurface** — useCardSurface.js — the DX-life card surface, as ONE rule set shared by every card component.
|
||||
`import … from '@map/composables/useCardSurface.js'`
|
||||
- **useCountryOutline** — useCountryOutline.js — country boundary OUTLINE overlay on country search.
|
||||
`import … from '@map/composables/useCountryOutline.js'`
|
||||
- **useCsvExport** — useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
|
||||
|
|
|
|||
133
library/components/charts/ProgressBar.vue
Normal file
133
library/components/charts/ProgressBar.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<!--
|
||||
ProgressBar.vue — the DX-life progress bar: a single-value track, or a segmented one with a legend.
|
||||
|
||||
Ported from blocks/_progress-bar.scss plus RegionDetail.vue's scoped .prayer-coverage-segments
|
||||
override. Three instances in the regions dashboard used it: one segmented bar with a three-key
|
||||
legend, and two plain adopted/engaged bars.
|
||||
|
||||
★ ONE NUMBER FEEDS BOTH THE WIDTH AND THE LEGEND, which is the actual reason this is a component
|
||||
and not two copies of markup. Before extraction the segment widths were computed from raw counts in
|
||||
the template while the legend printed those counts separately, so a bar and the number beside it
|
||||
could disagree and nothing would say so. Here a segment is { value, color, label } and the width
|
||||
IS value/max -- they cannot drift apart, because there is only one figure.
|
||||
|
||||
★ SEGMENT COLOUR IS A VALUE, NOT A CLASS, and this is the ONE substitution in the port. Their
|
||||
markup (and ours, before this) selected colours with .rd-seg--full / --partial / --none, three
|
||||
prayer-specific class names. A shared component cannot know what "full prayer coverage" is, and
|
||||
baking those three names in is exactly the monolith this extraction exists to undo. So the caller
|
||||
passes a CSS colour -- the regions plugin passes the same var(--dx-accent) / var(--dx-partial) /
|
||||
var(--dx-track) tokens those classes set, so every rendered colour is unchanged; only the mechanism
|
||||
moved from a stylesheet rule to an inline background. The three --full/--partial/--none rules are
|
||||
deleted rather than left behind, because a rule no selector can reach is not a fallback.
|
||||
|
||||
THE THREE TEMPLATE BRANCHES, and why there are no comments between them: a comment sitting between
|
||||
v-if branches is a RENDERED NODE, which turns the component's root into a FRAGMENT
|
||||
(<!--[-->...<!--]-->) instead of a single element. Measured by server-rendering it. So the branches
|
||||
are bare and the explanation lives here:
|
||||
• showLegend -> root is the ported .stack.stack--xs wrapper, 10px above the legend
|
||||
• segments only -> root is the segmented .rd-bar
|
||||
• neither -> root IS the plain .rd-bar, exactly as their cards 2 and 3 render it
|
||||
|
||||
DOM IS UNCHANGED FOR ALL THREE INSTANCES, deliberately, because the clone is measured against the
|
||||
live site. With a legend the root is the ported .stack.stack--xs wrapper (10px between bar and
|
||||
legend); without one the root IS the .rd-bar element, exactly as their cards 2 and 3 render it --
|
||||
hence the three template branches rather than one uniform wrapper, which would have added a box
|
||||
DX-life does not have to two of the three cards.
|
||||
|
||||
NOT ADDED HERE, ON PURPOSE: role="progressbar" / aria-valuenow. Neither their bar nor our port has
|
||||
it, and inventing an accessibility tree the clone target does not have is a change to request, not
|
||||
to slip in. Carded for a ruling instead.
|
||||
|
||||
CONTRACT: the containing shadow root carries the DX-life token layer (--dx-track, --dx-accent,
|
||||
--spacing-*). No i18n is installed or required -- every string arrives pre-localized.
|
||||
-->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** The filled amount, in the same units as `max`. Ignored when `segments` is non-empty. */
|
||||
value: { type: Number, default: 0 },
|
||||
/** The denominator. A zero or missing max renders an empty bar rather than dividing by zero. */
|
||||
max: { type: Number, default: 100 },
|
||||
/** The single-value fill colour. Any CSS colour; defaults to the DX-life accent. */
|
||||
color: { type: String, default: 'var(--dx-accent)' },
|
||||
/**
|
||||
* [{ value, color, label }] — when non-empty the bar is segmented. `value` is a RAW count over the
|
||||
* same `max`, not a percentage: the component divides, so the legend and the width agree.
|
||||
*/
|
||||
segments: { type: Array, default: () => [] },
|
||||
/** Render the legend built from `segments`. Has no effect without segments. */
|
||||
legend: { type: Boolean, default: false },
|
||||
/** aria-label for the track. Omitted entirely when empty, as their plain bars have none. */
|
||||
label: { type: String, default: '' },
|
||||
/** Formats a segment's value for the legend. The caller owns locale-dependent formatting. */
|
||||
format: { type: Function, default: (n) => String(n) },
|
||||
/** Between a legend key's label and its number. A prop because ':' is not universal punctuation. */
|
||||
separator: { type: String, default: ': ' },
|
||||
})
|
||||
|
||||
// RegionDetail.vue:28-30. Capped at 100 for the same reason theirs is: a bar wider than its
|
||||
// track is a rendering bug, not a data story. Ported verbatim from the plugin's percentOfSubject,
|
||||
// which existed only to serve these bars and is deleted there.
|
||||
const percentOf = (count) => (props.max ? Math.min(100, ((count || 0) / props.max) * 100) : 0)
|
||||
|
||||
const isSegmented = computed(() => props.segments.length > 0)
|
||||
const showLegend = computed(() => props.legend && isSegmented.value)
|
||||
const pct = computed(() => percentOf(props.value))
|
||||
const segStyles = computed(() => props.segments.map((s) => ({
|
||||
width: percentOf(s.value) + '%',
|
||||
background: s.color,
|
||||
})))
|
||||
|
||||
useShadowStyles(
|
||||
// ── .progress-bar: blocks/_progress-bar.scss. The HEIGHT comes from padding-top, not height
|
||||
// -- clamp(15px, 6%, 25px) at [data-size="md"] -- so the track scales with its own width.
|
||||
'.rd-bar{ position:relative; overflow:hidden; width:100%; border-radius:100px;' +
|
||||
' background:var(--dx-track); border:none; padding-top:clamp(15px, 6%, 25px); }' +
|
||||
'.rd-bar__slider{ position:absolute; top:0; height:100%; width:0; border-radius:100px;' +
|
||||
' background:var(--dx-accent); transition:width 1s ease-out; }' +
|
||||
// .prayer-coverage-segments overrides that padding with a real height (their own scoped rule)
|
||||
'.rd-bar--segments{ display:flex; height:clamp(15px, 6cqw, 25px); padding-top:0; }' +
|
||||
'.rd-seg{ height:100%; transition:width 1s ease-out; }' +
|
||||
// The ported .stack.stack--xs that separates the bar from its legend by 10px. It is the root only
|
||||
// when a legend is rendered, so a plain bar keeps their exact single-element shape.
|
||||
'.rd-bar-stack{ display:flex; flex-direction:column; }' +
|
||||
'.rd-bar-stack > * + *{ margin-top:10px; }' +
|
||||
'.rd-legend{ display:grid; justify-content:center; gap:3.5px; font-size:var(--font-size-sm);' +
|
||||
' font-weight:600; text-align:center; }' +
|
||||
'.rd-legend span{ display:inline-flex; align-items:center; justify-content:center; gap:4px; }' +
|
||||
'.rd-key{ display:inline-block; flex:0 0 auto; width:7.5px; height:7.5px; border-radius:100px; }' +
|
||||
// ★ THE COMPONENT CARRIES ITS OWN REDUCED-MOTION GUARD, and it is not redundant. base/_reset.scss
|
||||
// does this with a DOCUMENT-level universal selector, which styles nothing inside a shadow root, so
|
||||
// the regions plugin re-ported it scoped to its own `.rd-root *`. That covers these bars only while
|
||||
// they are inside .rd-root. A shared component used anywhere else would carry the 1s width
|
||||
// transition with nothing suppressing it -- reintroducing the exact regression that was measured
|
||||
// and fixed here (theirs 1s -> 1e-05s under emulated reduce, ours stayed 1s: a vestibular visitor
|
||||
// got a full one-second sliding bar their own site suppresses). A component that ships an animation
|
||||
// owes the guard for it; relying on an ancestor's reset is what makes a component non-portable.
|
||||
'@media (prefers-reduced-motion: reduce){' +
|
||||
' .rd-bar__slider, .rd-seg{ transition-duration:0.01ms !important; } }'
|
||||
, 'progress-bar')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showLegend" class="rd-bar-stack">
|
||||
<div class="rd-bar rd-bar--segments" :aria-label="label || undefined">
|
||||
<div v-for="(s, i) in segStyles" :key="i" class="rd-seg" :style="s" />
|
||||
</div>
|
||||
<div class="rd-legend">
|
||||
<span v-for="(s, i) in segments" :key="i">
|
||||
<i class="rd-key" :style="{ background: s.color }" />{{ s.label }}{{ separator }}{{ format(s.value) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isSegmented" class="rd-bar rd-bar--segments" :aria-label="label || undefined">
|
||||
<div v-for="(s, i) in segStyles" :key="i" class="rd-seg" :style="s" />
|
||||
</div>
|
||||
|
||||
<div v-else class="rd-bar" :aria-label="label || undefined">
|
||||
<div class="rd-bar__slider" :style="{ width: pct + '%', background: color }" />
|
||||
</div>
|
||||
</template>
|
||||
156
library/components/regions/GoalCard.vue
Normal file
156
library/components/regions/GoalCard.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<!--
|
||||
GoalCard.vue — a headline figure with a goal line pinned to the bottom of the card.
|
||||
|
||||
The Driver named these directly: "the banners are the cards that tell you the prayer progress".
|
||||
Their RegionDetail.vue renders three of them in a row -- Prayer Coverage, Adoption Progress,
|
||||
Engagement Progress -- identical in structure and different only in their numbers, their bar, and
|
||||
one dark variant. That is one component with props, which is why it is here and not three copies.
|
||||
|
||||
★ NAMED GoalCard, NOT PrayerGoalCard, and the deviation from the name in the order is deliberate:
|
||||
nothing in this component's props or CSS is about prayer. The shepherd's name described the first
|
||||
instance; a shared component named after one caller mis-describes it for every later one. Flagged
|
||||
in the handoff rather than decided silently.
|
||||
|
||||
★ THE MIDDLE IS A SLOT. What sits between the figure and the goal line differs per instance -- a
|
||||
segmented bar with a legend in one, a plain bar in the other two -- so the card does not import a
|
||||
bar at all. It knows about a title, a figure, and a goal; the caller supplies the rest. This is the
|
||||
same rule RegionSectionCard follows for its poster wall: one caller's feature must not become every
|
||||
caller's API.
|
||||
|
||||
★ margin-top:auto IS THE WHOLE POINT OF THE LAYOUT, not a detail. It is what makes three cards
|
||||
holding different amounts of copy align their goal lines flush along the bottom edge. The full
|
||||
measurement history is on the rules below; it survived a probe that measured the pair as "same".
|
||||
|
||||
TWO TEMPLATE DETAILS, documented here rather than in the template, because a comment between nodes
|
||||
is a rendered node and this card's DOM is compared against the markup it replaced:
|
||||
• the figure and its caption sit in an UNCLASSED <div>. That grouping is what makes the card's
|
||||
stack margin fall between the groups rather than between a number and its own label.
|
||||
• the space after </strong> in the goal line is INTERPOLATED, not written as template whitespace,
|
||||
so it cannot depend on the compiler's whitespace:condense behaviour.
|
||||
|
||||
CONTRACT, two things the host root must provide, exactly as for RegionSectionCard:
|
||||
• the DX-life token layer (--dx-radius, --dx-elevated, --dx-dark, --spacing-*, --font-size-*).
|
||||
• for the Cyrillic heading switch, a `lang` attribute on an ancestor INSIDE THE SAME shadow root.
|
||||
A [lang] selector cannot see the host document's attribute across a shadow boundary, so a
|
||||
consumer that does not set lang on an ancestor gets the default face and nothing else changes.
|
||||
No i18n is installed or required: every string arrives pre-localized, including the goal label.
|
||||
-->
|
||||
<script setup>
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
import { useCardSurface } from '../../composables/useCardSurface.js'
|
||||
|
||||
defineProps({
|
||||
/** The card heading, already localized. */
|
||||
title: { type: String, default: '' },
|
||||
/** The headline figure, pre-formatted by the caller, e.g. "21 / 2,107". */
|
||||
value: { type: [String, Number], default: '' },
|
||||
/** The caption under the figure, already localized. */
|
||||
valueLabel: { type: String, default: '' },
|
||||
/** The goal sentence, already localized. Pinned to the bottom of the card. */
|
||||
goal: { type: String, default: '' },
|
||||
/** The bold lead-in to the goal sentence, e.g. "Goal". Omitted entirely when empty. */
|
||||
goalLabel: { type: String, default: '' },
|
||||
/** Follows goalLabel inside the <strong>. A prop because ':' is not universal punctuation. */
|
||||
labelSeparator: { type: String, default: ':' },
|
||||
/** 'secondary' is their dark-on-light variant, used by the first of the three cards. */
|
||||
variant: { type: String, default: 'default' },
|
||||
})
|
||||
|
||||
// .rd-card / --secondary, shared with StatCard under one stable label.
|
||||
useCardSurface()
|
||||
|
||||
useShadowStyles(
|
||||
// ★ THEIR .stack IS margin-top, AND THAT IS LOAD-BEARING — not a stylistic detail.
|
||||
// composition/_stack.scss: `.stack > * + * { margin-top: var(--stack-spacing) }`, with
|
||||
// `.stack--lg` setting that to --spacing-lg. RegionDetail.vue's scoped
|
||||
// `.region-goal { margin-top: auto }` then OVERRIDES the same property on the last child,
|
||||
// so the goal line is pushed to the bottom and the 23.526px stack gap above it is GONE.
|
||||
// This was ported as `gap`, which margin cannot override: gap AND the auto both applied,
|
||||
// so every goal card carried ~23.5px of space their cards do not have. The DRIVER saw it
|
||||
// ('spcing is nto ths ame', naming these three cards). Ported as their mechanism.
|
||||
'.rd-goal-card{ display:flex; flex-direction:column; text-align:center; }' +
|
||||
'.rd-goal-card > * + *{ margin-top:var(--spacing-lg); }' +
|
||||
|
||||
// ── the heading. h2: _globals.scss clamp(--font-size-2xl, 6vw, --font-size-4xl).
|
||||
// ★ THIS IS A SECOND COPY OF THE PLUGIN'S .rd-h2 RULE, under this card's own class name, and the
|
||||
// duplication is deliberate rather than overlooked. Using .rd-h2 itself would put two identical
|
||||
// selectors in one shadow root from two different files -- the shape that drifts silently the first
|
||||
// time one is tuned. The plugin keeps .rd-h2 for the one heading it still owns (its table title).
|
||||
// The real fix is a shared ported-globals module beside the token layer, which is a batch of its
|
||||
// own; carded, not smuggled in here.
|
||||
'.rd-goal-card__title{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl)); line-height:1;' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; }' +
|
||||
// ★ 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.
|
||||
// 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"] .rd-goal-card__title, [lang="ru-RU"] .rd-goal-card__title{' +
|
||||
' font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' letter-spacing:-0.02em; }' +
|
||||
|
||||
// ── the figure. .font-size-4xl .font-weight-medium / .font-size-lg
|
||||
// ★ NO line-height HERE. Theirs is `<p class="font-size-4xl font-weight-medium">` and the
|
||||
// .font-size-4xl utility sets ONLY a font-size — so their number inherits body's 1.5. Ours forced
|
||||
// 1.1, which was compensating for the base line-height we never ported (now on :host in
|
||||
// dxlife-tokens.js). rega measured the cost: 24px of card height, their number ~12px lower and its
|
||||
// label ~24px lower. Inheriting is the faithful port.
|
||||
// ★ A CLAMP, LIKE THEIRS, AND IT RESOLVES THE NOWRAP WORRY RATHER THAN FIGHTING IT.
|
||||
// utilities/_typography.scss gives .font-size-4xl
|
||||
// font-size: clamp(var(--font-size-2xl), 6vw, var(--font-size-4xl))
|
||||
// and ours was a flat var(--font-size-4xl). Identical at 1440 and above (both sit on the ceiling),
|
||||
// but below that ours never shrank -- MEASURED against their resolved values:
|
||||
// 390px viewport theirs 38.15 ours 59.60 ours 21.46 too large
|
||||
// 768px viewport theirs 46.08 ours 59.60 ours 13.52 too large
|
||||
// 1024px and up theirs 59.60 ours 59.60 identical
|
||||
// I had reported this as unchangeable because a figure must never split across lines in a 372px
|
||||
// card. That was backwards: the clamp makes the figure SMALLER exactly where the wrap was observed.
|
||||
// In a 372px container 6cqw is 22.3px, below the 2xl floor, so it resolves to 38.15px against the
|
||||
// 59.60px that was breaking. The clamp helps the very problem the flat value was chosen for, and
|
||||
// the nowrap stays as a belt.
|
||||
// cqw not vw, consistent with every other clamp in this port (the container-vs-viewport
|
||||
// substitution the Driver is still ruling on); at 768 that lands 42.4 against their 46.08.
|
||||
'.rd-big{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl));' +
|
||||
' font-weight:500; }' +
|
||||
'.rd-goal-card .rd-big{ white-space:nowrap; }' +
|
||||
'.rd-big-label{ margin:0; font-size:var(--font-size-lg); }' +
|
||||
|
||||
// ── the goal line. .region-goal — margin-top:auto, spelled at higher specificity than the stack
|
||||
// rule above so the override is unambiguous rather than depending on source order.
|
||||
// ★ margin-top:auto, AND IT IS LOAD-BEARING. Theirs is RegionDetail.vue:24 `.region-goal {
|
||||
// margin-top: auto }` inside `<div class="stack stack--lg | card | text-center">`, and
|
||||
// composition/_stack.scss makes .stack a flex COLUMN with `> * + * { margin-top:
|
||||
// var(--stack-spacing) }`. So the auto OVERRIDES that child margin and pushes the goal line to the
|
||||
// BOTTOM of the card. Ours was margin:0, which left it directly under the text above it.
|
||||
// The visible consequence, and why it matters for a clone: the three goal cards hold different
|
||||
// amounts of copy, so theirs align their goal lines flush along the cards' bottom edge while ours
|
||||
// floated at three different heights.
|
||||
// FOUND BY READING THEIR MARKUP after rega's spacing probe measured this pair as "same" -- which
|
||||
// it is, at 1440, because with no free space in the card the auto resolves to 0. An equal computed
|
||||
// margin hid a real difference, so the probe was right and incomplete at the same time.
|
||||
'.rd-goal{ margin:auto 0 0; font-size:var(--font-size-sm); }' +
|
||||
'.rd-goal-card > .rd-goal{ margin-top:auto; }' +
|
||||
// base/_globals.scss:200-202 — <strong> is --font-weight-medium (500) on their site, not the UA's
|
||||
// bold. The plugin's own `.rd-root strong` STAYS where it is: UupgsList renders a <strong> in the
|
||||
// same shadow root and depends on it, so this is a second rule for this card's own subtree, not a
|
||||
// move. Checked by reading the whole root rather than this card's markup -- a file-scoped grep
|
||||
// would have called the plugin's copy dead and deleted a rule another component needs.
|
||||
'.rd-goal strong{ font-weight:500; }'
|
||||
, 'goal-card')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-card rd-goal-card" :class="{ 'rd-card--secondary': variant === 'secondary' }">
|
||||
<h2 class="rd-goal-card__title">{{ title }}</h2>
|
||||
<div>
|
||||
<p class="rd-big">{{ value }}</p>
|
||||
<p class="rd-big-label">{{ valueLabel }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
<p class="rd-goal"><strong v-if="goalLabel">{{ goalLabel }}{{ labelSeparator }}</strong>{{ goalLabel ? ' ' : '' }}{{ goal }}</p>
|
||||
</div>
|
||||
</template>
|
||||
63
library/components/regions/StatCard.vue
Normal file
63
library/components/regions/StatCard.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<!--
|
||||
StatCard.vue — one figure with a caption, on the DX-life card surface.
|
||||
|
||||
Their RegionDetail.vue renders a row of these above the map: a big number and a small grey label,
|
||||
centred on a small-padding card. That is the whole component, and it is the smallest genuinely
|
||||
reusable piece of the regions dashboard.
|
||||
|
||||
★ THE GRID IS NOT IN HERE, AND THAT IS THE POINT. The plugin's `.rd-stats` container
|
||||
(grid-template-columns: repeat(auto-fit, minmax(180px, 1fr))) stays with the CONSUMER. How many
|
||||
stats there are and how they wrap is a decision belonging to the page, not to one stat; baking a
|
||||
grid into a single card would make it impossible to place one on its own, or two side by side, or
|
||||
in a column. So a consumer supplies its own container and puts as many StatCards in it as it has.
|
||||
|
||||
VALUES ARE PRE-FORMATTED BY THE CALLER. `value` is a string or number and is rendered verbatim,
|
||||
because number formatting is locale-dependent and the caller already knows the locale -- the
|
||||
regions plugin passes formatNumber(n) through its own i18n. This component installs no i18n and
|
||||
therefore works on a host that has none.
|
||||
|
||||
CONTRACT: the containing shadow root must carry the DX-life token layer (DXLIFE_TOKENS in the
|
||||
regions plugin) and, for the figure to render in the intended face, the Bebas Neue @font-face --
|
||||
which is DOCUMENT-scoped and so must be registered on the host document, not in the shadow root.
|
||||
See useDocumentFont.js. Without the face the declared fallbacks apply; nothing breaks.
|
||||
-->
|
||||
<script setup>
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
import { useCardSurface } from '../../composables/useCardSurface.js'
|
||||
|
||||
defineProps({
|
||||
/** The figure. Already formatted for the locale by the caller, e.g. "43,045,275". */
|
||||
value: { type: [String, Number], default: '' },
|
||||
/** The caption under the figure. Already localized by the caller. */
|
||||
label: { type: String, default: '' },
|
||||
})
|
||||
|
||||
// .rd-card / --small, shared with GoalCard under one stable label so only one copy is injected.
|
||||
useCardSurface()
|
||||
|
||||
useShadowStyles(
|
||||
// ── .region-stat: RegionDetail.vue's scoped block ──
|
||||
'.rd-stat{ text-align:center; }' +
|
||||
// ★ THE data-len SHRINK IS GONE, AND ITS OWN COMMENT SHOWED WHY. Their .region-stat__value is
|
||||
// font-family-heading + font-size-3xl + line-height:1 and nothing else -- no such attribute exists
|
||||
// in their markup. Ours was a shrink-to-fit workaround whose stated premise was "our condensed
|
||||
// fallback is wider" than Bebas. That is STALE: Bebas is vendored now (assets/fonts/bebas-neue,
|
||||
// glyph oracle 279 = 279 SAME), so there is no wider fallback to compensate for.
|
||||
// Its other claim -- "every value DX-life itself renders keeps their size" -- was FALSIFIED by
|
||||
// measurement: rega found "43,045,275" (10 chars, so it hit the 'long' bucket) rendered at
|
||||
// 38.15px against their 47.68px on the same-width card, while the other three stats matched
|
||||
// exactly. A rule whose own invariant is violated in practice is not protecting anything.
|
||||
// Deleted WITH its mechanism -- the binding and lenBucket() too, not just the rules.
|
||||
'.rd-stat__value{ margin:0; font-size:var(--font-size-3xl); line-height:1;' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; }' +
|
||||
'.rd-stat__label{ margin:var(--spacing-2xs) 0 0; font-size:var(--font-size-sm); color:var(--dx-muted); }'
|
||||
, 'stat-card')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rd-card rd-card--small rd-stat">
|
||||
<p class="rd-stat__value">{{ value }}</p>
|
||||
<p class="rd-stat__label">{{ label }}</p>
|
||||
</div>
|
||||
</template>
|
||||
44
library/composables/useCardSurface.js
Normal file
44
library/composables/useCardSurface.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* useCardSurface.js — the DX-life card surface, as ONE rule set shared by every card component.
|
||||
*
|
||||
* WHY THIS IS A MODULE AND NOT A COPY IN EACH CARD. Vue only injects the ROOT component's <style>
|
||||
* into a shadow root, so every library component that renders inside one carries its own CSS and
|
||||
* injects it with useShadowStyles (see that file). StatCard and GoalCard are two components that
|
||||
* need the SAME surface -- radius, elevation, padding, and the two variants -- and a copy in each is
|
||||
* a rule set that will drift the first time one of them is tuned.
|
||||
*
|
||||
* THE STABLE LABEL IS THE WHOLE TRICK. useShadowStyles dedups on its label, so both cards (and any
|
||||
* consumer that renders .rd-card markup of its own) can call useCardSurface() and exactly ONE
|
||||
* <style data-shadow-styles="dxlife-card-surface"> lands per shadow root, no matter how many card
|
||||
* components mount inside it. Calling it from N components is not N stylesheets.
|
||||
*
|
||||
* ★ THE CLASS NAMES KEEP THEIR `rd-` PREFIX, DELIBERATELY. It came from the regions-dashboard port
|
||||
* and means nothing outside it. Renaming would change no pixel and would cost the traceability from
|
||||
* each declaration back to the DX-life rule and the measurement that fixed it, which is the only
|
||||
* reason these values can be defended. The names are internal to the components; a consumer writes
|
||||
* <StatCard> and never types them. A rename is a safe later cleanup, not a prerequisite for reuse.
|
||||
*
|
||||
* CONTRACT: the shadow root must already carry the DX-life token layer (--dx-radius, --dx-elevated,
|
||||
* --dx-text, --dx-dark, --dx-on-dark, --spacing-*). The regions plugin injects it as DXLIFE_TOKENS.
|
||||
* Without it these rules resolve to nothing -- they are a skin over that scale, not a replacement.
|
||||
*/
|
||||
import { useShadowStyles } from './useShadowStyles.js'
|
||||
|
||||
export const CARD_SURFACE_CSS =
|
||||
// ── .card: blocks/_cards.scss:9-21. radius --border-radius-xlg (35px);
|
||||
// padding clamp(--spacing-lg, 6vw, --spacing-2xl) clamp(--spacing-lg, 6vw, --spacing-3xl).
|
||||
'.rd-card{ position:relative; border-radius:var(--dx-radius);' +
|
||||
' background:var(--dx-elevated); color:var(--dx-text);' +
|
||||
' padding:clamp(var(--spacing-lg), 6cqw, var(--spacing-2xl)) clamp(var(--spacing-lg), 6cqw, var(--spacing-3xl)); }' +
|
||||
// .card[padding-small] -> --spacing-lg flat
|
||||
'.rd-card--small{ padding:var(--spacing-lg); }' +
|
||||
// .card[data-variant="secondary"] -> surface-brand-dark on text-on-brand-light
|
||||
'.rd-card--secondary{ background:var(--dx-dark); color:var(--dx-on-dark); }'
|
||||
|
||||
/**
|
||||
* Injects the card surface into the containing shadow root, once, under a stable label.
|
||||
* Call it at setup() time from any component that renders .rd-card markup.
|
||||
*/
|
||||
export function useCardSurface() {
|
||||
useShadowStyles(CARD_SURFACE_CSS, 'dxlife-card-surface')
|
||||
}
|
||||
|
|
@ -47,6 +47,12 @@ import { PALETTE as REGION_PALETTE, WAGF_REGIONS, REGION_ORDER } from '@map/colo
|
|||
// component's shadow CSS so every var() below resolves inside the shadow root.
|
||||
import { DXLIFE_TOKENS } from './dxlife-tokens.js'
|
||||
import RegionCountryMap from './src/RegionCountryMap.vue'
|
||||
// REGLIB batch 2: the stat card, the three goal cards and their bars are library components now.
|
||||
// They carry their own shadow CSS (useShadowStyles) and the card surface they share, so the rules
|
||||
// for .rd-card / .rd-stat* / .rd-goal* / .rd-big* / .rd-bar* / .rd-legend are no longer below.
|
||||
import StatCard from '@map/components/regions/StatCard.vue'
|
||||
import GoalCard from '@map/components/regions/GoalCard.vue'
|
||||
import ProgressBar from '@map/components/charts/ProgressBar.vue'
|
||||
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
|
||||
import { summarizeRegions, emptyStats, toNumber, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
|
||||
|
||||
|
|
@ -255,12 +261,16 @@ const tiles = computed(() => {
|
|||
const partialPrayerCount = computed(() => Math.max(0, (subject.value.stats.withPrayer || 0) - (subject.value.stats.withFullPrayer || 0)))
|
||||
const noPrayerCount = computed(() => Math.max(0, (subject.value.stats.count || 0) - (subject.value.stats.withPrayer || 0)))
|
||||
|
||||
// RegionDetail.vue:28-30. Capped at 100 for the same reason theirs is: a bar wider than its
|
||||
// track is a rendering bug, not a data story.
|
||||
function percentOfSubject(count) {
|
||||
const total = subject.value.stats.count || 0
|
||||
return total ? Math.min(100, (count / total) * 100) : 0
|
||||
}
|
||||
// The three prayer-coverage bands as ONE list, feeding both the bar widths and the legend numbers.
|
||||
// ProgressBar divides each `value` by its `max`, so a width and the figure printed beside it come
|
||||
// from the same number and cannot drift apart -- before extraction they were computed separately.
|
||||
// The colours are the very tokens the deleted .rd-seg--full/--partial/--none rules set, so every
|
||||
// rendered colour is unchanged; only the mechanism moved from a class to a value.
|
||||
const prayerSegments = computed(() => [
|
||||
{ value: subject.value.stats.withFullPrayer || 0, color: 'var(--dx-accent)', label: t('100+ People Praying') },
|
||||
{ value: partialPrayerCount.value, color: 'var(--dx-partial)', label: t('1+ People Praying') },
|
||||
{ value: noPrayerCount.value, color: 'var(--dx-track)', label: t('No One Praying') },
|
||||
])
|
||||
|
||||
// Their four headline stats, RegionDetail.vue:52-69: People Groups / Countries / Population /
|
||||
// Unengaged. A COUNTRY subject has no countryCount, and their own country page does not show one
|
||||
|
|
@ -280,8 +290,6 @@ const totalCountryCount = computed(() => regions.value.reduce((n, r) => n + (r.c
|
|||
|
||||
// Their three goal cards, RegionDetail.vue:88-141. The goal SENTENCES are theirs verbatim --
|
||||
// they are the DOXA goals, not copy we get to reword.
|
||||
const adoptedPct = computed(() => percentOfSubject(subject.value.stats.adopted || 0))
|
||||
const engagedPct = computed(() => percentOfSubject(subject.value.stats.engaged || 0))
|
||||
|
||||
// Their per-country table, RegionDetail.vue:144-171 -- five columns, same order.
|
||||
// A country subject keeps its REGION's rows with itself highlighted, for the reason the chart
|
||||
|
|
@ -833,35 +841,17 @@ useShadowStyles(
|
|||
'.rd-summary{ color:var(--dx-muted); font-size:var(--font-size-md); }' +
|
||||
'.rd-prompt{ margin:0; color:var(--dx-muted); font-size:var(--font-size-sm); }' +
|
||||
|
||||
// ── .card: blocks/_cards.scss:9-21. radius --border-radius-xlg (35px);
|
||||
// padding clamp(--spacing-lg, 6vw, --spacing-2xl) clamp(--spacing-lg, 6vw, --spacing-3xl).
|
||||
'.rd-card{ position:relative; border-radius:var(--dx-radius);' +
|
||||
' background:var(--dx-elevated); color:var(--dx-text);' +
|
||||
' padding:clamp(var(--spacing-lg), 6cqw, var(--spacing-2xl)) clamp(var(--spacing-lg), 6cqw, var(--spacing-3xl)); }' +
|
||||
// .card[padding-small] -> --spacing-lg flat
|
||||
'.rd-card--small{ padding:var(--spacing-lg); }' +
|
||||
// .card[data-variant="secondary"] -> surface-brand-dark on text-on-brand-light
|
||||
'.rd-card--secondary{ background:var(--dx-dark); color:var(--dx-on-dark); }' +
|
||||
// ── .card (blocks/_cards.scss:9-21) lives in library/composables/useCardSurface.js now: StatCard
|
||||
// and GoalCard both need it, and both call useCardSurface(), which injects ONE copy per shadow
|
||||
// root under a stable label. This element renders no .rd-card markup of its own any more, so it
|
||||
// does not call it. If markup here ever needs the surface again, call useCardSurface() -- do not
|
||||
// re-declare the rules, or there will be two copies to keep in step.
|
||||
|
||||
// ── .region-stats + .region-stat: RegionDetail.vue's scoped block ──
|
||||
// ── .region-stats: RegionDetail.vue's scoped block. The GRID stays here and the CARD went to the
|
||||
// library, deliberately: how many stats there are and how they wrap is this page's decision,
|
||||
// while the look of one stat is the reusable part. See library/components/regions/StatCard.vue.
|
||||
'.rd-stats{ display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));' +
|
||||
' gap:var(--spacing-md); }' +
|
||||
'.rd-stat{ text-align:center; }' +
|
||||
// ★ THE data-len SHRINK IS GONE, AND ITS OWN COMMENT SHOWED WHY. Their .region-stat__value is
|
||||
// font-family-heading + font-size-3xl + line-height:1 and nothing else -- no such attribute exists
|
||||
// in their markup. Ours was a shrink-to-fit workaround whose stated premise was "our condensed
|
||||
// fallback is wider" than Bebas. That is STALE: Bebas is vendored now (assets/fonts/bebas-neue,
|
||||
// glyph oracle 279 = 279 SAME), so there is no wider fallback to compensate for.
|
||||
// Its other claim -- "every value DX-life itself renders keeps their size" -- was FALSIFIED by
|
||||
// measurement: rega found "43,045,275" (10 chars, so it hit the 'long' bucket) rendered at
|
||||
// 38.15px against their 47.68px on the same-width card, while the other three stats matched
|
||||
// exactly. A rule whose own invariant is violated in practice is not protecting anything.
|
||||
// Deleted WITH its mechanism -- the binding and lenBucket() too, not just the rules.
|
||||
'.rd-stat__value{ margin:0; font-size:var(--font-size-3xl); line-height:1;' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; }' +
|
||||
'.rd-stat__label{ margin:var(--spacing-2xs) 0 0; font-size:var(--font-size-sm); color:var(--dx-muted); }' +
|
||||
|
||||
'.rd-chart{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-xl)); }' +
|
||||
// RegionDetail wraps the note and the map in a .stack--sm (13.3px). The note is their
|
||||
// .text-center .color-primary .font-weight-bold -- --color-brand-primary at weight 700.
|
||||
|
|
@ -925,18 +915,9 @@ useShadowStyles(
|
|||
// substituted face can render in their card. Pinning it there keeps THEIR LAYOUT (three
|
||||
// cards across, heading on two lines) which is what the clone is actually for. The table
|
||||
// heading keeps the full clamp -- it has 1200px and no such constraint.
|
||||
// .stack--lg (var(--spacing-lg)) inside each card, centred; .region-goal pins to the bottom
|
||||
// (margin-top:auto) so three cards of different heights still align their goal lines.
|
||||
// ★ THEIR .stack IS margin-top, AND THAT IS LOAD-BEARING — not a stylistic detail.
|
||||
// composition/_stack.scss: `.stack > * + * { margin-top: var(--stack-spacing) }`, with
|
||||
// `.stack--lg` setting that to --spacing-lg. RegionDetail.vue's scoped
|
||||
// `.region-goal { margin-top: auto }` then OVERRIDES the same property on the last child,
|
||||
// so the goal line is pushed to the bottom and the 23.526px stack gap above it is GONE.
|
||||
// This was ported as `gap`, which margin cannot override: gap AND the auto both applied,
|
||||
// so every goal card carried ~23.5px of space their cards do not have. The DRIVER saw it
|
||||
// ('spcing is nto ths ame', naming these three cards). Ported as their mechanism.
|
||||
'.rd-goal-card{ display:flex; flex-direction:column; text-align:center; }' +
|
||||
'.rd-goal-card > * + *{ margin-top:var(--spacing-lg); }' +
|
||||
// The three goal cards are library/components/regions/GoalCard.vue now, and the .stack--lg /
|
||||
// margin-top:auto layout that aligns their goal lines went with them. The .rd-h2 rule below is
|
||||
// still here because this element keeps ONE heading of its own -- the breakdown table's title.
|
||||
// h2: _globals.scss clamp(--font-size-2xl, 6vw, --font-size-4xl) = clamp(var(--font-size-2xl),_,var(--font-size-4xl))
|
||||
'.rd-h2{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl)); line-height:1;' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
|
|
@ -955,78 +936,19 @@ useShadowStyles(
|
|||
'[lang="ru"] .rd-title, [lang="ru-RU"] .rd-title,' +
|
||||
'[lang="ru"] .rd-h2, [lang="ru-RU"] .rd-h2{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' letter-spacing:-0.02em; }' +
|
||||
// .font-size-4xl .font-weight-medium / .font-size-lg
|
||||
// Their .font-size-4xl (var(--font-size-4xl)) verified live. Same substitution consequence as the h2:
|
||||
// in our wider stand-in face 'Adoption Progress' 21 / 2,107 breaks after the slash inside a
|
||||
// 372px card, which reads as two numbers. Stepped to --font-size-3xl (the next rung down on
|
||||
// THEIR scale) and pinned nowrap so a figure can never be split across lines. The stat-card
|
||||
// values keep the full var(--font-size-3xl) -- those cards are wider and were measured to fit.
|
||||
// ★ NO line-height HERE ANY MORE. Theirs is `<p class="font-size-4xl font-weight-medium">` and the
|
||||
// .font-size-4xl utility sets ONLY a font-size — so their number inherits body's 1.5. Ours forced
|
||||
// 1.1, which was compensating for the base line-height we never ported (now on :host in
|
||||
// dxlife-tokens.js). rega measured the cost: 24px of card height, their number ~12px lower and its
|
||||
// label ~24px lower. Inheriting is the faithful port.
|
||||
// ★ NOW A CLAMP, LIKE THEIRS, AND IT RESOLVES THE NOWRAP WORRY RATHER THAN FIGHTING IT.
|
||||
// utilities/_typography.scss gives .font-size-4xl
|
||||
// font-size: clamp(var(--font-size-2xl), 6vw, var(--font-size-4xl))
|
||||
// and ours was a flat var(--font-size-4xl). Identical at 1440 and above (both sit on the ceiling),
|
||||
// but below that ours never shrank -- MEASURED against their resolved values:
|
||||
// 390px viewport theirs 38.15 ours 59.60 ours 21.46 too large
|
||||
// 768px viewport theirs 46.08 ours 59.60 ours 13.52 too large
|
||||
// 1024px and up theirs 59.60 ours 59.60 identical
|
||||
// I had reported this as unchangeable because of the nowrap note above -- a figure must never
|
||||
// split across lines in a 372px card. That was backwards: the clamp makes the figure SMALLER
|
||||
// exactly where the wrap was observed. In a 372px container 6cqw is 22.3px, below the 2xl floor,
|
||||
// so it resolves to 38.15px against the 59.60px that was breaking. The clamp helps the very
|
||||
// problem the flat value was chosen for, and the nowrap stays as a belt.
|
||||
// cqw not vw, consistent with every other clamp in this element (the container-vs-viewport
|
||||
// substitution the Driver is still ruling on); at 768 that lands 42.4 against their 46.08, which
|
||||
// is 3.68 off instead of 13.52.
|
||||
'.rd-big{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl));' +
|
||||
' font-weight:500; }' +
|
||||
'.rd-goal-card .rd-big{ white-space:nowrap; }' +
|
||||
'.rd-big-label{ margin:0; font-size:var(--font-size-lg); }' +
|
||||
// .stack .stack--xs, inside the Prayer Coverage card.
|
||||
'.rd-stack-xs{ display:flex; flex-direction:column; }' +
|
||||
'.rd-stack-xs > * + *{ margin-top:10px; }' +
|
||||
// .region-goal — margin-top:auto, spelled at higher specificity than the stack rule above
|
||||
// so the override is unambiguous rather than depending on source order.
|
||||
// ★ margin-top:auto, AND IT IS LOAD-BEARING. Theirs is RegionDetail.vue:24 `.region-goal {
|
||||
// margin-top: auto }` inside `<div class="stack stack--lg | card | text-center">`, and
|
||||
// composition/_stack.scss makes .stack a flex COLUMN with `> * + * { margin-top:
|
||||
// var(--stack-spacing) }`. So the auto OVERRIDES that child margin and pushes the goal line to the
|
||||
// BOTTOM of the card. Ours was margin:0, which left it directly under the text above it.
|
||||
// The visible consequence, and why it matters for a clone: the three goal cards hold different
|
||||
// amounts of copy, so theirs align their goal lines flush along the cards' bottom edge while ours
|
||||
// floated at three different heights.
|
||||
// FOUND BY READING THEIR MARKUP after rega's spacing probe measured this pair as "same" -- which
|
||||
// it is, at 1440, because with no free space in the card the auto resolves to 0. An equal computed
|
||||
// margin hid a real difference, so the probe was right and incomplete at the same time.
|
||||
'.rd-goal{ margin:auto 0 0; font-size:var(--font-size-sm); }' +
|
||||
// .rd-big / .rd-big-label / .rd-goal and the .stack--xs that separates a bar from its legend are
|
||||
// in library/components/regions/GoalCard.vue and library/components/charts/ProgressBar.vue now,
|
||||
// together with the measurement history that fixed each value. The `strong` rule below STAYS:
|
||||
// UupgsList renders a <strong> in this same shadow root and depends on it.
|
||||
// base/_globals.scss:200-202 — <strong> is --font-weight-medium (500) on their site, not the
|
||||
// UA's bold. The 'Goal:' label in all three cards is a <strong>, so ours was rendering 700.
|
||||
'.rd-root strong{ font-weight:500; }' +
|
||||
'.rd-goal-card > .rd-goal{ margin-top:auto; }' +
|
||||
|
||||
// ── .progress-bar: blocks/_progress-bar.scss. The HEIGHT comes from padding-top, not height
|
||||
// -- clamp(15px, 6%, 25px) at [data-size="md"] -- so the track scales with its own width.
|
||||
'.rd-bar{ position:relative; overflow:hidden; width:100%; border-radius:100px;' +
|
||||
' background:var(--dx-track); border:none; padding-top:clamp(15px, 6%, 25px); }' +
|
||||
'.rd-bar__slider{ position:absolute; top:0; height:100%; width:0; border-radius:100px;' +
|
||||
' background:var(--dx-accent); transition:width 1s ease-out; }' +
|
||||
// .prayer-coverage-segments overrides that padding with a real height (their own scoped rule)
|
||||
'.rd-bar--segments{ display:flex; height:clamp(15px, 6cqw, 25px); padding-top:0; }' +
|
||||
'.rd-seg{ height:100%; transition:width 1s ease-out; }' +
|
||||
'.rd-seg--full{ background:var(--dx-accent); }' +
|
||||
'.rd-seg--partial{ background:var(--dx-partial); }' +
|
||||
'.rd-seg--none{ background:var(--dx-track); }' +
|
||||
'.rd-legend{ display:grid; justify-content:center; gap:3.5px; font-size:var(--font-size-sm);' +
|
||||
' font-weight:600; text-align:center; }' +
|
||||
'.rd-legend span{ display:inline-flex; align-items:center; justify-content:center; gap:4px; }' +
|
||||
'.rd-key{ display:inline-block; flex:0 0 auto; width:7.5px; height:7.5px; border-radius:100px; }' +
|
||||
'.rd-key--full{ background:var(--dx-accent); }' +
|
||||
'.rd-key--partial{ background:var(--dx-partial); }' +
|
||||
'.rd-key--none{ background:var(--dx-track); }' +
|
||||
// ── .progress-bar (blocks/_progress-bar.scss) + their .prayer-coverage-segments override and
|
||||
// legend are library/components/charts/ProgressBar.vue now. The three --full/--partial/--none
|
||||
// colour rules are GONE rather than left behind: a segment's colour is a prop there, passed as
|
||||
// the same var(--dx-accent) / var(--dx-partial) / var(--dx-track) tokens these rules set, so a
|
||||
// shared bar needs no vocabulary about prayer. A rule no selector can reach is not a fallback.
|
||||
|
||||
// ── the breakdown table: RegionDetail.vue's scoped block. min-width 560px inside an
|
||||
// overflow-x:auto wrapper is how they keep five columns readable on a phone instead of
|
||||
|
|
@ -1152,10 +1074,7 @@ useShadowStyles(
|
|||
|
||||
<!-- RegionDetail.vue:53-70 — four stat cards. CountryDetail has none. -->
|
||||
<div v-if="subject.kind !== 'country'" class="rd-stats">
|
||||
<div v-for="st in stats" :key="st.label" class="rd-card rd-card--small rd-stat">
|
||||
<p class="rd-stat__value">{{ st.value }}</p>
|
||||
<p class="rd-stat__label">{{ st.label }}</p>
|
||||
</div>
|
||||
<StatCard v-for="st in stats" :key="st.label" :value="st.value" :label="st.label" />
|
||||
</div>
|
||||
|
||||
<!-- RegionDetail.vue:72-86 and CountryDetail.vue:47-61 — a .stack--sm of the note and the
|
||||
|
|
@ -1209,46 +1128,42 @@ useShadowStyles(
|
|||
|
||||
<!-- RegionDetail.vue:88-141 — the three DOXA goal cards in their .switcher -->
|
||||
<div v-if="subject.kind === 'region'" class="rd-switcher">
|
||||
<div class="rd-card rd-card--secondary rd-goal-card">
|
||||
<h2 class="rd-h2">{{ t('Prayer Coverage') }}</h2>
|
||||
<div>
|
||||
<p class="rd-big">{{ formatNumber(subject.stats.peopleCommitted) }}</p>
|
||||
<p class="rd-big-label">{{ t('People committed to praying') }}</p>
|
||||
</div>
|
||||
<div class="rd-stack-xs">
|
||||
<div class="rd-bar rd-bar--segments" :aria-label="t('Prayer coverage progress')">
|
||||
<div class="rd-seg rd-seg--full" :style="{ width: percentOfSubject(subject.stats.withFullPrayer) + '%' }" />
|
||||
<div class="rd-seg rd-seg--partial" :style="{ width: percentOfSubject(partialPrayerCount) + '%' }" />
|
||||
<div class="rd-seg rd-seg--none" :style="{ width: percentOfSubject(noPrayerCount) + '%' }" />
|
||||
</div>
|
||||
<div class="rd-legend">
|
||||
<span><i class="rd-key rd-key--full" />{{ t('100+ People Praying') }}: {{ formatNumber(subject.stats.withFullPrayer) }}</span>
|
||||
<span><i class="rd-key rd-key--partial" />{{ t('1+ People Praying') }}: {{ formatNumber(partialPrayerCount) }}</span>
|
||||
<span><i class="rd-key rd-key--none" />{{ t('No One Praying') }}: {{ formatNumber(noPrayerCount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rd-goal"><strong>{{ t('Goal') }}:</strong> {{ t('Mobilize 100+ people praying daily for each DOXA people group') }}</p>
|
||||
</div>
|
||||
<GoalCard
|
||||
variant="secondary"
|
||||
:title="t('Prayer Coverage')"
|
||||
:value="formatNumber(subject.stats.peopleCommitted)"
|
||||
:value-label="t('People committed to praying')"
|
||||
:goal-label="t('Goal')"
|
||||
:goal="t('Mobilize 100+ people praying daily for each DOXA people group')"
|
||||
>
|
||||
<ProgressBar
|
||||
:max="subject.stats.count"
|
||||
:segments="prayerSegments"
|
||||
legend
|
||||
:format="formatNumber"
|
||||
:label="t('Prayer coverage progress')"
|
||||
/>
|
||||
</GoalCard>
|
||||
|
||||
<div class="rd-card rd-goal-card">
|
||||
<h2 class="rd-h2">{{ t('Adoption Progress') }}</h2>
|
||||
<div>
|
||||
<p class="rd-big">{{ formatNumber(subject.stats.adopted) }} / {{ formatNumber(subject.stats.count) }}</p>
|
||||
<p class="rd-big-label">{{ t('people groups adopted') }}</p>
|
||||
</div>
|
||||
<div class="rd-bar"><div class="rd-bar__slider" :style="{ width: adoptedPct + '%' }" /></div>
|
||||
<p class="rd-goal"><strong>{{ t('Goal') }}:</strong> {{ t('Every unengaged people group adopted by a church committed to prayer, giving, and sending') }}</p>
|
||||
</div>
|
||||
<GoalCard
|
||||
:title="t('Adoption Progress')"
|
||||
:value="formatNumber(subject.stats.adopted) + ' / ' + formatNumber(subject.stats.count)"
|
||||
:value-label="t('people groups adopted')"
|
||||
:goal-label="t('Goal')"
|
||||
:goal="t('Every unengaged people group adopted by a church committed to prayer, giving, and sending')"
|
||||
>
|
||||
<ProgressBar :value="subject.stats.adopted" :max="subject.stats.count" />
|
||||
</GoalCard>
|
||||
|
||||
<div class="rd-card rd-goal-card">
|
||||
<h2 class="rd-h2">{{ t('Engagement Progress') }}</h2>
|
||||
<div>
|
||||
<p class="rd-big">{{ formatNumber(subject.stats.engaged) }} / {{ formatNumber(subject.stats.count) }}</p>
|
||||
<p class="rd-big-label">{{ t('people groups engaged') }}</p>
|
||||
</div>
|
||||
<div class="rd-bar"><div class="rd-bar__slider" :style="{ width: engagedPct + '%' }" /></div>
|
||||
<p class="rd-goal"><strong>{{ t('Goal') }}:</strong> {{ t('No unengaged people groups by 2033') }}</p>
|
||||
</div>
|
||||
<GoalCard
|
||||
:title="t('Engagement Progress')"
|
||||
:value="formatNumber(subject.stats.engaged) + ' / ' + formatNumber(subject.stats.count)"
|
||||
:value-label="t('people groups engaged')"
|
||||
:goal-label="t('Goal')"
|
||||
:goal="t('No unengaged people groups by 2033')"
|
||||
>
|
||||
<ProgressBar :value="subject.stats.engaged" :max="subject.stats.count" />
|
||||
</GoalCard>
|
||||
</div>
|
||||
|
||||
<!-- RegionDetail.vue:144-171 — the five-column breakdown. CountryDetail has no table. -->
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ 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
|
||||
// area, library file, the plugin file that must consume it, the classes the library file now owns.
|
||||
// `css` names the file(s) allowed to carry those rules -- normally the component itself, but a rule set
|
||||
// two components share (the card surface) legitimately lives in one module both of them call.
|
||||
const EXTRACTED = [
|
||||
{
|
||||
lib: 'library/components/regions/RegionSectionCard.vue',
|
||||
|
|
@ -28,13 +30,33 @@ const EXTRACTED = [
|
|||
tag: 'RegionSectionCard',
|
||||
owns: ['region-card', 'countries-grid', 'country-link'],
|
||||
},
|
||||
// ── REGLIB batch 2: the Driver's "banners/cards that tell you the prayer progress" ─────────────
|
||||
{
|
||||
lib: 'library/components/regions/StatCard.vue',
|
||||
consumer: 'plugin-designs/regions-dashboard/region-dashboards.vue',
|
||||
tag: 'StatCard',
|
||||
owns: ['rd-stat'],
|
||||
},
|
||||
{
|
||||
lib: 'library/components/regions/GoalCard.vue',
|
||||
consumer: 'plugin-designs/regions-dashboard/region-dashboards.vue',
|
||||
tag: 'GoalCard',
|
||||
owns: ['rd-goal-card', 'rd-big', 'rd-goal'],
|
||||
},
|
||||
{
|
||||
lib: 'library/components/charts/ProgressBar.vue',
|
||||
consumer: 'plugin-designs/regions-dashboard/region-dashboards.vue',
|
||||
tag: 'ProgressBar',
|
||||
owns: ['rd-bar', 'rd-seg', 'rd-legend', 'rd-key'],
|
||||
},
|
||||
]
|
||||
|
||||
for (const e of EXTRACTED) {
|
||||
const name = e.lib.split('/').pop()
|
||||
const dir = e.lib.split('/').slice(-2, -1)[0]
|
||||
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(plugin.includes(`@map/components/${dir}/${name}`), `${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.
|
||||
|
|
@ -52,6 +74,47 @@ for (const e of EXTRACTED) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── the card surface: ONE rule set, two components, one injected copy ───────────────────────────
|
||||
// StatCard and GoalCard both need .rd-card. A copy in each is the shape that drifts, so it lives in a
|
||||
// composable they both call, dedup'd on a stable label. This asserts that arrangement rather than
|
||||
// trusting it: the module carries the rules, both cards call it, and the plugin has neither.
|
||||
{
|
||||
const SURFACE = 'library/composables/useCardSurface.js'
|
||||
ok(existsSync(resolve(ROOT, SURFACE)), 'useCardSurface.js exists')
|
||||
const surface = read(SURFACE)
|
||||
for (const cls of ['rd-card', 'rd-card--small', 'rd-card--secondary']) {
|
||||
ok(new RegExp(`'\\.${cls}[{ ]`).test(surface), `the shared surface carries .${cls}`)
|
||||
}
|
||||
ok(/useShadowStyles\(CARD_SURFACE_CSS, 'dxlife-card-surface'\)/.test(surface),
|
||||
'and injects it under a STABLE label, so N calling components still put exactly one <style> in the root')
|
||||
for (const f of ['library/components/regions/StatCard.vue', 'library/components/regions/GoalCard.vue']) {
|
||||
ok(/useCardSurface\(\)/.test(read(f)), `${f.split('/').pop()} calls useCardSurface()`)
|
||||
}
|
||||
const dash = read('plugin-designs/regions-dashboard/region-dashboards.vue')
|
||||
ok(!/'\.rd-card[{ -]/.test(dash), 'the plugin ships NO .rd-card CSS of its own any more')
|
||||
ok(!/class="[^"]*\brd-card\b/.test(dash.slice(dash.indexOf('<template>'))),
|
||||
'and renders no .rd-card markup of its own -- all four cards are library components')
|
||||
}
|
||||
|
||||
// ── the ONE substitution in the port, asserted rather than described ────────────────────────────
|
||||
// A segment's colour moved from three prayer-specific classes (.rd-seg--full/--partial/--none) to a
|
||||
// prop. That is only faithful if the VALUES are unchanged, so: the classes are gone tree-wide, and the
|
||||
// consumer passes the very tokens they used to set.
|
||||
{
|
||||
const bar = read('library/components/charts/ProgressBar.vue')
|
||||
const dash = read('plugin-designs/regions-dashboard/region-dashboards.vue')
|
||||
for (const dead of ['rd-seg--full', 'rd-seg--partial', 'rd-seg--none', 'rd-key--full', 'rd-key--partial', 'rd-key--none']) {
|
||||
ok(!new RegExp(`'\\.${dead}[{ ]`).test(bar) && !new RegExp(`'\\.${dead}[{ ]`).test(dash),
|
||||
`.${dead} is DELETED, not left behind unreachable`)
|
||||
}
|
||||
for (const token of ['var(--dx-accent)', 'var(--dx-partial)', 'var(--dx-track)']) {
|
||||
ok(dash.includes(`color: '${token}'`), `the consumer still passes ${token} -- the colour is unchanged, only its mechanism moved`)
|
||||
}
|
||||
ok(/segments: \{ type: Array/.test(bar), 'ProgressBar takes segments as a prop, so it needs no vocabulary about prayer')
|
||||
ok(/prefers-reduced-motion/.test(bar),
|
||||
'and carries its own reduced-motion guard -- the plugin\'s reset is scoped to .rd-root and would not follow it to another consumer')
|
||||
}
|
||||
|
||||
// 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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue