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 —
— 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:
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, thegetUrl()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-contentfor 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-colorand--bs-secondary-bg. - plain-css — it declares its palette itself, and read two names it had never
declared.
--border-colorand--bg-subtleare now in its:rootblock.
Added¶
tests/Unit/Theme/ScaffoldThemeStylesheetTokensTest.php— everyvar(--x)in a bundled theme'sstyle.cssmust be declared in that file, be a token of the paletteinitwrites (read throughThemeTokens::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'sstyle.cssswaps it:
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.jsasserted against an element the focus trap excludes. The two wrap-around tests built their expected first and last element fromscreen.getAllByRole('button'), which includes the modal backdrop — a<button>for the click, held out of the tab order withtabindex="-1"on purpose. The component was right:focusable()filters!el.disabled && el.tabIndex !== -1. The test now filters the same way, through atabbableButtons()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.sveltewarned on everynpm run build.let searchBox = $state(search)reads a prop at init and an$effectre-syncs it, which is exactly whatstate_referenced_locallyis about. It is now$state(untrack(() => search))— the initialiser really is a one-shot read, anduntrackis 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:
— 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 ownextendsclause 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
::classconstants, 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\UsersbesideUsers).
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
initwrites, 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:buildwrites 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
-rgbtriplets Bootstrap needs are computed, not aliased..bg-primaryisrgba(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 torgba()and CSS cannot decompose it, so the conversion to sRGB happens at build time, by the matrix CSS Color 4 defines.oklch(), hex andrgb()all convert; a value that cannot be resolved —color-mix(), a named colour — gets no-rgbalias 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
:rootare the values it composes — a hover shade, a muted tone, the gradient — written over the aliases withcolor-mix(). Its--whiteis now--surface: a variable called white that is#0f172aunder 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-uidestroyed the project's palette. It goes throughinstallUiFramework()→scaffoldTheme()→scaffoldPalette(), and that last step wrote the scaffold's template overapp/themes/theme.cssunconditionally — then derivedtheme-tokens.cssfrom the template. So a project with a red brand and themes calledmybrand/mybrand-darkcame back blue and calledapp/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.mjsis the second reader ofapp/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 buildprintedtheme: 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()returnedoklch()to callers that cannot read it. Its own doc-block names<meta name="theme-color">and HTML email, and daisyUI's generator writesoklch()— 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 conversiontheme:builduses for Bootstrap's-rgbtriplets, so an email and the page agree by construction. A value it cannot resolve to a colour returns the fallback rather than itself: handing backcolor-mix(…)from a method calledhex()is the same broken email one step later.
Documentation¶
- The Theme Guide now says
project:switch-uileaves the palette alone, that there are two parsers of one file and they must be changed together, and that an HTML email wantshex()rather thantoken().