Журнал изменений
The site header stops cycling through the modes one press at a time and stands all three open on a rail; the panel stops spending its toolbar row on six flags and a toggle, and gathers the colour mode, the content editor and the language into one window opened from a single item. Both spend handlers that already existed: no new storage, no new op, and the editor keeps writing the administrator row it always wrote.
Core changes:
- Colour mode control (core/system.php, templates/lite/fragments/mode-switch.html):
- getThemeModeSwitch() returns the three modes with the one in force marked, instead of the next one to step to
- the order is light, auto, dark, so the answer that follows the system sits between the two it chooses from
- the knob rides over the cells and the active cell hands its icon to it, so nothing is drawn twice
- the rail places its knob with :has() on the active cell, so no state attribute is written from PHP
- Settings window of the panel (core/admin.php, templates/admin/fragments/window-settings.html):
- getAdminSettingsWindow() builds one window from three rows, each posting to the handler that already owns it
- the mode writes its cookie, the editor writes _admins.editor through changeeditor(), the language writes the session
- Editor::getSelect() is reused as it stands, so the sidebar block and the window stay in step
- getLanguageList() is the one list the flag strip of the plain panel and the language row both read
- the toolbar keeps one item for the window and rules the row where the kind of destination changes
- Theme components (templates//assets/css/.css, tools/ui-contract.php):
- .sl-mode-rail, .sl-mode-cell and .sl-mode-knob replace the single .sl-mode-but of the site
- .sl-set-row and .sl-set-item carry the window rows and the toolbar item; the component set is declared in the contract
- a toolbar item may now hold a button as well as a link, which is what lets the window open from the menu row
- the social row takes its hover ground from the category palette instead of eight hand-picked colours
Benefits:
- any mode is one press away on the site, where three presses were needed to come back to the system answer
- the panel toolbar returns to one line: 40px against the 70px the flags and the toggle wrapped it to
- one place for the three answers an administrator changes from any screen, with room for a fourth
- eight colour rules fall to one rotation the tree already spends on categories
Technical notes:
- no schema change and no new op: every row posts to an existing handler
- fragments/mode-switch.html diverges by theme on purpose, with the reason recorded in tools/ui-contract.php
- --sl-mode-width and --sl-mode-height stay declared in both themes, as a frozen API may not lose a name
- demo/ carries the stand the variants were chosen on; it links the shipped CSS and JS and ships no theme of its own
A hook that is present but never runs is worse than no hook: it reads as covered. On any clone other than the one it was written in, this one was present and silent for three unrelated reasons, and on a machine without composer it would have refused every commit without saying why.
Core changes:
- The hook is enabled per machine, not per repository (package.json):
core.hooksPathlives in.git/config, which neither a clone nor a pull carries- git will not let a repository enable its own hooks, which is a security property and not an oversight, so something has to set it on each machine
postinstallsets it duringnpm install, andnpm run ui:hookssets it by hand- the postinstall falls through with a message outside a git checkout instead of failing the install
- The hook file was committed without its executable bit (tools/hooks/pre-commit):
core.fileModeis false in this checkout, sochmod +xnever reached the index and the file went in as 100644- Windows does not care - git runs hooks through sh either way - but git on Linux and macOS skips a non-executable hook without a word
git update-index --chmod=+xputs it in as 100755
- The hook assumed two tools that a fresh machine does not have (tools/hooks/pre-commit):
phphas to be on the PATH of the shell git runs hooks invendor/is gitignored, so phpunit is absent untilcomposer install- both are checked before anything else and each stops with its own reason and the remedy, rather than failing on a confusing error or stepping aside quietly
- verified in all four directions: no php stops with the PATH message, no vendor stops
with the composer message,
SLAED_SKIP_GATES=1passes silently, and a normal tree prints[gates] ok
- Documentation (CONTRIBUTING.md, docs/TESTS.md):
- both said "enable it once per clone", which reads as something the clone does
- both now say it is per machine, name
core.hooksPathand.git/configas the reason a clone and a pull leave it behind, and record what the hook refuses to run without
Benefits:
- the hook either runs or says why it did not, on any machine
npm installis enough to arm it after a clone
Technical notes:
- this commit carries no file the gates watch, so the hook exits early on it by design; the fast set was run by hand instead and is green
- a gate that steps aside when its tools are missing is the failure this repository has now closed five times, which is why both new checks refuse rather than skip
The etalon plan is finished, so its prose moves into the reference documentation and the plan file goes. What replaces the checklist nobody would remember is a pre-commit hook for the cheap gates and a two-word pair for the visual one. Four gates that read zero while checking nothing are closed, and the committed screenshot baseline is dropped because it cannot be stable on a stand whose own content moves.
Core changes:
- Gates that no longer answer when their input vanishes:
tools/ui-contrast.jsoncould be deleted and the contrast count still read zero- getContract() folds the registry in only
if (is_file(...)), so a missing file left nothing to check and ThemeContractTest stayed green - ThemeContractTest now asserts the file exists and carries pairs for every theme
- getContract() folds the registry in only
node tools/ui-shots.mjs --checkwrote a missing baseline image instead of failing- a check whose baseline is absent re-baselined itself against the tree it was asked to judge and exited zero; it reports MISSING and exits non-zero now
php tools/ui-audit.php --markuphad no limit beside the countmarkup.limitis 0, so the scan fails on the next hardcoded class, style or tag
- the screenshot rig captured a degraded set without credentials
--beforeand--afterrefuse to start without SLAED_UI_USER and SLAED_UI_PASS, because a run without them skips every state that needs a session
- Automation (tools/hooks/pre-commit, package.json):
- the pre-commit hook runs the audit, the markup scan and the theme, template and
parser tests on every commit carrying a file able to move a count
- about twenty seconds, and nothing at all on a documentation commit
- enable once per clone with
npm run ui:hooks;SLAED_SKIP_GATES=1skips one - when a commit touches theme CSS or a canon template it prints the two visual commands, because no count can see a moved pixel
npm run ui:beforeandnpm run ui:afterare the visual gateui:afterempties the page and template caches, compares against the captureui:beforetook, regenerates the contrast registry when a base.css moved, reads the counts and prints one verdict- verified in both directions: no change gives PASS, a padding shift on a class the page really carries gives eight DIFFs and FAIL
- The screenshot baseline is no longer committed (tools/ui-baseline, .gitignore):
- measured: a check against the committed set went red hours after capture on a tree whose frontend was byte-identical - 100% size changed at sm, md and lg on the front page and the forum list, 1.6% to 3.9% even at xl
- the page height follows the content of the stand, and a mask cannot save a box whose height is what moves, so the reference is captured per change into a temp directory
- 168 images and about 80 MB leave the working tree; the path is gitignored so a stray capture cannot put them back
- A scratch theme stopped leaking its compiled templates (tests/Support/theme_scratch.php):
- deleteScratchTheme() removed
templates/scratch-*and leftstorage/cache/templates/scratch-*behind, so every phpunit run and every--newthemeleaked about half a megabyte under a name nothing can read again
- Documentation (docs/, root):
- docs/THEME-ETALON-2026.md is deleted; its contract, its gate table and its measured findings move into docs/TEMPLATES.md as three reference sections, without the batch narrative and without any superseded figure
- docs/PARSER.md records that the parser owns no markup and needs a global $tpl
- docs/ARCHITECTURE.md records that $tpl is created before $prs because the parser depends on it, not by coincidence
- docs/EDITORS.md records that every driver mounts through fragments/editor-mount.html
- CONTRIBUTING.md, UPGRADING.md and docs/TEMPLATES.md stop describing HTML leaving PHP as work in progress; it is done and a gate holds it
- five files that pointed at the deleted plan now point at docs/TEMPLATES.md
Benefits:
- the cheap gates need nothing remembered: they run on commit or refuse it
- a gate whose input is missing now fails instead of reporting a clean zero
- the working tree drops from about 145 MB to 65 MB
Technical notes:
.gitis unchanged in size: the baseline blobs stay in history and any image can be recovered withgit show b115eaa0:tools/ui-baseline/<name>.png- the hook needs
core.hooksPathpointed attools/hooks, whichnpm run ui:hooksdoes - the full 168-image walk has not been run under the new
--before/--afterflags; the cycle was verified with--only=voting, and the contrast branch ofui:afterhas not executed because no palette moved in that test - no breaking change: 915 tests pass, phpstan is clean, every ratcheted count holds
A theme cannot restyle what PHP hardcodes, so the closing batch of the etalon plan moves every class attribute, inline style and HTML tag out of PHP and into the theme layer. The scan that measures it falls from 143 occurrences in 21 files to zero and gains a limit, so the next one fails the build instead of printing a number.
Core changes:
- Extended statistics panel (admin/modules/statistic.php):
- The inline <style> block it printed painted with --line, --bg, --soft, --muted,
--blue2, --green, --purple and --orange
- None of those eight names exists in either theme, so every border, background, shadow and accent in the panel resolved to nothing and it had never been styled
- No count could see it: the audit reads CSS files and this was a string in PHP
- The markup is partials/statistic-extended.html and the CSS is 24 rules in theme.css
- Every value lands on a ladder; the four accents are primary, success, warning and accent; five component tokens are declared under statx
- These eight images are the only ones of 168 that moved, and they moved on purpose
- Monitor traffic chart (admin/modules/monitor.php):
- The 900x285 SVG becomes partials/monitor-chart.html taking four series, six hover slots and two axis lists
- Four attribute kinds are dropped as dead
- stroke and filter on lines and areas are SVG presentation attributes, which lose to any CSS rule, and .sl-chart-line-up { stroke: ... } had been winning already
- .sl-chart-tip .muted set fill: var(--sl-text), which .sl-chart-tip text two rules above already set, so the rule and the class are both gone
- Markdown and BBCode renderer (core/classes/parser.php):
- 55 tags, 7 inline styles and 4 classes move into four new fragments plus the existing list, list-item, code-highlight, blockquote, div and parser-image
- A fragment can own a regular expression's replacement string once it renders the
placeholder
- parser-inline.html rendered with content_html set to the literal $1 gives the template a whole element and PHP a replacement string
- A pair that wraps text the parser has not finished reading renders the same whole element with \x01 as its content and is split there, so a theme never spells half a tag
- The blockquote and attachment fallbacks are gone: the parser renders through the theme or not at all
- One decision, one address, across eighteen smaller files:
- class strings inside form_attr become semantic flags in form-add.html and form-wrap.html
- style="display:none" becomes the hidden attribute, which the reset gives !important so nothing a component sets can put back what the page took off screen; the tab script writes .hidden instead of style.display
- Eight sites that joined text with a tag now call getTplLines() over fragments/lines.html
- add_voting() built class="sl-field sl-form-control" into select_attr, where select.html had already written class="sl-select..."; an HTML parser keeps the first attribute of a name, so the string had never reached a pixel and is deleted with its parameter
- The captcha directory guard copies storage/index.html instead of building a document
- Gates (tools/ui-contract.php, tests/Unit/ParserFixturesTest.php):
- markup.limit is 0, so php tools/ui-audit.php --markup exits non-zero on the next hardcoded class, style or tag
- ParserFixturesTest asserts the exact HTML of 86 cases and ran with no engine, so every expected string was what PHP concatenated; it builds a Template now, because the bytes it asserts are a theme's answer
- tools/ui-contrast.json is regenerated beside the manifest that feeds it: 438 pairs, 118 admin and 320 lite, contrast at zero in both
Benefits:
- A theme can restyle every element the CMS emits, which is the goal of the plan expressed as a measurement: markup 0, and every count either theme controls at zero
- The statistics panel is readable for the first time, in both light and dark
- Three gates that were reporting stale answers now report current ones
Technical notes:
- Six fragments are byte-identical in both themes: lines, editor-mount, parser-block, parser-inline, parser-link, parser-table; two partials are the panel's alone
- The parser now hard-depends on the theme layer; there is no plain-HTML fallback left
- The parser disk cache already carries getTheme() and the class mtime in its key, so theme-owned markup cannot be served across themes or across this change
- Masking an element for the screenshot gate removes it from the contrast gate, because the collector skips visibility:hidden and display:none; the registry shrank from 460 pairs to 438 for that reason and not from a regression, which the retired span.sl-but.sl-hidden still sitting in the committed file independently proves
- No breaking change: 86 byte-exact parser fixtures pass unchanged, and 160 of 168 baseline images are identical to the pristine tree
Batch 8 of docs/THEME-ETALON-2026.md closes the remainder of canon and freezes the theme API. Every shared name the two etalons spell differently is now either one file or an entry with a written reason, and the freeze is held by a gate rather than by a sentence: the baseline carries a roster of the 297 and 404 names each API block declares, the audit compares it every run and --store refuses to write while a name has gone missing.
Core changes:
- The 28 same-named templates of canon (tools/ui-contract.php, tools/ui-audit.php):
- --cross reads a shared template the way it already read a shared selector
templatescarries a written reason per divergence,canonnames the two directories the demand applies to, and unanswered ones join the globalcross- layouts and pages are reported and demand nothing: the page shells differ by nature
- two of the 28 were one contract with two spellings and are now one file
- radio.html: admin's is lite's plus four optional keys no lite caller passes
- changelog-body.html: the admin h3 skipped a level under the h1 of module-head, so both are h2 and the admin rule moved with it at the same --sl-font-h4
- the other 26 are two contracts under one name, which the key sets show rather than the line counts: link maps twelve names in the panel against thirty-three on the site with not one in both, inline-badge fourteen against twenty, span one against 22
- One name per intent for hiding and dimming (both themes, core/user.php, modules/forum/index.php):
- .sl-hidden meant
display: nonein admin and an opacity step in lite- read by producer rather than by class: six lite call sites mean gone, four mean closed but still readable - a comment awaiting approval, an unpublished forum post, an opened message, an action the reader may not take
- those four take .sl-dimmed, which both themes already carried
- .sl-none and .sl-hidden were two spellings of
display: none, so the name that says the role keeps the intent and .sl-none is retired from both themes- the admin
is_hiddenbranch of span.html had no producer at all, and the !important that served it goes with it
- the admin
- The focus ring of a link (both base.css):
a { outline: none }was in both themes, not one: in admin it sits in the groupinput, textarea, select, a, which is why the tool reported only lite- neither theme drew a:focus-visible in its place, so both take the ring the rest of each theme already draws, spelled identically
- The !important flags, read once (both theme.css):
- three in lite were fighting nothing: a hover rule that already outscored its target, an id selector nothing in the file can reach, and a dead rule whose class no template or script writes
- every one that remains carries the reason beside it - the glyph face beating an element font, the editor engine writing its height inline, a print rule reaching past the inline display the tab script writes
- The HTTP half of the theme-creation gate (tools/ui-shots.mjs, tests/Support/theme_scratch.php, tests/Support/theme_probe.php):
node tools/ui-shots.mjs --newthemebuilds a scratch copy of an etalon, points the account the rig signs in as at it, renders every frontend page of the manifest and restores the column and removes the tree in finally- both halves build that copy through one lifecycle, theme_scratch.php, so a lifecycle spelled twice cannot drift into two
- the three site logs are read before and after the walk: a theme that renders a page and writes a notice behind it has still failed
- The freeze (tools/ui-contract.php, tools/ui-audit.php, tests/Unit/ThemeContractTest.php):
frozenis true, and the roster underapiin the baseline is what holds it- an addition wrongs no copy; a rename in place is refused by the run, by --store and by phpunit alike
- The rig learns two pages it could not hold still (tools/ui-shots.json):
- admin-monitor grew 6954 to 7092 to 7557 pixels in the seven seconds after load: two panels arrive on load and are then replaced out of band by a four-second poller, and what moved was their height, so masking could never have held them
- the bar of the statistic page was masked and the count and per cent printed beside it were not, so the page flapped around the threshold on a walk long enough for the rig's own visits to move the figures
Benefits:
- every shared name across the two etalons is now answered for, by one file or by one written reason, which is what hundreds of copies will inherit
- one name for gone and one for dimmed, in both themes, with no name left holding two intents at the moment the API is frozen
- a link that is focused says so on both sides of the CMS
- creating a theme is checked as a file and as a served page, through one lifecycle
- important falls 23 to 22 in admin and 17 to 13 in lite, and what is left says why
Technical notes:
- --sl-font-hero reads 38px: the top step of the font-size ladder holds a different value, with the step count and the role names unchanged
- probe_boot.php answers 404 and exits unless PHP_SAPI is cli. The probes are served by a stand, register_argc_argv turns a query string into $argv, and theme_probe.php stopped being read-only in this commit
- tools/ui-baseline is re-captured whole and checked against itself: 168 of 168 states match and every noise floor sits below 0.2%
- no breaking change to any runtime contract: the template keys that moved are is_hidden to is_dimmed at four call sites, all inside this repository
Batch 8 of docs/THEME-ETALON-2026.md. Canon reconciliation is what makes the two shipped themes etalons rather than two codebases with matching names, and the checks that were meant to drive it were adding unlike populations together: one figure of 424 duplicates and one of 135 divergences is answered by grinding rather than by thinking. Split into what they actually measure, they left 50 duplicate groups and 46 divergences - sets small enough to decide one by one.
Core changes:
- Cross-theme divergence becomes a gate (tools/ui-audit.php, tools/ui-contract.php):
- checkCrossTheme() reads a shared selector two ways
- same property set, different values: legal, one canon carrying many skins
- different property sets: needs a written reason under
divergent
- 135 divergences are now 117 = 71 in value alone and 46 in structure, all explained
- the element reset was settled structurally instead of registered: h1..h5, hr, ol, fieldset, a:hover and the two check inputs hold one property set per theme
- The duplicate check stops counting need as debt (tools/ui-audit.php):
- a body of one declaration is need, the way
display: flexunder 122 containers is - a body met in two files cannot be merged: CSS has no cross-file selector list
- of the 50 groups left, 7 merged, 1 redundant rule deleted, 34 given a reason
dup178 and 246 -> 0 and 0
- A theme is created and checked by a test (tests/Unit/ThemeCreationTest.php):
- copies an etalon, repaints only the API block, asks the whole contract of the copy
- contrast is measured on the real pair registry put through the same repaint
- checkThemeAssets() answered by tests/Support/theme_probe.php on the live core
- the skeleton is checked against both gates that demand it, never against itself
- The contrast walk composites instead of passing through (tools/ui-shots.mjs):
- every sheer layer is composited back down onto the ground that really paints
- background-image is walked layer by layer, back to front, not stop by stop
- it found the admin footer copyright at 4.26:1 under its own decorative hatch; the hatch alpha is now what the ratio allows and the pair reads 4.60:1
- registry 442 -> 460 pairs, both halves at zero below AA
- Dark is guarded by pixels (tools/ui-shots.json, tools/ui-baseline):
modesgainsdark: 168 committed images, every noise floor under the threshold--out=sends a capture outside the repository, so a batch can compare its own two captures instead of a committed baseline the stand's own data has moved under- the site counters the profile and the statistic page print are masked
- The two names nothing wrote are gone (plugins/system/slaed.js, both themes):
- placeFloat() writes --sl-d-float-top and --sl-d-float-left; the theme owns the rule
- six inline layout properties and a hardcoded z-index leave the script
unmet2 -> 0 in both themes
- Three zoos in admin take the shape lite had already settled (templates/admin):
- the alert reads one --sl-alert-tint and its glyph the base tone; the last raw hex goes
- the loading dots read the API block and the tone directly
- the switch spells its four aliased tokens and draws the theme's one focus ring
scoped84 -> 54, --sl-danger-subtle deleted, --sl-knob-dur folded onto the ladder
- error.html on the settled names, and four spellings of one intent closed:
- 60 tokens declared and 60 read, closed on itself
- ::before/::after at 57 sites, "bootstrap-icons" at seven, --sl-radius-circle at 28
Benefits:
- every count either theme controls reads zero, and
crossreads zero beside them - a new theme is now provably one copy and one repaint, asserted rather than claimed
- the dark half of both themes is guarded by images and not only by ratios
- a contrast failure nobody could see is fixed, and the walk that finds its kind is honest
Technical notes:
- ui-audit gains isClassUsed(), whose boundary is the name alphabet: a class emitted after a template tag was invisible to it, which read as five dead classes in lite
- nine classes really were dead and are deleted, with --sl-move-height beside them
- admin
ulkeeps its longhands on purpose: a shorthand resets every longhand it omits, which flattened 82 nested list marks when it was written as one - ten lite templates carried CRLF in the working tree, which .gitattributes normalises on the way into the index: four template divergences the tool reported were local drift and never in the repository, which is why this commit shows none of them
- fixtures cover every classifier change: classuse.html, dup-need.css
- no theme file below the marker names a mode; frozen stays false while canon is open
Both are about what the gates can see rather than about either theme, and neither blocks the freeze. They are recorded in the risk that names them, in the description of the walk that would change, and as a step of batch 8 that has to answer them.
Core changes:
- The dark-mode risk carries what batch 7 measured (docs/THEME-ETALON-2026.md):
- Two dark breaks shipped green through every gate and were found by looking
- The headline over the photo band flipped from white to near-black, because the band is a photograph with no dark variant while --sl-on-solid turns over
- The footer wordmark vanished when the band under it inverted, because the wordmark is an image with white baked into it
- Both pass AA in both modes, so no contrast pair could report either
- Until
modesgainsdark, a batch that touches colour opens the pages in dark, looks at them, and says in its report that it did
- The walk's sheer-fill reading is named as a half answer (docs/THEME-ETALON-2026.md):
- Passing through a fill under 0.5 alpha removed exactly two false readings in batch 7
- It also leaves the honest ones unmeasured: white text on a twenty per cent veil over a photograph now reports nothing rather than something wrong
- Compositing is the whole answer, about twenty lines in getContrastPairs(), and needs both halves of the registry re-generated in one run
- Batch 8 gains the step that decides them (docs/THEME-ETALON-2026.md):
- The cost of each is stated: 168 images and about 72 MB against the only gate that would have caught either break; twenty lines against a registry that may surface new pairs
Benefits:
- A question asked once is not asked again from scratch, and its cost is already priced
- The next colour batch knows it owes a look in dark until the manifest covers it
Technical notes:
- Documentation only; no code, no theme and no gate changes
Batch 7 of docs/THEME-ETALON-2026.md. Lite's last 386 untokenised decisions reach a token, its whole palette gains a second half through light-dark(), and the frontend gains the colour-mode switch on the plumbing the panel already had. Both themes now report zero on every count they control, in both modes.
Core changes:
- Every decision left in lite reaches one address (templates/lite/assets/css/*.css):
- Size, animation, shadow, background, border and outline take a component token
- A figure sizing a control, an icon or a page region has one name, as admin has
- An animation carries its own dur and ease; a shadow is one whole value at one name
- Four laws replaced four zoos rather than renaming them
- The tinted pill: six tone classes and eight state marks read one --sl-chip-bg
- The focus ring: outline and box-shadow draw one --sl-ring-width in one --sl-ring-bg, and a status ring mixes its own colour at --sl-ring-mix
- The tile: --sl-ico-bg and --sl-ico-border read currentColor, and only the ratio is hoisted where the tone is itself scoped
- The arrow: every CSS triangle in the theme reads one --sl-arrow-width
- The colour tail folded onto the roles it had been spelling by hand
- --sl-color-brand and -banner onto --sl-primary-subtle, -strong onto --sl-primary-strong
- --sl-color-text-ink onto --sl-text, -link and --sl-login-link-hover-color onto -strong
- The eight --sl-changelog-* and the two service tones of the social icons onto roles
- Lite carries both modes (templates/lite/assets/css/base.css):
- color-scheme on :root, light-dark() through the palette, two data-theme selectors under the marker; no rule outside the API block names the mode
- A tint step is a ground and nothing else: every mark that read one takes the base tone of its family, which turns over by itself
- What a token cannot repaint stays fixed: the footer band carries the wordmark and the flag as images with white baked in, the page head carries a photograph, and the text standing on them reads --sl-band-text
- Contrast fell 53 to 0 in light and dark together, each value derived from the luminance the ratio allows against the worst ground it really meets
- The colour mode switch reaches the site (core/system.php, index.php, templates/lite):
- getAdminModeSwitch() becomes getThemeModeSwitch($action) for the panel and the site alike, and one setThemeMode() writes the cookie for both handlers
- index.php answers op=mode on the token scope the panel uses; lite's four document templates carry data-theme and the site header carries the toggle
- A visitor who picked a mode is not cacheable: the page-cache key is the route alone, so a stored copy would hand the next visitor someone else's mode
- Two measuring tools were wrong and are fixed (tools/ui-audit.php, tools/ui-shots.mjs):
- getValueKind() could not read a shadow whose colour or length arrives through a token, so one name held two kinds across the themes for no visible reason; isShadowValue() reads it now, with tests/Fixtures/ui/shadow.css under it
- The contrast walk read a mostly transparent fill as a solid surface and reported a failure against a colour nobody can see; it passes through to the ancestor that paints, at the threshold its own gradient rule already used
Benefits:
- A new theme is one copied directory and one repainted API block, in two modes
- Every visual decision in both shipped themes has exactly one address
- The whole site meets AA in light and dark, measured on pairs that really meet on screen
- dup falls 266 to 246 and scoped 103 to 92 as the folds land
Technical notes:
- tools/ui-audit-baseline.json re-stored at the new lows; the ratchet holds them
- tools/ui-contrast.json regenerated with credentials: 442 pairs, admin's 124 unchanged
- 84 baseline images re-captured in one pass and verified stable on a second run
- The mode cookie is written only through setCookies(); the toggle posts with a CSRF token that rides the dynamic-region contract on cacheable pages
- Backward compatible: no schema, route or template contract changes
Batch 6 of docs/THEME-ETALON-2026.md. Lite's untokenised visual decisions fall from 1209 to 386 and its bare numbers from 107 to zero: font size, line height, weight, tracking, opacity, radius and every rhythm step now reach their value through a token, and the last of the legacy scale names is retired.
Core changes:
- The scales of lite (templates/lite/assets/css/base.css, theme.css):
- 823 decisions snapped onto the shared ladders and named
- font-weight, line-height, opacity and letter-spacing leave the bare check at zero
- h1 to h5 take the same token per element as admin, so the heading ladder is strictly descending at 24 / 20 / 18 / 16 / 14
- --sl-space-xs..xl, --sl-radius-control, --sl-radius-card and the four --sl-overlay-* are gone; the overlays are two --sl-scrim-* steps
- the login geometry loses six names of four and five segments
- every viewport breakpoint sits on 560 / 768 / 900 / 1200, and a query above a step is spelled (width > step) so no pixel belongs to both branches
- three placements written as a negative half-width margin become one left: calc(50% - n), which is the same pixel at one address
- the thumbnail column of a list row and the padding of the login profile now say what they make room for instead of holding a figure
- Two contract changes (tools/ui-contract.php):
- --sl-font-hero at 48px above display: a slider headline and a dashboard number are the largest type a page carries, and folding them onto display would size a hero like an h1
- --sl-space-9, -10 and -11 at 32 / 40 / 48: lite carries a second rhythm between the sections of a page that admin has not one atom of
- Three figures the audit had been counting are not decisions (tools/ui-audit.php):
- a descriptor block holds no rule, because var() is invalid inside @font-face
- the middle of a clamp() is the rate between two bounds that are decisions themselves, and no ladder step in the contract can carry it
- a percentage gap is measured against the width of the container, not against the rhythm of the page
- each is covered by a fixture in tests/Fixtures/ui/ and a test that reads the answer off the fixture
Benefits:
- one address per visual decision across every scale lite owns
- the two etalons now share the same ladders, values included, which is what batch 8 reconciles the rest against
- the audit stops reporting a figure that could never have been tokenised at all
Technical notes:
- the top menu wrapped onto a second row at 900px when its gutter took the upward tie from 15 to 16; it takes the step below at 12 instead. A geometry probe over the screenshot manifest caught it, and no count could see it
- tools/ui-baseline is re-captured for the frontend states; profile and private render in lite and need a run with SLAED_UI_USER and SLAED_UI_PASS to follow
- the admin theme is untouched and every count it holds is still zero
Batch 5 of the theme etalon gives lite the motion, layer and gradient axes admin already carried. Not one transition, one layer or one gradient in the theme had an address of its own, so a theme author could not restyle any of them without forking rules. All three now read the same names admin does, the untokenised metric falls from 1396 to 1209 and the grammar violations from 99 to 44.
Core changes:
- Motion (templates/lite/assets/css/base.css, theme.css):
- 81 duration parts over 13 spellings snap onto --sl-time-fast, --sl-time-base
and --sl-time-slow; 32 were already on a step, 31 move by 20ms or less and 18
move further
- the two longest, the poll bar filling at 0.8s and the profile ring drawing itself at 0.9s, take --sl-time-slow with the rest: an exception granted by argument is how a zoo grows back, so the ladder has none in this theme
- admin still spells its own ring --sl-knob-dur: 1s, and that divergence is batch 8's to settle in the direction lite has taken
- of the five curve spellings ease stays, linear is allowlisted, cubic-bezier(0.2, 0.8, 0.2, 1) folds onto --sl-ease-in-out and cubic-bezier(0.2, 0.7, 0.3, 1) onto --sl-ease-out
- the overshoot cubic-bezier(0.3, 1.6, 0.5, 1) becomes --sl-vote-ease, because a curve leaving the 0-1 range is a different kind of motion and no ladder holds it
- the speed dial's five hand-written delays become calc(var(--sl-dial-dur) * n) on a 0.05s step, which is the spelling admin already used
- Layers (templates/lite/assets/css/base.css, theme.css):
- twelve values over 26 sites become seven roles: --sl-z-base, -raised, -overlay,
-dropdown, -popover, -modal and -toast
- 0, 1, 30, 3000 and the image popup at 1000 were already a role's own value
- 2 and 5 both mean "painted over the box it sits in" and merge into -overlay; the only place the two sets meet is the user card, where the tool chip sits at top: -38px and the online badge at top: 20px
- 4, the session panel, goes up to -dropdown to stay above that badge; it clears the tool chip by 12px, which is measured and not argued
- four orders change and each is recorded in the plan: the header login form and the top-menu submenu fall to -dropdown and keep their old order by tie and document position, the tooltip's anchored panel and the image popup rise past the submenu, and the two fixed edge tabs fall to -popover where the footer's document position still resolves the tie their way
- --sl-z-sticky stays undeclared, because nothing in lite reads it and a step nothing reads is a dead token
- Gradients (templates/lite/assets/css/base.css, theme.css):
- --sl-line-gradient becomes --sl-grad-line and --sl-bg-hover-gloss becomes --sl-grad-gloss, which drops the last name stacking a state on an axis
- the five --sl-progress-fill-* triples fold into the categorical --sl-progress-1..5 and the five --sl-grad-progress-*, and the track into --sl-progress-bg, -border, -radius and -height; the fill height is now calc(var(--sl-progress-height) - 1px - 1px) and the flat fallback under each gradient, which no browser in the baseline ever painted, is gone
- the three gradients one component owns take that component's address: --sl-changelog-date-bg, --sl-feedback-bg and --sl-idea-bg
- the button bevel is composed: --sl-btn-bg holds the face, --sl-btn-text the label and one --sl-btn-shadow both edges and the lift under them, which retires the two scoped internals .sl-but had been setting on itself
- --sl-grad-info-start/-end were a colour under a gradient prefix and one of them also painted the search button flat, so they take colour names --sl-info-subtle and --sl-info-muted; --sl-grad-success-start proved to be --sl-success-subtle spelled twice and collapses onto it, its partner becoming --sl-success-muted
- The contract and the baseline (tools/ui-contract.php, tools/ui-audit-baseline.json):
- the components changelog-date, feedback and idea are declared
- the baseline is re-stored: count 1209, bare 107, names 44, scoped 107, tokens 213, with admin unchanged at zero
- The plan (docs/THEME-ETALON-2026.md):
- the batch 5 section is deleted rather than marked done, and the status moves to batch 6
- every figure it touched is re-derived from the tool: the metric tables, the baseline table, the transition and layer ladder rows, the cross-theme selector count and the grammar violation split
- the merge reasoning for the layers and the three colour names minted here are written down, because the batches after this one inherit them and no counter would recover them
Benefits:
- a theme author can now restyle every transition, every stacking layer and every gradient in lite by editing the API block, which is what the etalon promises
- the layer roles replace fourteen numbers picked to win one argument each, so a new overlay has a role to take instead of a number to outbid
- one duration ladder across both themes means a motion change is one edit, not a search for eight spellings of the same fifth of a second
Technical notes:
- durations move on purpose: 0.5s and 0.6s fall to 0.35s at seven fade and zoom sites, and the poll bar and profile ring fall to it from 0.8s and 0.9s
- no screenshot sees a duration, so motion is verified by reading computed transition-duration, -delay and -timing-function off the rendered page: 843 transitioning elements over five lite pages, all on the ladder
- layers are verified the same way, by resolving every computed z-index on those pages against the named roles
- all 42 tokens this batch added or renamed resolve on the live page to the values they held before it
- lite screenshots are identical to the stored baseline; the admin diffs the rig reports are the stand's own data, and admin renders from files this batch does not touch
- the button label highlight is now spelled with color-mix() at --sl-btn-mix: 85%, which is the same paint as the rgba() it replaces