Skip to content

Tier 8, waves 7-14: i18n, blocks, embed assets, data contract, CSS, lint gates, tests - #25

Merged
fmadore merged 17 commits into
mainfrom
tier8-waves-7-14
Sep 7, 2026
Merged

Tier 8, waves 7-14: i18n, blocks, embed assets, data contract, CSS, lint gates, tests#25
fmadore merged 17 commits into
mainfrom
tier8-waves-7-14

Conversation

@fmadore

@fmadore fmadore commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Continues the Tier 8 roadmap. Opened early so CI can exercise the Omeka integration additions (the embed parameter matrix needs a booted Omeka with a database, which is not reproducible locally). Still adding commits.

M7. Seven different "West Africa" defaults had accumulated for one
region, so the same six countries opened at a different scale depending
on which block you were reading. `P.WEST_AFRICA_VIEW` and `P.FIT_OPTS`
are that view and that padding; the three maps whose subject really is
wider — source provenance, scholarship provenance, the admin-boundary
overlay — pass their own and say why.

The globe toggle shipped on ten of twelve maps while ROADMAP §4 and §10
both list globe projection as "won't do", because the factory defaulted
it on. It is opt-in now, and nothing opts in. Rotation and pitch are off
on flat thematic maps, so the compass that resets a bearing nothing can
change and the dial that visualises an angle that is always zero are off
too. The references map had two fullscreen buttons doing the same thing
to different elements.

M8. MapLibre's own strings stayed English on the French site — a screen
reader on islam.zmo.de/s/afrique_ouest heard "Zoom in", "Enter
fullscreen", "Close popup". Every key MapLibre 6.6 reads, verified
against the pinned bundle's own defaultLocale, is translated. Eleven of
twelve map hosts also carried no accessible name at all, so the reader
reached an interactive canvas called "application"; `config.title` is
now both `Map.Title` and the host's aria-label.

M9. `ns.prefersReducedMotion` has existed since v1.30 and the canvas
graphs honour it; no map did. Eleven animated camera moves go through
`P.mapMotion(ms)`, which returns 0 under the preference.

M6. Every map on every page carried `preserveDrawingBuffer: true` — the
whole site paying on every frame for a PNG export almost nobody runs.
The toolbar reads the canvas inside MapLibre's own `render` event
instead, which is the moment the buffer is both filled and still
readable; that made `resolveDataUrl` a promise, which both callers
already were.

M5. The choropleth wrote its counts into the GeoJSON as `_iwac_count`,
so every count change re-ran `setData` and MapLibre re-parsed and
re-tiled ~200 KB of polygons in the worker — the keywords-attention
slider does that per tick and per play step. The counts are a `match`
expression on the country name now: geometry uploaded once, a count
change is one `setPaintProperty`.

M3. The laïcité places view stopped leaking a WebGL context in v1.59.0
but still built a new one per activation, and browsers cap live contexts
around sixteen and silently lose the oldest. The view is parked between
switches, like scary-terms'. Its ranked-places list — the keyboard route
to the same data — is rebuilt on update, because a parked view that
refreshed the map and not the list would have left the two disagreeing.

M19. The popup bounds were ~150 lines: six overridden methods, a
`resize` listener per open popup, and three layout constants transcribed
from MapLibre's stylesheet and left citing 6.3 while the pin moved to
6.6. `.iwac-vis-map` is a size container now and `iwac-maplibre.css`
states the two guarantees directly — half the map's usable height minus
the tip, two thirds of its usable width.

One line of JavaScript survives and it is not styling: MapLibre picks its
anchor by comparing the marker against `maxWidth`, the OPTION, not the
box CSS produced, so left at 320px it chose a left/right anchor one
threshold early and hung the popup outside a narrow map. `addTo` reads
back the resolved width and hands MapLibre that number. The browser spec
that pins the guarantee at all seven anchor thresholds passes; it caught
two real errors on the way here — the usable box is the map minus
MapLibre's 16px anchor inset, not the whole map, and a `min-width` floor
beats a `max-width` cap.

M21. Nothing outside the asset partial stated the current pin, so
ARCHITECTURE.md now says the partial is the only place versions live and
that the historical 5.24 entries are records of a decision, not claims
about today.
E2. The bar chart race animated bar lengths in fixed slots while the
labels swapped instantly, which is a bar chart that changes. Three
things were wrong at once: `realtimeSort` was never set, each frame
sliced its own top ten and re-supplied the category axis, and every
frame was `notMerge`, so there was nothing to animate from.

It is now ECharts' documented recipe — every term against a fixed axis
ordered by final total, `realtimeSort` doing the ranking, `yAxis.max`
bounding the podium at ten, `label.valueAnimation`, timings matched to
the 1 s tick with linear easing, and an advance that MERGES
`series[0].data`. `C.scaryTermItems` builds those items for both paths,
because a merge replaces the array wholesale and bare numbers would have
dropped every per-term colour on the first tick. Under
prefers-reduced-motion the theme still sets `animation: false` and the
frames snap, which is the right reading for a chart whose content is
motion.

E11. Two heatmaps painted their axis labels `muted` where the registered
theme paints every other axis on the site `inkLight` — not a duplicate
but a contradiction, and the heatmaps were the only charts whose axis
text sat below everyone else's contrast. Gone, along with the
`axisLine` and `visualMap.textStyle` restatements the theme already
owns. `'#fff'` on the segmented bar's in-bar percentage was a genuine
hardcode measuring 3.15:1 on the palette's orange; `C.inkOn(fill)`
picks between the theme's two extremes by contrast, which is what the
treemap has done per tile since v1.53. The `fontSize` literal appeared
at four values for one decision and is `P.AXIS_FONT_SM` — in panels.js,
not chart-options.js, so a block that skips the chart bundle gets a
number rather than `undefined`.

E12. `progressive: 2500 / progressiveThreshold: 3000` on the semantic
landscape was configuration that did nothing: ECharts compares the
threshold against ONE SERIES' length, and every facet splits ten
thousand points into buckets of a few hundred. Deleted rather than
tuned. `large: true` would engage and is rejected — it drops per-point
emphasis, which on a map whose purpose is inspecting individual points
is the feature, not the cost.

The other half of E12 was real: the collaboration network was bounded by
`min_degree`, which limits how CONNECTED a node must be, not how many
survive — a denser dataset raises every degree and keeps more. The front
end lays that graph out with `layoutAnimation: false`, synchronously, on
the main thread. `MAX_COLLABORATION_NODES = 400`, most prolific kept,
count of dropped nodes in the payload.

E13. `echarts.init` took no options, so ECharts worded anything it
generates itself in its own default language rather than the site's.
`registerChart(el, render, initOpts)` passes the locale and forwards
whatever else a caller wants — `renderer: 'svg'` for a small static
chart is now available rather than unreachable.

E15, decided rather than deferred. ADOPTED: `legend.selector` on the two
legends dense enough to need it (twelve term-trends series, thirty LDA
topics), with translated button labels. SKIPPED, with reasons recorded:
the `matrix` coordinate system, which closes ROADMAP 4.6 — its one
distinguishing feature is column super-headers and neither matrix wants
one; piecewise `visualMap` on the centrality heatmap, because that cell
is a MEAN on a 1-5 scale and five discrete pieces would present a
continuous quantity as category membership.

E16. `C.itemTooltip(title, lines)` is the shape fifteen formatters built
by hand. It takes the title and the lines separately on purpose: the
title is always a datum and is escaped here, the lines are HTML the
caller composed, and folding them into one string is how an unescaped
title eventually ships. `C.tooltipDot` is ECharts' own marker for a row
a formatter draws itself.
…panels

S10. The i18n lint read one file — about 55 % of the module's strings. The
other 45 % live in thirteen dictionaries that call `addTranslations`, the
newest code in the module, watched by nothing. It reads all fourteen now,
pairs them, and reports a per-block key that shadows a shared one.

It found thirteen shadowings on its first run, twelve redundant and one
real: `Top subjects` rendered as *Principaux sujets* from the item-set
dashboard and *Sujets récurrents* from everywhere else, and which one a
page got depended on bundle order. Every shadow is gone; parity is
checked on the merged dictionary, because that is what `t()` resolves
against. Both new rules are proved able to fail.

S14. `t()` interpolated and nothing more, so a dashboard reporting one
article said "1 articles", and French "0 article" could not be expressed
at all — English and French disagree about zero, which is why this is an
`Intl.PluralRules` lookup and not an `n === 1` test written once and
wrong on one of the two sites. `key_one` / `key_other` are tried before
the bare key, so nothing had to be migrated and a string that does not
vary needs no variants. The three hand-rolled exceptions with three
different conventions are gone. `P.labelColon` gives French its
non-breaking space before the colon — `Pays :`, not `Pays:`.

S12. The keyword explorer read its current page back out of the
PAGINATION LABEL with a regex and re-attached its Add / Remove buttons
from a MutationObserver on the tbody. A change to that copy, the page
size or the French wording broke the buttons silently. `P.buildTable`
takes `render: function (row, td)` now and builds the cell from the row
it is already holding; `api.page()` exposes the cursor for anyone who
needs it. Forty lines of DOM archaeology deleted.

S13. Embed panel slugs were positions in document order, and three
blocks do not always render the same panels: sentiment-atlas inserts per
model, periodicals drops one for an old bundle, and a panel built after
the settle window was never counted. Any of those silently renumbered
every permalink after it. `P.buildPanel(…, { key })` names a panel and
enumeration prefers the name; the seventeen panels in those two blocks
are named.

S16. `compact` was sampled once per draw, so rotating a phone, opening a
panel to fullscreen or dragging a window past 600 px left the dense
layout on a narrow chart until something else happened to redraw.
`registerChart`'s existing ResizeObserver re-runs the render when the
answer changes — and only then. `P.isCompact(el)` reads the ELEMENT's
width; the arenas grid read `window.innerWidth` and so laid a 400 px
embed out in three columns.

S20. Three empty states bypassed the shared banners and so announced
nothing to a screen reader. The scary-terms matrix built an ECharts
`graphic` text that re-resolved `--muted` and the font family by hand,
two lines from two calls to the shared option that does the same thing.

S21. Spatial's pinned popup waited 720 ms for a 700 ms `easeTo` — two
numbers kept in step by hand, and three quarters of a second of nothing
under prefers-reduced-motion. It waits for `moveend`. The embed layer
fired seven synthetic `window.resize` events over 1.5 seconds hoping one
landed after the reflow, waking every unrelated listener on the page six
times over; `ns.resizeCharts()` re-measures what this module tracks, and
a ResizeObserver answers when the layout actually settles.

S23. `P.fetchJSON` de-duplicates concurrent requests for one URL —
item-set-dashboard and compare-newspapers both fetch the same index, and
every minimal-item container on a page fetches the same summary. Only
the in-flight promise is shared, so this is de-duplication and not a
cache: a failure is not remembered. The choropleth's in-flight half is
retired; its RESULT cache and spatial's LRU are not the same thing and
stay, with a note saying so.
S24. The shared dictionary was 104 KB on every block page, including
article pages that use about a tenth of it. 116 keys belong to exactly
one block — the five the finding names — and now live in
`charts/<block>/i18n.js`, loaded first in that block's bundle.
`shared-core.min.js` drops from 134.8 KB to 118.6 KB on every page.

Which keys could move was derived, not guessed: every `t('literal')` in
`asset/js` was attributed to the bundles that carry the file, and a key
was only a candidate when every reference resolved to one block bundle
AND the key was not named as a bare string anywhere outside it — a
per-block `TYPE_I18N` map elsewhere would have made it look local while
being reached dynamically. Thirteen candidates failed that second test
and stayed.

The move is CHECKED, which is what makes it safe to do at all.
`check-i18n.js` gained a reachability rule: `addTranslations` merges at
runtime, so a per-block dictionary is only in memory on a page that
loaded that block, and a key moved into the wrong one renders as itself
— silently, and only on the pages that do not load that block. The rule
walks every literal `t()` key in every bundle and fails when it is not
reachable from shared plus that bundle's own dictionaries.

It found a live-adjacent bug on its first run, before any key moved:
`charts/shared/concordance.js` is in `shared.ui` and reached for
`concordance.tagged_hint`, which lives in the LAÏCITÉ dictionary. Only
laïcité uses the component today, so nothing is broken in production —
but the string names "Laïcité" specifically, so the hint is the caller's
to supply now, and the second block to use the shared concordance gets a
tooltip rather than a raw key.

S19. The last two `<select>` elements in the module rendering as the
browser's default control got `.iwac-vis-control`; a test now walks
every `P.el('select')` and fails on a bare one.
S4. Five collection-overview panels each built their own Country facet,
so picking Bénin on the timeline left the Gantt, the languages bar and
the word cloud on "all countries" — the same choice made five times, and
any two panels free to disagree about what the reader was looking at.
The laïcité dossier held four separate country keys for the same reason:
Togo on the timeline had to be picked again on the map, the arenas and
the concordance.

`P.linkFacet` is the contract: a panel says how to READ its current
country and how to APPLY one, and publishes from its own facet's
onChange. A block store holds the value. `P.buildLinkedFilterBar` is the
chip under the block heading that says a filter is active and clears it
— without it a linked selection is invisible, and four panels narrowing
with no visible cause is worse than four panels not linked at all.

Two things the shape has to get right, and does:

  - The loop. A facet bar's `setActive` fires its own `onChange`, so a
    panel applying a value that came FROM the store would publish it
    straight back, and two linked panels would ping-pong. The applying
    flag stops it; a test asserts each listener moves exactly once.
  - The ordering. `apply` normally reaches back for the object
    `linkFacet` is still returning — the caller writes
    `var link = P.linkFacet({ apply: … })` and its apply calls
    `link.publish`. So the initial adopt is a microtask; running it
    synchronously is a ReferenceError in strict mode and a silent no-op
    behind an `if (link)` guard, neither being what a caller meant.

A panel that renders late — the lazy map, the deferred word cloud —
adopts whatever the block already holds rather than opening on "all".
The two multi-mode panels clear back to Global rather than to an empty
country picker, and ignore a country they have no slice for, so linking
can never blank a panel.

The country bar and the treemap are selectors now: they were the two
charts on the page that named a country and did nothing when clicked.
Clicking the active one clears the filter.

Not linked, deliberately: the collection map's "By country" facet is a
mode toggle plus a TYPE picker that draws a choropleth of every country,
not a single-country filter — there is no country in it to share. In
laïcité, the four keys are kept in step through the reducer that already
holds the block's cross-field rules, and only for a country the dossier
actually has: an unknown value would narrow a view to nothing.
P8. `extract_year` called `pd.to_datetime` before its regex, and it is
called from ~40 sites several passes deep over 12k rows. Nearly every
date here is "YYYY", "YYYY-MM" or "YYYY-MM-DD", so the ISO shape is
matched first and everything else still goes to pandas: 275x faster on
that shape, and verified output-identical against the pre-change function
across 31 inputs. The two bare `except Exception` around it now name
what can actually be raised — a bare catch also swallowed a
KeyboardInterrupt and returned None as though the date were unparseable.

P10. The output contract was convention. `scripts/validate_data.py`
makes it a contract: every expected bundle parses, carries a generation
timestamp, and has the top-level keys its panel reads. The key table is
mirrored by hand from the consuming JS and says so — a regression net for
the shapes that exist, not a generated schema — and the script has a
self-test that proves each check can fail, wired into the lint workflow
where it needs no data.

It found two bundles with no provenance at all. `laicite-trends.json`
has a timestamp now; `scary-terms-temporal.json` is a bare year -> counts
map with nowhere to put one, so it is exempt by name rather than by
inference. All four metadata idioms are accepted — forcing one shape
would mean re-reading every consuming panel — but "no timestamp" and the
`+00:00` form are not. The three fan-outs that emitted `+00:00`, plus
two more raw `isoformat()` sites, go through `generate_timestamp()` now,
which is what Tier 5 already claimed.

P11. `tests/python/requirements.txt` pinned numpy 2.5.1 while numba
holds the generators below 2.5 and the lock resolves 2.4.6 — the unit
tests ran on a numpy the generators cannot use, which is the one
configuration a test suite must never be in. They match, and
`lint:python-lock` fails when they drift again. `scripts/README.md` told
local users to install the lock with `--require-hashes`, which cannot
work off Linux/3.12/x86-64: its hashes name wheels for the runner. CI
gained a failure summary; a 90-minute job that fails otherwise leaves
nothing but a red tick.

P15. The argparse prologue was hand-rolled in 19 of 31 generators.
`add_standard_args` + `parse_standard_args` replace it — 327 lines
deleted — and the migration is verified rather than eyeballed: a harness
captured every generator's parsed defaults and full option list before
and after, and all 31 are identical. The seven bundles that ship
pretty-printed are documented as deliberate rather than left looking
like oversights.

P16. Four generators counted a raw `country`, so "Benin", "Bénin" and
"benin" were three countries in the same chart. `iwac_utils.IWAC_COUNTRIES`
is the one list; the spatial generator's `FOCUS_COUNTRIES` reads it.

P18. `copy_to_build` had no callers and targeted a `build/data` this repo
does not have. `check-python.js` hard-coded one contributor's absolute
Python path — it looks for `$PYTHON`, then the project venv, now.

P19. Tests for two of the three largest untested generators. They found
a real bug on the first run: `compute_timeline` read the `country` cell
whole while `compute_country_distribution` one screen down splits the
pipe, so a value like "Bénin|Togo" became its own stacked series — the
country bar and the timeline of the same block disagreeing about what a
country is.
H5. Nineteen of the twenty-one page-block templates were the same single
call to `iwac-block-shell` with a different literal array — the CSS
sheet, the `needs` flags, the bundle, the loading message. That put a
block's asset declaration somewhere other than every other fact about
it: label, description, embed slug and embeddability all live in
`BlockRegistry`, which the block layouts, the embed controller and
`check-blocks.js` all read. "Which blocks load MapLibre?" was nineteen
files to open.

The array is a `shell` key on the registry row now, and those nineteen
render through one `_generic.phtml`. The two blocks that do more than
declare keep their own templates — collection-overview server-renders
its summary from the snapshot on disk, on-this-day reads a layout
setting — and the routing is on the key's presence, so adding logic to a
block means adding a template, not editing a method.

`check-blocks.js` grew with it, and its new rules are the interesting
part: a block with NEITHER a shell nor a template renders nothing, and
one with BOTH renders whichever the router picks. Both now fail the
build, as does a shell that declares its own `embedSlug` — `_generic`
passes the registry key, so a second copy could only disagree with it.
Its registry parser had to learn indentation: the arrays a shell nests
were being read as rows of their own. 62 more PHP checks.

H2. The sync job's temp trees are job-scoped and cleaned in a `finally`,
which does not run on a SIGKILL or an OOM kill — and the cleanup only
ever removed the CURRENT job's siblings. Every hard-killed sync left
roughly 18k files under `files/iwac-visualizations.tmp/`, forever. The
job sweeps them at start, under the exclusive lock it already holds, and
only entries matching the job-scoped names it creates.

It also refuses to extract without room: twice the expanded size, which
the archive scan already computes, because the staging tree and the
outgoing tree coexist until the second rename. A partial extraction is
the worst outcome available — it passes the marker check and publishes a
truncated dataset.

And it retries a download once, on the three transport errno values
only: 7, 28, 56. A 300 MB CDN transfer fails sometimes for reasons that
have nothing to do with the archive, and a single blip used to mean a
red job and a manual re-run; an HTTP 404 or a TLS failure still fails
immediately, because those will fail again.
H6. The on-view loader was ~50 lines of string-concatenated JavaScript
built inside `iwac-assets.phtml`, which made it the only inline script on
a block page. A host with a `script-src 'self'` Content-Security-Policy
therefore broke every block on the site, with no way to fix it: Omeka's
headScript has no nonce plumbing. It was also the one piece of
JavaScript deciding when everything else loads, and the only piece with
no lint and no tests — `assets.test.js` had to parse it back out of
rendered PHP concatenation to check anything about it.

It is `asset/js/iwac-lazy.js` now, bundled and loaded like any other
script, and each block's payload travels as
`<script type="application/json" class="iwac-vis-lazy-manifest">` — inert
under any CSP, because it is data. The tests read the file and supply the
payload the way the browser does; a new test asserts neither partial
calls `appendScript` and that the JSON is emitted with the HEX flags that
keep a `</script>` in a filename from breaking out.

The embed layout was the same story twice more: 32 lines of inline
`<style>` and a 28-line inline `<script>`. They are `asset/css/
iwac-embed.css` and `asset/js/iwac-embed-height.js`. The brand accent
splits along the line that matters — the per-request VALUES stay inline
as a `style` attribute on <html>, which `style-src-attr` governs and a
policy blocking `<style>` elements commonly still allows, while the RULES
that consume them, including the dark-mode lightening that cannot be an
attribute because it is a different selector, move into the sheet. The
escaping comment that block carried is still true and still needed, and
now says why an attribute is the one place `escapeHtmlAttr` is right.

H7. Embed responses carry `Cache-Control: public, max-age=300`. They are
public, read-only and fetched by third-party pages this module does not
control, and every view was hitting Omeka's full bootstrap. Five minutes
lets a data sync show up promptly and absorbs a page embedding several
panels of one block.

The data-sync admin page is `global_admin` only. A `site_admin`'s
authority is over one site; the job that page dispatches replaces the
whole `files/iwac-visualizations/` tree that every site on the
installation reads, with a several-hundred-megabyte download in front of
it. That is not a per-site operation.
C4. `npm run lint:css-dead` is the detector the finding asks for. The
hard part is that most unmatched class names are not dead — they are
composed at runtime, `'iwac-vis-table__cell--' + (col.render || 'text')`
— so the allowlist names the STEMS a concatenation actually builds, each
with the file that builds it. A blanket "ignore anything with a
modifier" would have silenced every real finding.

It found six dead rules, not the four the audit counted by hand:
`.iwac-vis-article__body`, `.iwac-vis-entity__body`,
`.iwac-vis-minimal-item__body`, `.iwac-vis-reference__body`,
`.iwac-vis-publication__body` and `.iwac-vis-scary-details-list` — five
`__body` classes left behind by a rename that gave every dashboard block
a different root class. `.iwac-vis-entity__body` shared a rule with
`.iwac-vis-person__body`, which is live, so only the selector went.

C3. Eight `z-index` literals across six sheets — 1, 2, 5, 6, 30, 1000
and a -1 — with nothing saying which sat above which. They are three
roles: `--iwac-vis-z-raised` for a thing that lifts off its panel,
`--iwac-vis-z-overlay` for a thing drawn over the panel's content,
`--iwac-vis-z-popover` for the fullscreen panel, which must clear the
site header. A block needing a fourth layer is describing a fourth role
and should name it, not reach for a bigger number.

The two `!important` declarations turn out to be one of each. The
entity-networks chip dot is the sanctioned case and now says so:
`entity-networks.js` writes that colour as an INLINE style because the
palette is read from the live theme, and an inline style beats every
selector. The person-dashboard one was not needed at all — those
selectors are two classes deep and already outrank the single-class
`display: flex` that makes a bare `[hidden]` insufficient.

C5. `laicite.css` was 1,458 lines, larger than iwac-maplibre.css plus
five block sheets together, for a block with fourteen views — so a
change to the concordance meant scrolling past the arenas. Five sheets
now, in cascade order, named by the registry (`blockCss` already took a
list). Verified rule-for-rule: 190 in, 190 out, none lost, none gained.

C2, the adopt half. `overscroll-behavior: contain` on all eleven scroll
containers: inside an embed the page behind is the HOST's, so reaching
the end of a table used to scroll the article the widget sits in.
`text-wrap: balance` on both heading levels. Not adopted, per the
finding: `@layer`, which would silently invert every module override
against the unlayered host theme.
Tier 8 / B2, the four gaps left after eslint shipped in v1.62.0.

The registry reader (B2's named example). `check-blocks.js` parsed a PHP
array with a regex, and that regex kept being the wrong shape: rows had to
be anchored on eight-space indentation so the arrays a `shell` nests would
not read as rows of their own. `php -r 'echo json_encode(...::BLOCKS)'`
returns the actual array. The regex stays as a fallback so a contributor
without PHP still gets the other twenty checks — and because both readers
now exist, the guard compares them and fails if they disagree. CI runs the
fallback (node only); without the comparison, local and CI would be
checking different things. Verified: 21 rows, identical either way.

ruff replaces pyflakes, same standard. ruff.toml selects `F` (ruff's
pyflakes port) plus `E9` — the previous gate rule for rule, so no Python
file had to change. What it buys is that the rule set, the exclusions and
the file discovery live in one checked-in config that both `npm run lint:py`
and the workflow read. The old explicit `pyflakes scripts/` had already
drifted: it linted the generators and never `tests/python/`, which ruff now
covers. Everything past `F`/`E9` stays off on purpose — a gate that opens
by flagging four hundred line lengths is a gate people learn to skip.

stylelint, two rules: `no-duplicate-selectors` and `declaration-no-important`.
Five hits, all real, none a bug: three sanctioned `!important`s (the
reduced-motion a11y override, and the one that beats an inline style in
entity-networks) and two token-only rules deliberately grouped with the
arithmetic that reads them. Each is now annotated with its reason rather
than the rule being switched off, so the guard still catches a fourth
`!important` or a genuine silent override. The entity-networks comment
claiming to be "the ONE sanctioned !important in the module" was wrong;
fixed.

PHPStan level 5, riding on the Omeka checkout `omeka-integration.yml`
already downloads and checksums — this module has no composer.json on
purpose, so static analysis has to be pointed at a real host. Sixteen
errors on the first run; fifteen are Laminas `__call` dispatch, which no
analyser can see, and are ignored by NAME rather than blanket, so a typo in
one of this module's own methods is still caught. Verified by planting one.
The sixteenth is true and stays visible in a comment: every block declares
`embeddable => true`, so the whitelist filter is constant. The flag stays —
`embeddable()` is also the directory-traversal guard for
`common/block-layout/<slug>`, and a security control is not dead code
because it currently admits everything.

Also: `_h5.txt`, a scratch file from the H5 wave, was tracked at the repo
root and shipped inside the release archive. Deleted, and the three new
dev configs are export-ignored.

php-cs-fixer (B2 (5)) is deliberately not done — it is a formatter, the PHP
here is already consistent, and it would trade a large mechanical diff for
no defect it can catch that `php -l` and PHPStan do not.
Tier 8 / B3, the three of its five that were still missing. (2) — every
bundle a template names resolves — is enforced by check-blocks.js since
v1.62.0, in both directions; (5), the source-to-pot-to-po chain, is
`lint:i18n-pot`. Flake reporting landed with check-flakes.js.

SyncData::perform() against real ZIP fixtures. The suite tested the static
predicates — isSafeArchiveEntryPath, isSafeUnixArchiveAttributes,
expectedDigestFromSidecar — which is not the same claim as "perform()
calls them". A refactor that dropped the zip-slip loop, or moved it after
extractTo(), would have left all of those green. So the job now runs for
real against archives built at run time by ZipArchive, with a filesystem
underneath: happy path, a `../` entry, an absolute path, a symlink, a
missing marker, a truncated body, an empty download, a 404 HTML page, the
stop switch before the swap, the concurrency lock, and the orphan sweep a
SIGKILLed run leaves behind. It asserts on what ends up on disk — that a
refused archive publishes nothing, and that a successful one REPLACES the
live tree rather than merging into it.

`download()` and `verifyDigest()` became protected. That is the whole
production change and the reason is in a comment on both: they are the two
methods that reach the network, so they are the seam a fixture is handed
through. Everything between them and the atomic swap is the shipping code.

Checked that the checks can fail: five separate mutants of SyncData — the
zip-slip guard, the symlink refusal, the marker check, the first rename,
the orphan sweep — each turn the suite red with the message naming the
thing that broke. 190 checks to 226.

hijri.js: pure, fourteen consumers, no tests, and the one place in the
module where being wrong looks right — an engine without the Islamic
calendar makes Intl fall back to Gregorian silently, so a chart prints a
well-formed date off by 580 years. Two years of days round-trip against an
independent Intl reader (731 of them), and a fake Intl that answers in
Gregorian is refused rather than trusted.

EmbedController::blockAction across its parameter matrix, in omeka_boot.php
rather than run.php because params(), layout() and getResponse() are real
Laminas plugins reading a real MvcEvent — stubbing them would test the
stubs. Six rejected slugs including two traversal attempts, eight ?theme
cases, thirteen ?primary cases (the value reaches a style attribute, so a
passthrough would be an injection), the :panel passthrough, and every
registry block dispatching to a partial that resolves — following the H5
rule, `_generic` for the nineteen that declare a shell.

It goes through dispatch() and not a direct blockAction() call: the request
and response the controller reads are its own properties, set only by
dispatch, so calling the action directly would hand it a blank request and
every query-parameter assertion would pass by reading nothing.

This part is CI-verified only — it needs a booted Omeka with a database.
Tier 8 / D2, D3, P17 and P9.

D2 — a design record that has drifted is worse than none, because it is
read as authoritative, and this repo has the receipt: CLAUDE.md's token
guidance sat a full redesign behind the code, naming a removed font family
and calling two real published tokens "phantom". So `lint:design-record`
now ties `.impeccable/design.json` and DESIGN.md to what ships: the five
`--iwac-vis-model-*` canonicals against iwac-core.css, their tonal ramps
against their own midpoints, the 64 mirrored theme tokens against
tokens.json, orphans in both directions, and DESIGN.md's slot count. Five
mutations, five different failures — the CSS drifting, a new slot with no
entry, a stale mirror, a ramp that stops running through its own colour,
and prose that disagrees about the count. The prose itself is deliberately
not checked: why slot 4 is a darkened cyan-leaning blue is the part a
record is for, and pinning the numbers is what buys it credibility.

D3 — the two stale claims, corrected in place rather than only in the
v1.63.0 section a thousand lines below. Tier 2 and Tier 5 described
`P.attachGraphClickThrough` as wired; it lost its callers in v1.22 and
v1.63.0 deleted it. Tier 3's "the `+00:00` outlier is gone" was true of
`world_map` and not of the tree — three fan-outs still emitted it. P10
closed those and `validate_data.py` now rejects the form, so that claim is
finally true of everything and enforced rather than asserted.

P17 — `compute_newspapers` and `compute_newspaper_coverage` ran the same
loop over the same two subsets. They had also already drifted: the coverage
one hard-coded `("articles", "publications")` while the other read
`NEWSPAPER_SUBSETS`, so a third subset would have changed one output and
not the other with nothing to say so. One `_scan_newspapers` keyed on
`(name, subset)` — the finer grain, from which the coarser is exact — and
two shapers. 60 comparisons against the pre-change functions on generated
frames covering pipe-separated names, "Unknown" in both cases, NaN names,
missing columns, absent subsets and ties on every sort key: 0 differ, and
3.4x faster on 15k rows.

P9 — and here the audit's premise is wrong, so this does not do what it
asked. "Replace the 55 `iterrows()` sites" is not a win: measured on a
12k x 46 frame, `to_dict("records")` beats `iterrows` only once the loop
body reads enough columns to amortise it —

    1 read per row    iterrows 0.26s   records 0.30s   (WORSE)
    5                 iterrows 0.32s   records 0.31s   (a wash)
    15                iterrows 0.49s   records 0.31s   (64%)
    46                iterrows 1.89s   records 0.65s   (35%)

So `iwac_utils.iter_records()` went in with that table in its docstring,
and it is used at the eight wide readers only — the dashboard aggregator
(11 columns), the article fan-out (14), the sentiment atlas (10), the
laicite scan (every text field). Four narrow loops were converted and then
converted BACK: `aggregate_prevalence` reads two columns, the index-subset
scans two or three, and there this would have cost more than it saved.
Where a narrow loop is genuinely hot the fix is the column-list `zip` form,
which is what P17 above used.

The other half of P9 was real and is fixed: `index_overview` ran
`index_df["Type"].apply(_entity_type_label)` over the whole index
SEVENTEEN times per build, because three of the five type-filtered views
called it inside a loop over the five types. Computed once and threaded
down as an optional argument — a parameter and not a column on the frame,
since a derived column would ride along into the `.copy()` subsets and out
into whatever they serialise. Every view still works called on its own.

18 comparisons for the iter_records conversions and 60 for the index hoist
(including a non-RangeIndex, where a boolean-mask hoist would go wrong):
0 differ.

Also: the embed dispatch helper added in the last commit needs a fresh
plugin manager per call. `Plugin\Layout` caches the first event it is asked
for and never looks again, and the application's ControllerPluginManager is
shared — so every later dispatch wrote its layout variables onto the first
dispatch's view model, and CI read null for every ?theme and ?primary case.
Tier 8 / H4. Module.php carried four unrelated concerns in 381 lines; it is
250 now, and the two that left are the two with their own subject matter.

Three backed enums, `src/Sentiment/{Polarite,Centralite,Subjectivite}.php`.
The vocabulary was five arrays: three mapping controlled-vocabulary item id
to label, two mapping label back to an ordinal, with nothing but convention
keeping the key sets aligned — a new value added to one and not the other
would have read as a rating of 0 rather than as a mistake. A `match` over
`$this` is exhaustive, so that is now an error at the point of the change.
`Module::getPolariteLabel()` and the four others stay as one-line shims:
`SentimentExtractor` and `article.phtml` call them statically, and threading
a module instance into a view partial to reach an enum is a worse trade than
five shims.

Proved equivalent rather than assumed: a harness parses the five arrays out
of `HEAD:Module.php`, rebuilds each from the enum cases, and compares — then
runs all five static helpers side by side over every item id from 78028 to
78056 plus null/0/-1/999999, and every label plus the empty string, null,
nonsense and a wrong-case one. All equivalent; a one-digit mutation to an
ordinal turns it red. 37 permanent checks went into run.php for the parts
worth keeping: the item ids, the sizes, distinct non-empty labels (they are
the key `ordinalForLabel` resolves by), both scales gapless 1-5, polarity's
"Not applicable" still deliberately off the scale at 0, and every id outside
the vocabulary resolving to null rather than to a rating.

The `@translate` markers moved with the labels. `extract-pot.js` scans
`src/`, so all 132 msgids are unchanged — its own docblock said they were
"declared once in Module.php", which is now the enums.

`src/Mvc/EmbedFramingListener.php` takes the ~100 lines of CSP parsing.
Header rewriting is HTTP plumbing with its own parsing rules, and the only
part of Module.php with pure functions worth testing on their own — which
`run.php` already did, through `Module::relaxFrameAncestorsPolicies()`, so
that name survives as a delegating shim alongside `relaxEmbedFraming()`,
which `onBootstrap` attaches and the integration test calls.

`declare(strict_types=1)` added to the 30 files under `src/` that lacked it
(4 already had it), plus Module.php. PHPStan level 5 is clean after, and
clean at level 6 too.
Tier 8 / S17. `draw()` cleared `viewHost` on every state change, so choosing
a different actor type, arena country, sentiment model or reference type
tore down a view whose chrome had not changed. Four costs followed, none of
them visible in a screenshot: the chart transition was lost (a disposed
instance cannot animate into its replacement), `registerChart` re-registered
new hosts on every change, the host collapsed to zero height between the
clear and the append so the page jumped, and the `role=status` region was
re-announced in full.

The orchestrator change is six lines: a view whose builder exposes
`update(state)` gets repainted in place; one that does not falls through to
the rebuild, unchanged. That keeps this incremental — the nine other views
behave exactly as before, and gaining `update` later is a per-file change.

Four builders gained one, the four the finding names:

- actors — the panel chrome and the heatmap HOST are built once; only the
  option and the ranked list change, so the heatmap animates between
  filters. A filter matching nothing hides the host rather than removing
  it, because removing it would mean a new host on the way back.
- arenas — only the series data changes; the grid, the axes and the panel
  count are the same, so the ten small multiples merge rather than snap.
  The layout arithmetic moved into `layoutOption()`, shared by mount and
  update.
- references — the clearest case of pure waste: the year chart is the whole
  literature's growth curve and is not filtered at all, so changing the
  type select disposed and rebuilt a chart the change does not touch. Now
  only the work list is swapped.
- sentiment — all six panels are built once, including the two a model may
  have no data for; those are hidden with `hidden` rather than omitted, and
  resized when they reappear, because a chart inside `display: none`
  measures zero. `notMerge: true` here and not elsewhere: a different model
  can carry a different set of decades and a different newspaper list, so
  merging would leave the previous model's categories standing.

Five tests hold the contract on a small DOM stub — node identity and which
instance received the option are exactly what separates a repaint from a
rebuild, and both survive a stub that answers `appendChild`,
`replaceChild`, `innerHTML = ''` and `hidden`. Two mutations checked: an
`update` that re-registers instead of calling `setOption`, and one that
skips the list repaint. Each turns two tests red.

The parked map view also benefits — its `update()` now receives the state
rather than reading a captured reference, and re-entering it no longer
detaches and re-attaches the WebGL canvas.
The embed 404 assertion was testing the framework, not the module. Once
blockAction() sets a 404 status, Laminas' own dispatch listeners replace the
result with `error/404` before dispatch() returns — which is the right thing
to happen in production, and means the returned template says nothing about
what the module decided. The six rejected-slug cases now call blockAction()
directly and assert on what it chose: the not-found template, marked
terminal, with a 404 on the controller's response. A fresh controller per
call, because the response is a controller property and a 404 would
otherwise leak into the next one. The dispatch() path stays for the
parameter matrix, where it is the point.

`build-tree.js` read `git ls-files`, which reads the INDEX. So a new file
counted only once it had been `git add`ed, and running `npm run build:tree`
before staging wrote a tree that `lint:tree` then rejected after staging —
which is how the tree guard turned CI red twice in this session while
passing locally each time. It reads the working tree now (`--cached
--others --exclude-standard`, existing files only), so both orders agree and
the local answer matches CI's. Verified: an unstaged new file fails the
guard, a gitignored one still does not appear, and `asset/data/` is still
represented only by its two committed exceptions.
Ticks the 46 findings waves 7-14 closed, records what landed differently
from its write-up, and annotates the six that stay open with why in place —
B1 step 2 and the ECharts self-host wait on the owner's ROADMAP 5.4
decision, M20 is an Info "keep it", E12 and E15 were considered and
rejected in v1.64.0, S15 was measured and declined, and B5's matrix is
superseded by P1's single-process runner.

The wave note leads with the three premises that did not survive
measurement, because the number is what the next reader needs, not the
instruction: P9's iterrows replacement is worse below five column reads per
row, S15's ~700 lines of boilerplate is 66 buildPanel calls after v1.63.0
absorbed the rest, and B5's parallelism was hiding a per-process load that
no longer happens.

Version bumped in all five declarations.
@fmadore
fmadore merged commit 28daeeb into main Sep 7, 2026
6 checks passed
@fmadore
fmadore deleted the tier8-waves-7-14 branch September 7, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant