Официальный сайт SLAED CMS
Журнал изменений
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 100644Windows 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 installboth 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
- getContract() folds the registry in only
left nothing to check and ThemeContractTest stayed green
- ThemeContractTest now asserts the file exists and carries pairs for every theme
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 capture
`ui:before` took, 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 global `cross`
- 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
- the admin
!important that served it goes with it
- 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 liteneither 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 finallyboth 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 itan 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 oneten 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
Batch 4 of the theme etalon closes admin: every visual decision in its three CSS files now has exactly one address in the API block, and every colour in that block carries both modes in one declaration, so a page follows the operating system or the button in its toolbar without a second rule anywhere.
Core changes:
The remaining 302 decisions (templates/admin/assets/css/base.css, theme.css, editors/toastui/skin.css):
the API block grows from 135 names to 291, from --sl-form-width to the eleven --sl-col-*-width that are the track list of a fixed list table
- a size that names a control, a panel or a column is a token; a figure that
only repeats a neighbour is a calc of it, which is why the second editor on a
form says calc(var(--sl-editor-height) * 2) and the coarse-pointer button
says calc(var(--sl-modal-act-width) + var(--sl-space-3))
- animation duration and curve are the character of one element and become
--sl-loading-dur, --sl-drift-dur, --sl-row-dur, --sl-led-dur, --sl-alert-dur,
--sl-skel-dur, --sl-flash-dur, --sl-modal-dur and --sl-modal-ease
- the three alert durations collapse to one, and the modal close takes the
transition ladder's fast step
the nine shadows and ring colours that fitted no role are settled: --sl-wrap-shadow, --sl-bar-shadow, --sl-check-ring, --sl-check-shadow, --sl-switch-ring, and one law for every focus ring -- --sl-ring-width, --sl-ring-gap, --sl-ring-mix and --sl-ring-bg
the two colour tails fold: --sl-color-primary-hover-soft onto --sl-primary-strong, --sl-color-text-soft onto --sl-text
skin.css is migrated once and copied to lite byte for byte, and lite's API block gains exactly the 41 names the copy reads
- Light and dark (templates/admin/assets/css/base.css):
every colour token carries light-dark(); color-scheme is declared and the two :root[data-theme] selectors switch it
a shadow or a gradient stop keeps its own colour and puts the mode inside it, so the light values the screenshot baseline holds are untouched
--sl-on-solid turns over with the mode: in light the solid fill is the dark one and its text is white, in dark the ramp reverses and its text goes dark
the thirteen rules that painted text on a solid fill with var(--sl-bg) now say the role they mean; in light the two names are the same white, and only inversion tells them apart
The mode plumbing (core/system.php, core/security.php, core/admin.php, admin/index.php, templates/admin/layouts/*.html):
getThemeMode() reads the mode cookie through getCookies() and answers auto to everything it does not recognise
$adminvars, $sitevars and the message page core/security.php builds itself all carry the key; a render path missing it would flash the wrong mode
getAdminModeSwitch() renders one POST button with a CSRF token, and op=mode stores the choice through setCookies(); setCookies() marks the cookie httponly, so a script toggle could never have worked
- both admin layouts write data-theme="{{ mode }}" on <html>
The contrast registry (tools/ui-shots.mjs, tools/ui-shots.json, tools/ui-contrast.json):
the walk drives light and dark through the cookie the CMS really reads, and files a pair once when its two colours are the same in both modes
a pair whose foreground equals its background is dropped: it is an element between the text and the ground the walk could not follow, not a reading
- admin's 124 pairs replace its old 53; lite's 150 are left for its own batch
the seventeen failures found are closed by five darker light values and one role, and admin is at zero below AA in both modes
- The audit tool (tools/ui-audit.php, tools/ui-contract.php):
color-mix() and light-dark() are walked instead of counted whole, and the mix ratio is a decision of its own kind
getValueKind() reads both as colours; getRgbValues() resolves light-dark() to the half asked for, or every categorical check silently measures nothing
the prop list gains min-width, max-width, min-height, max-height, ease and mix; the colour axis gains the tint role; vertical-align, contain-intrinsic-size and linear join the allowlist with their reasons
Benefits:
a new admin-like theme is made by editing one block: 291 tokens, no literal below the marker, and both modes in the same declarations
dark mode costs no second stylesheet and cannot drift from light, because the two values sit in one declaration and no rule outside the API block names them
every focus ring, tint and scrim in the theme has one address instead of nine near-identical rgba() values
Technical notes:
browser baseline rises to Chrome 123 / Safari 17.5 / Firefox 120 for light-dark(), above the :has() baseline already assumed
five light values move for contrast: --sl-primary-strong, --sl-success, --sl-success-strong, --sl-text-muted and --sl-danger are each darkened to the luminance the ratio 4.5 allows against the worst ground they really meet
the admin toolbar row wraps instead of squeezing, so the menu, the flags and the new toggle all keep their labels
the sidebar gains the 4px row gap and 20px line box its scoped set had asked for and never declared
screenshot baselines re-captured; only admin states moved, and the frontend is refreshed against the stand as it is today
- lite is untouched except for the skin copy and the names it declares for it