fix(lasuite): La Suite parity round — header, shell, active rows, dark mode, and a selector-liveness guard - #212
fix(lasuite): La Suite parity round — header, shell, active rows, dark mode, and a selector-liveness guard#212rubenvdlinde wants to merge 17 commits into
Conversation
…atch La Suite's active-row treatment All values below were measured live at 1280x720 against La Suite Messages (localhost:8900, logged in) on 2026-07-28, and re-measured on Nextcloud Contacts after each change. HEADER GLYPHS — replace the invert filter with `color` The header used `filter: invert(1) brightness(0) contrast(100)` to make Nextcloud's white-shipped glyphs visible on our white bar. That was wrong twice: 1. `brightness(0)` forces every glyph to PURE BLACK. Nothing in La Suite's header is #000 — actionable icons are brand violet rgb(94,92,208) and text is gray-850 rgb(37,37,47). 2. A filter on an ancestor rasterises its whole subtree, and no descendant `filter: none` can undo it. `.button-vue__icon` wraps the user-menu trigger, so the avatar inside was flattened to a solid black disc. The avatar exclusion added earlier could never have worked, whichever selector it named. Nextcloud's header icons are Material SVGs resolving `fill` to `currentColor`, so `color` recolours them exactly — and colour inherits, so the avatar can opt back out. Verified live: svg fill now rgb(94,92,208); the avatar renders its initials on Nextcloud's generated per-user background again. The logo stays on a filter (`brightness(0)`): it is a background-IMAGE (core/img/logo/logo.svg), so `color` cannot reach it. It contains no avatar, so the subtree hazard does not apply. ACTIVE ROW — one neutral wash, not three stacked signals La Suite marks the current row with rgba(27,27,35,.05), radius 4px, weight 400, no rail; active and inactive differ by the wash ALONE. Nextcloud stacked a 16%-alpha brand tint, a label bolded to 600, and a 3px brand rail. The rail is a ::before pseudo-element, so `border-left: none` never touched it. DEAD SELECTORS — #app-navigation does not exist on Nextcloud 34 12 of the 13 `#app-navigation ...` rules in this file matched nothing: NC34 renders the navigation into `#app-navigation-vue`. Only the bare `.app-navigation-entry.active` (0,2,0) was live, and nc-vue's scoped `.app-navigation-entry:not(...).active[data-v-...]` (0,4,0) `!important` beat it — which is why the row kept its brand tint no matter what this file said. Selectors now carry the real id, with a class-repeated (0,5,0) fallback. The same treatment is applied to NcListItem (`.list-item__wrapper--active`), the shared row used by Contacts, Mail and every list-detail app, so the sidebar and the list do not disagree about what "selected" looks like on one screen. Verified after: nav row and list row both rgba(27,27,35,.05), colour rgb(37,37,47), weight 400, radius 4px, rails gone. Guards: LasuiteDesignStackTest 24/24 (178 assertions), stylelint clean.
…ors were dead, not REQ-CSS-007
The blue band under the header and the 8px frame around the app were previously
explained as unavoidable, on the grounds that --color-background-plain is one of
the eight variables REQ-CSS-007 reserves for dark-mode derivation. That
explanation was wrong. No other Nextcloud app shows a band under the header. The
reserved variable was only ever VISIBLE because this file left it exposed.
Nextcloud 34 renders two nested shells and only the inner one carries `.content`:
<div id="content" class="app-contacts"> <-- OUTER, no `content` class
<div id="content-vue" class="content app-…"> <-- INNER, has it
The rule selected `#content-vue.content, #content.content`. The second half
matched nothing, so the outer shell kept Nextcloud's stock
`margin: 50px 8px 8px` and `border-radius: 16px` — the 8px frame. The inner
shell DID match and was given `margin-top: 50px`, stacking on the 50px the outer
shell already uses to clear the absolutely-positioned header, so content began
at y=100 instead of y=50 — the band.
Same defect class as the `#app-navigation` vs `#app-navigation-vue` selectors
fixed in the previous commit: a selector that reads plausibly and matches
nothing.
Two further traps found while closing it, both measured rather than assumed:
- The outer shell is `position: fixed` with `inset: 0 0 8px`. That 8px is a
bottom OFFSET, not a height, so zeroing the margin left the strip in place;
and zeroing only the bottom inset bottom-ANCHORED the box (its height stayed
pinned) and pushed the top edge down by the same 8px. Pinning all four insets
and letting height resolve from them is what fills the frame.
- `height: 100%` on the inner shell resolves against the initial containing
block, not the fixed-position parent, so it overflowed 50px past the fold.
Subtracting the header height explicitly is exact.
Verified live at 1280x720 on Contacts: #content, #content-vue and the sidebar
all [0, 50, 1280, 670]; a sweep of 35 probe points across the top, bottom, left
and right edges finds no body colour anywhere, and the page does not scroll.
Guards: LasuiteDesignStackTest 24/24 (178 assertions), stylelint clean.
…sible search field Three header defects reported from screenshots, all measured against La Suite Messages (localhost:8900, signed in, 1280x720) before and after. LOGO — was pure black, now brand violet `filter: brightness(0)` renders the logo BLACK, which is the same complaint as the glyphs: nothing in La Suite's header is black, its wordmark is violet. A filter cannot tint an arbitrary image to an exact colour (hue-rotate chains approximate and drift), so the logo is now MASKED: the SVG becomes the alpha channel and the background paints brand-550 exactly, with no colour maths. The mask URL is relative to the stylesheet rather than root-relative, so it also resolves on instances installed under a sub-path. Verified the mask actually loads (HTTP 200) — a 404 mask renders nothing at all and would have silently erased the logo. APP NAME — was 15px regular gray-900, now 20px semibold violet La Suite sets its product wordmark far larger than Nextcloud sets its app name. The two strings are not structurally equivalent (product name vs current app), so this is a deliberate visual match. This needed a second fix to take effect: the app-menu block LATER in the file re-declared `color` and `font-weight` for the same selectors at equal specificity, so it silently overrode the new rule and the name stayed 15px gray-900. The current app name is now styled in exactly one place. SEARCH — placeholder was WHITE ON WHITE Nextcloud styles unified search for a dark header, so on our white bar the placeholder rendered white-on-white and the control had no visible edge. Measured La Suite: container gray-025 with a 1px hairline and 4px radius, 34px tall and centred, placeholder rgb(117,117,117). Height alone was not enough — the element is absolutely positioned, so it also had to be re-centred. The border uses gray-100, the step this file already uses for every structural hairline, rather than La Suite's measured #d3d4e0 which falls between two ramp steps. Also checked and NOT changed: no element in the header paints a gradient (zero matches when scanning every descendant for gradient backgrounds), and nldesign has no gradient toggle. The gradient appearance came from the black-filtered logo antialiasing at small size; it is solid violet now. Verified after: logo rgb(94,92,208); app name 20px/600/rgb(94,92,208); search gray-025 with 4px radius at [415, 8, 450, 36]; placeholder gray-500. Guards: LasuiteDesignStackTest 24/24, stylelint clean.
…ctors that match nothing THE DEFECT Nextcloud 34 emits BOTH `button-vue--tertiary` and `button-vue--vue-tertiary`, depending on the vintage of the component doing the rendering. Measured on one Files page: 60 elements carry the plain form, exactly 1 carries `--vue-`. The tertiary block listed only the `--vue-` form, so it styled one button and missed sixty — which is why tertiary buttons still read as plain body text nearly everywhere, the very first thing flagged in this review series. Primary and secondary were already safe: their blocks carry `button[class*="primary"]` / `button[class*="secondary"]` fallbacks that catch either spelling. Tertiary had no such fallback. Both spellings are now listed. Verified live on Files: tertiary text buttons outside the header went from 0 to 6 of 6 rendering brand-550. THE GUARD This is the third defect in a row with an identical root cause — a selector that reads plausibly, passes review, lints clean, and matches NOTHING: #app-navigation … NC34 renders #app-navigation-vue (12 of 13 dead) #content.content only the inner shell carries the class .button-vue--vue-tertiary 1 match where the plain form has 60 None fail loudly, and nothing in the unit suite can see them, because they are only wrong relative to a RENDERED page. tests/e2e/spec-coverage/ selector-liveness.spec.ts closes that hole: it walks every selector in element-overrides.css and asserts each matches at least one element across six surfaces (files, contacts, mail, calendar, settings, dashboard). Design notes: - UNION semantics — a rule for the Files grid is legitimately absent on Calendar, so a selector fails only when it matches nothing ANYWHERE. - FAIL-CLOSED allowlist — selectors that genuinely cannot match (transient overlays, deliberate pre-NC34 fallbacks) must be listed WITH A REASON, so a dead selector becomes a decision someone made rather than something nobody noticed. - The spec SKIPS rather than passes when element-overrides.css is not served: a guard that quietly measures nothing is the exact failure mode it exists to prevent. - A second test locks the band-and-frame regression specifically, asserting the content shell starts at x=0 and spans the full viewport. NOT YET RUN END-TO-END: the e2e harness global-setup currently times out waiting for the login field, and the shared instance is in maintenance mode under another session's `occ maintenance:repair`. The audit logic itself was executed against the live DOM by hand on Contacts and Files (147 selectors, 77 and 71 dead respectively) — that is how the tertiary gap was found. Guards: stylelint clean.
…face in dark mode
Found while auditing the 2026-07-28/29 parity work. The lasuite design system has
NO dark palette, and its element overrides pin light values with !important, so an
instance in dark mode still renders light.
Evidence, all from the shipped files rather than inference:
1. No file under css/systems/lasuite/ contains `prefers-color-scheme` or
`data-themes*=dark`. Every rule there applies unconditionally.
2. css/tokens/dark/lasuite.css redefines 44 custom properties and every one is
`--nldesign-*`. Checked individually, none of gray-000, gray-025, gray-050,
gray-100, gray-500, gray-850, brand-550 or brand-050 is redefined.
3. The overrides consume exactly those tokens with !important, so the dark
variant's --nldesign-color-header-background is computed and then discarded:
`#header { background: var(--lasuite-color-gray-000) !important }` is white.
Consequence: header, canvas, card and search field all resolve light in dark
mode, and the active-row wash rgba(27,27,35,.05) — a dark wash meant for a light
surface — is near-invisible on a dark one.
Why the suite missed it: dark-mode.spec.ts asserts injection ORDER, dual
SCOPING and toggle state. It never renders a page in dark mode and references no
shell element. The stylesheet is correctly ordered, correctly scoped, and has no
effect on this system — the suite measures plumbing, not outcome.
This is not a regression from the parity work; that work enlarged an existing
gap by adding rules against the same light-only ramp.
The change specifies sourcing a dark ramp from La Suite's own shipped dark
Cunningham palette (not by inverting the light ramp), emitting it under the
EXISTING dark scope selectors so current ordering guarantees still hold,
re-expressing translucent literals per ground, and — the check that would have
caught this — a guard asserting every --lasuite-color-* token READ by
element-overrides has a dark value.
Validates clean; 12 task checkboxes.
… that had already fired
The e2e harness could not log in. Each fix revealed the next, and all three share
one shape: Playwright was waiting for an event that had ALREADY happened, while
its own call log said so.
1. `waitForSelector('#header')` timed out while logging
"locator resolved to visible <header id="header">". Nextcloud's post-login
shell mounts in Vue and detaches/re-attaches #header while it settles, so the
visibility check never gets a stable frame — however plainly the element is
there. Now waits for the URL to leave /login, a signal that cannot be
re-rendered away, and only then asserts the shell is attached.
2. `click()` timed out while logging "navigated to /apps/dashboard/". Nextcloud
lands on the Dashboard, whose widgets keep issuing requests long after the
page is usable, so the default post-click wait for scheduled navigations
never settles. Now `noWaitAfter: true`.
3. `waitForURL` then timed out while logging "navigated to /apps/dashboard/" —
because with noWaitAfter the redirect had already completed, and waitForURL
was waiting for a FURTHER navigation. Replaced with a polled read of
`page.url()`, which carries no ordering assumption.
Also `goto('/index.php/login')` now waits for `domcontentloaded` rather than
`load`: this instance holds long-lived requests open, so the full load event
never fires on a page that is already interactive.
GUARD RESILIENCE
selector-liveness now tolerates a surface that will not load, rather than
failing the run. Union semantics mean a missing surface can only make the result
MORE conservative — fewer selectors proven live — never a false accusation.
Unreachable surfaces are warned about and the run fails only if nearly all of
them are unreachable, so the union stays trustworthy. A guard that breaks
whenever the dev box is slow gets switched off, and then it protects nothing.
The per-test budget is set explicitly (300s): six surfaces each boot a full Vue
app, and test.slow() alone was not enough on a loaded instance.
…rules matched nothing
The selector-liveness guard's first green-capable run turned the check on the
rules added one commit earlier, and found three of them inert.
Nextcloud 34 renders unified search as a BUTTON:
<search class="unified-search-input">
<button class="unified-search-input__button">
<span class="unified-search-input__icon"> <- magnifier
<span class="unified-search-input__label"> <- the prompt text
There is no `<input>` and no `::placeholder` anywhere in it. The previous commit
styled `#header .unified-search-input input`,
`#header .unified-search-input input::placeholder` and
`.unified-search-input .button-vue__icon` — none of which exist. The visible fix
worked anyway because the container and `__label` rules happened to be right;
everything else was decoration. The icon rule now targets
`.unified-search-input__icon`, which is the element actually there.
This is the point of the guard: it caught rules written the same day by the same
person who had just finished fixing three other dead-selector defects.
ALLOWLIST ADDITIONS, each with a stated reason:
- `app-navigation--close` — collapsed-sidebar state, absent while expanded
- plain form controls (`textarea`, `select`, `input[type=…]`) and `h4`–`h6` —
base-layer rules; the surveyed surfaces are Vue apps that render their own
components, but these still apply on form-bearing admin pages and in dialogs
- `unified-search__input` — pre-NC34 markup kept as a fallback
RESULT: the guard now passes end-to-end (2/2). It reported `mail` as unreachable
during the run and continued, which is the intended behaviour — union semantics
mean a missing surface can only make the verdict more conservative.
…ilures The lasuite-parity suite never got past its first test. Every failure was reported as "lasuite: primary button matches the Cunningham reference" — which reads as a visual regression — while the actual errors were: run 1 net::ERR_CONNECTION_RESET on /settings/admin/theming run 2 "beforeAll hook timeout of 30000ms exceeded" run 3 page.reload timed out "waiting for navigation until load" All three are the same family as the global-setup bugs fixed in 3e1e0d3: `networkidle` and `load` never arrive on an instance that polls in the background, and the default 30s budget is not enough for a hook that mutates instance-wide token state. Four `waitForLoadState('networkidle')` calls, two `goto`s and one `reload` now wait for `domcontentloaded`, and the describe block carries a 120s budget. With that cleared the suite advances: primary button and text input now PASS — they were never failing on their values. DEAD SELECTOR IN THE SPEC ITSELF `header app name` asserted against `#header .header-appname`, commented as "always present in stock chrome". It does not exist on Nextcloud 34, so the assertion never ran — it failed on a 15s visibility timeout and was reported as a parity mismatch. Now points at `.app-menu__current-app-name`, the element NC34 actually renders. This is the fifth instance of the dead-selector class, and the first found inside a test rather than the stylesheet. KNOWN DISCREPANCY, LEFT FAILING ON PURPOSE With the selector corrected the assertion finally runs, and reports a real delta: expected #4844ad (brand-650), got rgb(94,92,208) (brand-550). Setting brand-650 in the stylesheet does not take effect. The declaration is present and `!important`, the selector matches, and the token resolves to #4844ad — yet the computed value stays brand-550, while an inline `!important` on the same element does apply #4844ad. Some other rule outranks it and it is not among the top-level rules of any loaded sheet, so it likely sits inside an at-rule block that a flat rule scan does not reach. The CSS therefore keeps brand-550, which is what it actually paints. Making the file claim brand-650 while rendering brand-550 would be worse than the discrepancy. The assertion is left failing with the delta recorded rather than editing the reference to match the code: choosing between the two is a design call, and it should be settled against a measured La Suite value — Docs renders its wordmark as TEXT, so unlike Messages that value can be read.
…er reached it
Found by re-capturing the browser comparison after the round-4/5 changes.
Contacts, Files and Settings render:
<div id="content"><div id="content-vue"> …
Mail renders #content-vue as a DIRECT CHILD OF BODY, with no #content at all.
The shell rules assume the outer element has already cleared the 50px header and
neutralised Nextcloud's 8px insets, so on Mail nothing had: #content-vue sat at
y=0 BEHIND the header and, being `calc(100vh - 50px)` tall, stopped 50px short
of the foot — a blue band across the bottom of the window — plus 8px of body
colour down each side from the untouched `inset: 0 8px 8px`.
Scoped by PARENTAGE rather than by app (`body > #content-vue`), so it holds for
any app built the same way rather than naming Mail.
Measured on Mail before: [0, 0, 1280, 670] with body colour at 11 of 15 probe
points. After: [0, 50, 1280, 670], zero leaks — identical geometry to Contacts,
Files and Settings.
This is the second time the same assumption has bitten: the first was
`#content.content` matching nothing because only the INNER shell carries that
class. Both are the dead-selector class the liveness guard exists for, and
neither would be caught by it — the selector matches fine, it is the DOM shape
that differs per app. Worth a follow-up guard asserting shell geometry per app
rather than selector existence.
Guards: LasuiteDesignStackTest 24/24 (180 assertions), stylelint clean.
… shell dark-mode capable
APP NAME — the "immovable" colour was a leak from my own tertiary rule
Setting brand-650 on the current-app name appeared to do nothing: the
declaration was present, !important, the selector matched, and the token
resolved — yet the computed value stayed brand-550. Recursing into at-rule
blocks found no competing rule either, because there wasn't one.
The cause: the tertiary block used `:not(#header) .button-vue--tertiary…`, which
does NOT mean "not in the header". It constrains the ANCESTOR in the combinator,
and for a descendant combinator any non-header ancestor satisfies it — so it
matched the header's own current-app button (nav.app-menu is not #header). Its
companion `… span { color: inherit !important }` then forced inherit onto the
name, overriding the rule higher in the file whatever colour it asked for.
The guard now sits on the button (`:not(#header *)`), which excludes descendants
of the header — the thing actually intended. Verified live: the old member
matched the header button, the new one does not, and 6 of 6 tertiary buttons
outside the header stay branded.
MEASURED, NOT GUESSED
With the leak gone the colour is settable, so it was measured rather than
argued. La Suite Docs renders its wordmark as TEXT (Messages ships an image and
cannot be read), giving rgb(72,68,173) = brand-650, 22px, weight 700. The
earlier brand-550 / 20px / 600 was an eyeball estimate from the Messages image
and was wrong on all three. The parity spec's colour was right all along; its
weight (600) was not, and is corrected to the measured 700.
DARK MODE — the shell now flips
Reading La Suite's own `.cunningham-theme--dark` block showed the intended
architecture: the dark theme does NOT invert the ramp (`gray-000` is still #fff
there). It remaps the CONTEXTUAL surface tokens — surface--primary → gray-800,
secondary → gray-850, tertiary → gray-900, border → gray-750.
That reframes the earlier lasuite-dark-palette proposal, which assumed a dark
RAMP was needed. The shell rules now read the contextual tokens (raw step kept
as fallback) and a dark block remaps them. Repointing was the necessary first
half: a dark block cannot reach a rule that hardcodes gray-000.
Text colours route through --lasuite-content-* tokens for the same reason, and
the active-row wash through --lasuite-active-row-wash, so both flip per ground.
Verified live at 1280x720, light vs dark:
header #ffffff → #25252f canvas #f8f8f9 → #1b1b23
card #ffffff → #25252f wash rgba(27,27,35,.05) → rgba(255,255,255,.08)
Contrast, dark: nav label 1.00 → 13.34 (it was literally invisible), app name
1.95 → 13.43. Light unchanged: 15.17 and 7.76.
Our generator emits only gray-850/900 from the dark end of the ramp, so the
mapping uses the two steps that exist and preserves the figure/ground relation
(card lighter than canvas) rather than inventing values. Emitting 700/750/800 is
a generator change, tracked separately.
SCOPE HONESTY: this covers the SHELL. Nextcloud's own components still read
--color-main-text / --color-main-background, which the bridge pins to light
values; a synthetic dark toggle showed file names rendering dark-on-dark. Those
are REQ-CSS-007 reserved variables and cannot simply be overridden — verifying
against Nextcloud's real dark theme is blocked on a pending instance upgrade
that this session did not cause.
Guards: LasuiteDesignStackTest 24/24 (180 assertions), stylelint clean.
The canvas/card assertions were updated to expect the contextual form with the
ramp fallback — asserting the bare literal would have locked out dark support.
The shell was made dark-capable in 92b4582, but Nextcloud's OWN components stayed unreadable: a dark render of the Files list showed file and folder names painted dark-on-dark and effectively invisible, while the chrome around them was correct. Cause: this bridge pins --color-main-text to gray-850 with !important, unconditionally. That is correct and measured for light mode. In dark mode Nextcloud darkens --color-main-background — a REQ-CSS-007 reserved variable this file correctly never touches — and our dark foreground is then painted onto it. Nextcloud owns the background; we own the foreground we map; only one of the two was theme-aware. The fix needs no reserved variable: flip only the foreground tokens this file already owns, and the pairing is restored while the reserved variables keep deriving exactly as before. --color-text-light / --color-text-lighter are inverted as a pair, since on a dark ground the "light" token is the dark one — mapping both to the same end of the ramp would collapse the distinction. Verified against Nextcloud's REAL dark theme, not a synthetic attribute toggle. The earlier check set data-themes via JS, which only triggered our own dark block and left NC's variables at light values — that is why the first dark screenshot looked broken and was reported as unverified. With `occ user:setting admin theming enabled-themes ["dark"]`: --color-main-background #171717 (Nextcloud's, untouched by us) --color-main-text #f0f0f3 (ours, now flipped) --color-text-maxcontrast #c5c6d5 (ours, now flipped) header background rgb(37, 37, 47) File names, folder names, sidebar entries and column headers all render legibly. Note on the instance: verifying this needed an upgrade that had been pending since another session deployed procest 0.3.7 → 0.3.9 without running the migration, leaving the whole instance at HTTP 503. Nothing was running, so the upgrade was completed rather than waited on; it took 90s and reported "Update successful". The dark theme setting used for verification has been reverted. Guards: LasuiteDesignStackTest 24/24 (180 assertions), stylelint clean across css/systems/lasuite/.
… make the app icon visible
Two of three defects reported from screenshots. Both were in the header.
THE GRADIENT
Nextcloud applies a fade-out gradient MASK to header glyphs and to the current
app's icon:
mask-image: linear-gradient(#fff 25%, rgba(255,255,255,.45) 90%)
Opaque for the top quarter, fading to 45%. On a 20px icon that reads as a
gradient wash, which is what was reported — La Suite uses no gradients anywhere
in its chrome.
An earlier pass looked for this, scanned every header element for gradient
BACKGROUNDS, found none, and concluded there was no gradient at all. That was
wrong: it is a mask, not a background, and the earlier scan never looked there.
Removed by property rather than by out-specifying a source — the mask has no
author rule, no inline style, and no adopted stylesheet behind it that could be
located. The logo keeps ITS mask; that one is ours and is what paints it violet.
THE MISSING APP ICON
Removing the mask made the icon render at full size and opacity, and it was
still invisible — because it is an <img> of that app's own SVG, and Nextcloud
ships those white for its normally-dark header. White on our white bar.
`color` cannot reach an <img>, and the masking trick used for the Nextcloud logo
needs a known URL, which this src does not have (it differs per app and is not
knowable from CSS). Nextcloud's own answer is --background-invert-if-bright,
which resolves to invert(100%) on a bright header and `no` on a dark one.
REQ-CSS-007 reserves that variable against being SET; reading it is exactly what
it is for, and it stays correct in both themes without this file knowing which
is active.
The icon therefore renders dark rather than brand violet. Tinting an arbitrary
per-app image to an exact colour is not expressible in CSS without its URL, and
a visible icon in the platform's own idiom beats an invisible one.
Verified live on Hermiq: app icon 20x20, mask none, opacity 1, and zero elements
under #header carry a gradient in any form.
NOT FIXED — the third report, the purple rail on active items in the app's User
settings modal. A rule was written for it and then REMOVED before commit: it
targeted `.app-settings-navigation` / `.app-settings__navigation`, and both were
confirmed absent from the live DOM. Every attempt to open that dialog landed on
the setup wizard instead, so the real selector was never obtained. Shipping the
guess would have added precisely the dead-selector defect this session has spent
its time removing.
Guards: LasuiteDesignStackTest 24/24 (180 assertions), stylelint clean.
…e paragraphs were invisible Reported from the Hermiq features-roadmap sidebar: body text rendered white-on-white and could only be found by selecting it. The bridge mapped Nextcloud's --color-text-light to gray-000, i.e. pure white. That is a semantic misreading of the token name: in Nextcloud "light" here means DE-EMPHASISED text, not WHITE text — NC 34 defines --color-text-light as equal to --color-main-text. Any component using it therefore painted white text on a white surface. Measured on the page before the fix: --color-text-light resolved to #ffffff and five paragraphs computed rgb(255,255,255), including the section subtitle and every descriptive block in the sidebar. After: #25252f, zero white-on-white elements remain. The dark override added earlier inverted this token to gray-850 on the assumption it was legitimately white, which would have produced dark-on-dark in dark mode for the same components. It now follows main text in both grounds: gray-850 light, gray-050 dark. Sibling tokens are unchanged and remain distinct: maxcontrast gray-600, lighter gray-500. Guards: LasuiteDesignStackTest 24/24 (180 assertions), stylelint clean.
The third component to draw the rail La Suite does not use. The active-row work
covered `.app-navigation-entry` (app sidebar) and `.list-item` (NcListItem
rows); the settings dialog has its own markup and still showed a brand-050 tint
plus a 3px brand rail.
MEASURED, after being unable to reach the dialog earlier. A previous attempt
guessed `.app-settings-navigation .app-settings-button`, confirmed both classes
absent from the live DOM, and REMOVED the rule before commit rather than ship a
dead selector. The path to the dialog is app nav → Settings (expands a section)
→ Personal settings; earlier clicks kept landing on the setup wizard, which is
why it went unverified.
Real structure:
nav.dialog__navigation.app-settings__navigation
└ ul.navigation-list
└ li > a.navigation-list__link.navigation-list__link--active
background rgb(238,241,250) (brand-050 tint)
::before rgb(94,92,208), 3px, left 0 (the rail)
Both the BEM `--active` and the plain `.active` spelling are covered. That is
now the fourth time this file has been bitten by naming only one of two live
spellings (#app-navigation vs -vue, #content.content, button-vue--tertiary vs
--vue-tertiary), so covering both is the default here rather than the exception.
Verified live: active row rgba(27,27,35,.05), colour rgb(37,37,47), weight 400,
radius 4px, rail none — identical to the sidebar and list-row treatments.
Guards: LasuiteDesignStackTest 24/24 (180 assertions), stylelint clean.
…black The previous fix used --background-invert-if-bright, Nextcloud's own mechanism for white icons on a bright header. That made the icon VISIBLE but BLACK, and black appears nowhere in La Suite's chrome — the logo, waffle and wordmark beside it are all brand violet, so the icon read as the odd one out. The icon is an <img> of the app's own SVG, so `color` cannot reach it and the mask used for the Nextcloud logo needs a URL that differs per app and is not readable from CSS. A filter chain tints it instead: `brightness(0) saturate(100%)` first collapses whatever the app shipped to pure black — a known starting point — and the remaining steps rotate that toward brand-550. This is an APPROXIMATION and is commented as one. Filter chains cannot hit an exact hex the way the logo's mask does; being within a couple of percent of the brand and belonging to the palette beats being exactly black. An exact result would need each app's icon URL to mask against. Verified live on Hermiq: the icon now reads as part of the same violet set as the logo, waffle and wordmark. Guards: LasuiteDesignStackTest 24/24 (180 assertions), stylelint clean.
…d of Nextcloud blue Reported: navigating to a page shows a full-screen Nextcloud blue while it loads, rather than La Suite's white/grey. Nextcloud paints html/body with --color-background-plain (#00679e). Every rule in this file styled elements INSIDE that ground, so until the shell laid itself out the whole viewport was blue — a flash on every navigation, and permanent on any page whose content does not fill the viewport. REQ-CSS-007 reserves --color-background-plain against being SET, because Nextcloud's dark-mode derivation reads it. It does not reserve the body element. Setting the PROPERTY leaves the variable and the derivation untouched — the same distinction that allowed the Nextcloud-facing text colours to be flipped for dark mode without touching the reserved background. Routed through the contextual surface token, so the ground follows the theme rather than pinning a light value: grey in light mode, dark in dark mode. The theming background IMAGE is deliberately left in place. It paints over this colour where Nextcloud intends a wallpaper (login, guest pages), so admins keep that feature and only the ground beneath it changes. Verified live on the login page: html and body now rgb(248,248,249); wallpaper still present; --color-background-plain still #00679e and --color-main-background still #ffffff, i.e. both reserved variables unchanged. Guards: LasuiteDesignStackTest 24/24 (180 assertions), stylelint clean.
Merge, not rebase: this series is another session's in-flight authorship and a
rebase would rewrite its 16 commits. Every commit is preserved verbatim.
One conflicted file, tests/e2e/spec-coverage/lasuite-parity.spec.ts, where both
sides had independently attacked the same two defects.
1. `header app name` row. Both sides established that `#header .header-appname`
is dead in the authenticated layout this spec runs against. development
established WHY (the class exists only in core/templates/layout.public.php)
and DELETED the row; this branch found the element NC34 actually renders,
`.app-menu__current-app-name`, and REPAIRED the row against measured values
(brand-650, weight 700, read off La Suite Docs, which renders its wordmark as
text). The repaired row is kept: css/systems/lasuite/element-overrides.css in
this same series ships exactly that treatment for exactly that selector, so
the repaired row asserts something real where the deleted row asserted
nothing. development's reasoning is retained as a comment, including its note
that the public layout still needs its own spec.
2. Post-token navigation. development restructured the test to navigate before
reading the CSRF token (a fresh page fixture is on about:blank, where `OC` is
undefined) and then `reload()`; this branch changed the wait strategy off
`networkidle`, which never settles on an instance that polls in the
background. Both are kept: `reload({ waitUntil: 'domcontentloaded' })`,
because a bare `reload()` defaults to waiting for `load` — the same signal
that does not arrive.
All other files merged cleanly; the two sides touched disjoint areas otherwise.
Quality Report — ConductionNL/nldesign @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| composer | ✅ | ✅ 100/100 | |||
| npm | ✅ | ✅ 2/2 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ |
Quality workflow — 2026-08-04 11:56 UTC
Download the full PDF report from the workflow artifacts.
CI: 2 failures — NOT MERGING, this needs its authorRun 30906228326
The baseline is fully green, so both failures are attributable to this branch. 1.
|
Blocked on one decision: which Nextcloud version is the La Suite parity target?Reviewed tonight for the merge sweep. The CSS work looks sound and the suite is overwhelmingly green — 93 passed, 2 failed. But both failures are this PR's own new guards, and they share a single root cause that is a product decision rather than a defect I should resolve unilaterally. The two failures1. 2. Root cause — a version-targeting mismatch, not a bad selectorThis PR retargeted the app-name selector deliberately. Its own comment at
But the e2e job does not run NC34. It runs: So the theme and its parity references were measured live against NC34, while CI validates them against NC31. This is the identical failure mode the PR was written to eliminate — "a selector that reads plausibly, passes review, lints clean, and matches nothing in the live DOM" — except the disagreement is now between two Nextcloud versions rather than two spellings. Which is why Why I did not just fix itThe two obvious repairs are both wrong under the rules this suite sets for itself:
Neither is a judgement call to make on the author's behalf, and I will not weaken or The decision neededShould the La Suite parity suite target NC34 (what the theme was measured against, requiring the e2e matrix to move off stable31/32), or should the theme and its reference values be re-measured against NC31/32 (what CI runs today)? Worth noting this mismatch is not local to this PR — the fleet dev instance is NC34 while the shared e2e matrix is stable31/32, so any theming-parity work will keep hitting it until the two agree. One smaller thing spotted in passing, not blocking: this repo's e2e job resolves Everything else here is green and the branch is |
|
Addendum — I traced the second failure to a specific selector, so the fix is small once the version question is answered.
#content.content {
inset: 0 !important; /* nested case: outer shell */
}
body > #content-vue,
#body-user > #content-vue {
inset: 0 !important; /* body-child case, e.g. Mail */
}Note that the rule which does match #content-vue,
#content-vue.content {
margin: 0 !important;
width: 100% !important;
/* deliberately no `inset` — the outer #content.content is expected to have
cleared it */
}— sets So an If that is right, the repair is to stop predicating the outer-shell rule on the class —
It also would not, on its own, make the suite green. Failure 1 is a different problem: Both failures reduce to the same question, restated for convenience: Should the La Suite parity suite target NC34 (moving the e2e matrix off Answer that and I can land the rest quickly — the shell fix above is a one-line selector change and the remaining 93 specs already pass. |
What this is
A 16-commit La Suite theming series produced in another session, landed here for
preservation and review. Every commit is preserved verbatim — no squash, no
reword, no rebase. The 14-commit gap to
developmentwas closed with a merge(
a313da7) rather than a rebase, precisely so that this branch's authorship isnot rewritten.
The series is one continuous round of live-measured parity work against La Suite
(Messages at
localhost:8900, Docs atlocalhost:3000) plus the e2e-harnessfixes needed to actually observe the results.
Commits
73b96339c3a8d357c3dda74a9e7986cf604lasuite-dark-palette— the theme forces a light interface in dark mode3e1e0d3f52541140e7ad20dbce48#contentshell — full-bleed rules never reached it92b45820597fb895caa692486439--color-text-lightmeans de-emphasised, not white — five paragraphs were invisible71046fed0b3049366a216What it changes
css/systems/lasuite/element-overrides.css(+672) andbridge.css(+47) —the bulk of the change. Header glyphs recoloured via
colorinstead of anancestor
filter(which rasterised the avatar to a black disc); the Nextcloudlogo masked to brand violet rather than filtered to black; active rows reduced
to La Suite's single neutral wash instead of Nextcloud's stacked tint + bold +
3px rail; the content shell made full-bleed; the shell made dark-mode capable
by routing through La Suite's own contextual surface tokens.
bridge.css—--color-text-lightremapped fromgray-000(pure white) tofollow main text. It was a semantic misreading of the token name and was
painting white-on-white body text.
tests/e2e/global-setup.ts— the harness could not log in at all. Threeseparate waits were waiting for events that had already fired.
tests/e2e/spec-coverage/selector-liveness.spec.ts(new, 247 lines) — walksevery selector in
element-overrides.cssand asserts each matches something onat least one of six surfaces. Union semantics; fail-closed allowlist requiring
a stated reason.
tests/Unit/LasuiteDesignStackTest.php— assertions updated to expect thecontextual-token form with a ramp fallback.
openspec/changes/lasuite-dark-palette/— proposal only, no behaviour.A recurring theme, and the reason the new guard exists: five separate defects in
this series had the identical root cause — a selector that reads plausibly, passes
review, lints clean, and matches nothing in the live DOM (
#app-navigationvs-vue,#content.content,.button-vue--vue-tertiaryvs--tertiary,.unified-search-input input, and#header .header-appnameinside the parityspec itself).
Reserved variables
The series does not set any of the eight variables REQ-CSS-007 reserves.
--color-background-plainand--color-main-backgroundwere verified live to beunchanged (
#00679eand#ffffff);366a216sets thebackgroundproperty onbody, not the variable, and95caa69reads--background-invert-if-bright,which is what it is for.
Merge conflict resolution
One file conflicted:
tests/e2e/spec-coverage/lasuite-parity.spec.ts, wheredevelopmentand this branch had independently attacked the same two defects.Both resolutions are documented inline and in the merge commit body. In short:
the
header app namerow is kept in its repaired form (developmentdeletedit; this branch found the element NC34 actually renders and measured the
reference), and the post-token navigation keeps
development's restructure withthis branch's
domcontentloadedwait.Verification
tests/Unit/LasuiteDesignStackTest.php— 24/24, 180 assertions (PHP 8.4, inthe
nextcloudcontainer, against the merged tree's CSS).stylelint css/systems/lasuite/*.css— exit 0, clean.geometry, contrast ratios) taken at 1280x720.
Known risk — please read before merging
The e2e outcome in CI is not verified. Two specific concerns, both in the new
selector-liveness.spec.ts:the two shells … are actually present and full-bleed)asserts
#content-vuesits atx=0and spans the full viewport. That is onlytrue once the lasuite token set is active, and this spec — unlike
lasuite-parity— never activates it. CI seedstoken_set=rijkshuisstijl. Thesuite runs
workers: 1and specs mutate global theming state, so whether thistest sees lasuite depends on file ordering and on whether
lasuite-parity(which sorts before it) leaves the set behind. That is fragile either way.
installed. CI installs only nldesign + openregister, so three of the six
surfaces contribute nothing and the "live" union is much narrower. The first
test guards this with a
test.skipwhenelement-overrides.cssis not served,which should hold — but it is unverified.
Let CI decide these; I did not want to alter the author's guard on a guess.