Skip to content

Desktop Themes — uploadable ZIP theme system - #420

Merged
AllTerrainDeveloper merged 12 commits into
trunkfrom
feat/desktop-themes
Jul 27, 2026
Merged

Desktop Themes — uploadable ZIP theme system#420
AllTerrainDeveloper merged 12 commits into
trunkfrom
feat/desktop-themes

Conversation

@AllTerrainDeveloper

@AllTerrainDeveloper AllTerrainDeveloper commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

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.

image

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 a rawurlencoded path. Token values pass a charset allowlist that makes declaration escape, at-rule injection and !important impossible.

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 every var(--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 into windows.css to dodge it). Now six separate handles, chained by dependency to preserve cascade order — which also removes the @import request waterfall.
  • Theme asset URLs were unversioned, so a re-upload (an update, by design) served the previous version's icons and textures.

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

Open WordPress Playground Preview

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
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
@nickhamze

Copy link
Copy Markdown
Collaborator

Looks good, I'll work on a couple themes today.

AllTerrainDeveloper and others added 9 commits July 25, 2026 19:27
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
@AllTerrainDeveloper

Copy link
Copy Markdown
Collaborator Author

@claude how you feel about leaving a review here? :)

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown

Claude finished @AllTerrainDeveloper's task in 2m 51s —— View job


Review

  • Gather context (PR body, diff, docs)
  • Review PHP security-sensitive code (includes/desktop-themes/*.php)
  • Review TS apply/registry/slots/icon code (src/desktop-themes/*.ts, src/icon.ts)
  • Review CSS token migration approach & storage
  • Post review feedback

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:

  • includes/desktop-themes/install.php: two-phase validate-then-write (desktop_mode_desktop_theme_validate_zip() fully walks ZipArchive::statIndex() before a single byte is extracted), NUL-byte/backslash/absolute-path/..-segment rejection, per-entry and total-uncompressed size caps, and only manifest-referenced assets get moved out of staging ($assets built from the sanitized manifest, not the archive listing). desktop_mode_desktop_theme_rmdir() refuses to operate outside the themes base dir via realpath() containment checks — good defense against a future caller misuse turning it into an arbitrary-delete primitive.
  • SVG handling (desktop_mode_desktop_theme_sanitize_svg()): DOCTYPE/ENTITY rejected before the parser sees them (XXE/billion-laughs), LIBXML_NONET, then a DOM walk stripping script/foreignobject/iframe/object/embed, every on* handler, non-fragment href/xlink:href, and style with url(/javascript:/expression(. Missing DOMDocument → hard reject rather than silently shipping unexamined SVG. Solid.
  • includes/desktop-themes/compile.php / manifest.php: the compiler never writes an author string into a selector, property name, or url() — every URL is PHP-generated from a rawurlencoded, containment-checked path (desktop_mode_desktop_theme_staging_asset_resolver() re-realpath()s and checks the result is still under $base before returning). Token values go through desktop_mode_desktop_theme_is_safe_css_value(), a charset allowlist + banned-substring list (url(, var(, expression, javascript) + balanced-paren check, which closes off declaration escape and var()-based alias attacks in one pass. @font-face is the one at-rule and it's entirely PHP-authored except a ^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$ family name.
  • JS side re-validates rather than trusting the payload: src/desktop-themes/registry.ts's isPaintableIcon() and src/desktop-themes/icons.ts's sanitizeIconColor() both explicitly note why — a desktop_mode_desktop_themes PHP filter runs after sanitization and could reintroduce something unsafe, so the shell doesn't get to assume the payload is clean just because PHP touched it once. paint-tinted-icon.ts's isMaskableIcon() mirrors this for the CSS url("…") interpolation. Nice layered posture.
  • Storage hygiene: the theme index option is registered with add_option(..., '', 'no') (autoload off) since it carries full manifests — correct, this isn't something every page load needs.
  • REST routes (rest.php) gate on desktop_mode_desktop_theme_upload_capability() (filterable, defaults to manage_options) for both POST and DELETE, and the upload's filename check blocks double-extension tricks (theme.php.zip) in addition to the ZIP-entry-level checks — redundant in a good way.
  • Frontend follows AGENTS.md: desktop-themes-api.ts uses trackedFetch exclusively, themes.ts uses wpdConfirm for the destructive delete, no raw fetch/confirm anywhere I found in the new code.
  • Test coverage checks out structurally: 8 new PHPUnit classes under tests/phpunit/tests/desktopThemes*.php and 4 desktop-theme vitest suites, roughly matching the "+7 classes / +3 suites" claim (some pre-dated this diff).

Smaller things worth a look (none blocking, ordered by how much I'd want them addressed):

  1. desktop_mode_desktop_theme_is_safe_css_value() (manifest.php ~line 70) allows both ' and " in the token-value charset. That's needed for quoted font stacks ("Segoe UI", sans-serif), and since every consumer I traced writes the value as a raw custom-property declaration (never string-concatenated into an HTML attribute), I don't think it's exploitable today — but it's the one part of the allowlist that isn't self-evidently justified by a comment the way the rest of the function is. A one-line note on why quotes are safe here (mirroring the reasoning already given for the banned characters) would make the invariant easier for the next person touching this file to verify without re-deriving it.
  2. wp_generate_uuid4()-named staging dirs under the public uploads-adjacent themes base dir are cleaned up on every exit path I checked, but if a request dies between wp_mkdir_p( $staging ) and the final desktop_mode_desktop_theme_rmdir( $staging ) (fatal error, OOM, host kill) the .staging-<uuid> directory would leak with no desktop_mode_desktop_theme_rmdir() GC pass anywhere I could find. Probably fine at theme-upload frequency, but worth a periodic sweep (e.g. on the same init hook that registers wallpapers, delete any .staging-* older than a day) if this becomes a heavily-used feature.
  3. desktop_mode_desktop_theme_wallpaper_label() interpolates the theme's manifest name via sprintf(). Since only manage_options users can upload, this is at most a self-XSS-shaped edge case if the label is ever rendered unescaped client-side — I didn't find a spot where it is, but given the file's own rigor everywhere else, an explicit sanitize_text_field()/esc_html() note at the point the label is finally painted (rather than relying on "it's the wallpaper picker, which presumably escapes") would close the loop the same way the rest of the pipeline does.

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 npm run typecheck / npm run lint / npm run test:js / npm run test:php myself in this sandbox (Bash execution requires interactive approval this environment doesn't grant), so I can't independently confirm the green-CI claim beyond reading the code — if you want me to actually execute the suites, allow the corresponding Bash commands and I'll follow up.

…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 `&amp;` 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
@AllTerrainDeveloper
AllTerrainDeveloper merged commit c1029fa into trunk Jul 27, 2026
5 checks passed
@AllTerrainDeveloper
AllTerrainDeveloper deleted the feat/desktop-themes branch July 27, 2026 10:28
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.

2 participants