Desktop Themes — uploadable ZIP theme system - #420
Conversation
Adds Desktop Themes: an admin uploads a ZIP (theme.json + images) and each user picks one. A theme can replace every --desktop-mode-* and --wpd-* token, the title-bar / dock / desktop textures, the window frame and corners, and the full iconset down to the control glyphs. No author CSS or JS ever executes. PHP validates the manifest and compiles a stylesheet of custom-property declarations, generating every url() itself from a rawurlencode'd path. To make theming actually reach window bodies, the feature stylesheets and the <wpd-*> kit were unified onto one --wpd-* palette (~800 refs). Four parallel vocabularies were collapsed, and every site keeps its own literal as the var() fallback, so with no theme active every computed colour is byte-identical to before. Also fixes two standing cache bugs found on the way: @import'ed sub-sheets carried no ?ver= (now separate handles, chained by dependency to preserve cascade order), and theme asset URLs were unversioned so a re-upload served stale icons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
1a48e09 to
fb9aabd
Compare
Widens the desktop-theme format from "tokens, icons, and nine window- chrome textures" to a format that can restyle the whole OS. Fonts. A `fonts` block compiles to `@font-face` rules PHP generates itself — the only at-rule this feature emits. Two author substrings reach the stylesheet: a family name locked to a pattern strict enough that double-quoting it is airtight, and a path that passed a font extension allowlist disjoint from the image one. The `format()` hint is derived from the extension, never read from the manifest. Four typography tokens carry it, split chrome vs. window bodies. Icon tinting. `iconColor` (manifest-wide) and `color` (per slot) change how a glyph is PAINTED, not just its colour: an image icon becomes a CSS mask filled with the tint, so only its alpha survives. `currentColor` then inherits each surface's own text colour, which is what makes one monochrome silhouette set legible on a dark dock, a light title bar, and a red danger-hover at once. Without it a black-stroked iconset renders as an `<img>` and disappears against a dark dock — the failure this fixes. Textures on 24 surfaces, up from 9. New: focused window frame, window body, tab bar, dock item, icon tile, widget, the title-bar control cluster and button faces, and seven component-kit surfaces (menus, dialogs, scrim, panels, toasts, table headers, buttons). Image descriptors gain `position`, without which a repeating grid re-anchors on every resize and appears to crawl. The structural change behind that count: the compiler no longer knows slot names. It reads `desktop_mode_desktop_theme_texture_slots()`, where each entry now carries the custom property to write, so a plugin can texture a surface the framework has never heard of with one filter entry plus one CSS rule. Control clusters and button faces are transparent by default so a title-bar texture runs edge to edge underneath them; the tokens to plate them exist for themes that want the opposite. Several `background:` shorthands became `background-color` along the way — the shorthand silently resets `background-image`, which would have made textures vanish on hover, on native window bodies, and on every button variant. Docs, hook reference, JS reference and the registration example are updated in the same change. 52 new PHP assertions, 20 new vitest cases; full suites green (1592 PHP, 2285 JS). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
|
Looks good, I'll work on a couple themes today. |
Plugin Check fails the build on `WordPress.WP.AlternativeFunctions. rename_rename`. The existing `phpcs:ignore` named a different sniff code (`file_system_operations_rename`) so it never suppressed anything. `WP_Filesystem::move()` is the right call regardless: it is the documented API, it works on the non-direct transports `unzip_file()` already used to write these same files, and its Direct implementation falls back to copy-then-delete when a plain rename fails — which is the common case when the staging dir and uploads land on different devices. The global is populated by the `WP_Filesystem()` call this function already makes. The neighbouring `@copy()` of the author's manifest gets the same treatment. It is not currently flagged (Plugin Check rates it a warning, which CI filters), but leaving one raw filesystem call beside the converted one invites the identical failure later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
The bottom dock hardcoded its background, its hairline, its top-edge highlight and its drop shadow. A theme could restyle every dock placement except that one, and there was no way to remove the outline it drew over an empty desktop — which is where it was conspicuous, because no window covered it. Each is now a token. The border chain runs floating-specific → `--desktop-mode-dock-border` → literal, so a theme that tokenized its side rail gets the pill for free, which is what anyone setting that token already expected. The default hairline shifts from 0.12 to the shared 0.08 alpha as a result — a difference of a few percent on a 1px line. Also fixes a real regression from the texture work: the pill set `background:` with the shorthand, which resets `background-image` and therefore wiped out the DOCK texture slot declared on the base `.desktop-mode-dock` rule. A themed dock texture never appeared in the bottom placement at all. It is `background-color` now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
The Arrange submenu and the keyboard-shortcuts popover painted their background from `--desktop-mode-window-bg` — a token every theme sets — while painting their text from `--desktop-mode-text`, which nothing in the codebase has ever defined. It was not a token; it was a typo with a fallback, so the text was permanently pinned to near-black `#1d2327`. Under any dark theme that meant near-black text on the theme's own near-black panel. The submenu's hover state made it worse by hardcoding `color: #000`. Both surfaces now read `--wpd-surface` / `--wpd-fg` / `--wpd-fg-muted`, the documented panel palette, with the previous literals as fallbacks — so an unthemed admin bar is unchanged and a themed one tracks the theme on both axes at once. Key chips, hairlines and row tints follow the same palette. The tooltip keeps its hardcoded dark pill and white text: those two are already a matched pair and deliberately ignore the surface. `--desktop-mode-text` and `--desktop-mode-muted-fg` appear nowhere else in the source, the stylesheets, or the docs, so nothing could have been relying on them resolving. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
Every badge in the shell was a fixed pixel size while the icon under it was not. The dock is user-sizable (18 / 20 / 26px icons via OS Settings) and the badge stayed 16px through all three, so on Large it read as a speck stuck to an oversized tile. The recycle bin's lid badge was worse: 15px with a 9px digit, and its offsets were hardcoded to the 48px wallpaper icon, so scaling icons slid the badge off the artwork. Each badge now derives its box, text, padding and offsets from the icon it belongs to — the dock badge off `--desktop-mode-dock-icon-size`, the wallpaper badges off the new `--desktop-mode-icon-image-size`. The ratios are picked so the default sizes resolve to exactly the values these rules used to hardcode (16/10, 18/11, 15/9 at 39/20), so nothing moves for anyone on default settings; Large dock now gets a ~21px badge instead of 16. All of it is overridable, which is the other half of the ask: a theme zip can set `--desktop-mode-dock-badge-size` or `--desktop-mode-icon-badge-size` and the font size and padding follow from it, so one token is usually enough. The bin's own token falls through to the generic icon-badge one, so "badges are 22px" reaches the bin without naming it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
The stale-while-revalidate path answered a runtime-cache miss from the STATIC precache using `ignoreSearch: true`. That lookup ignores the `?ver=<mtime>` WordPress stamps on every asset, so it matched the old bytes by construction — and a version-mismatched precache entry is exactly the thing you must not serve. The effect: edit any of the four sheets in `PRECACHE_PATHS` (desktop.css, variables.css, dock.css, windows.css), get a new `?ver=`, miss the runtime cache, and receive the PREVIOUS build's CSS. The change only appeared on the following load. It was worst when a stylesheet and a bundle had to ship together — fresh JS rendering elements the stale CSS had no rules for, which is how themed dock icons came out unsized and therefore invisible until a hard reload. The runtime cache still answers exact `?ver=` hits instantly, so the common path is unchanged. On a miss we now go to the network and fall back to the precache only if that fails, which is the offline role the precache was documented to have all along. Also gives the masked dock icon its geometry inline as well as in the stylesheet. A masked span has no intrinsic size — unlike the `<img>` it replaces — so any missing CSS rule collapses it to nothing rather than merely restyling it. The element that needs the size should carry it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
The badge digit derived at 0.625 of the badge box — 10px on the default 16px badge, which is what the rule had hardcoded for years. That is too small to read at a glance, and the count is the entire point of the badge: at 10px it registers as texture rather than a number. 0.75 instead, so the default resolves to 12px and the digit fills the pill the way a notification count should. It scales with the dock-size setting like the rest of the badge geometry: 10.8px on Compact, 15.6px on Large. The padding ratio drops from 0.3 to 0.25 to match — at 0.3 a single 12px digit pushed the pill past its 16px min-width and the circle went subtly oval. `--desktop-mode-dock-badge-font-size` still overrides the whole thing for themes that want something else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
Reverts the previous commit's generic ratio. Raising `.desktop-mode-dock__badge` from 0.625 to 0.75 enlarged the alert count on every MENU tile — the left rail in the Classic layout — when the badge that was actually unreadable is the Recycle Bin's, which never used that rule at all. It has its own override and sat at a flat 13px box with an 8px digit. The two are different problems. A menu badge sits on the tile CORNER as a red pill: the pill carries the signal and the digit only confirms it, so 10px is right and is what it has always been. The bin's badge sits INSIDE the bin glyph as a quiet neutral marker, where the pill reads as part of the artwork and the number has to carry itself — so it gets the larger 0.8 ratio, landing on a 12px digit in a 15px box. The bin's geometry now derives from the dock icon size like everything else (13.5/10 on Compact, 15/12 on Default, 19.5/15.6 on Large) instead of being pinned to the 20px default, and its hover rule re-asserts the derived offset rather than the old literal. Both size and font size chain through the generic dock-badge tokens, so a theme can still move them together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
Answers "do themes change the wallpaper?" with: they contribute one, they do not impose it. A theme declares any number of wallpapers and each is published into the ordinary wallpaper registry, labelled `<name> - (theme)` — or `<name>: <label> - (theme)` once a theme ships more than one. Every theme in the library contributes, not just the active one. Deliberately NOT the macOS/Windows behaviour of swapping the wallpaper on activation. A wallpaper here is a stored user preference, so applying a theme would have to either overwrite it silently or grow a shadow "what did they have before" record to undo later. Offering the artwork where the user already goes to change wallpapers is better on both counts, and it buys something the OS behaviour cannot: a theme's wallpaper is usable WITHOUT wearing the theme, and wearing the theme never costs a user the wallpaper they picked. Four author shapes all normalize to one list, because all four are things people reasonably write: a bare path, a single descriptor, a list, or a map keyed by id. `wallpaper` and `wallpapers` are interchangeable keys. Ids get particular care because the user's selection persists by id. They derive from an explicit `id`, then the map key, then a slug of the label, then the image filename — never the array index, so reordering a list cannot silently move every user onto a different picture. Duplicate ids drop rather than shadow. Distinct from the DESKTOP texture slot, which follows the theme and layers over whatever wallpaper is active. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
Installing a theme did not add its wallpapers to the picker until the next page load. The cause was an ordering seam, not a missing hook: `desktop_mode_register_desktop_theme_wallpapers()` runs on `init`, and for the upload request that fired before the theme existed. The registry the response was built from therefore predated the theme by milliseconds. Both REST routes now carry a rebuilt `serverWallpapers` list — re-running the registration on install (idempotent: same ids, same store), and filtering the deleted theme's ids out on delete. Delete needs no unregister API: the registry is per-request static state, so it dies with the request and the next one never registers them. Getting that list from the OS Settings panel to the wallpaper registry crosses a bundle boundary — the panel is lazily loaded, the registry sync lives in the always-on shell — so it travels the hook bus, which is genuinely global where module state is not. New action `desktop-mode.wallpapers.server-changed`; the settings API announces on it, the shell reconciles. Announcing from the API module rather than the UI means install and delete both get it without either call site knowing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
|
@claude how you feel about leaving a review here? :) |
|
Claude finished @AllTerrainDeveloper's task in 2m 51s —— View job Review
This is an unusually careful piece of engineering for something as high-risk as "let admins upload arbitrary ZIPs that reskin the whole shell." I read through the intake pipeline end to end rather than skimming — notes below are genuine findings, not a rubber stamp. What I verified and liked:
Smaller things worth a look (none blocking, ordered by how much I'd want them addressed):
None of these are things I'd hold the PR on — the actual attack surface (the ZIP → filesystem → CSS pipeline) is tight and clearly took real threat-modeling. Nice work, particularly the mechanical byte-diff verification you did on the 232-file CSS token migration rather than eyeballing it. I wasn't able to run |
…nitization
Three non-blocking review findings.
**Why quotes are allowed in the token charset.** They are needed for
font stacks and were the one entry in the allowlist without a stated
reason. Documented: a value only ever reaches a custom-property
declaration in a compiled stylesheet, or that stylesheet assigned via
`style.textContent`. A quote opens a CSS string it cannot close the
declaration from (`;` `{` `}` banned) and cannot escape the stylesheet
from (`<` `>` banned, so `</style>` is unwritable). Tested with three
quoted breakout payloads.
**Abandoned staging directories.** Correct: every path unwinds its own
`.staging-<uuid>`, but a request killed between mkdir and cleanup
leaves one with nothing to collect it. Added a sweep — deliberately at
the top of an install rather than on `init`, because an `init` sweep
would put a `glob()` on every request in the site to tidy up after
something that happens a handful of times in a plugin's life, and this
module's posture is that an unused feature costs nothing. An age floor
protects concurrent uploads, whose staging dir is seconds old.
**Wallpaper labels.** Sanitized at registration, matching how
`description` in the same registry has always been treated.
Deliberately NOT escaped at the paint site as suggested. Labels are
painted through the `html` tagged template, whose text slots build DOM
with `createTextNode()` — never `innerHTML` — so there is no HTML
boundary on that path, and `esc_html()` would encode the `&` in an
ordinary label ("Black & White") into a literal `&` on screen.
Stripping is the right tool here; escaping would be a visible bug. A
test pins that distinction so nobody "fixes" it later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qgLreRcFeR7mkgPLcFMrZ
Admins upload a ZIP (
theme.json+ images) via OS Settings → Themes; each user picks one. A theme can replace every--desktop-mode-*and--wpd-*token, the title-bar / dock / desktop textures, the window frame and corners, and the full iconset including window control glyphs.Security model
A theme is data, never code. No author CSS or JS ever executes. PHP walks the archive before writing a byte (traversal / absolute paths / extension allowlist / size caps), sanitizes the manifest, strips SVGs with DOMDocument, moves only manifest-referenced assets, and compiles a stylesheet of custom-property declarations — generating every
url()itself from arawurlencoded path. Token values pass a charset allowlist that makes declaration escape, at-rule injection and!importantimpossible.Palette unification
Shell tokens alone only restyle the frame around a window. Window bodies are built from
<wpd-*>components and feature CSS, which had grown four parallel vocabularies (--wpd-text/--wpd-fg,--desktop-mode-text,--desktop-mode-border, …) — none of them ever defined anywhere. Collapsed onto one--wpd-*palette, ~800 references.Every rewrite keeps the original literal as the
var()fallback. Verified mechanically rather than by eye: unwrapping everyvar(--x, <colour>)in both trees and diffing gives byte-identical output across 232 files — with no theme active, nothing moved.Two cache bugs fixed on the way
@imported sub-sheets carry no?ver=, so edits could be served stale indefinitely (rules had twice been relocated intowindows.cssto dodge it). Now six separate handles, chained by dependency to preserve cascade order — which also removes the@importrequest waterfall.Non-goals
<wpd-icon>content icons, letter badges, fonts, layout, and art-direction colour (note paper, game palettes, graph node hues) stay fixed.Tests
PHPUnit 1489 (+7 classes) · Vitest 2260 (+3 suites) · build / lint / typecheck green.
Docs: new
docs/desktop-themes.md+examples/register-desktop-theme.md; hooks, JS, api-index, architecture updated.Manual QA
Upload a theme as admin → instant appearance; pick → full reskin with no reload; boot with one active → no FOUC; System default → reverts; delete active → falls back; non-admin sees the picker but no upload/delete; unthemed baseline shows no extra stylesheet or shell attribute.
You can use the reference theme used in through the development:
neon-glass.zip