Repository navigation
feat: put the skin switcher in the toolbox and drop the footer select - #351
Merged
Merged
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This was referenced Aug 15, 2026
lens0021
force-pushed
the
claude/tools-portlet-skins
branch
from
August 15, 2026 15:25
2a5604c to
48fcaa1
Compare
The toolbox is emptied because everything in it is a Special: page. The other
skins' copies of the current page are not: the bake writes them, so they are
honest static links and give the portlet something real to hold.
The href is computable at render time. Links are written root-relative ("./x"
from the main skin's output, "../x" from a skin subdirectory) and rename.php
reparents them by the page's own depth afterwards, exactly as it already does
for the non-main-skin canonical. A subpage two levels down therefore gets
"../../minerva/Manual/Deep.html" without anyone computing that here.
Server-rendered hrefs need no JS, so skin-switcher.js and its module go, along
with the footer <select> they drove. Removing the module is enough:
buildScripts.php discovers modules from what the rendered pages queue, so it
drops out of the bundle on its own.
Minerva is the awkward one. It feeds TOOLBOX into its page-actions menu, whose
builder keeps only entries with an icon and hands each to SingleMenuEntry, whose
$url is a typed string -- so an icon without an href fatals the bake. There every
entry carries both and the current skin is a self-link; elsewhere the current
skin is marked and not a link.
The empty-box rules move to their own style module, loaded only when one skin is
enabled and the toolbox really does stay empty. They cannot then hide a box with
the switcher in it, which is now the only one there is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qiZSGWHrxkjvFxA8swynp
Vector 2022 draws the dropdown as a checkbox with a label beneath it, and the checkbox is what a click lands on -- so clicking the label timed out against the checkbox intercepting every attempt. Citizen's opener is a <details> summary and Minerva's toggle is its own control, so only Vector was naming the affordance instead of the thing that responds. Minerva is not enabled on this branch, so its opener is untested here either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qiZSGWHrxkjvFxA8swynp
The sidebar skins wrap the link in a list item wider than the link, so a click aimed at the entry landed beside it and no navigation followed -- only from a skin whose list item is wider, which is why the journeys starting at the main skin passed and the others did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qiZSGWHrxkjvFxA8swynp
Two holes in the new specs. The current skin's entry was asserted to carry no href, on the list item, which never carries one -- so it passed however the entry rendered, and the one skin whose current entry is deliberately a self-link (Minerva) would not have been caught. And beforeAll read `.find(...).skin` off a list that a single-skin export leaves empty, so every test in the file failed with a TypeError instead of skipping. docs/ went from three skins to two last week; two to one is the same edit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJtGZbbjBAHWmnC4QQyRUj
Vector 2022 labels that menu "General" and Citizen labels it "Tools", so a reader opening it found two product names under a heading that explained neither. The footer switcher had a "Skin" label; this restores one. Vector ignores core's `toolbox` message and substitutes its own key (VectorComponentPageTools::getMenus), so both keys are overridden, through MessageCacheFetchOverrides. Only when there is a skin list to name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJtGZbbjBAHWmnC4QQyRUj
#349 brought this journey back on main while this branch was rewriting the helper it calls, so the merge produced the same test twice, one of them using the footer <select> API that no longer exists. Keep the discovered-skins version and take the return trip to the root copy from the restored one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJtGZbbjBAHWmnC4QQyRUj
lens0021
force-pushed
the
claude/tools-portlet-skins
branch
from
August 15, 2026 16:18
cdbb7f0 to
719d643
Compare
lens0021
marked this pull request as ready for review
August 15, 2026 16:20
lens0021
enabled auto-merge (squash)
August 15, 2026 16:20
Same shape as Vector: a transparent checkbox is laid over the label that draws the button, so a click aimed at the label is intercepted by the checkbox and never lands. Confirmed on the deployed preview, where both elements occupy the same 88x44 box and clicking the checkbox reveals the entries. Minerva only started running these specs when #349 put it in docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJtGZbbjBAHWmnC4QQyRUj
This was referenced Aug 15, 2026
lens0021
added a commit
that referenced
this pull request
Aug 15, 2026
…ox (#363) #351 named the toolbox after the skin list it fills, by overriding core's `toolbox` message and Vector's `vector-page-tools-general-label`. That was the wrong instrument, twice over, and this replaces it with the one MediaWiki already provides. ## What was wrong with the override **It renamed more than a heading.** Vector labels its tab-row dropdown button with `msg( 'toolbox' )` (`SkinVector22.php:476`), so the button read "Skins". On the live site the panel became a "Skins" button over a "Tools" title over a "Skins" section. **It assumed what the toolbox holds.** `Hider` empties it because everything `makeToolbox()` produces is unservable, and this extension refills it. That does not make the toolbox the skin list: a site whose own extensions put static-servable entries there would find them under a heading naming something else. The docs site has nothing else in its toolbox, which is what the change was verified against, and wikven cannot assume that of its users. ## What replaces it A sidebar section of our own. Core turns any sidebar key into a portlet whose heading is the message of the same name (`SkinComponentMenu::getMenuLabel`, falling back to the key), so `$sidebar['wikven-skins']` renders as `p-wikven-skins` headed by `wikven-skins`. Nothing another skin owns is touched, and Vector's button stays "Tools": ``` [Tools ⌄] tab-row button, msg( 'toolbox' ) Tools panel title, vector-page-tools-label Skins our section, msg( 'wikven-skins' ) Vector (2022) Citizen MinervaNeue ``` **Vector 2022 only.** `SkinVector22::extractPageToolsFromSidebar()` splices from the toolbox index to the end (`array_splice( $rest, $i )`, no length), so every section appended after the toolbox joins the page-tools menu. Core drops `SEARCH` and `LANGUAGES` from that list, which leaves the toolbox last and ours immediately after it. The other two skins keep the toolbox, and nothing about them changes: | skin | why | | --- | --- | | Citizen | splices the toolbox alone (`array_splice( $rest, $i, 1 )`), so a section of ours would leave the "More actions" card for the sidebar drawer | | Minerva | reads only `$sidebar['navigation']` and `$sidebar['TOOLBOX']` (`SkinMinerva.php:149`, `MainMenuDirector.php:75`); every other section is dropped | Vector's toolbox is then empty, and core hides an empty portlet, so no stray "General" heading is left behind. `ext.Wikven.emptyToolbox` still gates on the skin count and still applies to exactly the case it was written for: a single-skin export, where there is no list to show in any skin. ## Also The `.active` rule marking the current skin gains the new portlet, since for Vector the entries are no longer inside `.mw-portlet-tb`. Two tests cover the routing: the section a skin's entries land in, and the href written for a skin at the export root versus one in a subdirectory. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lens0021
added a commit
that referenced
this pull request
Aug 15, 2026
Closes the gap #353 collected. #352 declared Vector, Minerva and Citizen the supported skins; Citizen was the one whose support lived in the docs site's `MediaWiki:Citizen.css` rather than in wikven, so every wikven site enabling it had to rediscover the same workarounds. That file is deleted, and what it stood for now happens in the build. Three of the four items in the issue are fixed rather than relocated, and two more turned up while checking. ## The trigger: the skin name in the Tools portlet Citizen hangs a menu item's box off the `<a>`. The current skin is text rather than a link (`Hooks\Adder`), so its name sat flush against the card edge beside two padded links. One rule in `ext.Wikven.styles.less` gives it the same box, in Citizen's custom properties, the ones that follow its light/dark theme, where its Less tokens stay at static Codex values. It could not go in the skin's own module. `ResourceModuleSkinStyles` is keyed by skin name and `ExtensionProcessor` **assigns** rather than merges, so `"citizen": { "+skins.citizen.styles": … }` from wikven would replace the ~200-entry map Citizen declares for itself (or be replaced by it, depending on load order. Citizen loads second, so wikven's silently vanished). Verified by baking it: the rules never reached the output. `ext.Wikven.styles.css` is linked after the skin's, so equal specificity is enough there. ## Preferences: it loads now `skins.citizen.preferences` is seeded into the bundle in `buildScripts.php`, exactly as the lazy search module already is. The bundle only *implements* untriggered modules, so Citizen's own `mw.loader.using` on first open is what executes it, with the DOM present, which matters: the bundle runs in `<head>`, and the module mounts at import time. That is a real feature, not just a hidden error message: light/dark/auto, text size, content width, pure black, image dimming and performance mode all work on the static site, and the choice survives navigation through `clientPrefs`. ## Search: it works now Citizen's own search is the REST-backed command palette, which an export has no backend for. Underneath it the skin renders an ordinary form for readers without JavaScript, and `commandPalette.js` deletes that form the moment it finds its trigger **by id**, so `rewriteScripts.php` drops the id and the working search is left standing. SifterSearch's `ext.sifter.retarget` already points it at the results page; submitting reaches `Search.html?search=…` with real Pagefind results. Gated on `Search::hasResultsPage()`, not on SifterSearch being loaded. A results page is not configured by default, and the skins that keep a typeahead do not need one (`ext.sifter` sends the submit to the top Pagefind result instead). Citizen, whose box nothing else wires up, has only this path. Without one the whole box is removed, the way Vector's is. Both branches were baked. ## Two more, found while checking **The manifest carried the build host.** `$wgCitizenEnableManifest` left `<link rel="manifest" href="http://localhost:4000/api.php?action=appmanifest">` in every Citizen page. Turned off in `WikvenSettings.php`, beside the ULS webfonts precedent. **A service worker 404 per page.** Citizen registers one from `load.php` whenever the client-side script path is the wiki root, which is what the build installs with. There is no script path in a static export, so Citizen pages now say so and the registration returns early on its own guard. Scoped to Citizen: it is the only thing that reads the value for a decision, and the two other readers in the bundle (`mw.util.wikiScript` building `api.php` and `rest.php` URLs) are dead in an export either way. **And the typeface.** `AssetLocalizer` copied direct `/skins/…` paths for images only, so Citizen's eight `@font-face` files 404ed and the site rendered in a fallback font. It now copies `woff/woff2/ttf/otf` too, for stylesheets only, since inlining a typeface into the JS bundle would carry the whole thing, in base64, in every page's JavaScript. `localizeImages` is renamed `localizeAssets` to match. +440 KB in `dist/citizen`, and the skin looks like itself. ## The docs site `MediaWiki:Citizen.css` is gone. Its `!important` (the `biome ci` warning the issue notes) goes with it. Its external-link rule went the other way. `MediaWiki:Common.css` recolours the arrow by swapping a background image, which assumes core's `content-links-external` feature; Citizen opts out of it and draws its own arrow as a `currentColor` mask, already teal from the token above, so the image tiled across the link and `Citizen.css` had to suppress *both*. The rule is now scoped away from Citizen, which gets its indicator back instead of losing it. ## Verification Docker Hub was reachable this session, so this is the real pipeline rather than a reconstruction: the image built and `docs/` baked with it, three skins, then read in Chromium. - **The four new tests** in `tests/e2e/citizen.spec.js` (search, preferences, nothing missing or backend-bound, the toolbox entry lining up) were each run against a bake of `main` first: **all four fail there**, so they are testing the change and not the weather. The alignment one measures where the label text actually starts, not a CSS property. - **PHPUnit passes** against MediaWiki REL1_46. - **A second bake** with Citizen alone and no results page: its search box is removed cleanly, Vector's is kept. - The smoke workflow's own shell assertions re-run by hand: main page present, no `load.php` in dumped CSS, webfonts intact, 324/324 language-bar entries. - `biome ci`, `mago format --check`, `mago lint`, `composer phpcs`, `yamllint --strict`, `typos`, `rumdl`, `taplo` all clean. Rebased onto `main` after #363 landed, and re-verified from the image up. Citizen keeps the skin list in the toolbox there, so that change and this one compose. ## Not fixed `/` and `Ctrl`+`K` still call Citizen's palette loader, which 404s twice and then fails to load the notification module too, so the keypress is inert rather than visibly broken. Unchanged by this PR; it does the same on `main`. Making those open the search box needs either a wikven client module for the shortcut or an upstream way to feed the palette a Pagefind search client; its `SearchProvider` is constructed inline, and the `citizen.commandPalette.register` hook only takes prefix-triggered modes. ## The panel becomes where the skin is chosen, and the chrome around it clears Review feedback on the preview, and what checking each item turned up. ### The skin list moves into the preferences panel Which skin to read in is a choice about how the page looks, so on Citizen it now sits beside the theme, the text size and the content width rather than behind "More actions". Citizen's panel is a public extension point for exactly this (`citizen.preferences.register` / `.changed`, documented and stable since 3.14.0, under the skin's deprecation policy), which is the half of #342's comparison that made Citizen worth extending rather than working around. `resources/citizen-skins.js` **reads the toolbox entries wikven already renders** rather than building its own list. Those carry hrefs the bake has reparented for the page's own depth, so the module has no idea where in the export it is running and no URL arithmetic to keep correct. The thing #351 was pleased to delete stays deleted. It removes them once read, which leaves a reader with **no JavaScript** the plain list of links, still working, rather than nothing. The empty "More actions" card goes with them, unless something else on the site put an entry in it. Two details the skin dictates: Citizen stores a preference value under `/^[a-zA-Z0-9]+$/` (the `clientPrefs.polyfill.js` rule #353 notes), so `vector-2022` becomes `vector2022` and the entry it came from carries the real target; and the panel reads its selection off the `<html>` class, which the inline client-preferences script may have restored from the page before, so the module restates the skin actually being rendered. ### Search suggests as you type Citizen sets `SkinPageReadyConfig`'s `search` to false, because its own search is the command palette. With the palette gone and the plain form standing, that flag is the only thing between the box and SifterSearch's Pagefind typeahead, so wikven turns it back on for Citizen. It has to be registered through `HookContainer::register()` rather than declared in `extension.json`: handlers declared there run in load order, wikven loads before the skins, and Citizen would overrule it. A runtime registration appends after every extension and skin handler. The widget behind it is core's `jquery.suggestions`, whose panel is a hardcoded white box. Citizen's palette is in custom properties that follow its theme, so in dark mode the panel stayed white while the text in it turned light, the trap #353 describes, in the flesh. Restated in the skin's own properties. ### The drawer counted nothing The page count was empty because `$wgArticleCountMethod` defaults to `link`, which counts only pages carrying an internal link, and a translated site links through `Special:MyLanguage/`, which is a special page and so is never recorded in `pagelinks`. Every page in `docs/` was uncountable. `ArticleCountMethod: any` in `default.yml` fixes the count wherever it shows: the drawer, and `{{NUMBEROFARTICLES}}`. It reads 40 now. The user and edit counts are hidden. One is the build's own admin account, the other is how many revisions building the site happened to take; neither says anything about the site being read. ### Four dead requests, and one dead link - **Upload file.** Citizen moves the entry out of the toolbox (which `Hider` empties) into the sidebar, pointing at a `Special:` page the export does not have. Source images are uploaded by the pass that populates the wiki, so by the time a skin renders there is nothing left to upload: the per-skin pass now sets `$wgEnableUploads = false` and the entry is never built. - **Help about MediaWiki.** Not a docs-site choice: it came in with the sidebar's first commit, copied from MediaWiki's default, and points readers of a wikven site at mediawiki.org's help. Removed from `docs/MediaWiki:Sidebar.wikitext`. - **Input methods.** Focusing any text input had the browser fetch `ext.uls.ime` and `jquery.ime` from `load.php`. Nothing in an export takes typed input anywhere, so `$wgULSIMEEnabled` joins the webfonts switch beside it. - **TabberNeue's icons.** `mw.loader.load( 'ext.tabberNeue.icons' )` the moment it finds a tabber, so nothing in the rendered HTML names it and the bundle never carried it. Seeded like the other run-time modules. ### The footer `docs/MediaWiki:Citizen-footer-desc` and `-tagline` are created, so the footer stops reading "Edit this text on MediaWiki:Citizen-footer-desc". Both are rendered `inContentLanguage()`, so a `/ko` variant would never be shown and none is added. **The tagline states the licence**. That is a claim about the project, so please read it rather than take my word: it says the software and this documentation are under GPL-3.0-or-later, which is what `LICENSE` at the repository root covers. ### Verification Rebuilt, re-baked and re-read, same as before. The suite grew a case for the chooser (followed from `citizen/Getting_Started/ko.html`, two directories down, to check the href it inherits is depth-correct), and the alignment case above becomes a **JavaScript-disabled** one, which is the only place that list still renders and exactly the fallback the design rests on. `skin-switcher.spec.js`'s journeys now leave Citizen through the panel. A Citizen page, and the pages the other two skins render, request nothing from a backend and get everything they ask for. ### Not fixed, and worth knowing - **Minerva's search is broken on this site**, and always was. It sets `searchModule` to `skins.minerva.search`, which SifterSearch does not recognise (it swaps `mediawiki.searchSuggest` and `skins.vector.search`), so typing in Minerva's box queries `rest.php/v1/search/title` and gets a 404. Not touched here: the fix belongs in SifterSearch, as an `ext.sifter.minerva` beside the Vector one. Filed as #381. - `/` and `Ctrl`+`K` still call Citizen's palette loader, as on `main`. Filed as #384. ## A diff link goes to the repository, where what changed actually is Citizen's "last modified" button asks for the latest diff (`getLocalURL( [ 'diff' => '' ] )`), and `GetLocalURL` was resolving it to the page it was already on, since only `action=edit` and `action=history` were special-cased. A diff is history: the export holds one revision of a page, so what changed is only in the repository, and the button now goes where the history link goes. Keyed on the query rather than on the button, so any link asking for a diff lands there. ## Covering the new hook handling, and one thing the typeahead does not do `codecov/patch` was the one check that failed once the chooser landed: the search wiring and the chooser's load conditions were new code in `includes/` with nothing asserting them. What decides the wiring moves out of a closure into `Main::enableCitizenSearch()`, so the decision can be asserted rather than only the registration that carries it, and the chooser's four conditions get a case each. Writing the typeahead's test turned up what it actually does, which is worth stating plainly: **a suggestion does not link to its page.** Core's `jquery.suggestions` builds each suggestion's href from the search form, and SifterSearch has pointed that form at the results page, so clicking "Standalone binary" lands on `Search.html?search=Standalone binary`, which does list the page first, but is a hop more than Vector's typeahead, where `ext.sifter.vector` builds real page links. Any skin on the `ext.sifter` path has this, not just Citizen. The fix belongs beside `rewriteSpecialSuggestionLinks()` in SifterSearch, which already rewrites the "containing…" row for exactly this reason. Filed as #381. ### Where `codecov/patch` still stands Every other check passes. The patch figure Codecov reports is **28.3% (15 hit, 38 missed)**, and that is the whole diff, `maintenance/` included, which is where the misses are: `rewriteScripts.php`'s Citizen branch and `buildScripts.php`'s seeding, neither of which the unit suite reaches. `.codecov.yml` says as much and scopes the status to `includes/` for exactly this reason. In `includes/` the misses are down to two things, and both are inherent rather than skipped: - **`WikvenSettings.php`, four lines.** It is LocalSettings-time code; PHPUnit never loads it. Anything added there counts as missed. - **`Main::restoreCitizenSearchWiring()`'s registration.** Its guard and the handler it registers are both tested; the `HookContainer::register()` call between them only runs when SifterSearch is loaded, which it is not in this suite. It could be made reachable by moving the `Search::isActive()` check into the handler, but then the test would be asserting that Citizen's search stays off, the opposite of the behaviour, and not worth one line. #351 merged in this state; flagging it rather than deciding it. ## Verified after rebasing onto `main` Rebased once more once `main` moved past the merge base CI had tested, and re-run from the image up: **225 PHPUnit tests and 25 Playwright tests pass**, along with the smoke workflow's own shell assertions and all eight linters. Three things this leaves are tracked rather than carried: #380 (two menu keys reaching assistive technology unresolved), #381 (search reaching Vector properly and the other skins only partly) and #384 (the command palette's shortcuts). --- _Generated by [Claude Code](https://claude.ai/code/session_01R6DSxPcqj4BAejWJQ88X8J)_ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lens0021
pushed a commit
that referenced
this pull request
Aug 31, 2026
🤖 I have created a release *beep* *boop* --- ## 1.0.0 (2026-08-31) The first release. There is no earlier version to compare against or upgrade from, so what follows is not a list of changes to one but the work this release is made of, as the commits recorded it. ### Features * **action:** default to a pinned image version and cache it between runs ([#188](#188)) ([f7fcb40](f7fcb40)) * **actions:** offer a guiding pull request comment on translation findings ([#520](#520)) ([1ab0175](1ab0175)) * add a "View source" tab linking to the repo source ([#119](#119)) ([24e4d16](24e4d16)) * add a {{WIKVENVERSION}} variable ([#604](#604)) ([a702b9f](a702b9f)) * add a footer skin switcher ([#114](#114)) ([1867ea6](1867ea6)) * add a mark command to number translation units ([#216](#216)) ([0fcde73](0fcde73)) * add a serve subcommand for local preview ([#155](#155)) ([8d97799](8d97799)) * add translate scaffold to generate translation skeletons ([#223](#223)) ([48ce2ab](48ce2ab)) * bake ULS webfonts into the static export (opt-in) ([#286](#286)) ([074a103](074a103)) * bake with the standalone binary in CI, and fix the three things that found ([#481](#481)) ([8cf3bc3](8cf3bc3)) * base the image on mediawiki:1.46-fpm-alpine ([#332](#332)) ([f3fdcee](f3fdcee)) * **build:** list the site's extensions and skins, with their licenses ([#523](#523)) ([42d75d2](42d75d2)) * **build:** name wikven in the User-Agent of every request it makes ([#542](#542)) ([7d6d1ce](7d6d1ce)) * bundle the native search closure so SifterSearch search works offline ([#98](#98)) ([cf6b910](cf6b910)), closes [#97](#97) * derive page files from content model, dropping the .css.wikitext suffix ([#62](#62)) ([ef645c4](ef645c4)) * derive the footer project link label from the repo host ([#118](#118)) ([e14c059](e14c059)) * **docs:** give the landing page symbols, an output it can show, and some warmth ([#524](#524)) ([3b07995](3b07995)) * **docs:** give the note template a teal icon ([#341](#341)) ([05a7ea2](05a7ea2)) * **docs:** keep the rendered page behind the tree instead of trading places ([#530](#530)) ([83fc9ee](83fc9ee)) * **docs:** put a copy button on the code blocks ([#377](#377)) ([3beee91](3beee91)) * **docs:** stop the sidebar linking to the page you are reading ([#499](#499)) ([1bf9e2b](1bf9e2b)) * **docs:** turn the diagram's output pane over on scroll where the browser can ([#526](#526)) ([6c7bcc8](6c7bcc8)) * fold the version page into an About page ([#426](#426)) ([af61267](af61267)) * gate translation checks on broken source pages, not on stale translations ([#385](#385)) ([aacfbfc](aacfbfc)) * generate a Special:Version-style page linked from the footer ([#117](#117)) ([d2dfa8a](d2dfa8a)) * give Minerva a main menu the export can serve ([#366](#366)) ([fc59117](fc59117)) * give the standalone binary the translate commands ([#507](#507)) ([25ba28e](25ba28e)) * **i18n:** translate the footer, skin switcher and version page strings ([#123](#123)) ([9849b36](9849b36)) * index each language separately, and assert it stays that way ([#447](#447)) ([b4b01cc](b4b01cc)) * keep non-main skin pages out of search indexes ([#113](#113)) ([0d8d94e](0d8d94e)) * keep Vector's appearance menu, which works statically ([#387](#387)) ([ce30fea](ce30fea)) * let a site choose the skin it is read in ([#589](#589)) ([5f96e9e](5f96e9e)) * let a skin author turn off the chrome wikven imposes ([#551](#551)) ([ea78cab](ea78cab)) * let a stamp mean someone read the translation ([#572](#572)) ([12d3292](12d3292)) * load .wikven.yaml through MediaWiki's settings system and validate it ([#77](#77)) ([0952b3b](0952b3b)) * localize translated-page chrome and route Special:Translate links to the edit host ([#229](#229)) ([3afa7c4](3afa7c4)) * make page titles translatable, and translate them into Korean ([#256](#256)) ([acdd975](acdd975)) * make the Translating page translatable ([#248](#248)) ([6122151](6122151)) * name a setting that belongs to no one ([#578](#578)) ([3df5b0e](3df5b0e)) * name the setting core cannot accept ([#574](#574)) ([56f2d80](56f2d80)) * offer the Citizen skin (search hidden) ([#115](#115)) ([b83717b](b83717b)) * pin and verify fetched extensions/skins ([#74](#74)) ([8ccd5e7](8ccd5e7)) * point non-main skin pages' canonical at the main copy ([#116](#116)) ([5bf1d6a](5bf1d6a)) * publish images on releases and nightlies, not on every commit to main ([#488](#488)) ([18fa018](18fa018)) * publish the image to quay.io as well, with no token to keep ([#485](#485)) ([f387e5e](f387e5e)) * put everything the build generates in one directory ([#584](#584)) ([d8b8f43](d8b8f43)) * put the generated templates under one name, and keep the old ones ([#559](#559)) ([2cd766a](2cd766a)) * put the pictures pages carry where the rest of the output goes ([#592](#592)) ([78fdf9f](78fdf9f)) * put the skin switcher in the toolbox and drop the footer select ([#351](#351)) ([2f2551e](2f2551e)) * render each enabled skin into its own output directory ([#111](#111)) ([b0adc94](b0adc94)) * require MediaWiki 1.46, dropping 1.45 support ([#236](#236)) ([993e62c](993e62c)) * resolve Special:MyLanguage/ links in the static export ([#225](#225)) ([f4904ea](f4904ea)) * say what every built site redistributes, on a page the footer links ([#560](#560)) ([3fe4eb6](3fe4eb6)) * say what wikven does about Lua, and stop guessing at it ([#473](#473)) ([ea10b7d](ea10b7d)) * select the build skin via WIKVEN_BUILD_SKIN ([#110](#110)) ([126a021](126a021)) * serve logos from a shared upload instead of inlining them per page ([#56](#56)) ([ad4017a](ad4017a)) * serve the search results at Search and exclude it from indexing ([#104](#104)) ([85d35e2](85d35e2)) * settle the actions' inputs before 1.0.0 freezes them ([#583](#583)) ([2bee0c1](2bee0c1)) * settle what a page is called in the output ([#605](#605)) ([a2860f7](a2860f7)) * ship a composite action and dogfood it in the docs deploy ([#150](#150)) ([19392b7](19392b7)) * ship SifterSearch as a built-in, on-by-default search extension ([#195](#195)) ([7b19be8](7b19be8)) * stop the build on three things it used to publish around ([#590](#590)) ([9c0a0b3](9c0a0b3)) * support Citizen in wikven instead of per-site workarounds ([#369](#369)) ([bced0dd](bced0dd)) * translate page content into other languages ([#214](#214)) ([28ff2b2](28ff2b2)) * use the core viewsource message for the View source tab ([#120](#120)) ([24230c0](24230c0)) ### Bugfixes * abort the build when a page fails to import ([#146](#146)) ([cf0a007](cf0a007)) * **action:** use a locally-built image instead of always pulling ([#194](#194)) ([8046aa4](8046aa4)) * answer symlinks where a source tree is read, not where a path is bounded ([#591](#591)) ([d3be13e](d3be13e)) * build the image when a Translate dev dependency has an advisory ([#277](#277)) ([00838b9](00838b9)) * build the search index once, at the end ([#283](#283)) ([09a5324](09a5324)) * **build:** put wikven in front of the User-Agent core builds for Commons ([#543](#543)) ([4c774a3](4c774a3)) * bump bundled SifterSearch to v0.6.0 (stop the double search box) ([#200](#200)) ([cd38aa7](cd38aa7)) * bundle the modules core loads by looking at the rendered page ([#484](#484)) ([c96c107](c96c107)) * **check-translations:** keep the comment to what the change touches ([#541](#541)) ([8288229](8288229)) * clear the output directory before each build ([#72](#72)) ([b4cf416](b4cf416)) * clear the title cache once the translation units exist ([#466](#466)) ([562ea9e](562ea9e)) * create directories with wfMkdirParents ([#289](#289)) ([5157174](5157174)) * date each page at the commit that changed it, not at the bake ([#424](#424)) ([4cd31f4](4cd31f4)), closes [#406](#406) * declare the build's directory settings in extension.json ([#262](#262)) ([65432b5](65432b5)) * decode JS-bundle URL escapes with json_decode in AssetLocalizer ([#297](#297)) ([ccbf702](ccbf702)) * **docs:** cross the output pane's two states gradually instead of at a line ([#540](#540)) ([85fb9c9](85fb9c9)) * **docs:** cut the hero's light at the screen, and turn the pane over mid-screen ([#531](#531)) ([ea482af](ea482af)) * **docs:** keep sidebar links in the reader's language ([#382](#382)) ([f2b27f4](f2b27f4)) * **docs:** keep the hero's light inside the hero ([#525](#525)) ([aaeef7a](aaeef7a)) * **docs:** keep the logo in the reader's language ([#391](#391)) ([749f507](749f507)) * **docs:** let the hero's light run off the frame so it reads as ground ([#529](#529)) ([3d40660](3d40660)) * **docs:** let the hero's light run past the text column again ([#539](#539)) ([2fbee0e](2fbee0e)) * **docs:** let the output pane settle back on the page as the diagram leaves ([#544](#544)) ([9d0785a](9d0785a)) * **docs:** light the hero from its corners, and drop the clip [#531](#531) needed ([#536](#536)) ([186421c](186421c)) * **docs:** make the brand teal and the note box legible in dark mode ([#489](#489)) ([a03a76c](a03a76c)) * **docs:** make the hero's light read as ground rather than as two spheres ([#528](#528)) ([e31f7a5](e31f7a5)) * **docs:** paint the landing page's marks on its translations, and its buttons anywhere ([#498](#498)) ([1ff3a50](1ff3a50)) * **docs:** stop the diagram clipping its own file names on a narrow screen ([#527](#527)) ([74c35fb](74c35fb)) * **docs:** translate the Minerva section of Skins into Korean ([#415](#415)) ([2ac133a](2ac133a)) * don't treat a documented <translate> example as a translation source ([#239](#239)) ([cdcc388](cdcc388)) * drain translation jobs by hand again, reverting [#311](#311) ([#326](#326)) ([4170fd7](4170fd7)) * drop dead footer/search affordances from the static export ([#160](#160)) ([c08a5d2](c08a5d2)) * drop the .git the bundled extension clones leave in the image ([#330](#330)) ([d19dbf4](d19dbf4)) * drop the category footer's dead Special:Categories link ([#173](#173)) ([2df6bf8](2df6bf8)) * drop the discussion tab in every skin, Minerva included ([#493](#493)) ([cd0fccd](cd0fccd)) * dump ResourceLoader modules without load.php's HTTP response ([#259](#259)) ([3a39aac](3a39aac)) * end a source translation unit where its <translate> block ends ([#379](#379)) ([50aebd8](50aebd8)) * export subpage titles as real subdirectories ([#213](#213)) ([1db8e0d](1db8e0d)) * fail on un-localized images and hand output back to the host user ([#161](#161)) ([1bb8c86](1bb8c86)) * fail the build when a stylesheet never reaches the disk ([#565](#565)) ([6ad1b41](6ad1b41)) * fail the build when the webfonts a site asked for cannot be copied ([#566](#566)) ([6fbd87a](6fbd87a)) * fail when a composer package lands where nothing loads it ([#581](#581)) ([263fee5](263fee5)) * fetch a third-party extension again when its pin moves ([#546](#546)) ([44938c2](44938c2)) * fetch the bundled extensions as tarballs, not clones ([#331](#331)) ([9f60efa](9f60efa)) * freeze page_touched so wiki-page modules hash the same every bake ([#271](#271)) ([853c06f](853c06f)) * give a fetch that reaches the network more than one go ([#469](#469)) ([ba9fb1d](ba9fb1d)) * give Citizen's "View source" tab an icon ([#440](#440)) ([f0b4e02](f0b4e02)) * give Citizen's search shortcuts the search the export has ([#397](#397)) ([27eeebf](27eeebf)) * give every source image its own File: page ([#603](#603)) ([623b866](623b866)) * give the per-skin build its own environment and working directory ([#298](#298)) ([cfb0243](cfb0243)) * give the skin list its own section instead of renaming the toolbox ([#363](#363)) ([aa13b7b](aa13b7b)) * grant the release binary job the provenance permissions it needs ([#177](#177)) ([be9f283](be9f283)) * hide dead skin chrome left by the static export in Timeless and Citizen ([#196](#196)) ([1b4c9e1](1b4c9e1)) * hide edit/history/source links on generated pages ([#130](#130)) ([fb6df1e](fb6df1e)) * hide the category footer on the static export ([#121](#121)) ([939c435](939c435)) * hide the empty tools box each skin draws around the toolbox ([#346](#346)) ([5afb909](5afb909)) * index a translated page once, not once per title it has ([#458](#458)) ([e934e00](e934e00)) * keep $wgCacheEpoch out of module versions ([#270](#270)) ([7795bf9](7795bf9)) * keep a search inside the skin copy the reader is in ([#434](#434)) ([32e7f6d](32e7f6d)) * keep a skin pass out of the other skins' pages ([#430](#430)) ([e84cd11](e84cd11)) * keep the build's chrome off a licenses page the site wrote ([#568](#568)) ([e45596e](e45596e)) * keep the paths the build derives out of a site's hands ([#573](#573)) ([e21b16b](e21b16b)) * keep the site's own styles linked from a style directory ([#563](#563)) ([5dfea21](5dfea21)) * leave a page of its own where a translation would go ([#597](#597)) ([09934b7](09934b7)) * link the search toggle to the results page, not to Special:Search ([#421](#421)) ([3b3b2d5](3b3b2d5)) * list directories with FilesystemIterator, not glob ([#294](#294)) ([f8188fa](f8188fa)) * make edit/history links correct for all valid titles ([950c0b2](950c0b2)), closes [#67](#67) * make storeImages downloads status-aware and stream to disk ([#307](#307)) ([b75afc3](b75afc3)) * make the exported HTML the same in every bake ([#282](#282)) ([caf6661](caf6661)) * make the first listed skin the default skin ([#109](#109)) ([19e3fae](19e3fae)) * make the main page configurable and fail if it was not imported ([#71](#71)) ([4399b8f](4399b8f)) * make verbatimRanges() comment-aware ([#312](#312)) ([59117f7](59117f7)) * match <translate> with its attributes, as Translate does ([#292](#292)) ([243bbb0](243bbb0)) * move Vector's skin list into the appearance menu ([#423](#423)) ([68917e0](68917e0)), closes [#405](#405) * name the project namespace Wikven instead of clashing with MediaWiki ([#105](#105)) ([4695978](4695978)) * never read a translation file as a base page ([#390](#390)) ([6adb270](6adb270)) * obtain GadgetRepo from the service, not the removed singleton() ([288c733](288c733)) * point prevnext navigation at the reader's-language pages ([#245](#245)) ([a3c8411](a3c8411)) * probe openssl's own compiled-in CA bundle location first ([#309](#309)) ([c82d5b1](c82d5b1)) * propagate the child's exit code from wikven build and serve ([#302](#302)) ([5c75797](5c75797)) * read a source page the way Translate segments it ([#388](#388)) ([ff95548](ff95548)) * read a subpage as a translation only where it says it is one ([#580](#580)) ([e967fb5](e967fb5)) * read units the way Translate does ([#358](#358)) ([4642fc8](4642fc8)) * read Vector feature classes through classList ([#301](#301)) ([9dfebd1](9dfebd1)) * rebase the printfooter link on a page exported into a subdirectory ([#422](#422)) ([5e5b905](5e5b905)) * refuse an extension or skin name that points outside the image ([#570](#570)) ([35c0678](35c0678)) * refuse an image or asset path that climbs out of its directory ([#588](#588)) ([8fe34a1](8fe34a1)) * refuse images the source tree only points at ([#596](#596)) ([38669f5](38669f5)) * render every translated page after all pages are marked ([#243](#243)) ([0d987d3](0d987d3)) * reparent the local URLs a page carries in its JavaScript config ([#445](#445)) ([beba746](beba746)) * replace fetchExtensions' curl subprocess with core's HTTP client ([#308](#308)) ([9be62ec](9be62ec)) * replace hand-rolled balanced-div matcher with RemexHtml ([#314](#314)) ([3173c2d](3173c2d)) * report a vetoed File:/MediaWiki: import as a failure ([#291](#291)) ([8140261](8140261)) * require real tag context before rewriting a relative reference ([#313](#313)) ([0f04ab6](0f04ab6)) * require UniversalLanguageSelector explicitly and disable its webfonts ([#218](#218)) ([1b3e1b7](1b3e1b7)) * resolve $wgWikvenLogos through Title/RepoGroup, not by hand ([#315](#315)) ([6f4de14](6f4de14)) * resolve phan findings and target MediaWiki 1.45 ([be87a48](be87a48)) * resolve translate mark/stamp file paths against the source directory ([#220](#220)) ([0aa95ea](0aa95ea)) * retry and cache the build's Wikimedia Commons lookups ([#257](#257)) ([2ac2cce](2ac2cce)) * run the job queue in a fixed order ([#281](#281)) ([6aa694e](6aa694e)) * say what language the licenses page's copies are in ([#562](#562)) ([4242977](4242977)) * send a Special:MyLanguage link to the file it means ([#611](#611)) ([1aad459](1aad459)) * set the maintenance user without StubGlobalUser ([8128dc5](8128dc5)), closes [#93](#93) * settle the search bundle's language order between bakes ([#451](#451)) ([e7fc96c](e7fc96c)) * show the search box when SifterSearch provides static search ([#99](#99)) ([a9cd689](a9cd689)) * small build-script and config papercuts ([#76](#76)) ([1302e23](1302e23)) * split translation units only on adjacent newlines, as Translate does ([#295](#295)) ([0feeebf](0feeebf)) * stop a new unit inheriting a deleted one's translations ([#564](#564)) ([5fdd228](5fdd228)) * stop suppressing real categories; drop Version __NOINDEX__ ([#122](#122)) ([a3686bf](a3686bf)) * stop the build when an image of yours did not import ([#569](#569)) ([c78b572](c78b572)) * stop the RLPAGEMODULES rewrite from editing article prose ([#296](#296)) ([9ef6b8a](9ef6b8a)) * stop three things going quiet ([#599](#599)) ([9103f0c](9103f0c)) * stop ULS reaching for input methods the export cannot serve ([#402](#402)) ([af60e0e](af60e0e)) * strip the clocks ImageMagick writes into exported thumbnails ([#276](#276)) ([a1bb8d0](a1bb8d0)) * switch skins correctly from a translated page ([#258](#258)) ([6f85fe6](6f85fe6)) * take the DISPLAYTITLE spellings from the magic word registry ([#293](#293)) ([7c88a7a](7c88a7a)) * take the translations comment down rather than turn it into an all-clear ([#595](#595)) ([6b88a10](6b88a10)) * **tf:** skip the vulnerability-alerts import when the resource is absent ([#265](#265)) ([b5d3ba3](b5d3ba3)) * translate a page whose file name is not spelled like its title ([#567](#567)) ([6ec596a](6ec596a)) * turn the parser cache off so a bake reads its own finished state ([#335](#335)) ([91036c3](91036c3)) * use CSSMin's data: URI encoder in AssetLocalizer ([#310](#310)) ([6fe63d5](6fe63d5)) * validate .wikven.yaml value shapes and flag warnings clearly ([#163](#163)) ([021f819](021f819)) * wrap a word too long for the screen instead of scrolling the page ([#545](#545)) ([818a0e9](818a0e9)) ### Performance * freeze page_touched once, in the orchestrator ([#431](#431)) ([a2d878b](a2d878b)) * render the skins beside each other ([#437](#437)) ([33bbbfb](33bbbfb)) * stop rendering the history pages the export throws away ([#432](#432)) ([39fe30c](39fe30c)) ### Refactoring * group the composite actions under actions/ ([#359](#359)) ([01f0f3d](01f0f3d)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 20, 2026
This was referenced Oct 2, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #346 (
claude/tools-portlet), which it modifies — review that first.The toolbox is emptied because everything
makeToolbox()produces is aSpecial:page or an
index.phpquery. The other skins' copies of the current page areneither: the bake writes them as real files, so they are honest static links, and
they give the portlet something to hold instead of nothing.
The crux: is the href computable at render time? Yes
targetUrl()works in the browser because it readslocation.pathnameand derivesthe page's depth from
wgPageName. A render-time href does not need any of that,because it does not need to be depth-correct when it is written.
Every link in the export is written root-relative —
Main::onGetLocalURL()emits./Name.html— andRenamelater moves subpage files into real directories andcalls
RelativeUrl::reparent(), which adds a../per level to everyhref="./…"and
href="../…"in the page. The non-main-skin canonical already relies on exactlythis, writing
"../$name.html"fromMain::onOutputPageAfterGetHeadLinksArray().So the switcher writes the same form —
./from the main skin's output,../froma skin subdirectory — and reparenting carries it with everything else. Confirmed on a
real bake with a
Manual/Deepsubpage:Installation.html./citizen/Installation.html./minerva/Installation.htmlManual/Deep.html../citizen/Manual/Deep.html../minerva/Manual/Deep.htmlcitizen/Installation.html../Installation.html../minerva/Installation.htmlcitizen/Manual/Deep.html../../Manual/Deep.html../../minerva/Manual/Deep.htmlEvery link in the baked tree was resolved against the file it sits in: 12 pages
carry the switcher, 28 links, 0 broken, none escaping
dist/.1. The non-clickable current entry
Core renders an entry with no
hrefas plain escaped text (SkinComponentLink), sothe
<li>is still there with the skin's owntext-wrapper:Vector and Citizen both render it, and both ignore an
iconkey on a toolbox entryentirely. Neither styles
.active, so unstyled text next to styled links reads asbroken rather than selected; one rule in
ext.Wikven.styles.lessmarks it instead.Labels were coming out as raw keys (
vector-2022,citizen,minerva) — the footer<select>had the same bug — becausegetInstalledSkins()returns only an explicitdisplaynamefromskin.jsonand never resolves theskinname-*message. Timelesslooked right purely because it declares one. Now read from the message first:
"Vector (2022)", "Citizen", "MinervaNeue".
3. Minerva — and a landmine
Minerva does not take entries the way the others do.
DefaultOverflowBuilderkeeps only entries carrying an
icon, so with plain entries Minerva renders zeroof them. Adding an icon so they appear, while the current entry has no href, fatals
the bake:
The
return $href ? … : nullon the next line is dead code — the constructor alreadythrew. This is the same
$sidebar['TOOLBOX']path that madeunset()fatal in #346.So Minerva gets
icon+hrefon every entry, current skin included, and the entryshapes that fatal are never constructed. The cost is that Minerva's current skin is a
clickable self-link — Minerva's
SingleMenuEntryhas no notion ofactive, so itcannot be marked there.
Is it reachable? Yes, and I checked rather than assuming. The links land in the
"More" (ellipsis) menu of the page-actions bar, beside "Edit full page" — a visible
button, one tap, no scrolling — and the bar is present on every exported page,
main page and subpages included (checked across all four pages of the bake). It is
not the tidiest home for it: that menu is page actions, and the icon (
listBullet)is borrowed, because Minerva's overflow icon pack has nothing appearance-related.
Sensible enough to ship; a touch incongruous.
2. The footer
<select>— removedThe precondition holds: Vector 2022 (Tools dropdown), Citizen ("More actions" card)
and Minerva (page-actions overflow) all carry the switcher, on every page. So the
control is gone, and with it:
resources/skin-switcher.jsand itsext.Wikven.skinSwitchermodule — the prize: the navigation logic stops existing rather than being maintained.wgWikvenMainSkinJS config var. The global stays; the build still needs it.Adder::skinSwitcher()and theskin-switcherfooter item.wikven-skin-switcher-labelmessage fromen.json/qqq.json.Nothing reaches the module by a non-obvious path:
buildScripts.phpdiscoversmodules from what rendered pages queue, so dropping
addModules()was enough —targetUrlno longer appears inmodules-static.jsin a real bake.rewriteScripts.phpnever referenced it.4. What happens to #346
Keep it, and this branch changes how it applies. A filled toolbox must never be
hidden — it is now the only switcher there is — so the rules move out of the
always-loaded stylesheet into
ext.Wikven.emptyToolbox, added only when fewer thantwo skins are enabled.
That direction matters: if the gate is ever wrong, a single-skin site shows an empty
box (cosmetic). Hiding-by-default with a CSS escape hatch would fail the other way
and lose the switcher, so the gate is in PHP, mirroring the
#p-searchprecedent.Both paths verified on real bakes — multi-skin emits no such stylesheet and links
none; single-skin emits and links it with the rules intact.
Worth knowing: with stock
default.ymla.wikven.ymladds toskins: [Vector],so a single-skin export is always Vector-only. The Timeless and Citizen selectors in
that module are unreachable in practice; kept as they are cheap and mirror #346.
Tests
skin-switcher.spec.jsis rewritten, not deleted — same journeys (main → non-main,non-main → non-main, back to root, on a translated page and a source page), now by
opening each skin's tools control and clicking the link. It discovers the skin list
from the page instead of naming skins, so it survives Timeless leaving and Minerva
arriving.
tools-portlet.spec.jsbecomes the reachability test: open each skin'scontrol, assert a switcher link is actually visible.
How this was verified
Docker Hub layer blobs are egress-blocked here, so the bake was reproduced without
Docker (MediaWiki 1.46 + mediawiki-vendor, Vector / Citizen / MinervaNeue at pinned
refs,
WikvenSettings.php,maintenance/build.php) over a source tree shaped likedocs/, with aManual/Deepsubpage added to exercise depth.targetUrlare absent from the baked output.composer phpcs,mago format --check,mago lint,biome cipass.Not verified
cdn.playwright.devblocked, Ubuntuchromiumis a snap stub), so the Playwright specs have not been run. They are the only check on the opening of each skin's control — the disclosure selectors are read from baked markup, not exercised. CI runs them.min-widthmedia features, so the Vector rule inext.Wikven.emptyToolboxis unverified locally; and a single-skin export is always Vector, so that module's effect could not be laid out at all here.Generated by Claude Code