Skip to content

15 September 2026

8 changes:

  • A comment inside a palette block became a colour token
  • The SPA shell's service-worker registration was a fatal
  • Bundled theme stylesheets read custom properties nothing declared
  • The tailwind header showed the dark-ink logo on the dark theme
  • A scaffolded front end arrived with two red tests and a build warning
  • The generated controller contract test named twelve classes that do not exist
  • "One palette, every UI system" was true of one system
  • Three more holes in the palette pipeline, found by walking it end to end

A comment inside a palette block became a colour token

app/themes/theme.css is a stylesheet a person edits, and the Theme Guide invites annotating the colours in it. Doing that broke the build:

@plugin "daisyui/theme" {
    name: "acme";
    /* The surfaces: white cards on a light grey page. */
    --color-base-100: #ffffff;
}

ThemeTokens::parse() split the block on ; and read every piece as name: value, with nothing that knew what /* … */ was. So the comment's own text became a key in www/assets/theme-tokens.json —

"/* The surfaces": "white cards on a light grey page. */\n    --color-base-100: #ffffff"

— and the declaration on the line after it went with it. A SPA reading that file for --color-base-100 got nothing, and the only evidence was a JSON key nobody opens.

Fixed

  • Comments are stripped before anything else looks at the file, so /* … */ is safe anywhere: beside a declaration, on its own line inside a block, or wrapped around a declaration being tried out. Stripping first rather than per line also means a commented-out } no longer ends a block early and takes every declaration below it — which was the same bug with a quieter failure.

Documentation

  • The Theme Guide's "One palette, every UI system" section now says comments are safe and shows one, instead of the palette file inviting something that did not work.

The SPA shell's service-worker registration was a fatal

A scaffolded SPA with --service-worker=y ended every page with:

Fatal error: Uncaught Error: Undefined constant "sURL"

The registration snippet is built in PHP so the URL follows a subdirectory install, and sURL is what knows the base. The theme footer has it — it renders inside a booted application. The SPA shell does not: it requires the autoloader and nothing else, by design, because it is not an MVC view.

It survived because of where it lands. The SPA had already booted by then, the page looked right, and the only consequence was a worker that was never installed — which leaves no trace anywhere.

Fixed

  • The shell's copy reads $siteUrl, the getUrl() call the shell already makes a few lines higher to derive the API key. Same value, same subdirectory behaviour, and it is a variable the file actually has. The theme footer's copy is unchanged.

Documentation

  • The Service Worker Guide's "Where the registration lives" now shows both forms and says why they differ.

Bundled theme stylesheets read custom properties nothing declared

The tailwind theme's breadcrumb and pagination rules asked for --primary-color, --text-muted, --border-color, --text-main and --white. The palette pipeline emits daisyUI's names — --color-primary, --color-base-content, --color-base-300, --color-base-100 — and nothing anywhere declares the first set.

var(--name, #literal) never fails. The browser takes the fallback, renders it and says nothing, so those two components were Tailwind blue on every project regardless of its palette and stayed light under the dark theme. The rest of the same file already used --color-*, which is what made it invisible: it read as a style choice rather than a stylesheet asking for a vocabulary the page has not got.

Fixed

  • tailwind — the whole file now reads --color-*, including --color-primary-content for text on a primary background rather than white.
  • bootstrap — the same class of defect across twenty-two rules, and that theme has its own vocabulary on the page: they now read --bs-primary, --bs-body-color, --bs-body-bg, --bs-border-color, --bs-secondary-color and --bs-secondary-bg.
  • plain-css — it declares its palette itself, and read two names it had never declared. --border-color and --bg-subtle are now in its :root block.

Added

  • tests/Unit/Theme/ScaffoldThemeStylesheetTokensTest.php — every var(--x) in a bundled theme's style.css must be declared in that file, be a token of the palette init writes (read through ThemeTokens::parse(), so the check follows the palette), or carry a prefix the theme's page genuinely provides. It found three more dead names than the report did.

Documentation

  • The Theme Guide's table of bundled themes now has a third column — which vocabulary each one's stylesheet reads — and says what a rule in the wrong one looks like.

The tailwind header showed the dark-ink logo on the dark theme

The scaffold copies two ink variants of the placeholder wordmark into www/assets/img/ — logo.png dark, logo-inverse.png light — and the tailwind header hard-coded the first. That theme is the only one with a light/dark toggle, so under data-theme="<slug>-dark" the brand was near-black on a near-black navbar: no error, no broken image, just a header with nothing in the corner. Nothing referenced the inverse file at all.

Fixed

  • The brand image carries pf-logo, and the theme's style.css swaps it:
[data-theme$="-dark"] .pf-logo { content: url("../img/logo-inverse.png"); }

plus the same rule under prefers-color-scheme: dark scoped to :root:not([data-theme]), for a visitor who has not chosen — the scoping theme-tokens.css already uses. The suffix match is because the dark theme is named after the project and the stylesheet is static.

No second <img> to keep in sync and no JavaScript in the path. bootstrap is unchanged — its navbar is dark in both directions and already gets the inverse file — and plain-css ships no dark theme.

Documentation

  • The Theme Guide's "The tailwind theme is a daisyUI theme" section says the logo follows the theme and that rebranding means replacing both files.

A scaffolded front end arrived with two red tests and a build warning

Two defects in the shared Svelte components, both of which a newly scaffolded project inherits and neither of which it caused.

Fixed

  • ConfirmDialog.test.js asserted against an element the focus trap excludes. The two wrap-around tests built their expected first and last element from screen.getAllByRole('button'), which includes the modal backdrop — a <button> for the click, held out of the tab order with tabindex="-1" on purpose. The component was right: focusable() filters !el.disabled && el.tabIndex !== -1. The test now filters the same way, through a tabbableButtons() helper, so the expectation mirrors the trap instead of re-querying the DOM with looser rules. Two tests red out of the box, for a component that worked.
  • DataTable.svelte warned on every npm run build. let searchBox = $state(search) reads a prop at init and an $effect re-syncs it, which is exactly what state_referenced_locally is about. It is now $state(untrack(() => search)) — the initialiser really is a one-shot read, and untrack is how you say so.

Documentation

  • The Console Guide's "The shared components" section says the scaffolded suite is green and the build warning-free, and explains both constructs — each reads like a slip and reverting either brings the failure back.

The generated controller contract test named twelve classes that do not exist

ControllersContractTest.php is close to the first thing anybody runs in a new project, and a scaffolded project opened with 24 errors:

Error: Class "App\Controllers\Dashboard" not found

— and the same for Users, Settings, Logs, Services, Organizations, Emails, TokenActions, Tokens, Applications, Permissions and Queue. Every one of those is written to src/Admin/Controllers, under an Admin\ namespace segment, and the roster named all twelve under App\Controllers.

It drifted the other way as well, silently: Register, Sso, Webhook and Messages were written and had no row, so the contract nobody thought to add was the contract nobody was checking. Four real gaps, and they were the cheap half — twelve false failures say the scaffold is broken, on the first command, about controllers that are fine.

The cause is the shape rather than the list: the roster was maintained beside the code that writes the controllers, guarded by the same feature flags, so keeping the two in step was a thing somebody had to remember on every change.

Fixed

  • The roster is read off the project's src/ after the controllers are written. Both directories, with the expected parent resolved from each controller's own extends clause and imports — the wrappers alias theirs (use …\Controllers\Account as FrameworkAccount), so the token alone would have named a class that exists nowhere.
  • Rows are plain class-name strings rather than ::class constants, so a controller that does not load fails an assertion with its name in the message instead of erroring inside the data provider.
  • Names are the short class name, with the parent segment kept when two directories would collide (Admin\Users beside Users).

A project scaffolded with auth, authserver, queue and messaging now gets 33 rows, all of which load — including the eight that were never checked before.

Documentation

  • The Console Guide has a new section, "The controllers init writes, and the test that names them": the two directories and their namespaces, and why the roster is read from disk rather than written down.

"One palette, every UI system" was true of one system

app/themes/theme.css is meant to be the only place a project's colours live, and theme:build turns it into theme-tokens.css, which head.php links for every server-rendered theme. Only one of the three could read it.

The tokens are daisyUI's — --color-primary, --color-base-100 — and only the tailwind theme speaks that vocabulary. Bootstrap's components read --bs-*, defined by the stylesheet it vendors; the plain-CSS theme read its own names, which it declared itself in style.css, after theme-tokens.css and therefore winning. So a project on either of those two could edit its palette all day and get Bootstrap blue.

Fixed

  • theme:build writes the palette a second time, under the names those themes already read. --bs-primary: var(--color-primary), --primary-color: var(--color-primary), and so on for surfaces, text, borders and the semantic colours.

Aliasing rather than rewriting the two stylesheets is the point of the shape: Bootstrap's own components read --bs-*, so redefining the variable reaches .btn-primary, .navbar and .card as well — and nothing in this repository can edit Bootstrap. The aliases go into every theme block, the prefers-color-scheme one included, so the dark palette carries its own.

  • The -rgb triplets Bootstrap needs are computed, not aliased. .bg-primary is rgba(var(--bs-primary-rgb), var(--bs-bg-opacity)), and the scaffolded bootstrap navbar is a .bg-primary — so without this the most visible element on the page would have kept Bootstrap's blue while everything around it followed the project. oklch() cannot be fed to rgba() and CSS cannot decompose it, so the conversion to sRGB happens at build time, by the matrix CSS Color 4 defines. oklch(), hex and rgb() all convert; a value that cannot be resolved — color-mix(), a named colour — gets no -rgb alias at all, because a missing triplet leaves Bootstrap agreeing with itself and a guessed one is a colour nobody chose.

  • The plain-CSS theme stopped declaring its own colours. They come from the alias block now; what stays in its :root are the values it composes — a hover shade, a muted tone, the gradient — written over the aliases with color-mix(). Its --white is now --surface: a variable called white that is #0f172a under a dark palette is a name the next reader corrects back.

Every rule keeps its literal fallback, so a project that has never run theme:build still renders.

What this turns on

A prefersdark theme now reaches the bootstrap and plain-CSS themes too. A project on either will follow the visitor's operating system into dark mode where it previously stayed light. The controls are the ones that were already there: drop prefersdark: true from the palette, or set data-theme on <html>. There is a row in the Upgrade Guide.

Documentation

  • The Theme Guide's "One palette, every UI system" has a new subsection, "The same palette, under each system's own names", and its table of bundled themes now says which vocabulary each reads and that all three follow the palette.

Three more holes in the palette pipeline, found by walking it end to end

Having changed how the palette is read, it was worth following it to every place it goes rather than only the one that had been reported. Three things were wrong.

Fixed

  • project:switch-ui destroyed the project's palette. It goes through installUiFramework() → scaffoldTheme() → scaffoldPalette(), and that last step wrote the scaffold's template over app/themes/theme.css unconditionally — then derived theme-tokens.css from the template. So a project with a red brand and themes called mybrand / mybrand-dark came back blue and called app / app-dark, from a command whose documented job is to flip between UI frameworks on a live project. Nothing said so: it reports the files it installed, and the palette was one of them.

A palette that exists is now read, never rewritten, and the tokens are generated from it. A project that has none still gets the template, named after the application — which is how a project scaffolded before the palette existed acquires one.

  • The SPA's palette build and the PHP one disagreed about comments. scripts/build-theme.mjs is the second reader of app/themes/theme.css, and it had no comment handling. A /* … */ became a declaration in the generated stylesheet and swallowed the token after it — and a comment containing } ended the block regex early, leaving a /* was: ; fragment that opened a comment nothing closed, so the rest of the generated file, the font rule included, was inside it. npm run build printed theme: palette written ✓.

Both parsers now strip comments first. tests/js/spa-build-theme.test.js runs the real script against a commented palette and counts that every comment in the output is closed.

  • ThemeTokens::token() returned oklch() to callers that cannot read it. Its own doc-block names <meta name="theme-color"> and HTML email, and daisyUI's generator writes oklch() — which no mail client parses. The declaration is dropped and the colour becomes whatever the surrounding markup says, which in a dark template is black on black.

Added

  • ThemeTokens::hex() — the same lookup, converted to #rrggbb, through the same conversion theme:build uses for Bootstrap's -rgb triplets, so an email and the page agree by construction. A value it cannot resolve to a colour returns the fallback rather than itself: handing back color-mix(…) from a method called hex() is the same broken email one step later.

Documentation

  • The Theme Guide now says project:switch-ui leaves the palette alone, that there are two parsers of one file and they must be changed together, and that an HTML email wants hex() rather than token().