Skip to content

fix(lasuite): La Suite parity round — header, shell, active rows, dark mode, and a selector-liveness guard - #212

Open
rubenvdlinde wants to merge 17 commits into
developmentfrom
wip/preserve-2026-08-04
Open

fix(lasuite): La Suite parity round — header, shell, active rows, dark mode, and a selector-liveness guard#212
rubenvdlinde wants to merge 17 commits into
developmentfrom
wip/preserve-2026-08-04

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

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 development was closed with a merge
(a313da7) rather than a rebase, precisely so that this branch's authorship is
not rewritten.

The series is one continuous round of live-measured parity work against La Suite
(Messages at localhost:8900, Docs at localhost:3000) plus the e2e-harness
fixes needed to actually observe the results.

Commits

73b9633 recolour header glyphs instead of filtering them; match La Suite's active-row treatment
9c3a8d3 close the body-colour band and frame — the shell selectors were dead, not REQ-CSS-007
57c3dda violet logo via mask, La Suite-sized app name, visible search field
74a9e79 style both tertiary spellings; add the selector-liveness guard
86cf604 docs(openspec): lasuite-dark-palette — the theme forces a light interface in dark mode
3e1e0d3 fix(e2e): unblock global-setup — three waits hung on signals that had already fired
f525411 search is a button, not a text field — three rules matched nothing
40e7ad2 fix(e2e): parity suite reported navigation timeouts as parity failures
0dbce48 Mail has no outer #content shell — full-bleed rules never reached it
92b4582 settle the app-name colour by measurement; make the shell dark-mode capable
0597fb8 flip the Nextcloud-facing text variables in dark mode
95caa69 remove Nextcloud's fade gradient from header icons; make the app icon visible
2486439 --color-text-light means de-emphasised, not white — five paragraphs were invisible
71046fe remove the brand rail from the settings-dialog navigation
d0b3049 tint the current app's icon to brand violet instead of black
366a216 theme the ground, so loading shows La Suite grey instead of Nextcloud blue

What it changes

  • css/systems/lasuite/element-overrides.css (+672) and bridge.css (+47) —
    the bulk of the change. Header glyphs recoloured via color instead of an
    ancestor filter (which rasterised the avatar to a black disc); the Nextcloud
    logo 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-light remapped from gray-000 (pure white) to
    follow 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. Three
    separate waits were waiting for events that had already fired.
  • tests/e2e/spec-coverage/selector-liveness.spec.ts (new, 247 lines) — walks
    every selector in element-overrides.css and asserts each matches something on
    at least one of six surfaces. Union semantics; fail-closed allowlist requiring
    a stated reason.
  • tests/Unit/LasuiteDesignStackTest.php — assertions updated to expect the
    contextual-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-navigation vs
-vue, #content.content, .button-vue--vue-tertiary vs --tertiary,
.unified-search-input input, and #header .header-appname inside the parity
spec itself).

Reserved variables

The series does not set any of the eight variables REQ-CSS-007 reserves.
--color-background-plain and --color-main-background were verified live to be
unchanged (#00679e and #ffffff); 366a216 sets the background property on
body, not the variable, and 95caa69 reads --background-invert-if-bright,
which is what it is for.

Merge conflict resolution

One file conflicted: tests/e2e/spec-coverage/lasuite-parity.spec.ts, where
development and 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 name row is kept in its repaired form (development deleted
it; this branch found the element NC34 actually renders and measured the
reference), and the post-token navigation keeps development's restructure with
this branch's domcontentloaded wait.

Verification

  • tests/Unit/LasuiteDesignStackTest.php24/24, 180 assertions (PHP 8.4, in
    the nextcloud container, against the merged tree's CSS).
  • stylelint css/systems/lasuite/*.cssexit 0, clean.
  • Each commit body records its own live measurement (computed colours, box
    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:

  1. Its second test (the two shells … are actually present and full-bleed)
    asserts #content-vue sits at x=0 and spans the full viewport. That is only
    true once the lasuite token set is active, and this spec — unlike
    lasuite-parity — never activates it. CI seeds token_set=rijkshuisstijl. The
    suite runs workers: 1 and specs mutate global theming state, so whether this
    test sees lasuite depends on file ordering and on whether lasuite-parity
    (which sorts before it) leaves the set behind. That is fragile either way.
  2. Its allowlist was tuned against a dev instance with Contacts, Mail and Calendar
    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.skip when element-overrides.css is 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.

Conduction Release Bot and others added 17 commits July 28, 2026 13:29
…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.
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/nldesign @ bcddd7e

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.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

CI: 2 failures — NOT MERGING, this needs its author

Run 30906228326
against baseRefOid d96c89d. Baseline-compared by failing job name, not count:

baseline (d96c89d, run 30903057027) this PR
jobs 29 29 (identical names)
quality / E2E Tests (Playwright) success failure
quality / Quality Report success failure (downstream aggregate)
everything else success/skipped unchanged

The baseline is fully green, so both failures are attributable to this branch.
The run carries its summary line (93 passed, 2 failed, 5.8m), so it is a real
verdict rather than an aborted run.

1. lasuite: header app name — my merge resolution's fault, not the series'

TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
  lasuite-parity.spec.ts:367

This is the conflicted row. development had deleted it; this branch had
repaired it to #header .app-menu__current-app-name, measured live on
NC34. I resolved in favour of the repaired row — and CI runs
NC stable31 / stable32, where that element does not exist either. So both
sides were right about different Nextclouds, and I picked the one CI does not run.

Because the describe block is mode: 'serial', this one failure also skipped
the 10 parity assertions after it
(tests 53–62).

I did not simply revert to development's deletion, because that discards
measured work from this series that is correct on NC34, and the real fix —
making the row version-tolerant, or scoping it to NC34 — is a design call that
belongs to the author. Reverting the resolution is a one-line change if that is
the preferred answer.

2. selector-liveness › the two shells … full-bleed — a real gap in the new guard

Error: the content shell must start at the window edge, not inset
expect(received).toBe(expected)   // selector-liveness.spec.ts:241

The first test in this file skipped (test 68), which is the file's own
test.skip(!sheetSeenOnce, …) firing — i.e. element-overrides.css was never
served, because CI seeds token_set=rijkshuisstijl and nothing in this spec
activates lasuite.

The second test has no such guard. It therefore asserted lasuite-specific
geometry (#content-vue at x=0, full viewport width) on a page rendering
stock Nextcloud's 8px inset. It is asserting the theme's effect without the
theme.

This is the guard's stated design principle turned on itself — test 1 correctly
refuses to measure nothing, test 2 measures nothing and calls it a failure. It
needs either the same sheet-presence skip, or an explicit setTokenSet(…, 'lasuite').

Verified green

  • LasuiteDesignStackTest — 24/24, 180 assertions (PHP 8.4).
  • stylelint css/systems/lasuite/*.css — exit 0.
  • All 25 non-e2e CI jobs, unchanged from baseline.
  • The four Frontend Check scripts, run locally because CI never runs them
    (see below): test:lasuite-tokens, test:lasuite-override,
    test:lasuite-bridge-coverage, check:manifest — all exit 0. The bridge
    coverage check confirms all 68 audited --color-* variables are accounted for
    and no dark-mode-compat variable is actively overridden, which is the check
    most relevant to the --color-text-light remap in 2486439.

Separate finding: a dead CI gate

quality / Frontend Check (${{ matrix.script }}) appears in both runs as
skipped, with the matrix expression unexpanded in the job name. The matrix
resolves empty, so none of nldesign's frontend test:* scripts have been running
in CI at all — including test:lasuite-bridge-coverage and test:lasuite-tokens,
the two checks that most directly cover this series. Its absence and its success
look identical on the checks list. This lives in the shared
ConductionNL/.github workflow, not in this repo, and is reported here only —
not touched.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

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 failures

1. lasuite-parity.spec.ts:355 — "header app name matches the Cunningham reference"

TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
  waiting for locator('#header .app-menu__current-app-name') to be visible

2. selector-liveness.spec.ts:241 — "the two shells the theme resizes are actually present and full-bleed"

Error: the content shell must start at the window edge, not inset
Expected: 0
Received: 8

Root cause — a version-targeting mismatch, not a bad selector

This PR retargeted the app-name selector deliberately. Its own comment at lasuite-parity.spec.ts:144:

.app-menu__current-app-name. The previous selector here, #header .header-appname, does not exist on NC34 at all

But the e2e job does not run NC34. It runs:

nextcloud-test-refs: ["stable31", "stable32"]
server version reported in-run: 31.0.14.1

So the theme and its parity references were measured live against NC34, while CI validates them against NC31. .app-menu__current-app-name is NC34 app-menu markup and simply is not in NC31's DOM — hence a 15s timeout rather than a value mismatch. The #content-vue inset of exactly 8px is the same story one layer down: that is the browser-default body margin showing through, because NC31's shell markup is not the one the full-bleed rules were written against.

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 selector-liveness.spec.ts is earning its keep on its very first run.

Why I did not just fix it

The two obvious repairs are both wrong under the rules this suite sets for itself:

  • Making the selector try NC34 then fall back to NC31 would let the assertion pass while matching nothing if both spellings miss — manufacturing exactly the dead assertion this PR exists to kill. It would also compare NC31 computed styles against reference values measured on NC34, so it would be red on the numbers even once the element resolved.
  • Moving the e2e matrix to NC34 is a repo-wide CI change that would re-condition all 106 specs, not just these 2. Not something to land unreviewed overnight.

Neither is a judgement call to make on the author's behalf, and I will not weaken or .skip a guard to go green.

The decision needed

Should 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 hydra-gates-ref: main, while the rest of the fleet is pinned to v1.3.0. An unpinned main is how a day of gate changes can silently alter meaning underneath a repo.

Everything else here is green and the branch is MERGEABLE. Ready to land the moment the version question is answered.

@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Addendum — I traced the second failure to a specific selector, so the fix is small once the version question is answered.

selector-liveness.spec.ts:241 fails with #content-vue at x=8 instead of 0. That 8px is not a stray margin — it is Nextcloud's own inset: 0 8px 8px on the shell, which this branch already knows about and already neutralises. Both neutralising rules are present in element-overrides.css:

#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 generally —

#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 margin and width but deliberately not inset, because it relies on the outer #content.content rule having already pinned it.

So an x of exactly 8 means neither neutraliser applied on NC31: the measured #content-vue is not a child of body/#body-user, and its outer shell did not match #content.content. The most likely reason is that NC31 emits <div id="content"> without the .content class that NC34 adds, so #content.content matches nothing and the outer shell keeps its 8px side inset, which the child then sits inside.

If that is right, the repair is to stop predicating the outer-shell rule on the class — #content, #content.content { … } — which is a no-op on NC34 (where both match) and fixes NC31. I have not pushed that. Two reasons:

  1. It is a hypothesis about NC31's DOM that I cannot confirm without an NC31 instance with the theme installed, and the whole point of this branch is that plausible-reading selectors which match nothing are the recurring defect here. Guessing at another one would be repeating the mistake it documents.
  2. This branch is explicitly a verbatim-preservation branch — "Every commit is preserved verbatim, no squash, no reword, no rebase" — so speculative commits do not belong on it.

It also would not, on its own, make the suite green. Failure 1 is a different problem: #header .app-menu__current-app-name does not exist in NC31's DOM at all, and even once an element resolved, its computed styles would be compared against Cunningham reference values that were measured on NC34. That one cannot be fixed by a selector edit — it needs the version decision.

Both failures reduce to the same question, restated for convenience:

Should the La Suite parity suite target NC34 (moving the e2e matrix off stable31/stable32), or should the theme and its reference values be re-measured against NC31/32?

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant