IWAC-theme — an Omeka S 4.2+ theme (fork of Freedom) for the Islam West Africa
Collection, a francophone West African digital collection at ZMO Berlin. PHP templates
(view/), Sass on the modern module system (asset/sass/), Gulp build, vanilla JS.
Before any visual change, read docs/DESIGN-PHILOSOPHY.md. The register is specific — "press archive", not museum, not dashboard — and easy to violate by accident.
The Impeccable design skill reads its own artifact layer: PRODUCT.md (product truth),
root DESIGN.md + .impeccable/design.json (machine-readable design system, North Star
"The Research Broadsheet"). DESIGN.md's frontmatter mirrors tokens.json light
values — tokens.json stays normative; when tokens change, refresh DESIGN.md via
/impeccable document rather than letting the two drift.
npm run check:tokens # fast gate: fails if any var(--…) in asset/sass doesn't resolve
npm run build # check:tokens → build:tokens → build:i18n → compile CSS
npm run start # compile once, then watch .scss
npm run bump -- patch # write every version declaration (patch|minor|major|X.Y.Z)Match the command to the change. npm run build regenerates tokens.json and the
i18n catalogue, so on a PHP- or JS-only edit it produces unrelated diffs — check:tokens
is the right gate there.
Omeka uses the helpers[] string in config/theme.ini as the service name, the class
name, and the helper/<Name>.php filename. Filename and service name are
case-sensitive on the Linux server; only the class name is not. So helpers[] = "BrowseLayout" must be called $this->BrowseLayout(). A case mismatch 500s every page
that renders it — and "fixing" it by lowercasing theme.ini just moves the failure to a
require_once fatal that is invisible on a case-insensitive dev filesystem.
config/theme.ini, package.json, package-lock.json (twice — root and
packages[""]), CITATION.cff and the asset/sass/style.scss banner. Editing them by
hand is how the same failure keeps recurring: style.scss drifted from 2.9.0 across
thirteen releases, and the lockfile's two copies sat at 2.10.1 across four more — both
times because the release gate asserted its own inline list of the files a human
remembered, and the writer was a human.
So the list is now data, in scripts/lib/versions.js, read by both sides:
npm run bump -- <patch|minor|major|X.Y.Z>writes all six and stampsdate-released.package.json+package-lock.jsonare delegated tonpm version, which does the semver arithmetic and keeps the lockfile's two copies in step; the increment keywords work without this repo parsing semver at all.npm run check:versionsasserts they agree — on every push (Quality), and against the tag inrelease.yml. Adding a seventh site means one entry in that file, and both the writer and the guard pick it up.
The release itself is still git tag vX.Y.Z && git push origin vX.Y.Z: pushing to
master deploys nothing, because the live sites install the release ZIP.
scripts/build-tokens.js reads the four variable files and writes tokens.json, then
syncs it into IwacSearch and IwacVisualizations, whose check-theme-tokens guards fail
their builds on anything that disagrees with it. It publishes five things:
| Key | What |
|---|---|
light / dark |
every OKLCH colour token resolved to sRGB hex |
values.light / values.dark |
every other token resolved to a literal CSS value — type steps, spacing, radii, control sizes, font stacks, shadows (collapsed to rgba()), transitions |
names |
the full custom-property vocabulary |
breakpoints |
the six media-query widths |
series |
the ordered categorical chart palette (--series-1 … --series-20), light + dark, with the theme-driven lead slots marked |
- A wrong or invented token name is caught by
npm run check:tokens. Run it; don't reason about it from memory. - Adding a token is a cross-repo change:
npm run sync:tokens, then rebuild both modules. - Never hand-edit
tokens.jsonor the<!-- BEGIN GENERATED -->tables in docs/DESIGN-SYSTEM.md.
values and breakpoints exist because the guards used to check colour and nothing
else: the fallback assertion was a regex matching a hex literal in the fallback slot, so
every non-colour fallback in three repos was unchecked, and roughly 290 of them had
drifted — line-heights, control sizes, type steps, font stacks (one still naming the
removed Noto Serif), shadows, transitions. Drift here has never been a discipline
problem; it is a coverage problem. Every value the generator publishes and a guard
compares has stayed correct across a major redesign. Every value left to prose moved.
So: when you add a design decision, publish it and assert it — a comment saying
/* sm */ beside a 640px media query is what "documented" looked like right up until
it was wrong.
npm run check:tokens also fails on a font-size carrying an absolute literal
(px/rem/pt) anywhere in asset/sass — including inside a clamp()/min()/max(),
which was the blind spot until 2.14 (clamp(4rem, 15vw, 8rem) was a private 64–128px
scale the guard could not see). Use a --text-* token; --text-2xs (11px) is the floor,
and there is deliberately no 14px step. Relative units (em, %, vw) stay legal.
The breakpoint contract is enforced here too as of 2.14 (it was enforced only in the two
modules before): min-width sits on a published breakpoint, max-width at
breakpoint − 1, so the halves of a pair never both match. #{$md - 1px} is now the
only legal spelling of the "below" half — the - 0.02px variant is gone.
A custom property is substituted on the element that declares it. So a light-scope
token whose value references a token the dark block redeclares must itself be redeclared
in the dark block, or dark pages inherit the light composition forever — and neither
tokens.json nor any value-comparing guard can see it, because the generator resolves
the dark block in isolation and the cascade does not.
This has bitten the repo three times: the --type-* map (2.13, browse dots at 2.41:1 on
dark), the composed focus tokens (2.13, every dark focus ring in the light primary),
and --panel-shadow + --glow-xs/sm/md (2.14 — the file carried a comment asserting the
glows "auto-update", which was true of the @media path and false of the manual toggle).
npm run check:tokens now asserts it statically. Practical rule: if a new token's value
contains a var(), put it in the light/dark mixin pair, not in :root, unless the
referent is theme-independent.
Do not hand-edit it. On a token change the guard will fail until
/impeccable document regenerates it — that failure is the artifact telling you it is
stale. Release order: edit tokens → npm run build → documenter → green.
Edit asset/sass/. Anything written to asset/css/ is overwritten by the next build.
@use / @forward only — never @import. @forward rules must come before any other
rule in a file, and every file using variables or mixins needs its own
@use "../../abstracts/abstracts" as *;.
sRGB mixing muddies mid-tones (blue + yellow → gray). The palette is OKLCH throughout; keep the mixing perceptual.
Use > dl > .property > dd. Value-annotation tooltips nest their own <dl> inside a
<dd>, and a descendant selector leaks the 168px label-column layout into them.
Inverted in 2.10. A bare <button> is now an outlined flat control (ink text, hairline
border, no shadow, no lift). The filled-primary treatment — brand fill, --glow-sm
halo, hover lift — comes from .btn--primary or from being a submit control
(input[type=submit] / button[type=submit]), which Omeka core and module forms render
without any theme class to hook.
Before this, the base selector painted every button filled-and-glowing, so sixteen
component files reset border-radius / box-shadow / transform purely to escape the
default, and a component overriding only background/color silently kept a rounded
floating halo. Those resets are now redundant rather than load-bearing — harmless where
they remain, and safe to drop when you're already editing the file. The thing to watch
now is the reverse: a control that needs to shout must say so, or it will render
quiet.
Omeka modules ship their own markup and vendor CSS (tablesaw; RightsStatements inline-styles
height:4em). Selectors written against assumed markup silently match nothing, and a
vendor max-width: 100% beats your min-width.
The AI-sentiment properties are hidden by IwacVisualizations, not by the theme
The iwac:*Centralite / *Polarite / *SubjectiviteScore terms and their
*Justification siblings never reach the public value list: IwacVisualizations listens on
rep.resource.display_values and strips every annotator family it knows about
(Module::SENTIMENT_MODEL_STEMS), across both annotation generations. The theme has no
part in it — no excludeProperties list, no display:none rule, no
components/sentiment/ partial. All three existed once and all three were dead by the
time they were removed in 2.9.14; a hardcoded list here can only fall behind the next
model rename. If a sentiment field shows up on an item page, the fix belongs in the
module's stem list.
The live sites' resource-page stack uses the Mirador module's block in place of core's
mediaEmbeds, and Mirador builds its manifest from media that have a stored file. A media
with no file — a youtube-ingested one, say — yields a canvas-less manifest, so the block
renders an empty <div class="block block-mirador"> and the item's only content is
invisible. That is what the theme's videoEmbeds block
(video-embeds.phtml) exists to
cover; webArchive covers .wacz/.warc the same way. Both output nothing on items they
don't apply to, and both have their media excluded from mediaEmbeds so no source is ever
rendered twice. A new fileless ingester needs the same treatment — plus a line in
config/theme.ini and an admin visit to Themes → Configure resource pages, since a site
whose stack is already customised does not pick up new theme defaults.
Mirador maximizes a window inside its own workspace (position: absolute; inset: 0 against
.mirador-workspace-viewport), and the module sizes that workspace as a card
(.mirador { height: 70vh; min-height: 600px }). So the control did exactly what Mirador
intends and still read as broken: the window grew by the width of the workspace rail and
stopped. Mirador's actual full-page path is the separate "Full screen" button — an
unlabelled icon in that rail, and absent on iOS, where element fullscreen doesn't exist.
The theme now lifts the container instead: mirador-theme-sync.js subscribes to each
viewer's store, mirrors state.windows[*].maximized onto .mirador.viewer as
.is-maximized, and the stylesheet takes that container position: fixed; inset: 0.
Two things are load-bearing and easy to drop:
.block-mirador'sz-index: 1has to be lifted with it. That containment is what keeps MUI's 1300-range z-indexes below the sticky header — but a fixed child resolves its z-index inside that stacking context, so the overlay paints under the header unless the block goes to--z-modaltoo.- The block gets an inline
min-heightplaceholder while maximized. The container leaves the flow; letting the page collapse behind the overlay clampsscrollTopand the reader lands somewhere else on the way out.
state.windows and the mirador/MINIMIZE_WINDOW action are the same
undocumented-but-stable store surface the dark-mode sync already rides on.
This theme is the single source of truth for design tokens. Two sibling modules consume them instead of defining their own:
| Repo | What it is |
|---|---|
| IwacSearch | Svelte 5 search / discovery client |
| IwacVisualizations | ECharts / MapLibre dashboards |
The full contract is docs/DESIGN-SYSTEM.md. Data-encoding colours
(chart series, sentiment scales) are the only colours a module may own, and they live
there prefixed --iwac-vis-*; everything else must resolve from a theme token.
Mirador is React/MUI and cannot read CSS custom properties, so its palette is concrete hex in the module config — canonical values and setup in docs/MIRADOR.md.
The "How to cite" panel is a resource page block owned by
IWAC-SEO, placed via Admin → Themes → Configure
resource pages. The theme supplies only the UI (view/common/citation.phtml); the
formatters live in the module. Don't reimplement citation formatting here.
There is a local preview rig in .claude/ (gitignored, machine-local): a reverse proxy
plus a headless-Chrome screenshot script that renders the live site against local CSS.
Prefer it over guessing, and check light and dark mode.