The gallery re-drove the visible tab by re-setting its profile-config, which works only for a map
that re-reads its whole config live. Two production maps do not: Big Picture resolves its tStatic
strings once (2 of 352 changed on en to ar, measured) and Simple Research copies its config into its
panes once (0 of 43, 0 of 105). A fresh element reads the new locale at mount, so the visible tab is
now rebuilt in the new language whatever the map does inside, the way a provider change rebuilds.
View state is lost on a language switch; that is accepted.
Hidden tabs that are still mounted (a light tab stays mounted when hidden, and select() never
remounts a mounted tab) are reset too, so their next open mounts in the new language. The old comment
said they picked it up lazily; for light tabs they did not. Tabs are reset in place, keeping the slot
ref, because a light pane renders once and releaseTab's deleted ref would never come back.
DX Life publishes the gallery shell on a hidden page named Deep Research, so that is now the
default heading (profile-config.heading or the heading prop still override it), and the meta,
instance and demo page say the same.
profile-config experimental:false (or "hidden") hides the Experimental folder: the manifest is not
mined for experimental tabs, the folder has no rows, and a tab= request for one falls back to the
first visible tab. It is off only on request, so every other host keeps the folder as before.
useShadowStyles keys its <style> by the label and overwrites an existing
tag with the same key, so two components sharing a label keep only the
last one's CSS in a shared shadow root. The regions list lost its sheet
this way to the region card, which had kept the list's label when it was
extracted.
check:shadow-style-labels-unique (gates:pre) fails any label two calls
share, globally rather than per shadow root, so a copied or forked
component is caught when it is copied. It reads the real parsers,
classifies every reference to the function (a rebinding, .call, a
re-export or a namespace import fails instead of hiding a call),
cross-checks the AST against the token stream, and covers the other
ways two calls share a key: unlabelled calls under the dev server's
per-component key, and a fixed label over CSS that varies per caller.
countries-map.vue, a fork of research-map.vue, carried the same label;
it is now 'countries-map', with the country-posters bundle rebuilt and
the tree regenerated. The composable's JSDoc called the label a
debugging name; it now says it is the key.
country-map.vue's poster-wall wiring put the poster table, peopleGroupRecords
and the sheet code into the bundle of EVERY DX Life country page, for a wall
that is hidden by default. Measured: country-map.js 391,475 bytes on
development against 683,401 here -- +291,926, a 74% increase -- and with it
removed the bundle is 391,625, back within 150 bytes of development.
Making the three imports lazy does not help, and I measured that rather than
assuming it: with all three behind await import() and the table fetched at
mount, the bundle grew to 684,700. vite.config.js:282 sets
inlineDynamicImports, so a dynamic import is flattened straight back in and only
the wrapper is added. Removing the weight needs the pinned-served-asset pattern
that poster-render.js already uses -- card
PERF-LOAD-THE-PRINTING-SUBSYSTEM-ON-DEMAND.
library/packet/countrySheet.js stays: it is unreached at runtime (0 hits in the
built bundle) so it adds nothing, and it is the library half this element will
use once the printing code loads on demand.
ProfileLoader's comment now names both populations -- 20 of the 25 bundles are
single-profile, 10 of the 17 that actually mount this loader, which are the only
ones the default affects.
Resolving package.json conflicts by unioning unique lines is right for a file
that holds one entry per line. Applied to ordinary source it duplicates
declarations, because both sides of a hunk keep their copy and nothing
de-duplicates across the join.
Eleven plugin entry points ended with the same import twice, and the poster
renderer with two identical `const sheet`. Each is a SyntaxError: every one of
those bundles would have failed to build, and the poster export would not run.
Found by parse-checking every changed .js against the merge base, not by the
gates -- gates:pre passed 101 of 102 with all twelve files broken, because its
parse gates do not cover plugin entry points.
One measurement trap worth recording. `node --check` on a .js file parses it as
CommonJS, so a bare `import` is a syntax error and ALL 28 entry points appear
broken, on development too. That reading says the tree is on fire and is simply
wrong. Copying each file to a .mjs and checking that parses it as a module,
which separates the eleven that are genuinely broken here from the rest that
are fine.
The handoff merges brought in three references that name the Driver in shipped
text: two comments in CenteredSetTarget.vue and one _why in the people-groups-grid
instances. Reworded to state the engineering reason without the speaker -- an
open decision now reads as pending a product decision, which is what the rule
asks for. The instances file still parses as JSON.
Three lines brought in with the list-pattern doc cited the Driver; the
reference gate refuses them. The rule, the scope and the open exception
stay as stated, without the attribution.
Merging gospel/a5-cpu and gospel/panel-followup brought back two comments that
quote the Driver directly, and check:no-new-driver-references failed on them —
correctly. Development had already swept these; the merge reintroduced them from
an older base.
Both keep their reasoning and lose the attribution: a recording pill that
changes width on every word reads as instability, and on a phone the header can
take most of the panel, leaving the draft unscrollable and its last lines
unreachable. Those are the facts the rules encode; who said them is not.
The consolidation brought back two Driver citations from older branches that
the reference sweep had already cleared from development. Both keep their
engineering reason and lose the speaker: a gate's rationale for existing, and
the mechanism of the print collapse.
Caught by check:no-new-driver-references, which is the point of running the
full chain rather than the seven gates the merged package.json had left.
Each of these shadow roots is position:absolute; inset:0, while its :host had
display:block with no position. An absolutely positioned element resolves against
its nearest POSITIONED ancestor, and an unpositioned host is not one -- so the
root fell through to whatever the partner page happened to have, or to the
viewport. It only looked right standalone, where the page fills the viewport
anyway, and inside a composite's .pane, which is itself positioned. A partner
embedding the element directly gets it painted outside its box.
All three are the shadow root's own root element, with only comments between
<template> and them, so none of them has a positioned ancestor to fall back on:
regions-list.vue:408 .rl-root at :409
region-dashboards.vue:733 .rd-root at :734
regions-mfe.vue:194 .rmfe at :199
dx-shepherd's census named the first as confirmed and the other two as
candidates to check. Checked: both are roots, not children of something already
positioned, so all three are real.
The fix is the house pattern -- position:relative on :host -- matching the
already-fixed cities-internet/connectivity-map byte for byte in shape, and
prayer-walk-planner.vue:628.
One line changed per file. Verified in the ARTIFACT rather than the source: the
rebuilt regions-dashboard.js carries all three corrected host rules (two at
min-height 320px, one at 420px). check:sfc-parse, check:synced-css and
check:one-scroll-container pass; the manifest diff is integrity hashes only,
0 other lines.
country-map.vue fetched /api/people-groups/list and mapped the posts straight to
points, without applyPeopleGroupList(). Every other consumer of that endpoint
calls it -- DataSourceManager, useDashboardData, useCsvExport, peopleGroupRecords
and the people-groups grid. So a host that asked for one curated list got the
WHOLE endpoint drawn on its country map, and nothing said so: the pins looked
like data rather than like the wrong list.
The list is applied to the raw posts, before they become points, which is where
every other consumer applies it. That is what keeps this element and a dashboard
showing the same country from disagreeing about which groups are in it -- the
same reason the country filter beside it is client-side.
The default list returns the rows untouched and makes no fetch, so an
unconfigured host pays nothing. A list whose membership cannot be loaded yields
NO groups rather than the wrong ones: applyPeopleGroupList's own contract, and
the safer failure for a prayer surface.
Measured three ways, with a temporary copy of check:universal-props that adds
country-map to its BUNDLES list (the reason the gate never saw this: 19 bundles
listed, this one absent):
- RED on the base: BYPASS country-map LIST-FETCH country-map.vue:98, exit 1;
- GREEN here: 20 bundles read engine, token, locale and list through the
library, exit 0, the gate's 9 planted-bypass self-tests still caught;
- a real list value changes the drawn count, through the shipped
applyPeopleGroupList and the served membership files: Nepal draws 34 points
on the default list, 29 on "frontier", 0 on "frontier-500k". That last zero
is data, not a failed load: the same membership matches 2 India rows.
Rebuilt on this base rather than carrying the earlier bundle, because
development rebuilt the same files in between and generated output is
regenerated, never merged. The list-membership URL is present in the built
bundle; the manifest diff is integrity hashes only, 0 other lines.
Two defects, one cause: on a short panel the engine-offline notice took the room
the draft needed.
FIRST, A PHONE RULE THAT NEVER RAN. bac2431d0 (19:59) put notices below the
draft "so the draft stays in view". 4e94e9216 (21:58) added the corner widget
with `.compact .notice { order: 0 }`. A media query adds NO specificity, so
`.compact .notice` (0,2,0) beat `@media { .notice }` (0,1,0) at every width and
the phone rule has been dead since the day it landed. Ordering the compact rule
to 1 fixes the widget at every width, not only under 480px, because the corner
panel is short regardless of the viewport.
SECOND, ORDER ALONE WAS NOT ENOUGH, which I only found by doing the arithmetic
rather than trusting the first fix. .notice is flex:0 1 auto and .compact
.compose is flex:1 1 0, so the draft yields whatever the notice's content asks
for no matter where it sits. The offline notice is ~132px at 375 and ~179px at
390 — reference text a reader needs once, holding a third of a short panel
forever. Capping it at 96px with its own scroll gives the draft 58px at 375x450
and 124px at 390x560, against rega's measured 22px and 41px.
The panel is 60vh (plugin-studio.vue:466), NOT the viewport. Sizing off the
viewport is what made my earlier estimate wrong by 58px; the model that uses the
real panel reproduces rega's 22px exactly.
No floor anywhere: a floor is what clipped the draft to begin with.
(cherry picked from commit 79cbb41bc3)
Same defect as cities-internet's list and table: no :host rule at all, so the
element falls back to display:inline with no height while .gt is
position:absolute;inset:0 — which then resolves against whatever the partner
page has positioned.
TWO MOUNTS, ONE RULE, and they do not collide:
- STANDALONE (data-tables.meta.json) this is its own custom element, so :host
applies and .gt is finally contained.
- COMPOSED, gospel-dashboard renders <DataTables/> as an inline Vue component
with no shadow host of its own, so :host matches nothing there. Its
'.gd-tables .gt{position:static;…}' override (gospel-dashboard.vue:81) keeps
working, which is what lets the stacked section take its natural height and the
page scroll. That override records a MEASURED failure — docScrollHeight 900
against clientHeight 900, a wheel gesture moving 0px — and must not return.
min-height, not height alone: with no height, .gt's inset:0 would give the table
0px in an auto-height box, so it would stop escaping by becoming invisible. A
floor gives an auto host something to grow from; a definite-height slot still
decides through height:100%.
NOT the ProfileLoader 100dvh floor (ProfileLoader.vue:410), which is the separate
upstream cause of the connectivity-map overflow and is studio's card. .gt is not
that wrapper, so this fix stands on its own.
(cherry picked from commit 10f2a5c13f8d08c0086ada8eafa4ad819863bd0a)
Neither element had a :host rule at all, so each fell back to the custom-element
default — display:inline, no height of its own — while its root div carried
position:absolute;inset:0. Absolute positioning resolves against the nearest
POSITIONED ancestor, so on a partner page both resolved against the PARTNER's
layout rather than against this element.
rega measured it on a partner page with a 420px host box: connectivity-list
painted to y=16,799 and data-tables to y=1,698 — 16,355px and 1,254px below the
box, over whatever the partner had underneath.
The same fix as connectivity-map's (60cb4ff18), for the same reason:
:host{display:block;position:relative;width:100%;height:100%;min-height:320px}
display:block gives the element a box; position:relative makes that box the
containing block; height:100% lets a definite-height slot decide; min-height is
the floor so an auto-height host grows to fit instead of collapsing.
NO NEW SCROLL CONTAINER WAS NEEDED. .ci and .ct already carry overflow-y:auto —
the scroller existed, it simply had no host to be bounded by. Once contained,
the list and the table scroll inside the box, which is what an embed should do
in a definite-height slot.
min-height is 320px here, not the map's 420px: regions-list.vue:408 is the house
pattern for list profiles, and a list is legible shorter than a map is.
(cherry picked from commit 08ad95fa86e64dfe043befb156e358c91319e511)
country-map.vue fetched /api/people-groups/list and mapped the posts straight to
points, without applyPeopleGroupList(). Every other consumer of that endpoint
calls it -- DataSourceManager, useDashboardData, useCsvExport, peopleGroupRecords
and the people-groups grid. So a host that asked for one curated list got the
WHOLE endpoint drawn on its country map, and nothing said so: the pins looked
like data rather than like the wrong list.
The list is applied to the raw posts, before they become points, which is where
every other consumer applies it. That is what keeps this element and a dashboard
showing the same country from disagreeing about which groups are in it -- the
same reason the country filter beside it is client-side.
The default list returns the rows untouched and makes no fetch, so an
unconfigured host pays nothing. A list whose membership cannot be loaded yields
NO groups rather than the wrong ones: applyPeopleGroupList's own contract, and
the safer failure for a prayer surface.
Measured three ways, with a temporary copy of check:universal-props that adds
country-map to its BUNDLES list (the reason the gate never saw this: 19 bundles
listed, this one absent):
- RED on development: BYPASS country-map LIST-FETCH country-map.vue:98, exit 1;
- GREEN here: 20 bundles read engine, token, locale and list through the
library, exit 0, the gate's 9 planted-bypass self-tests still caught;
- a real list value changes the drawn count, through the shipped
applyPeopleGroupList and the served membership files: Nepal draws 34 points
on the default list, 29 on "frontier", 0 on "frontier-500k".
Rebuilt, tree regenerated, SRI refreshed. The list-membership URL is present in
the built bundle; the manifest diff is integrity hashes only, 0 other lines.
The row meta joined its first three languages with English commas and cut the rest silently, so a truncated list read as complete. It now goes through formatList (Intl.ListFormat) as a conjunction whose LAST item states the cut, from keyCities.list.moreLanguages ("{n} more", landed in R29): "Hindi, Urdu, Bengali, and 2 more" / "Hindi وUrdu وBengali و2 أخرى" / "Hindi、Urdu、Bengali和另外2种". Three or fewer languages show as they are. Not 'unit': in zh it joins names with no separator, and in ar and hi it still adds a conjunction (studio, measured).
join(', ') is English list grammar. The two user-facing sites now go through the library's formatList (Intl.ListFormat, R29), as conjunctions because each is the FULL list:
- PPLR's Rosetta popup, "Not yet cross-mapped in PPLR: {ids}";
- UPG Neighbor's bridge-language cards, "Believers in: {list}".
Key Cities' shortened language list follows once its "{n} more" key (keyCities.list.moreLanguages) is on development. The joins that stay are not reader lists: a thrown error's details, the bridge-languages CSV columns, and a gate's console line.
Two reader-visible lists were joined with a hardcoded ', ': the ADM1 names
in the selection error ('no people-group data for {names}') and the
selected territories in the batch HUD. Both use formatList now (R29), so
de reads 'Gujarat, Rajasthan und Odisha', zh 'Gujarat、Rajasthan和Odisha',
ar 'Gujarat وRajasthan وOdisha'. The locale is passed from the i18n ref
(packetLang), not left to getRuntimeLocale, so the HUD's list re-renders
on a runtime language switch. formatClipboard's join stays: its only
caller is a test handle.
Three places joined names with ", ", which is a list only in English. Arabic
separates with a word, Chinese uses a different mark entirely and no comma at
any position, and German and French put a word before the last item. The
separator and the final connector are the grammar, so no catalogue key can fix
them from outside.
Two of the three carried a second, worse error. A multi-value filter holds
ALTERNATIVES -- the bus matches a row if ANY of its values matches, in
useSelectionBus with some() and in the data table with includes() -- so the
chip means "Islam or Hinduism". Joined with a comma it reads as "and" in every
language with an explicit connector, which states the opposite of what the
filter does. Those two become a disjunction.
The third is the screen-reader live region, which names the facets currently
applied. Those hold together and narrow the set in turn, so it is a
conjunction. It is also the one string here that some readers rely on entirely,
having no chips to glance at.
The composable is the single owner: every surface that turns a filter into
words comes through it, so the chip bar, the research-filter chips and the CSV
provenance line now say the same thing in the same language.
Left alone deliberately: five joins in this plugin's gate scripts. Those are
failure messages read in a terminal by whoever runs the gate, not text shown to
a reader, and formatting them per locale would help nobody.
Source only. This needs the bundle rebuilt before it ships.
formatList (R29) replaces join(', ') at the two sites that a reader sees. The
separator was never a missing key — no key can fix punctuation between
interpolated values — which is why both survived every i18n gate.
connectivity-map.vue:365, CONJUNCTION. The Indian states with no boundary are
ALL missing one. The names land inside a translated sentence at :595, and the ar
message reads "لا توجد بعد حدود ولاية لـ {names}: …", so English commas were
being printed inside Arabic prose.
GiCombos.vue:49, DISJUNCTION, decided from the matcher and not from what reads
better: useSelectionBus.js:299 tests cur.includes(value) and toggleFilterValue
adds to the list, so two values on one facet WIDEN the result set. "Africa and
Asia" would state the opposite of what the filter does. This changes the English
too — "Africa, Asia" becomes "Africa or Asia" — which is a gain, because a comma
silently reads as ALL.
Measured, the same three names and two facet values through each type:
en "Ladakh, Puducherry, and Chandigarh" "Africa or Asia"
ar "Ladakh وPuducherry وChandigarh" "Africa أو Asia"
zh "Ladakh、Puducherry和Chandigarh" "Africa或Asia"
was "Ladakh, Puducherry, Chandigarh" "Africa, Asia"
ar joins with و and no comma at all; zh uses 、. Neither is reachable by keying.
Not converted, and deliberately: useBoard.js:126 is a console.warn for
developers, and rudder-voice.vue:971 is in a component with zero i18n imports and
no catalogue area — English by construction, so it converts with the rest of
Rudder if Rudder is ever localized.
Source only. The rebuilds are regenerated after this rebases onto landed R29.
73bc270bc gave :host a height and stopped the ELEMENT overflowing. It did not
stop the MAP, because it never made the host a containing block.
.gm is position:absolute;inset:0, which resolves against the nearest POSITIONED
ancestor. :host had display:block and a height but no position, so on a partner
page .gm resolved against whatever the PARTNER had positioned and the map sized
itself to that. rega measured it in the hostbox run on ca07fc3c8: the element
correct at 420px inside a 420px box, the canvas inside it still 900px, and the
"Tap a coloured dot" pill and the attribution painting 478px below the box —
538px in a 360px box — over the partner's own text, whose top sits at y=464/404.
position:relative on :host is the whole fix, and it is the house pattern:
prayer-walk-planner.vue:628 carries the identical shape with an absolute child.
Worth recording, because the first fix passed every check: a host box that is
the right size proves nothing about what is drawn inside it. Only a partner-page
shot with content BELOW the box can see this, which is why rega's hostbox run
found it and my source reading did not.
With no resolver configured, the kit chip bar joined a multi-value filter with ', ', so "Africa,
Asia" read as ALL to most people and carried English commas in every locale. The selection bus
matches several values on one facet with includes() (useSelectionBus isFilterActive), so adding a
value WIDENS the result: they are alternatives. The chip now uses formatList(value, { type:
'disjunction' }): "Africa or Asia", "Africa أو Asia", "Africa或Asia". gospel traced the matcher
and made the call as the only consumer's owner (gospel-internet's GiCombos). The English on this
chip changes from a comma to "or".
check:legend-mirrors lifts this exact expression and drives it with no resolver. It now lifts
formatList from library/i18n the same way (the shipped grammar, not a copy) and expects
"Islam or Hinduism". Poisoned with the old join it fails; restored it passes. formatList's doc now
says to choose the type by what the list MEANS, and never 'unit' for names.
useLegendMapBridge.js now exports regionIdle (sr, pair 1). The local
function is deleted and the name imported beside the bridge's other
exports; all 15 calls keep their values, the dynamic regionIdle(v)
included. Built bundle: the idle expression occurs exactly once before
and after (the local copy before, the library's after), 1 byte apart;
the country-posters composite still mounts. check:region-paint-single-
source PASS. Also drops a comment left behind when clearCountryHighlight
moved to the same library module.
useLegendMapBridge took the idle paint helper as an injected dependency and
called it five times, while each consuming map defined its own copy of the
one-line body and handed it over. The library owned the paint logic and not the
expression it painted with, so the copies could drift apart silently and the
two maps would render the same idle state differently.
Export it from the composable instead, and take it from there in this map. The
injected parameter is deliberately KEPT for one round: inside the composable
the parameter shadows the module export, so what the bridge paints with is
still exactly what its callers pass, and this change cannot move behaviour. It
is a deletion in the plugin and an addition in the library, nothing more. The
parameter goes when the second map has adopted the export too.
This map held a THIRD copy under a different name -- a function-scoped helper
in the paint prewarm, byte-identical body, used once. It now refers to the
library helper.
Verified rather than assumed: the library version and both original plugin
versions produce identical output for every value the maps actually pass,
including the dynamic one. check:region-paint-single-source is green for this
map and stays red for the other until it adopts the export, which is the state
the gate is meant to report.
The second map is the other plugin's file and is not touched here; its owner
takes that half.
Until now these ran from _qa-cycle, so they proved a landing once and guarded nothing after it. Each now lives with its plugin, locates the repo from its own path, and fails on its known-bad:
- wall-of-people-groups/scripts/check-wall-wysiwyg.mjs: the screen poster and the print are one layout on every preset (sheets, 2,107 card rects to 0.002 in, name size and wrap, reference size, chrome blocks). Planted: a card moved 0.01 in, a name a point larger, the footer missing on screen. About 0.6 s.
- wall-of-people-groups/scripts/check-wall-print-watch.mjs: the HD print counts its photos while they load, counts the ones that could not load, finishes at load or the 10-minute ceiling, and ends when its frame is gone. Planted copies of printWatch.js now go to the OS temp folder, never into the tree. About 0.3 s.
- wall-of-people-groups/scripts/check-wall-no-bg-photos.mjs + fixtures/pray-list-sample.json: the real store (through Vite) never requests a photo on its own, a group with no photo URL gets none, and one with a URL keeps it. Planted: the old background test, added through the store's $onAction. The store from before L12 (d9e9aa448) fails both arms: 12 photo requests in 3.5 s and 3 groups on the 404 placeholder. The fixture is a trimmed sample of the live list: 200 rows (120 with has_photo, 77 without, 3 synthetic with no image_url), only the fields the check reads, 66 KB. About 10 s.
- template-bundle/scripts/check-template-i18n.mjs: profile B renders from template.json in en, ar and zh, one sentence in each language's own order with both <code> islands, and no raw key. Planted: the English catalogue served as Arabic. About 4 s.
All four are headless: no browser seat.
This corrects my own "Other" fix (9d12e59b6), which could never fire. gospel's cities-internet analysis (87a168195) showed the same shape here: key-cities.json has no row with an empty region (1,646 rows), "Other" (2) and "N/A" (19) are region VALUES, and every section heading printed its raw English data string in every locale. The fallback I translated also put a label into the grouping key.
Now, as in cities-internet: the raw region string stays the grouping key (sections, :key, collapse state), with 'Other' as the data guard; the heading is projected at render by regionLabel.js from the keys the data's strings already are the English values of (dashboard.wagfRegion.*, dashboard.other, wall.na), written out in full so the build ships those areas. The poster wall, whose PosterWall prints its section name as given, groups by that projected heading, as country-posters' wall does. keyCities.posters.otherRegion is no longer used.
Headless (_qa-cycle/exp-shepherd/check-keycities-regions.mjs): every one of the nine region values maps to its catalogue heading in ar, zh, es and ru, and an unknown value passes through as data. key-cities is gated, so nothing in public/ changes.
The chips a reader sees on a group's sheet — "Scripture,", "Bible video",
"Training", "Gospel presentation" — were plain template text. All four already
had keys with real ar and zh translations, so this routes them through
gospel.res.* and adds nothing: scripture, bibleVideo, training,
gospelPresentation.
WHAT IS DELIBERATELY LEFT ALONE, because it is not a label:
- g.audio.m keeps its raw value and its 'audio' default. That is the table's own
media descriptor, data rather than UI text.
- gospel-resource-rows.js is already correct. add(title, type, …) translates
title through tx() and keeps type as the category identifier the rows are
grouped by; the gate flags those type strings, but translating them would
break the grouping — the same value/label trap as the region headings.
- d.s.replace('Open Bible Stories', 'Bible Stories') at :2754 rewrites a DATA
value, not a label.
- data-tables.vue:122 already carries labelKey; its `en:` is the fallback beside
it, by design.
- The caveat prose (Driver row 80) and the title= tooltips (row 120) are
untouched and stay that way until those rows are answered.
check:hardcoded-english on gospel-resources: 91 -> 87, which is these four.
rega's L12 photo probe read host.__wallLod().cardPx as 0 after 46 wheel ticks: the field was set once when the cards were first laid out and never again, since the canvas renderer replaced the LOD component that used to update it. The canvas now records one card's on-screen width at every frame it draws (stats.cardPx, both the grid and the poster), and the hook returns that. The container's header comment no longer claims Font Awesome is loaded: the canvas draws its own icons, and d3 is pinned and kept off window.d3 (src/wallD3.js).