diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index e5bef527..c497c8ec 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -170,7 +170,7 @@ jobs: # entirely on the version openregister cannot load. # # EVERY MAJOR IN THE DECLARED RANGE, NOT JUST ITS ENDS. appinfo/info.xml declares - # , and + # , and # tests/Unit/ClaimAccuracyTest::testDeclaredNextcloudRangeMatchesTheTestedMatrix # asserts that the lowest ref here EQUALS that floor. A matrix of stable34 # alone therefore advertises 32 and 33 to the app store while exercising @@ -187,14 +187,20 @@ jobs: # in the LOOSER direction, which this fleet does not take. The floor is still # 32, so ClaimAccuracyTest's lowest-ref-equals-floor assertion is unaffected. # - # stable34 is FIRST deliberately. Playwright is the job that reads [0], and + # stable35 is FIRST deliberately, and moved there when max-version rose to + # 35. Playwright is the job that reads [0], and # tests/e2e/spec-coverage/selector-liveness.spec.ts is a cross-version # stylesheet audit whose SINCE deferrals expire only once the survey - # reaches the newest supported major (MAX_SUPPORTED_NC). Surveying 32 would - # defer the NC33+ header selectors indefinitely; surveying 34 forces them - # to be proven live — which is how the eight dead NC 32/33-only selectors - # now recorded in that file's ALLOWED list were found. - nextcloud-test-refs: '["stable34", "stable32", "stable33"]' + # reaches the newest supported major (MAX_SUPPORTED_NC, a constant in that + # spec file that mirrors info.xml BY HAND — it still reads 34, so raising + # it is a separate decision: it expires that file's SINCE deferrals). + # Surveying 32 would defer the NC33+ header selectors + # indefinitely; surveying the newest major forces them to be proven live — + # which is how the eight dead NC 32/33-only selectors now recorded in that + # file's ALLOWED list were found. Leaving stable34 in front of a declared + # 35 would put the survey one major behind the claim and start that drift + # over again. + nextcloud-test-refs: '["stable35", "stable32", "stable33", "stable34"]' # These three were `false`, and that is worse than it sounds. The shared # workflow builds one matrix leg per tool unconditionally; a disabled leg # prints " is disabled — skipping." and `exit 0`. The job therefore @@ -299,6 +305,26 @@ jobs: # recursively. enable-newman: true + # ── Licensing ──────────────────────────────────────────────────────── + # REUSE findings fail the build. The shared workflow defaults this OFF + # because most fleet apps ship no REUSE.toml or LICENSES/ directory; + # thematiq now ships both and lints at 3680/3680 with 0 missing, 0 + # unused and 0 invalid expressions, compliant with version 3.3 of the + # REUSE specification. + # + # It stayed off while the repo was NOT compliant, deliberately: turning + # it on then would have failed every open PR for a debt none of them + # introduced. Now that the tree is clean, off is the worse default — + # non-blocking surfaces a regression only as REUSE ❌ in the Quality + # Report comment, which is easy to miss. + # + # The blanket `path = "**"` annotation in REUSE.toml makes every new file + # compliant on arrival, whatever its type, so ordinary work cannot trip + # this. Two regression modes remain: vendoring a file whose own SPDX + # header names a licence with no text in LICENSES/, and writing prose + # that QUOTES an SPDX tag outside a REUSE-IgnoreStart/End pair. + reuse-blocking: true + # ── Frontend Check legs ────────────────────────────────────────────── # `frontend-checks` defaults to `[]`, and an empty list means the shared # workflow emits NO "Frontend Check" job at all — so these two validators @@ -397,3 +423,14 @@ jobs: # construction — <= v1.4.0 passed over it (permanently green) and v1.5.0 # refused with exit 99 (permanently red). The scope is now # `github.event.before...HEAD`, what the push actually changed. + # ── Cost controls (see ConductionNL/.github#596, #599) ─────────────── + # Run PHPUnit and Playwright only when the diff could change their + # verdict. Fails safe TOWARDS running: an unreadable diff, a force-push, + # a branch creation or a dispatch all run everything, and a filtered + # skip is a declared state in the Quality Report, not a missing one. + enable-path-filtering: true + # PR-time PHPUnit runs the primary PHP against the newest declared + # Nextcloud; the full matrix still runs on every push to a default + # branch and on the release PR into beta. Breadth moves from + # per-commit to per-merge, it is not dropped. + reduce-pr-matrix: true diff --git a/.gitignore b/.gitignore index 07ac4f51..4e64d4a6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,41 @@ # boot, so it must never be committed (a stray test value here restyles the # whole instance the moment the app is activated). /css/custom-overrides.css +# The same, per set, for every set on no design system: the stock Nextcloud +# theme (custom-overrides-nextcloud.css) and the themes saved off it. Only that +# set loads its file, so emptying the stock one (the reset) gives stock back. +/css/custom-overrides-*.css + +# Runtime theme state — Nextcloud's own logos, favicon and background image, +# copied into a theme when it is saved from the editor (BrandingCaptureService) +# so applying the theme brings them back. They are an admin's uploads on one +# instance, never part of the app. +/img/logos/*-captured-* +/img/backgrounds/*-captured-* + +# Runtime theme state — written by the freeform custom-CSS editor at runtime +# (CustomCssService::write(), atomic temp-file + rename). Unlike +# custom-overrides.css nothing recreates it on boot: CssInjectionService only +# emits the stylesheet when the feature is enabled AND the file has content, so +# its absence is the correct default state. It is admin-generated data, never +# source — a stray token dump committed here restyles every instance that +# enables the feature. +/css/custom-css.css + +# Local presentation mock — a visual shell of screens that do not exist yet, +# drawn over the admin panel and loaded only with `?mock=1`. It is scaffolding +# for screenshots and demos, not product: nothing it draws persists, none of it +# is translated, and it must never reach an instance. Kept out of the repo but +# usable in a working tree, so js/admin-mock.js and css/admin-mock.css can sit +# here without ever being committed (js/admin-mock.js is already covered by the +# /js/* rule below — the point is that it is NOT un-ignored there). +/css/admin-mock.css + +# Talking points for a slide deck that lives outside the repo, alongside the +# local presentation mock above. It names a .pptx on one machine's disk and +# nothing in the app refers to it, so it is personal working material rather +# than project documentation. +/PRESENTATION-SCRIPT.md # Scratch input for scripts/build-icons.js's DSFR pack — the pre-fetched # @gouvfr/dsfr dist/icons/**/*.svg tree (that package cannot be npm-installed: @@ -40,6 +75,8 @@ !/js/admin.js !/js/lib/ !/js/preview-banner.js +!/js/playground.js +!/js/playground/ /build/ /dist/ @@ -91,3 +128,10 @@ tests/e2e/.auth/ # facts about the LIVE docs.numerique.gouv.fr bundle, not app source. The # authoritative, committed output is css/systems/lasuite/brand-override.css. /.lasuite-src/ + + +# Working plans. These are deleted once the work they describe has landed, so +# nothing that stays in the repository may cite one — not by filename and not by +# its stage numbering. Write the condition instead ("until the converter has +# regenerated them"), or cite the openspec change the plan produced. +/MAKEOVER-PLAN.md \ No newline at end of file diff --git a/.npmrc b/.npmrc index d3db2232..7ab26f74 100644 --- a/.npmrc +++ b/.npmrc @@ -16,19 +16,12 @@ min-release-age=2 min-release-age-exclude[]=@conduction/* -# Required to install at all under npm 11. `@gouvfr/dsfr-nexus` (transitive, -# via the DSFR toolchain) declares peer dependencies on `@gouvfr/dsfr-token` -# and `@gouvfr/dsfr-weave` — TWO PACKAGES THAT WERE NEVER PUBLISHED. Both -# answer 404 on the registry, in every version of dsfr-nexus from 1.2.12 to -# 1.2.17, so there is no upgrade out of it. -# -# npm 10 tolerated the dangling peers. npm 11's `npm ci` does not: it reports -# Missing: @gouvfr/dsfr-token@ from lock file -# and refuses to install, which would have made this repo's CI red the moment -# the fleet moved to npm 11. Verified: npm ci fails on the UNCHANGED committed -# lock under npm 11 and succeeds with this line, 944 packages. -# -# This makes peers advisory rather than blocking (npm 6 behaviour), the same -# remedy 8 other apps in the fleet already carry for the same class of -# upstream defect. Remove it if dsfr-nexus ever drops the phantom peers. -legacy-peer-deps=true +# NO legacy-peer-deps. It was here because `@gouvfr/dsfr-nexus` declared peer +# dependencies on `@gouvfr/dsfr-token` and `@gouvfr/dsfr-weave`, two packages +# that were never published, which npm 11's `npm ci` refuses to install around. +# That package is no longer in this tree — the lockfile resolves `@gouvfr/dsfr` +# alone — so the flag no longer buys anything, and a flag that makes peer +# conflicts advisory hides the next one instead of reporting it. Verified on +# npm 11.19: `npm install --package-lock-only` and a clean `npm ci` both exit 0 +# without it. If a phantom peer returns, align the declared ranges until the +# tree resolves; do not reinstate the flag. diff --git a/.prettierignore b/.prettierignore index 0b7ce03f..c2901175 100644 --- a/.prettierignore +++ b/.prettierignore @@ -36,6 +36,12 @@ css/tokens/ css/systems/lasuite/defaults.css css/systems/lasuite/brand-override.css +# Same contract, different generator: emitted by scripts/generate-guest-css.mjs +# from the vendored core/css/guest.css and byte-compared against the committed +# copy by `npm run test:guest-css`. Its generator input lives under +# scripts/sources/, which is already ignored above. +css/playground-guest.css + # VENDORED UPSTREAM, AND GENERATOR INPUT. This is the deployed Cunningham token # dump that generate-lasuite-tokens.mjs READS to produce brand-override.css. # Reformatting the input changes the output, so the drift check would fail on a @@ -46,3 +52,10 @@ scripts/sources/ # file — so the validation tests have something to reject. Formatting them # would remove the very defect under test. tests/integration/fixtures/ + +# Same contract again: both emitted by scripts/generate-component-scopes.mjs +# from scripts/mapping/component-tokens.json and byte-compared against the +# committed copies by `npm run test:component-scopes`. Prettier would rewrap +# the var() fallback chains and fail that check on a file nobody edited. +css/component-scopes.css +css/primary-lock.css diff --git a/AGREEMENT-MARIANNE.md b/AGREEMENT-MARIANNE.md index bdd41aeb..132d2d6c 100644 --- a/AGREEMENT-MARIANNE.md +++ b/AGREEMENT-MARIANNE.md @@ -42,7 +42,7 @@ agrees that: becomes inapplicable. 4. **No warranty from the licence itself.** Per the Etalab Open Licence 2.0's own liability clause (reproduced in - [`LICENSES/Etalab-2.0.txt`](LICENSES/Etalab-2.0.txt)), the font files are + [`LICENSES/etalab-2.0.txt`](LICENSES/etalab-2.0.txt)), the font files are provided as-is; the French State ("le Concédant") gives no guarantee beyond what that licence states. diff --git a/CHANGELOG.md b/CHANGELOG.md index 370278e4..3be8def0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -390,6 +390,34 @@ All notable changes to this project will be documented in this file. redistribution, and `scripts/build-icons.js` no longer touches that directory at all. ### Added +- **Token-set vocabulary audit — "the examples look correct" is now a test, not an opinion.** + A new `TokenSetVocabularyAuditService` answers the question no existing gate could: + does a shipped token set actually declare the `--nldesign-*` tokens its design system + reads? Three mechanical rules per set — the 26 required semantic tokens it must declare + itself, `--nldesign-*` names it declares that no stylesheet reads, and disagreement + between its `--nldesign-color-primary` and `token-sets.json`'s `theming.primary_color`. + Sets whose design system reads no `--nldesign-*` name at all (`none`, `summer-breeze`) + are reported as not auditable rather than as failing. + + The measured baseline: of 48 shipped sets, **5 are complete** (`amsterdam`, + `conduction`, `denhaag`, `utrecht`, `vng`), 2 are not auditable, and **41 are + incomplete** — they fall through to `css/systems/nldesign/defaults.css` and therefore + render as Rijkshuisstijl rather than as their own brand. Those 41 are recorded in + `tests/Unit/fixtures/token-set-vocabulary-allowlist.json` so CI stays green; the new + `tests/Unit/TokenSetVocabularyTest.php` fails both on a set that is incomplete and + unlisted AND on a listed set that has started passing, so the list can only shrink. No + token set file is changed by this release — regenerating them is the next step. +- **`npm run audit:token-sets`** — a dependency-free Node mirror of the same three rules + (`scripts/audit-token-sets.mjs`), so a token-set author can see the verdict without a + PHP runtime or a `composer install`. Prints a per-set table; `--verbose` names every + offending token, `--json` emits machine-readable results, and + `npm run audit:token-sets:check` exits non-zero on a regression. +- **"Incomplete set" badge in the admin settings page.** A third badge state next to the + design-system and WCAG badges, hidden for a complete set, with a tooltip listing exactly + which required tokens are missing, which declared names nothing reads, and any + primary-colour disagreement. The apply dialog gains a matching non-blocking banner + stating that the missing tokens fall back to the Rijkshuisstijl defaults. Carried on the + existing `warnings` channel, distinguished by `kind: 'incomplete'`. - **Theme-switchable iconography — new `dsfr` icon pack + resolver.** The bundled icon set an app resolves through nldesign now travels with the active **design system**, so a French-government (`lasuite`) instance serves French-government icons and a @@ -433,12 +461,211 @@ All notable changes to this project will be documented in this file. (extended) for the full contract. ### Changed +- `css/custom-css.css` is now gitignored, next to `css/custom-overrides.css`. It is admin-authored runtime data written on demand by `CustomCssService::write()`, never app source — `CssInjectionService` only emits the stylesheet when the freeform-CSS feature is enabled and the file has content, so its absence is the correct default state. - Style injection moved from `Application::boot()` (every request) to a `ThemeInjectionListener` on `BeforeTemplateRenderedEvent`/`BeforeLoginTemplateRenderedEvent` (only actual template renders) — same stylesheets, same cascade order, same excluded-app behavior by default. Adds an occ-only `themed_contexts` appconfig key to selectively unthemed a render context (`user`/`login`/`guest`/`public`/`error`); absent (the default) themes every context exactly as before. See `openspec/changes/render-event-injection/`. ### Security - Hardened `CustomTokenSetValidator::isForbiddenValue()` to reject declaration values containing a semicolon (`;`) or a CSS comment marker (`/*`, `*/`), closing a CSS-injection gap where a single accepted `--nldesign-*`/`--{slug}-*` declaration's value could smuggle an arbitrary extra declaration (e.g. `background: url(...)`) past the name whitelist into the `:root {}` block served to every anonymous visitor (login page, share links). Applies to both the CSS upload path and the W3C Design Tokens JSON path (`CustomTokenSetController::mapFromJson()`), which shares the same gate. Only new uploads are affected — a custom token set uploaded before this fix is not retroactively re-validated; the served `custom-*.css` file for an existing set is unchanged until it is re-uploaded. See `openspec/changes/harden-custom-token-set-value-validation/`. ### Fixed +- **The token tables in the apply and theming-sync dialogs hid the column an admin is there + to read.** Every column was `white-space: nowrap`, so each one sized to its content — and + one value, the font stack (`-apple-system, BlinkMacSystemFont, 'Segoe UI', …`), is wider + than the 700px dialog by itself. That pushed the **New** column behind a horizontal + scrollbar, so the dialog appeared to list only the values already applied and none of the + ones being offered. The two value columns now cap at 230px and wrap; the checkbox and + token-name columns still do not. +- **Both dialogs listed bare hex strings with no colour next to them.** `js/admin.js` has + always emitted a `.nldesign-dialog-swatch` / `.nldesign-apply-swatch` span and set its + colour inline, but the rules giving those spans a box were lost in a stylesheet + reorganisation, and a span with no `display`, width or height is zero pixels wide. They + are sized again, so a colour change is judged by the colour rather than by reading hex. +- **Rows kept rendering below the action buttons in any dialog carrying a token table.** The + button bar used `position: sticky; bottom: 0`, which pins to the bottom of the + SCROLLPORT, not of the dialog. A dialog with a table is now a flex column — heading, hint + and select-all row fixed, the table the only scrolling child (`min-height: 0`, or it + refuses to shrink below its content and pushes the bar out again), the bar the last flex + item, so it cannot be anywhere but the bottom. Scoped with `:has()`, because the dialogs + without a table are short and should keep growing with their content. +- **On every themed Vue app — the Files app included — the page background showed through the + content panel.** The rule making the dashboard's panel transparent, so the instance's + background image can show through it, was unscoped: `body #content main.app-content` + matches every Vue app's main element, and the Files panel is exactly + `
`. With `#content` above it transparent + too, the page background appeared as a coloured line down the seam between navigation and + content, a wedge in the notch of the container's rounded top corner, and a frame along the + right and bottom edges. The rule is now scoped with the app class `#content` already + carries (`.app-dashboard`), so the two panels read as one sheet clipped by `#content`, + which is only true while both are opaque. +- **The active app was marked twice in the header, once invisibly.** Nextcloud already draws + that mark: `.app-menu-entry--active::before`, a 10×5 rounded pill under the icon, painted + with `--color-background-plain-text` — the colour core computes against the PAGE + background, because a stock header is transparent and the pill sits on that background. + These bundles paint the header, so on a set that paints it white (Rijkshuisstijl, + Amsterdam, Cunningham) core's pill came out white on white. Both bundles then drew a + SECOND mark no stock instance has: a full-width bar under the entry, 5px in the nldesign + bundle and 3px in La Suite. Core's pill now keeps its geometry and takes a colour it is + legible against — the header's own foreground in the nldesign bundle, the brand colour in + La Suite — and the extra bar is gone. +- **A brand with square controls squared off the entire app shell.** `--body-container-radius` + is a CONTAINER radius, like `--border-radius-container` next to it, but it was mapped to + the brand's CONTROL radius, and `#content` draws its `border-radius` from it. On a set + whose controls are near-square (Zwolle: 2px) the whole shell lost its rounding. It is left + to Nextcloud now: the base value is 0 and only the two top corners round, from + `--border-radius-large`, which IS themed — so the brand still decides how round the shell + reads, without a control radius deciding it. +- **The Cunningham set applied its flat 4px radius to the Nextcloud chrome, and painted every + table header.** Cunningham is a 4px system, and taking that literally squared off the app + shell, navigation entries, search field, buttons and modals where a stock instance rounds + them. This set is Nextcloud with Cunningham's COLOURS, so its radius tokens now carry + Nextcloud 32's own scale (4 / 8 / 8 / 28 / 100) while the brand's 4px stays available to + NL Design System components through the `--utrecht-*` bridge. Its table headers are + Nextcloud's too — transparent, muted, weight 400 — where the shared theme painted every + `th` with the NL Design System table-header colour and a stock Files list shows none. +- **The Cunningham set's `theming.background_color` was pure white**, which is not a colour + the set uses anywhere: it is now `#E1E2E5`, the set's own + `--nldesign-color-background-dark`, so Nextcloud's core theming (login page, plain + background) matches the background the stylesheet actually paints. +- **On Nextcloud 32, the La Suite / Cunningham search control overflowed its slot and the + rest of the header-end was drawn on top of it.** The bundle turns NC 32's icon-only search + trigger into a labelled pill so the control matches what NC 34 renders, but `inline-size: + auto` grows only the BUTTON to fit its `attr(aria-label)` label — about 130px against the + ~50px the bar reserves — while the `.header-menu` element around it keeps the icon-only + width. The flex row therefore laid the next controls over the top: screenshotted on + Cunningham with the pill running from the app menu to the avatar, the Notifications bell + and Contacts icon sitting on it, and the label reading "Unified se[bell]rch". Growing the + container instead needs a `:has()` guard to stay off NC 34 and only works if the trigger is + in flow, neither of which can be checked against a repository whose bundled Nextcloud is + 30.0.0-dev, so the treatment is withdrawn rather than tuned blind: on NC 32 the control is + an icon-only trigger again, tinted like the Notifications and Contacts glyphs next to it. + The NC 34 pill is untouched — none of the removed rules ever matched it — and the block + records what has to be verified before the pill comes back. +- **The user-status badge was excluded from the forced avatar colour in the La Suite bundle + too**, for the same reason as in the nldesign one: `avatardiv__user-status` matches + `[class*='avatar' i] *`, and a status indicator on its own light disc is not text on the + portrait. That bundle never forced `fill`, which is why its badge stayed green. +- **Every item in the header-end carried an opaque white plate.** `.header-menu` is the class + on each header-end ITEM — `#unified-search`, `#notifications`, `#contactsmenu` and + `#user-menu` all have it — and the panel that drops out of one is a child, + `.header-menu__wrapper` > `.header-menu__content`. A rule written for the panel addressed + the item instead, so `--color-main-background` was painted behind the account glyphs: a + white block across the right of Zwolle's blue header, the same block on Rotterdam's + green, with the body text colour handed to the glyphs on top of it. The rule now addresses + the panel; the trigger is transparent, as Nextcloud draws it, and the header colour runs + edge to edge. +- **The user-status badge on the avatar rendered as a black disc instead of the green + check.** The avatar's protection rule forced `fill: unset`, and because `fill` is an + inherited property that resolves to `inherit` and walks up to the initial value — black. + The badge's own markup asks for `fill="var(--user-status-color-online, var(--color-success, + #2d7b41))"`. The rule no longer touches `fill` or `stop-color` at all: the header-glyph + rules now exclude the avatar subtree, so there is nothing left for it to undo. It keeps + `filter: none`, which is load-bearing — core inverts the avatar photo whenever the PAGE + background is bright, regardless of the header the photo sits on. +- **The account glyphs on the right of the header were painted with the BODY text colour, + and on a saturated header that made them unfindable.** Search, notifications, contacts and + the user menu sit on the header, so they take the header's foreground, but the rules + covering them used `--nldesign-color-text`: Rotterdam drew `#404b4f` glyphs on its + `#00811f` green at **1.78:1** (its header text is white, 5.05:1), and Zwolle drew pure + black on its `#476db8` blue where its header text is white. Those glyphs were additionally dimmed + to `opacity: 0.8`, which spends contrast exactly where a saturated header has none to + spare — Rotterdam's composited glyph measured 1.05:1. They now take + `--nldesign-color-header-text` at full strength, with `fill: currentColor` so the SVGs + follow. The block is named `.header-end` on Nextcloud 32 and `.header-right` in the 30-era + layout, and only the `.header-end` half had been fixed, so on an installation serving the + older markup those glyphs were still painted by the legacy rules. Both halves now agree, + and the `.header-end` rule that forces `fill` on header SVGs no longer reaches into the + avatar and the status badge either. +- **The avatar and its user-status badge were squares.** `.header-menu *` forced the brand + radius onto every element in the user menu, including the avatar and the status badge that + sits on it, which Nextcloud draws as circles (`border-radius: 50%`). A brand radius belongs + to boxes, not to a portrait; the avatar subtree is excluded and keeps its own shape. The + badge is also excluded from the two rules that force white onto the avatar's contents — + those exist to keep the INITIALS legible on the plate, and a status indicator carries its + own colours on its own light disc. +- **The header logo slot was empty on every token set that ships no logo.** `theme.css` + blanks Nextcloud's own logo (`background-image: var(--nldesign-logo-url, none)`) so a + set's artwork can take its place, but roughly twenty shipped sets have no + `img/logos/.svg` — for those the declaration resolved to `none` and the header simply + had a 56px hole where a stock installation shows the Nextcloud logo. CSS alone cannot + recover it: an `!important` declaration whose `var()` chain ends unresolved is still the + winning declaration and computes to `unset`, so Nextcloud's own rule never returns, and + its fallback URL is relative to `core/css/server.css`. `CssInjectionService::injectLogoUrl()` + now supplies the fallback itself, where the webroot is known. An admin-uploaded logo is + brand artwork and is shown as it is, through the theming app's own `--image-logoheader` / + `--image-logo` with no filter. With no uploaded logo, core's `logo.svg` is used and + **masked** to `--nldesign-color-header-text` rather than filtered: the shipped sets paint + headers from white through ice blue to saturated blue, so no single filter is right for + all of them, while the header text colour is by definition the one the set says is legible + on its own header. That is the technique `systems/lasuite/element-overrides.css` already + documents for the same image. A set that ships artwork is unaffected. The one behaviour + change beyond the empty slot: `conduction` declared `img/logos/vng.svg` through a relative + url that resolved to a 404, and now shows the Nextcloud logo instead of a broken image. +- **Every avatar on a themed instance was a rounded square.** The "remove all rounded + corners" rule in `theme.css` listed `img`, so the brand radius was forced onto every + image on the page — including the user-menu avatar in the header, the contacts menu, + share recipients and file previews, all of which Nextcloud draws as circles + (`.avatardiv { border-radius: 50% }`). `img` is not a component and is no longer in that + list; the components that take the brand radius stay listed by name. +- **The settings sidebar rendered as a column of underlined brand-coloured links, with the + selected entry a pale tint instead of Nextcloud's solid selection.** Three shared rules + fought Nextcloud's own navigation: the blanket link rule handed every `` the link + colour, the typography section underlined every ``, and `theme.css` repainted the + active entry with `--nldesign-color-primary-light` plus a 4px left border and brand- + coloured bold text — which also pushed the selected row 4px out of line with the others + and put brand text on a brand tint. Navigation entries now take the body text colour + through the inherited `--nldesign-color-on-surface` opt-out, carry no underline, and the + selected entry is left to Nextcloud: `--color-primary-element` filled, + `--color-primary-element-text` labelled, both already mapped to the active set. The + active app-menu entry in the header keeps its brand accent. +- **The admin preview's app shell did not represent the page it previews.** It drew the app + navigation as a strip of muted mini-labels and the app sidebar as a second such strip, so + the panel an admin checks first showed nothing that is actually on screen. The shell now + mirrors Nextcloud's real geometry: the header spans the page background, the navigation + and the app content are clipped into one inset rounded container + (`--body-container-radius`), navigation entries are full-width pills + (`--border-radius-pill`) with an icon and a body-coloured label of which the selected one + is filled with `--color-primary-element`, and the sidebar is a panel with a heading and a + close control. The header bar also reads `--nldesign-header-border-bottom`, and the gap + around the container reads `--color-background-plain`. +- **Header glyphs were forced to pure black and the avatar to a black square on themed + pages.** `element-overrides.css` applied `filter: invert(1) brightness(0) contrast(100)` to + every header-end svg, icon and image: `brightness(0)` ignores the set's header-text token, + and a filter on an ancestor rasterises its whole subtree, so the user-menu trigger's + avatar was flattened to a black block that no descendant `filter: none` could rescue. The + glyphs are now coloured through `color: var(--nldesign-color-header-text)` (they are + `currentColor` SVGs), the avatar and user-status icon are excluded — the technique the + lasuite bundle already used, which is why Cunningham rendered correctly. The app menu's + icons are images and cannot be coloured, so their filter now follows the set through + `--nldesign-header-icon-filter` (black on the white default header in `defaults.css`; + `none` on dark headers, where the previous hard-coded black made them vanish), and + `theme.css` no longer applies that filter to the user-menu trigger or every header svg. + App-menu labels follow the header text colour instead of the body text colour. +- **The admin preview painted its header bar with the primary colour** even when the set + defines a separate header (Cunningham's white over a blue primary). The mini app shell now reads + `--nldesign-color-header-background` / `-text`, falling back to the primary only when the + page carries no header token. +- **Every themed page sat ~56px too low, with the page background showing as a band between + the header and the content container.** `#header` carried a + `position: relative !important`, which put Nextcloud's out-of-flow (`absolute`) header back + into normal flow. `#content` is `position: fixed` with `top: auto`, so its offset resolves + against its static position — which then started *below* the 50px flowed header, and its own + `margin-top: var(--header-height)` stacked on top of that (measured: `contentTop` 106.5px + themed vs 50px stock). The override also bought nothing: `#header::before`/`::after` are + disabled in the adjacent rule, the lint/ribbon is `#nextcloud::before` with its own + positioning, and an `absolute` element is already a containing block for absolute + descendants. +- **A blue strip appeared between the navigation and the content pane.** + `margin-right: 30px !important` on `#app-navigation` opened 30px of empty flex space inside + `#content`, which carries no background of its own, so `--color-background-plain` showed + straight through. Removed, along with its `.app-navigation--close` counterpart and the + per-panel `border-radius` on the navigation and content (`#content` already rounds both + panels together via `overflow: clip`; the container radius stays themed once, through + `--body-container-radius`). +- **The theming dialogs' sticky action bar let table rows scroll through the strip beneath the + buttons**, and a wide token table gave the whole dialog a horizontal scrollbar the sticky bar + could not follow, so cells drifted out beside the buttons. The bar now bleeds into the + dialog's padding, the dialog no longer scrolls sideways, and wide token tables scroll inside + their own container. - Corrected the declared licence in `appinfo/info.xml` from `agpl` to `eupl` (EUPL-1.2) to match the bundled `LICENSE`, the SPDX headers, and the rest of the Conduction fleet. Adopters may key compliance on the declared licence, so the App Store listing now states the correct EUPL-1.2 licence. - Documentation corrected to describe the real bundled, self-hosted Fira Sans delivery (no external CDN) and the true token-set count derived from `token-sets.json`. - `docs/reference/token-audit.md` scoped its "production-ready" verdict to the five manually-reviewed sets; contrast for all sets is now verified by the automated contrast audit. diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt new file mode 100644 index 00000000..0c97efd2 --- /dev/null +++ b/LICENSES/AGPL-3.0-or-later.txt @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/LICENSES/EUPL-1.2.txt b/LICENSES/EUPL-1.2.txt new file mode 100644 index 00000000..6d8cea43 --- /dev/null +++ b/LICENSES/EUPL-1.2.txt @@ -0,0 +1,190 @@ +EUROPEAN UNION PUBLIC LICENCE v. 1.2 +EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined below) which is provided under the +terms of this Licence. Any use of the Work, other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). +The Work is provided under the terms of this Licence when the Licensor (as defined below) has placed the following +notice immediately following the copyright notice for the Work: + Licensed under the EUPL +or has expressed by any other means his willingness to license under the EUPL. + +1.Definitions +In this Licence, the following terms have the following meaning: +— ‘The Licence’:this Licence. +— ‘The Original Work’:the work or software distributed or communicated by the Licensor under this Licence, available +as Source Code and also as Executable Code as the case may be. +— ‘Derivative Works’:the works or software that could be created by the Licensee, based upon the Original Work or +modifications thereof. This Licence does not define the extent of modification or dependence on the Original Work +required in order to classify a work as a Derivative Work; this extent is determined by copyright law applicable in +the country mentioned in Article 15. +— ‘The Work’:the Original Work or its Derivative Works. +— ‘The Source Code’:the human-readable form of the Work which is the most convenient for people to study and +modify. +— ‘The Executable Code’:any code which has generally been compiled and which is meant to be interpreted by +a computer as a program. +— ‘The Licensor’:the natural or legal person that distributes or communicates the Work under the Licence. +— ‘Contributor(s)’:any natural or legal person who modifies the Work under the Licence, or otherwise contributes to +the creation of a Derivative Work. +— ‘The Licensee’ or ‘You’:any natural or legal person who makes any usage of the Work under the terms of the +Licence. +— ‘Distribution’ or ‘Communication’:any act of selling, giving, lending, renting, distributing, communicating, +transmitting, or otherwise making available, online or offline, copies of the Work or providing access to its essential +functionalities at the disposal of any other natural or legal person. + +2.Scope of the rights granted by the Licence +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, sublicensable licence to do the following, for +the duration of copyright vested in the Original Work: +— use the Work in any circumstance and for all usage, +— reproduce the Work, +— modify the Work, and make Derivative Works based upon the Work, +— communicate to the public, including the right to make available or display the Work or copies thereof to the public +and perform publicly, as the case may be, the Work, +— distribute the Work or copies thereof, +— lend and rent the Work or copies thereof, +— sublicense rights in the Work or copies thereof. +Those rights can be exercised on any media, supports and formats, whether now known or later invented, as far as the +applicable law permits so. +In the countries where moral rights apply, the Licensor waives his right to exercise his moral right to the extent allowed +by law in order to make effective the licence of the economic rights here above listed. +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to any patents held by the Licensor, to the +extent necessary to make use of the rights granted on the Work under this Licence. + +3.Communication of the Source Code +The Licensor may provide the Work either in its Source Code form, or as Executable Code. If the Work is provided as +Executable Code, the Licensor provides in addition a machine-readable copy of the Source Code of the Work along with +each copy of the Work that the Licensor distributes or indicates, in a notice following the copyright notice attached to +the Work, a repository where the Source Code is easily and freely accessible for as long as the Licensor continues to +distribute or communicate the Work. + +4.Limitations on copyright +Nothing in this Licence is intended to deprive the Licensee of the benefits from any exception or limitation to the +exclusive rights of the rights owners in the Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5.Obligations of the Licensee +The grant of the rights mentioned above is subject to some restrictions and obligations imposed on the Licensee. Those +obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or trademarks notices and all notices that refer to +the Licence and to the disclaimer of warranties. The Licensee must include a copy of such notices and a copy of the +Licence with every copy of the Work he/she distributes or communicates. The Licensee must cause any Derivative Work +to carry prominent notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the Original Works or Derivative Works, this +Distribution or Communication will be done under the terms of this Licence or of a later version of this Licence unless +the Original Work is expressly distributed only under this version of the Licence — for example by communicating +‘EUPL v. 1.2 only’. The Licensee (becoming Licensor) cannot offer or impose any additional terms or conditions on the +Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative Works or copies thereof based upon both +the Work and another work licensed under a Compatible Licence, this Distribution or Communication can be done +under the terms of this Compatible Licence. For the sake of this clause, ‘Compatible Licence’ refers to the licences listed +in the appendix attached to this Licence. Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, the Licensee will provide +a machine-readable copy of the Source Code or indicate a repository where this Source will be easily and freely available +for as long as the Licensee continues to distribute or communicate the Work. +Legal Protection: This Licence does not grant permission to use the trade names, trademarks, service marks, or names +of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6.Chain of Authorship +The original Licensor warrants that the copyright in the Original Work granted hereunder is owned by him/her or +licensed to him/her and that he/she has the power and authority to grant the Licence. +Each Contributor warrants that the copyright in the modifications he/she brings to the Work are owned by him/her or +licensed to him/her and that he/she has the power and authority to grant the Licence. +Each time You accept the Licence, the original Licensor and subsequent Contributors grant You a licence to their contributions +to the Work, under the terms of this Licence. + +7.Disclaimer of Warranty +The Work is a work in progress, which is continuously improved by numerous Contributors. It is not a finished work +and may therefore contain defects or ‘bugs’ inherent to this type of development. +For the above reason, the Work is provided under the Licence on an ‘as is’ basis and without warranties of any kind +concerning the Work, including without limitation merchantability, fitness for a particular purpose, absence of defects or +errors, accuracy, non-infringement of intellectual property rights other than copyright as stated in Article 6 of this +Licence. +This disclaimer of warranty is an essential part of the Licence and a condition for the grant of any rights to the Work. + +8.Disclaimer of Liability +Except in the cases of wilful misconduct or damages directly caused to natural persons, the Licensor will in no event be +liable for any direct or indirect, material or moral, damages of any kind, arising out of the Licence or of the use of the +Work, including without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, loss +of data or any commercial damage, even if the Licensor has been advised of the possibility of such damage. However, +the Licensor will be liable under statutory product liability laws as far such laws apply to the Work. + +9.Additional agreements +While distributing the Work, You may choose to conclude an additional agreement, defining obligations or services +consistent with this Licence. However, if accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10.Acceptance of the Licence +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ placed under the bottom of a window +displaying the text of this Licence or by affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable acceptance of this Licence and all of its terms +and conditions. +Similarly, you irrevocably accept this Licence and all of its terms and conditions by exercising any rights granted to You +by Article 2 of this Licence, such as the use of the Work, the creation by You of a Derivative Work or the Distribution +or Communication by You of the Work or copies thereof. + +11.Information to the public +In case of any Distribution or Communication of the Work by means of electronic communication by You (for example, +by offering to download the Work from a remote location) the distribution channel or media (for example, a website) +must at least provide to the public the information requested by the applicable law regarding the Licensor, the Licence +and the way it may be accessible, concluded, stored and reproduced by the Licensee. + +12.Termination of the Licence +The Licence and the rights granted hereunder will terminate automatically upon any breach by the Licensee of the terms +of the Licence. +Such a termination will not terminate the licences of any person who has received the Work from the Licensee under +the Licence, provided such persons remain in full compliance with the Licence. + +13.Miscellaneous +Without prejudice of Article 9 above, the Licence represents the complete agreement between the Parties as to the +Work. +If any provision of the Licence is invalid or unenforceable under applicable law, this will not affect the validity or +enforceability of the Licence as a whole. Such provision will be construed or reformed so as necessary to make it valid +and enforceable. +The European Commission may publish other linguistic versions or new versions of this Licence or updated versions of +the Appendix, so far this is required and reasonable, without reducing the scope of the rights granted by the Licence. +New versions of the Licence will be published with a unique version number. +All linguistic versions of this Licence, approved by the European Commission, have identical value. Parties can take +advantage of the linguistic version of their choice. + +14.Jurisdiction +Without prejudice to specific agreement between parties, +— any litigation resulting from the interpretation of this License, arising between the European Union institutions, +bodies, offices or agencies, as a Licensor, and any Licensee, will be subject to the jurisdiction of the Court of Justice +of the European Union, as laid down in article 272 of the Treaty on the Functioning of the European Union, +— any litigation arising between other parties and resulting from the interpretation of this License, will be subject to +the exclusive jurisdiction of the competent court where the Licensor resides or conducts its primary business. + +15.Applicable Law +Without prejudice to specific agreement between parties, +— this Licence shall be governed by the law of the European Union Member State where the Licensor has his seat, +resides or has his registered office, +— this licence shall be governed by Belgian law if the Licensor has no seat, residence or registered office inside +a European Union Member State. + + + Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: +— GNU General Public License (GPL) v. 2, v. 3 +— GNU Affero General Public License (AGPL) v. 3 +— Open Software License (OSL) v. 2.1, v. 3.0 +— Eclipse Public License (EPL) v. 1.0 +— CeCILL v. 2.0, v. 2.1 +— Mozilla Public Licence (MPL) v. 2 +— GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +— Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for works other than software +— European Union Public Licence (EUPL) v. 1.1, v. 1.2 +— Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above licences without producing +a new version of the EUPL, as long as they provide the rights granted in Article 2 of this Licence and protect the +covered Source Code from exclusive appropriation. +All other changes or additions to this Appendix require the production of a new EUPL version. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 00000000..d817195d --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSES/Etalab-2.0.txt b/LICENSES/etalab-2.0.txt similarity index 99% rename from LICENSES/Etalab-2.0.txt rename to LICENSES/etalab-2.0.txt index 060a74df..738efab0 100644 --- a/LICENSES/Etalab-2.0.txt +++ b/LICENSES/etalab-2.0.txt @@ -1,4 +1,4 @@ -SPDX-License-Identifier: Etalab-2.0 +SPDX-License-Identifier: etalab-2.0 Etalab Open Licence 2.0 (Licence Ouverte 2.0), the French government's open data / open source licence. Governs the Marianne font files bundled under diff --git a/MARIANNE-LICENCE.md b/MARIANNE-LICENCE.md index 0745eaca..994bbe43 100644 --- a/MARIANNE-LICENCE.md +++ b/MARIANNE-LICENCE.md @@ -28,7 +28,7 @@ Source of the DSFR package and the official system-de-design site: The DSFR package — including the Marianne font files — is published under the **Etalab Open Licence 2.0** (`Etalab-2.0`, "Licence Ouverte 2.0" / "Open Licence 2.0"). The full licence text is reproduced at -[`LICENSES/Etalab-2.0.txt`](LICENSES/Etalab-2.0.txt). +[`LICENSES/etalab-2.0.txt`](LICENSES/etalab-2.0.txt). Official licence page: @@ -100,7 +100,7 @@ State agency, this app: 4. **Carries this licence and attribution with the files.** `.license-overrides.json` maps every bundled Marianne `woff2` path to the `Etalab-2.0` SPDX identifier, resolving to - [`LICENSES/Etalab-2.0.txt`](LICENSES/Etalab-2.0.txt). + [`LICENSES/etalab-2.0.txt`](LICENSES/etalab-2.0.txt). ## Copyright diff --git a/README.md b/README.md index 5d9af5a3..1d572743 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,7 @@ Dependencies with licenses not on this list will fail CI unless explicitly appro `@gouvfr/dsfr` (Etalab-2.0, devDependency, build-time only) is the source for **two** distinct, separately-governed assets, both approved via [`.license-overrides.json`](.license-overrides.json) — the Etalab Open Licence 2.0 is a free/open, attribution-only redistribution licence not yet on the general SPDX allowlist above: - **DSFR icons** (`scripts/build-icons.js` → `img/icons/dsfr/`) — freely redistributable, bundled unconditionally. -- **Marianne typeface** (`scripts/build-fonts-marianne.js` → `css/systems/lasuite/fonts/marianne/*.woff2`, each mapped to `Etalab-2.0`; full text: [`LICENSES/Etalab-2.0.txt`](LICENSES/Etalab-2.0.txt)) — the FR-State-**restricted** typeface, bundled behind an admin acknowledgement gate ([off by default](#marianne-font-la-suite-numérique--restricted-off-by-default)); see [`MARIANNE-LICENCE.md`](MARIANNE-LICENCE.md) and [`AGREEMENT-MARIANNE.md`](AGREEMENT-MARIANNE.md) for the usage restriction. +- **Marianne typeface** (`scripts/build-fonts-marianne.js` → `css/systems/lasuite/fonts/marianne/*.woff2`, each mapped to `Etalab-2.0`; full text: [`LICENSES/etalab-2.0.txt`](LICENSES/etalab-2.0.txt)) — the FR-State-**restricted** typeface, bundled behind an admin acknowledgement gate ([off by default](#marianne-font-la-suite-numérique--restricted-off-by-default)); see [`MARIANNE-LICENCE.md`](MARIANNE-LICENCE.md) and [`AGREEMENT-MARIANNE.md`](AGREEMENT-MARIANNE.md) for the usage restriction. `@conduction/nextcloud-vue` (EUPL-1.2, devDependency, build-time icon source only) is already covered by the EUPL-1.1/1.2 allowlist entry above, and the proprietary `@amsterdam/design-system-assets` / `@amsterdam/design-system-react-icons` packages that previously required an exception here have been removed — see [CHANGELOG.md](CHANGELOG.md). diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 00000000..064fad61 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,79 @@ +# REUSE / SPDX declaration for thematiq. +# +# `quality / REUSE compliance` (fsfe/reuse-action@v5, shared quality.yml) was +# red on every run. The repo carried SPDX headers on its hand-written source — +# 250 of 3682 tracked files — but LICENSES/ held only the Etalab text, so every +# `SPDX-License-Identifier: EUPL-1.2`, `AGPL-3.0-or-later` and `MIT` in the tree +# pointed at a licence text that was not there, and the ~3430 files carrying no +# header at all (l10n JSON, openspec specs, the 1038 DSFR icon SVGs, docs, +# fixtures, lockfiles) had no licensing information by REUSE's definition. +# +# Headers on those 3430 files would be the other fix, and it is the wrong one: +# `l10n/*.json` is machine-generated and an SPDX comment is not valid JSON, the +# icon SVGs are regenerated wholesale by `scripts/build-icons.js` from the +# upstream DSFR package, and lockfiles are rewritten by the package managers. +# The blanket below is what the specification is for. +version = 1 +SPDX-PackageName = "thematiq" +SPDX-PackageSupplier = "Conduction B.V. " +SPDX-PackageDownloadLocation = "https://github.com/ConductionNL/thematiq" + +# Repo-wide default. +# +# `precedence = "closest"` means a file's OWN header always wins over this +# entry — including the files carrying Nextcloud code, which are +# AGPL-3.0-or-later (`.editorconfig`, `css/playground-guest.css` and its source +# `scripts/sources/nextcloud-guest.css`), and the La Suite Cunningham token +# dump `scripts/sources/lasuite-deployed-cunningham-tokens.css`, which is MIT. +# +# It also fills in half a header: a file that declares a licence but no +# copyright takes the missing half from here rather than counting as +# non-compliant. +[[annotations]] +path = "**" +precedence = "closest" +SPDX-FileCopyrightText = "2026 Conduction B.V. " +SPDX-License-Identifier = "EUPL-1.2" + +# The bundled French-government assets from @gouvfr/dsfr, which are NOT ours to +# licence as EUPL: the Marianne typeface (bundled behind the admin +# acknowledgement gate — see MARIANNE-LICENCE.md and AGREEMENT-MARIANNE.md for +# the FR-State usage restriction that sits on top of this licence) and the DSFR +# icon pack that `scripts/build-icons.js` materializes. +# +# `precedence = "override"` rather than "closest" because these are binary and +# generated files that carry no header of their own and must not silently +# inherit the blanket above. It is also what makes `etalab-2.0` a USED licence; +# without it REUSE reports the licence text as unused. +[[annotations]] +path = [ + "css/systems/lasuite/fonts/marianne/**", + "img/icons/dsfr/**", +] +precedence = "override" +SPDX-FileCopyrightText = "2021 Gouvernement français (https://github.com/GouvernementFR/dsfr)" +SPDX-License-Identifier = "etalab-2.0" + +# Prose that QUOTES an SPDX tag rather than carrying one. +# +# These five files discuss the licensing invariant in running text — "every PHP +# file carries `SPDX-License-Identifier: EUPL-1.2`" and the like. REUSE reads +# the tag out of the sentence, cannot parse the rest of the sentence as a +# licence expression, and the file counts as having declared its licensing +# badly rather than not at all — so the blanket above never reaches it. +# +# `precedence = "override"` makes this entry win over what the tool thinks it +# found in the text. The alternative is REUSE-IgnoreStart/End comments inside +# the prose, which would split a Requirement paragraph and an archived spec in +# two to satisfy a linter. +[[annotations]] +path = [ + "openspec/specs/claim-accuracy/spec.md", + "openspec/changes/archive/2026-07-07-fix-readiness-claims/specs/claim-accuracy/spec.md", + "openspec/changes/beta-surface-alignment/proposal.md", + "openspec/changes/beta-surface-alignment/specs/beta-alignment/spec.md", + "openspec/changes/component-playground/design.md", +] +precedence = "override" +SPDX-FileCopyrightText = "2026 Conduction B.V. " +SPDX-License-Identifier = "EUPL-1.2" diff --git a/appinfo/info.xml b/appinfo/info.xml index d4c70fe5..c58532d4 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -64,7 +64,7 @@ Gratis en open source onder de EUPL-1.2 licentie. which quoted the grep command in order to WARN about the problem — made it read ")[^". The warning reproduced the bug it described. --> - 1.2.0 + 1.2.4-unstable.20260912203544 EUPL-1.2 Conduction Thematiq @@ -124,7 +124,7 @@ Gratis en open source onder de EUPL-1.2 licentie. max-version stays 34, which is the fleet-wide value; openconnector is the only repo that says 35. --> - + + +### Requirement: Read/Write PHP Endpoint +The backend MUST expose a PHP service that reads the current `custom-overrides.css` and writes a new version atomically. Direct file manipulation from Vue components MUST NOT be used. + +The overrides endpoint remains the only way the client writes `custom-overrides.css`, and the +token editor remains its only caller. The component playground MUST reach it through that +editor's own rows and its own Save rather than through a path of its own, so there is one +implementation of "what an edit is" and it cannot disagree with itself. + +#### Scenario: Read current overrides +- GIVEN `custom-overrides.css` exists with some overrides +- WHEN the admin settings panel loads +- THEN a GET request to `/settings/overrides` MUST return the list of currently overridden token names and values as JSON +- AND the response MUST include only tokens present in `custom-overrides.css` (not defaults or resolved values) + +#### Scenario: Write new overrides +- GIVEN the admin clicks Save with a new set of token values +- WHEN a POST request is made to `/settings/overrides` with the token map +- THEN the backend MUST validate each token name against the editable token registry +- AND it MUST write the validated tokens to `custom-overrides.css` atomically (write to temp file, rename) +- AND it MUST return HTTP 200 with the final set of written tokens + +#### Scenario: Write fails due to filesystem permissions +- GIVEN the CSS directory is not writable by the web server process +- WHEN the save endpoint is called +- THEN the server MUST return HTTP 500 +- AND the error response MUST include a message indicating the file could not be written +- AND the existing `custom-overrides.css` MUST remain unchanged + +#### Scenario: The playground saves through the editor, not beside it +- GIVEN an edit made under a component in the playground +- WHEN the admin saves +- THEN the request MUST be the one the token editor would have sent for the same edit +- AND the playground MUST NOT issue a write of its own + +#### Scenario: An edit under a component is an edit in the editor +- GIVEN a component open in the playground +- WHEN one of its tokens is edited +- THEN the token editor MUST report an unsaved change, exactly as if the value had been typed + in the full list diff --git a/openspec/changes/component-playground/specs/nextcloud-variable-mapping/spec.md b/openspec/changes/component-playground/specs/nextcloud-variable-mapping/spec.md new file mode 100644 index 00000000..93201f05 --- /dev/null +++ b/openspec/changes/component-playground/specs/nextcloud-variable-mapping/spec.md @@ -0,0 +1,76 @@ +# Spec delta: Nextcloud Variable Mapping (component-playground) + +The mapping itself is unchanged: `overrides.css` still says which `--color-*` variable reads +which `--nldesign-*` token, and the load order is untouched. What is added is a reader of that +mapping in the other direction — for a given token, which Nextcloud variable holds its stock +value — so the `nextcloud` token set can be resolved from the running instance instead of from +a hand-copied file. + +## ADDED Requirements + +### Requirement: The Stock Set Is Resolved From The Instance +The `nextcloud` token set exists to reproduce the appearance of the running instance, and that +appearance changes with every Nextcloud release. Its `--nldesign-*` values MUST therefore be +resolved from the instance's own stock theme rather than read from a shipped snapshot, and +`css/tokens/nextcloud.css` MUST be kept as the fallback for when they cannot be. + +The values MUST come from the same computation the server serves: the theming app's +`DefaultTheme::getCSSVariables()`, which is what produces `/apps/theming/theme/default.css`. +This is the instance's stock theme rather than Nextcloud's factory one — an admin who has set +a primary colour in core theming is wearing that colour, so that colour is what is reported. + +The resolution MUST be the INSTANCE's and MUST NOT depend on who is asking. Nextcloud's own +`getColorPrimary()` returns the signed-in user's personal colour when they have set one, and +user theming is enabled by default — but a token set is instance-wide, is served to the +anonymous login page as well, and is cached under a key that has no user in it. A per-user +resolve would therefore let one account's personal colour reach every other page. The +admin-level colour MUST be used instead, and a theming app that no longer offers one MUST +fall back to the shipped file rather than resolve per user. + +Resolution MUST produce literal values only. A `--nldesign-*` token MUST NOT be emitted as a +`var()` reference to a Nextcloud variable: `overrides.css` already declares the opposite +direction, so the reference would close a loop and CSS discards a custom property that depends +on itself. + +The inversion of the mapping is many-to-one, and the choice of source MUST be deterministic: +where several variables read one token, the variable carrying the token's own name defines it, +and where none does, the candidates are taken in sorted order so the result does not depend on +the order declarations appear in `overrides.css`. + +Every failure path MUST degrade to the shipped file. A stale stock theme is a cosmetic defect; +no stock theme at all is a blank page. + +#### Scenario: The set follows the installed version +- GIVEN an instance running a Nextcloud version whose stock values differ from `css/tokens/nextcloud.css` +- WHEN the `nextcloud` token set is applied +- THEN the `--nldesign-*` token values MUST be the ones the instance's own theme computes +- AND the shipped file MUST NOT be loaded for that set + +#### Scenario: The set follows core theming +- GIVEN an admin who has set a primary colour in Nextcloud's own theming settings +- WHEN the `nextcloud` token set is applied +- THEN the resolved tokens MUST carry that colour, because it is the colour the instance wears + +#### Scenario: A token read by several variables takes the defining one +- GIVEN `--color-primary`, `--color-primary-element`, `--color-primary-light-text` and + `--color-primary-element-light-text` all read `--nldesign-color-primary` +- WHEN the stock set is resolved +- THEN `--nldesign-color-primary` MUST take the value of `--color-primary` +- AND MUST NOT take a text colour from one of the other three + +#### Scenario: A user's personal colour does not reach anyone else +- GIVEN user theming is enabled and a user has set a personal primary colour +- WHEN the `nextcloud` token set is resolved, whoever the request belongs to +- THEN the emitted tokens MUST carry the admin's colour, not that user's +- AND the block served to another user, and to the anonymous login page, MUST be the same one + +#### Scenario: A value that cannot be frozen is dropped +- GIVEN a stock variable whose value is a gradient, a `color-mix()` or an unresolvable `var()` chain +- WHEN the stock set is resolved +- THEN no `--nldesign-*` token MUST be emitted for it, rather than one carrying a reference + +#### Scenario: The theming app is absent or fails +- GIVEN an instance where the theming app is disabled, or where reading the theme throws +- WHEN the `nextcloud` token set is applied +- THEN `css/tokens/nextcloud.css` MUST be loaded instead +- AND the failure MUST be recorded in the log, because the fallback is otherwise silent diff --git a/openspec/changes/component-playground/specs/theme-preview/spec.md b/openspec/changes/component-playground/specs/theme-preview/spec.md new file mode 100644 index 00000000..21a8eedc --- /dev/null +++ b/openspec/changes/component-playground/specs/theme-preview/spec.md @@ -0,0 +1,22 @@ +# Spec delta: Theme Preview (component-playground) + +Preview state, its endpoints, its switcher and its isolation are all unchanged. What is added +is that a second reader honours it. + +## ADDED Requirements + +### Requirement: The Instrument Describes The Previewed Set +When a session preview is active, the component playground MUST describe the previewed set: +the token values it exports and the values its specimens are drawn with MUST be the +previewed set's, not the instance-wide one. + +#### Scenario: The instrument follows the preview +- GIVEN an active session preview of a set other than the instance-wide one +- WHEN the admin opens the theming panel +- THEN the specimens MUST be drawn with the previewed set's values +- AND an export MUST serialise the previewed set + +#### Scenario: A preview does not leak to other sessions +- GIVEN an admin previewing a set +- WHEN another user loads any page +- THEN that user MUST see the instance-wide set, exactly as before this change diff --git a/openspec/changes/component-playground/tasks.md b/openspec/changes/component-playground/tasks.md new file mode 100644 index 00000000..e543f2d4 --- /dev/null +++ b/openspec/changes/component-playground/tasks.md @@ -0,0 +1,131 @@ +# Tasks: component playground + +Tick a box when the work is merged to `development`, not when it is started. + +## 1. Spec and design + +- [ ] 1.1 Write this change: `proposal.md`, `design.md`, `tasks.md`, spec deltas on + `component-playground` (new), `admin-settings`, `custom-css-overrides`, `theme-preview`, + `nextcloud-variable-mapping`. +- [ ] 1.2 Record the rendering decision and the reason the Vue build was not taken, measured + against what `js/admin-mock.js` already does. +- [ ] 1.3 Record why this is built into the token editor rather than served as a page, and + leave the rejected page in the design so the trade is not re-made (design decision 2). + +## 2. The instrument + +- [ ] 2.1 `js/playground.js`, loaded by `templates/settings/admin.php` after `admin.js`; it + waits for the editor admin.js renders and attaches to it. +- [ ] 2.2 The selector: move `.nldesign-tabs` above `#nldesign-preview`, add the chip row + under it, and keep the tab buttons in sync — admin.js can no longer deactivate them + once the strip has left its container. +- [ ] 2.3 The stage: a third `.nldesign-preview-stage[data-view="component"]` in the preview, + with the App/Login switch hidden because the tabs now decide the view. +- [ ] 2.4 `lib/Service/PlaygroundStateService.php` + `lib/Settings/Admin.php`: publish + `playgroundInventory`, `playgroundReasons`, `playgroundTokens`, + `playgroundTokenSources`, `playgroundVersion` and `playgroundSet` for the set the page + is wearing. +- [ ] 2.5 `css/playground.css`: the selector, the chips, the stage, the specimens and the + filtered list. Nothing in it may style a specimen from anything but the real + `--color-*` variables. + +## 3. Inventory and components + +- [ ] 3.1 `js/playground/components.json`: one entry per component — id, tab, title, subtitle, + the states its stage draws, the tokens it reads with what each paints and which state, + the token-less facts with their reason codes, and the class names its markup uses. +- [ ] 3.2 A component per chip, under the four editor tabs, covering every token the registry + carries: header bar, login card, login button, logo & slogan, background; app + navigation, content card, table, sidebar, text input, select, checkbox & switch, + textarea, dialog, list item, progress; primary/secondary/tertiary/error button, note + cards, badge & counter, toast; heading, paragraph, link, muted text, status text. +- [ ] 3.3 Stage markup per component, one specimen per state, each captioned with the state + it draws. +- [ ] 3.4 The filtered list: cloned editor rows, each saying what it paints, grouped by the + state that paints it. No "show all N tokens of " link back to the full list: + the chip row above the stage already carries the full view as its first chip, which is + the same destination and the place an admin is already looking to change what the stage + shows. A second route to it was considered and dropped. +- [ ] 3.5 Token-less rows render the fact, no editor, and the converter's reason code. + +## 4. Editing and exporting + +- [ ] 4.1 An edit in a cloned row is written back into the editor's original input and + re-dispatched there, so dirty tracking, the reset control and Save are untouched. +- [ ] 4.2 The live recolour is set on `#nldesign-preview`, so the specimen repaints and the + settings page does not. +- [ ] 4.3 The cloned row's reset drives the editor's own reset, then re-reads what it restored. +- [ ] 4.4 Export as token set, beside Download and Upload: the active set's resolved + `--nldesign-*` values with the SAVED overrides folded in, one sorted flat `:root { }` + block; overrides that map to no token are reported, not dropped, and where two overrides + read one token the variable carrying the token name wins and the loser is reported + (design decision 6). +- [ ] 4.5 The open tab and component live in the URL hash, and a stale hash degrades to the + plain panel. + +## 5. Tests + +- [ ] 5.1 `tests/vitest/playgroundInventory.spec.js`: every token exists in `TokenRegistry`, + the components reach every token the editor can write, every component has stage markup + and every piece of stage markup a component, every class name appears in a shipped + stylesheet, and every reason code is one the converter defines. +- [ ] 5.2 `tests/vitest/playgroundSelection.spec.js`: the chips of a tab, the rows grouped per + state, the URL hash round-trip and its refusals, and the export (round-trip, audit + rating, an override written back to its token, an override that cannot be expressed, and + two overrides competing for one token). +- [ ] 5.3 `tests/Unit/Service/PlaygroundStateServiceTest.php`: the six keys, the inventory is + the shipped file, the tabs are the editor's own, and a missing mapping table or + inventory costs only what it must. +- [ ] 5.4 `tests/Unit/Settings/AdminInitialStateTest.php`: the keys are published, and for the + set the page is wearing. +- [ ] 5.5 Playwright visual spec per component in light and dark, under `tests/e2e/visual/`. +- [ ] 5.6 `tests/Unit/Service/TokenSetPreviewServiceTest.php`: the variable-to-token map read + out of a fixture `overrides.css`, the resolved and declared layers, and what the semantic + layer drops. +- [ ] 5.7 l10n: new strings in `l10n/en.json`, translated in `nl.json`, backfilled everywhere + else by `check-l10n-completeness --write`, `.js` rebuilt. + +## 6. The stock token set + +- [ ] 6.1 `lib/Service/StockTokensService.php`: build the `nextcloud` set from + `DefaultTheme::getCSSVariables()`, inverting the `--color-*` → `--nldesign-*` map that + `TokenSetPreviewService` parses out of `overrides.css` (design decision 8). +- [ ] 6.2 Literals only: resolve `var()` chains, drop what cannot be frozen, and pick the + defining variable where several read one token. +- [ ] 6.3 `lib/Service/CssInjectionService.php`: emit the resolved block as the tokens layer + for that set, and fall back to `css/tokens/nextcloud.css` on every failure path. +- [ ] 6.4 Spec delta on `nextcloud-variable-mapping`, and `@spec` tags on the service. +- [ ] 6.5 `tests/Unit/Service/StockTokensServiceTest.php`: the inversion, the many-to-one + choice, the values that cannot be frozen, and each way the fallback is reached. +- [ ] 6.6 Resolve the ADMIN colour, not the signed-in user’s: `getColorPrimary()` returns a + personal colour when one is set and user theming is on by default, and this output is + instance-wide (design decision 8). +- [ ] 6.7 Cache the resolved block across requests, keyed on the Nextcloud version and the + theming cachebuster — this is the DEFAULT set and the layer list is built on every + render. Successes only, so a failure does not outlive its cause (design decision 8). + +## 7. The guest stylesheet + +- [ ] 7.1 Vendor `core/css/guest.css` from `nextcloud/server v34.0.0` verbatim as + `scripts/sources/nextcloud-guest.css`, keeping its AGPL-3.0-or-later SPDX header and both + copyright lines. +- [ ] 7.2 `scripts/generate-guest-css.mjs`: re-emit every rule under + `:where(#nldesign-preview .nldesign-pg-guestpage)` so the login specimens are painted by + core's own declarations without a single one reaching the settings page (design decision 9). +- [ ] 7.3 `npm run generate:guest-css` / `npm run test:guest-css`, the same check/--write shape + as `generate-lasuite-tokens.mjs`, and `css/playground-guest.css` committed as its output. +- [ ] 7.4 Keep the generated file out of the formatters that would rewrite it: `ignoreFiles` in + `stylelint.config.js`, and the generated pair in `.prettierignore`. + +## 8. Acceptance + +- [ ] 8.1 Pick Buttons & Status → Primary button: the stage draws it in four states, the list + shows five rows, changing the hover colour repaints the hover specimen, and Save writes + it exactly as the full list would. +- [ ] 8.2 Export a token set the vocabulary audit rates complete. This is the handover to the + converter — the reference its output is diffed against. + +## 9. Open + +- [ ] 9.1 Authoring the OpenWOO reference values is design work that follows this change; the + instrument is the tool, not the set (design decision 10). diff --git a/openspec/changes/component-scoped-tokens/design.md b/openspec/changes/component-scoped-tokens/design.md new file mode 100644 index 00000000..40c1345c --- /dev/null +++ b/openspec/changes/component-scoped-tokens/design.md @@ -0,0 +1,117 @@ +# Design: component-scoped tokens + +## Decision 1 — Re-scope the variable, do not repaint the component + +The house style in `css/systems/nldesign/theme.css` is an `!important` element rule per +component, and buttons, links, headings and form fields are already done that way. Extending +it was the obvious move and was rejected. + +Roughly twenty of the 34 playground components have no thematiq rule at all today — the app +navigation, the sidebar, dialogs, the progress bar, checkboxes, breadcrumbs, list rows, +counter bubbles. Painting each of them would mean writing selectors against Nextcloud +internals, with `!important`, for components whose markup Nextcloud is free to change. The +repo already records what that costs: the class-name drift guard in +`playgroundInventory.spec.js` exists because `.menutoggle`, `.unified-search__button` and +`.app-content-detail` were all names Nextcloud does not emit. + +Redeclaring the variable inside the component's subtree needs no selector knowledge beyond +the component's root, and leaves the painting to the stylesheet that ships with the component: + +```css +.app-navigation { + --color-primary-element: var( + --nldesign-component-navigation-active-background-color, + var(--thematiq-global-color-primary-element) + ); +} +``` + +**No `!important` is needed, and that is not an oversight.** Custom properties cascade per +element. A value inherited from `:root` is only used by an element that has no declaration of +its own, so a declaration on `.app-navigation` wins inside that subtree regardless of the +importance of the `:root` one. This is also why `custom-overrides.css` writing +`:root { --color-primary-element: … !important }` does not leak past a component scope — which +is exactly the isolation the change is for. + +## Decision 2 — Capture the globals under `--thematiq-global-*` + +The fallback has to be the global, so that a component nobody has themed is indistinguishable +from stock. It cannot be written directly: + +```css +/* INVALID — discarded, and takes the component with it */ +.app-navigation { + --color-primary-element: var(--X, var(--color-primary-element)); +} +``` + +A custom property whose value depends on itself is invalid at computed-value time. This is the +same cycle `StockTokensService` documents for `--nldesign-color-primary: var(--color-primary)` +against `overrides.css`. + +So `:root` copies each global first: + +```css +:root { + --thematiq-global-color-primary-element: var(--color-primary-element); +} +``` + +The copy is resolved at `:root`, where `--color-primary-element` holds its ordinary value, so +there is no cycle. The capture is deliberately **not** `!important`: `custom-overrides.css` +writes admin-set globals at `:root` with `!important` and loads later, and the capture has to +lose to it — an admin who moves the brand primary must move every component that has not been +given a value of its own. + +## Decision 3 — The component tokens are declared in exactly one place + +`css/component-scopes.css` declares no component token, only `var()` references to them. The +declarations live in `css/systems/nldesign/defaults.css` and nowhere else. + +That is what makes the layer safe to load under every design system, including `none`, +`summer-breeze`, `high-contrast`, `lasuite` and `cunningham`. Under those, the component +tokens are simply undefined, every `var()` falls through to the captured global, and the page +is byte-identical to one without the layer. A design system that wants component-level control +opts in by declaring the names. + +## Decision 4 — `primary_drives_components` defaults to OFF + +Off is both the new behaviour and a no-op on upgrade, which is why it can be the default. With +no per-component value stored, every component token resolves through `defaults.css` to the +brand primary already, so an instance that has never opened the playground renders the same +either way. The setting only starts to matter once someone sets a component value — which is +the point at which the admin should be choosing. + +On is implemented as a stylesheet rather than as a delete. `css/primary-lock.css` forces the +26 `primary`-flagged tokens back to the captured global with `!important`, and is emitted +**after** `custom-overrides.css` so it outranks a value stored there. Nothing is removed from +`custom-overrides.css`, so switching the setting off restores what the admin had. A delete +would have been simpler to implement and would have silently destroyed work. + +## Decision 5 — The brand globals stay editable, and no chip owns them + +`playgroundInventory.spec.js` asserted that every registry token is reachable from some chip, +on the reasoning that a token no component reads is one an admin can only find by scrolling. +That rule cannot survive a brand layer: the globals have no component to belong to, because +moving one is *meant* to move everything that has not opted out. + +The alternative considered was a synthetic `Brand` chip owning the primary family, which would +have kept the guard passing unchanged. It was rejected because a chip is a drawing of a +component and the brand level is not one — and because the `primary_drives_components` control +belongs with the other admin toggles, not inside the instrument. + +So the guard now exempts the brand globals by name, reading the list from the same mapping +file, and gains the inverse assertion: no chip may name a global. The coverage rule still has +teeth where it matters — all 123 component tokens must be reachable from a chip. + +## Decision 6 — One table, read by both runtimes + +`scripts/mapping/component-tokens.json` is read by `TokenRegistry.php` (what the editor may +write), by `scripts/generate-component-scopes.mjs` (what the CSS applies) and by +`playgroundInventory.spec.js` (what the chips may name). This follows +`scripts/mapping/nlds-to-nextcloud.json`, whose header states the same reason: PHP and JS both +load the file, so the two runtimes cannot drift. + +The generator refuses to emit when two tokens of one component map onto the same global, since +that would produce two declarations of one property in one rule and the second would silently +win — one of the two chips' controls would do nothing. diff --git a/openspec/changes/component-scoped-tokens/proposal.md b/openspec/changes/component-scoped-tokens/proposal.md new file mode 100644 index 00000000..e27ffce3 --- /dev/null +++ b/openspec/changes/component-scoped-tokens/proposal.md @@ -0,0 +1,86 @@ +--- +kind: code +--- + +## Why + +The token editor could only write Nextcloud's own globals, and Nextcloud has around sixty of +them for every component in the product. `--color-primary-element` alone paints the primary +button, the selected navigation entry, the sidebar's active tab, a focused text input, the +checked checkbox, the progress bar, the dialog's confirm button and the counter bubble. + +So the component playground — the instrument built precisely so an admin could style one +component while looking at it — could not reach a single component. Every one of its 34 chips +named a global. Moving `Primary button` moved seven other components with it, and moving the +login button was impossible without moving the main button too. The chip rendered a control +that did something other than what its title said. + +This was not a playground bug. The playground faithfully cloned rows from `TokenRegistry`, +and the registry only carried globals. Meanwhile `css/systems/nldesign/defaults.css` already +declared 104 `--nldesign-component-*` tokens and `theme.css` already painted buttons, links, +headings and form fields from them — but nothing exposed those names for editing, so the two +halves never met. + +## What Changes + +- **The mapping becomes data.** `scripts/mapping/component-tokens.json` holds, per component, + the selectors it occupies and, per token, the global it replaces, its label, its type and + whether the brand primary used to drive it. 123 component tokens across 33 components. + `TokenRegistry.php` and `scripts/generate-component-scopes.mjs` both read it, following the + precedent `scripts/mapping/nlds-to-nextcloud.json` set, so the editable registry and the + stylesheet that applies it cannot drift. + +- **`css/component-scopes.css` re-scopes rather than repaints.** For each component, the + Nextcloud variable it consumes is redeclared inside that component's own subtree: + + .app-navigation { + --color-primary-element: var( + --nldesign-component-navigation-active-background-color, + var(--thematiq-global-color-primary-element) + ); + } + + Nextcloud's own stylesheets keep doing the painting. The alternative — an `!important` + element rule per component, which is the house style in `theme.css` — was rejected: it + would have meant roughly twenty new rule blocks written against Nextcloud internals, each + able to drift on a release, to achieve what redirecting one variable achieves. + +- **The `--thematiq-global-*` capture makes the fallback possible.** A rule cannot say + `--color-primary-element: var(--X, var(--color-primary-element))`; a custom property that + depends on itself is discarded as invalid, the same cycle `StockTokensService` documents. + `:root` copies each global under a `--thematiq-global-*` name and the scopes fall back to + the copy. Because the component tokens are declared nowhere but `defaults.css`, an instance + that has set none renders exactly as it did before this layer existed. + +- **The registry gains a component layer.** `TokenRegistry::getTokens()` now returns the brand + globals plus the component tokens, each carrying `group` (`brand`, or the component id) and + `primary`. The brand globals stay editable from the four-tab list — that list IS the + brand-level control, and moving one is MEANT to move every component that has not opted out. + +- **`primary_drives_components`, a separate admin toggle.** Giving the primary back its reach + over every component stays possible, but becomes a deliberate choice rather than the only + behaviour available. While it is on, `css/primary-lock.css` forces the 26 tokens flagged + `primary` back to the captured global, and the editor renders those rows disabled. Stored + per-component values are never deleted, so switching it off restores them. Default off, + which changes nothing visually: with no per-component value stored the component tokens + already resolve to the brand primary. + +- **The playground's 34 chips are repointed**, and the inventory guard changes with them. + `playgroundInventory.spec.js` now exempts the brand globals from its coverage rule and adds + the inverse check — that no chip names a global — so the defect this change fixes cannot + come back silently. + +## Impact + +- Affected specs: `component-tokens` (the component layer and the toggle), `css-architecture` + (two new layers and where they sit), `admin-settings` (the toggle), `token-editor-ui` (the + locked rows), `component-playground` (chips name component tokens). +- Affected code: `scripts/mapping/component-tokens.json`, `scripts/generate-component-scopes.mjs`, + `css/component-scopes.css`, `css/primary-lock.css`, `css/admin.css`, + `lib/Service/TokenRegistry.php`, `lib/Service/TokenRegistryInterface.php`, + `lib/Service/CssInjectionService.php`, `lib/Service/ConfigBundleService.php`, + `lib/Controller/SettingsController.php`, `lib/Settings/Admin.php`, `appinfo/routes.php`, + `templates/settings/admin.php`, `js/admin.js`, `js/playground/components.json`. +- NOT affected, deliberately: `lib/Capabilities.php`. Its payload is a pinned eight-key public + contract, and this setting does not change what a client renders — the colours it affects + already reach the client as CSS. diff --git a/openspec/changes/component-scoped-tokens/specs/component-tokens/spec.md b/openspec/changes/component-scoped-tokens/specs/component-tokens/spec.md new file mode 100644 index 00000000..afd8ce19 --- /dev/null +++ b/openspec/changes/component-scoped-tokens/specs/component-tokens/spec.md @@ -0,0 +1,143 @@ +# Spec delta: Component Tokens (component-scoped tokens) + +The `--nldesign-component-*` vocabulary keeps everything it has. What is added is a way for a +component token to REACH its component without being painted by hand, a registry that exposes +those tokens for editing, and a deliberate way to give the brand primary its reach back. + +@e2e exclude CSS-cascade and registry spec — the scenarios are custom-property resolution and +file-structure assertions. The one user-visible surface, the admin toggle and its locked rows, +is on the admin theming page already covered by admin-settings tests. + +## ADDED Requirements + +### Requirement: Component Tokens Are Declared As Data +The component token layer MUST be defined in `scripts/mapping/component-tokens.json`, and both +the PHP registry and the CSS generator MUST read that file rather than restate it. + +#### Scenario: One table, two runtimes +- GIVEN a component token is added to the mapping table +- WHEN the registry is built and the stylesheets are regenerated +- THEN the token MUST be editable in the admin panel +- AND a scope rule applying it MUST appear in `css/component-scopes.css` + +#### Scenario: Two tokens may not claim one global +- GIVEN a component maps two of its tokens onto the same Nextcloud variable +- WHEN `scripts/generate-component-scopes.mjs` runs +- THEN it MUST refuse to emit and MUST name both tokens +- BECAUSE the rule would declare one property twice and the second would silently win + +#### Scenario: The committed stylesheets are drift-checked +- GIVEN the mapping table has changed and the stylesheets have not been regenerated +- WHEN `npm run test:component-scopes` runs +- THEN it MUST fail and MUST report the first differing line + +### Requirement: A Component Token Reaches Only Its Own Component +`css/component-scopes.css` MUST redeclare the Nextcloud variable a component consumes inside +that component's own subtree, so that setting a component token repaints that component and no +other. + +#### Scenario: Two components sharing one global move independently +- GIVEN `--nldesign-component-login-button-background-color` is set +- AND `--nldesign-component-button-primary-action-background-color` is not +- WHEN the login page and an app page are rendered +- THEN the login button MUST take the new colour +- AND every other primary button MUST keep the brand colour + +#### Scenario: An unset component token is indistinguishable from stock +- GIVEN no component token is declared or stored +- WHEN any page is rendered +- THEN every component MUST resolve to the same value it had before this layer existed + +#### Scenario: The scope does not need to outrank the brand layer +- GIVEN `custom-overrides.css` declares a global at `:root` with `!important` +- AND a component scope declares the same variable on the component's root +- WHEN the component is rendered +- THEN the component's own declaration MUST win, without `!important` +- BECAUSE an inherited value is only used by an element that has no declaration of its own + +### Requirement: The Fallback Is Captured, Not Self-Referential +The scope rules MUST fall back to a `--thematiq-global-*` copy of the global declared at +`:root`, and MUST NOT reference the global they are redeclaring. + +#### Scenario: The capture avoids the cycle +- GIVEN a scope rule needs the brand value as its fallback +- WHEN `css/component-scopes.css` is generated +- THEN the fallback MUST be `var(--thematiq-global-)` +- AND `:root` MUST declare that name as `var(--)` + +#### Scenario: The capture loses to an admin override +- GIVEN an admin sets a brand global in the token editor +- WHEN a component with no component token of its own is rendered +- THEN it MUST take the admin's value + +### Requirement: Component Tokens Are Declared By The Design System, Not The Scope Layer +`css/component-scopes.css` MUST NOT declare a component token. Declarations MUST live in a +design system's own defaults. + +#### Scenario: The layer is safe under a design system that knows nothing about it +- GIVEN the active design system is `none`, `summer-breeze`, `high-contrast`, `lasuite` or + `cunningham` +- WHEN a page is rendered with the component scopes loaded +- THEN every component token MUST be undefined +- AND every component MUST render exactly as it would without the layer + +### Requirement: The Registry Carries Both Layers +`TokenRegistry::getTokens()` MUST return Nextcloud's globals AND the component tokens, each +carrying the component it belongs to and whether the brand primary used to drive it. + +#### Scenario: A token says which layer it is in +- GIVEN the registry is requested +- WHEN a token is read +- THEN a Nextcloud global MUST carry `group: "brand"` +- AND a component token MUST carry the component's id as its `group` + +#### Scenario: A missing table is not a broken panel +- GIVEN `scripts/mapping/component-tokens.json` is absent or malformed +- WHEN the token editor loads +- THEN the brand tokens MUST still be editable +- AND the component layer MUST be empty rather than an error + +### Requirement: The Brand Primary May Be Given Its Reach Back, Deliberately +The `primary_drives_components` appconfig MUST default to off. While it is on, every component +token flagged `primary` MUST resolve to the captured brand value, and its editor row MUST +render disabled. + +#### Scenario: Off is a no-op on an instance that never themed a component +- GIVEN no per-component value has ever been stored +- WHEN the setting is off +- THEN every component MUST render in the brand primary +- BECAUSE the component defaults already resolve to it + +#### Scenario: On overrules a stored per-component value +- GIVEN a per-component colour is stored in `custom-overrides.css` +- WHEN the setting is on +- THEN `css/primary-lock.css` MUST be emitted after `custom-overrides.css` +- AND the component MUST render in the brand primary + +#### Scenario: On does not destroy what it overrules +- GIVEN the setting is turned on and then off again +- WHEN the page is rendered +- THEN the previously stored per-component values MUST take effect again + +#### Scenario: Only the tokens the primary owns are locked +- GIVEN the setting is on +- WHEN the token editor renders +- THEN the rows for `primary`-flagged component tokens MUST be disabled +- AND rows for radii, font weights and durations MUST stay editable +- AND the brand globals MUST stay editable, because the setting exists to make them win + +### Requirement: No Playground Chip May Write A Nextcloud Global +Every token named by a component chip in `js/playground/components.json` MUST be a component +token. + +#### Scenario: A chip reaches its own component +- GIVEN the `Primary button` chip +- WHEN its rows are read +- THEN every token MUST be a `--nldesign-component-*` name +- AND none MUST be `--color-primary-element` + +#### Scenario: The brand globals stay reachable, from the list rather than a chip +- GIVEN a Nextcloud global carried by the registry +- WHEN the inventory coverage guard runs +- THEN the global MUST be exempt from the chip-coverage rule +- AND it MUST remain editable from the token editor's four-tab list diff --git a/openspec/changes/component-scoped-tokens/tasks.md b/openspec/changes/component-scoped-tokens/tasks.md new file mode 100644 index 00000000..9e8bf3e5 --- /dev/null +++ b/openspec/changes/component-scoped-tokens/tasks.md @@ -0,0 +1,158 @@ +# Tasks: component-scoped tokens + +Tick a box when the work is merged to `development`, not when it is started. + +## 1. Spec and design + +- [x] 1.1 Write this change: `proposal.md`, `design.md`, `tasks.md`, spec deltas on + `component-tokens`, `css-architecture`, `admin-settings`, `token-editor-ui`, + `component-playground`. +- [x] 1.2 Record why the variable is re-scoped rather than the component repainted, and leave + the rejected `!important`-per-component approach in the design so the trade is not + re-made (design decision 1). +- [x] 1.3 Record the `--thematiq-global-*` capture and the cycle it exists to avoid, against + the one `StockTokensService` already documents (design decision 2). + +## 2. The mapping table + +- [x] 2.1 `scripts/mapping/component-tokens.json`: per component the selectors it occupies, + per token the global it replaces, its label, type, `primary` flag, and the `paints` / + `callout` the playground chip renders. +- [x] 2.2 `brandTokens`: the 56 globals, generated from the table rather than hand-listed, and + asserted equal to the hand-written registry in `TokenRegistry.php`. +- [x] 2.3 Leave `--color-main-background` out. `overrides.css` marks it "intentionally not + overridden — overriding breaks dark mode", so no component token may claim it. + +## 3. The stylesheets + +- [x] 3.1 `scripts/generate-component-scopes.mjs`, with `--check`, following the + `generate-guest-css.mjs` contract. Registered as `generate:component-scopes` / + `test:component-scopes`. +- [x] 3.2 The generator refuses to emit when one component maps two tokens onto one global. +- [x] 3.3 `css/component-scopes.css` and `css/primary-lock.css`, generated and committed, and + added to `.prettierignore` beside the other drift-checked generated files. +- [x] 3.4 `css/admin.css`: `.nldesign-token-row--locked`. + +## 3a. The rules that already paint these components + +A re-scoped variable is inert wherever thematiq already paints the property itself with +`!important`, because the shipped rule never reads the variable. Each of these was found by +cross-referencing the mapping against every `var(--nldesign-*)` in the two stylesheets, and +each fix keeps the previous token as the fallback so no shipped token set changes. + +- [x] 3a.1 `theme.css` login button: read `--nldesign-component-login-button-*` first, falling + back to `--nldesign-component-button-primary-action-*`. This is the case the whole + change was reported for — the login button could not be moved without moving the main + primary button, because both rules read the same token. +- [x] 3a.2 `theme.css` checked checkbox/radio: `--nldesign-component-checkbox-checked-background-color` + before `--nldesign-color-primary`. +- [x] 3a.3 `theme.css` `.icon-loading`: `--nldesign-component-progress-background-color` + before `--nldesign-color-primary`. +- [x] 3a.4 `#header` background and the six header-glyph rules: the component token before + `--nldesign-color-header-background` / `--nldesign-color-header-text`, which a token + set owns. +- [x] 3a.5 Headings: `defaults.css` derives all six per-level `-color` and `-font-weight` + tokens from one `--nldesign-component-heading-color` / `-font-weight`, so the chip's + single row moves h1–h6 while a set overriding one level still wins. `theme.css` gains + the heading and paragraph `font-family` rules the chip's typeface row needs. +- [x] 3a.6 Table: use `--nldesign-component-table-border-color` and + `--nldesign-component-table-row-hover-background-color`, the names `theme.css` already + consumes, rather than inventing near-synonyms it would have ignored. +- [x] 3a.7 The shared geometry rules, split by component. There were TWO copies of the same + blanket list — `theme.css`'s "REMOVE ALL ROUNDED CORNERS" and + `element-overrides.css`'s "BORDER RADIUS CONSISTENCY" — and because element-overrides + loads last, splitting theme.css alone changed nothing. Both are now one rule per + component, each reading its own token with `--nldesign-border-radius` behind it, so a + sharp theme squares off exactly as before while the Corner rows finally do something. + Same treatment for `.toastify.dialogs`, `#body-login .wrapper`, `.login-box` and + `#body-login button`, which each forced the brand radius onto one component. +- [x] 3a.8 `element-overrides.css` label rule: `button[class*='primary'] …` painted every + primary button's label AND every descendant from the BRAND token + `--nldesign-color-primary-text`, at a specificity the scope layer cannot outrank. It + now reads `--nldesign-component-button-primary-action-color` first — which is what + finally makes the login button's Label row work, since the alias redirects that token + inside the login button. +- [x] 3a.9 `#header .header-appname` read the brand primary; it now follows + `--nldesign-component-header-color`. +- [x] 3a.10 Aliases for the three remaining shadowed families found by the audit: + secondary-button's corner (its rule reads the shared button radius), primary-button's + label weight (its rule reads a primary-action weight token the chip does not own), and + textarea's border and corner (a textarea is styled by the TEXTBOX rules, one selector + list covering `input` and `textarea` together). + +## 3b. The guard + +- [x] 3b.1 `tests/vitest/componentTokenReach.spec.js`. For every component it attributes the + shipped rules to a chip, follows each token's chain through `defaults.css` and the + mapping's `aliases`, and fails on any chip row a rule paints over. This is the defect + class the login button shipped with — a control that renders, saves and does nothing — + and nothing detected it before. +- [x] 3b.2 Two allowlists, both asserted non-stale so they cannot quietly absorb new + breakage: `BY_DESIGN` (`--nldesign-color-on-surface`, the WCAG pairing mechanism, which + is meant to travel by inheritance rather than be per-component) and `NO_ROW_YET` (four + properties no chip offers a row for — see 8.7). + +## 4. The registry + +- [x] 4.1 `TokenRegistry::getComponentTokens()` reads the table; `getBrandTokens()` wraps the + four hand-written methods with `group` / `primary`; `getTokens()` merges them. +- [x] 4.2 The `primary` flag travels on each registry entry, so the locked control and the + locked value are decided by one flag in one file. No separate "which tokens are locked" + accessor: PHP never needs the list, and a second way to ask the question is a second + way for the two to disagree. +- [x] 4.3 A missing or malformed table degrades to an empty component layer, not an error. +- [x] 4.4 `TokenRegistryInterface`: the return shape gains `group` and `primary`. + +## 5. The cascade + +- [x] 5.1 `CssInjectionService::inject()` emits `component-scopes` after the design-system + layers and before the custom overrides. +- [x] 5.2 `injectConditionalStyles()` emits `primary-lock` last, so it outranks a + per-component value stored in `custom-overrides.css`. + +## 6. The toggle + +- [x] 6.1 `primary_drives_components` appconfig, default `0`. +- [x] 6.2 `SettingsController::setPrimaryDrivesComponentsSetting()` + route, audited through + `toggle_changed` like its siblings. +- [x] 6.3 `Settings/Admin.php` + `templates/settings/admin.php`: the checkbox, with the other + admin toggles rather than inside the playground. +- [x] 6.4 `js/admin.js`: save, swap the `primary-lock` layer live, and lock the affected rows + in place without discarding unsaved edits or resetting the open tab. +- [x] 6.5 `ConfigBundleService`: export, validate and apply, so a bundle round-trips it. +- [ ] 6.6 Decide whether `Capabilities` should carry it. Currently NOT added — the payload is + a pinned eight-key contract and this setting changes nothing a client renders. + +## 7. The playground + +- [x] 7.1 Repoint all 34 chips onto component tokens; bump `components.json` to version 4 and + rewrite its `$comment` to say what the tokens now are. +- [x] 7.2 `playgroundInventory.spec.js`: read the component layer from the table, exempt the + brand globals from the coverage rule, and add the inverse guard — no chip may name a + global. +- [x] 7.3 Replace the "reads a token filed under another tab, and does" assertion, which + pinned a symptom of chips naming globals, with the invariant that replaced it. +- [x] 7.4 `playgroundSelection.spec.js`: the primary button's first callout names the button's + own background token. + +## 8. Verification + +- [x] 8.1 `npm run test:component-scopes` — the committed stylesheets match the table. +- [x] 8.2 `npx vitest run tests/vitest/playgroundInventory.spec.js tests/vitest/playgroundSelection.spec.js`. +- [x] 8.3 `npx stylelint` on both generated stylesheets. +- [x] 8.4 l10n: `check-l10n`, `check-l10n-completeness`, `l10n:build` for the four new strings + (two in `js/admin.js`, two in the template). +- [ ] 8.5 PHP gates — `phpcs`, `phpstan`, `psalm`, `phpunit`. NOT RUN: no PHP on the authoring + machine and none in WSL, so every PHP file in this change is unverified beyond review. +- [x] 8.6 `npx vitest run tests/vitest/componentTokenReach.spec.js` — no chip row is painted + over by a shipped rule. +- [ ] 8.7 FOUR PROPERTIES STILL HAVE NO ROW, recorded in the guard's `NO_ROW_YET`. Each needs + a token added rather than a rule fixed, which is a separate decision: + the navigation panel's own background (its variable is `--color-main-background`, which + overrides.css deliberately leaves alone, so it needs a token the nav rule reads + directly rather than a re-scope); the text colour inside a text input and inside a + textarea; and the primary button's border colour, which tracks its background in every + shipped set but is a separate token. +- [ ] 8.8 Confirm in a browser that moving `Login button` leaves `Primary button` alone, that + the Corner rows now move their component, and that turning the toggle on greys the + colour rows out and repaints them to the brand primary. diff --git a/openspec/changes/governance-environment-marker/.openspec.yaml b/openspec/changes/governance-environment-marker/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/governance-environment-marker/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/governance-environment-marker/design.md b/openspec/changes/governance-environment-marker/design.md new file mode 100644 index 00000000..7ea2df8a --- /dev/null +++ b/openspec/changes/governance-environment-marker/design.md @@ -0,0 +1,45 @@ +# Design: environment marker + +## Where it fits (development b4e7568) + +- `lib/Listener/ThemeInjectionListener.php:115-130` handles `BeforeLoginTemplateRenderedEvent` (context `login`, line 117-119) and `BeforeTemplateRenderedEvent`. For the latter it returns early when the app is excluded from theming (`isThemingDisabledForResponse()`, line 126) before it calls `CssInjectionService::inject()` (line 130). +- `lib/Service/CssInjectionService.php:296-301` runs the preview banner as the last layer, through `ThemePreviewBannerService::inject()`. +- `lib/Service/ThemePreviewBannerService.php` is the pattern to copy: it fails open, provides one initial-state payload and emits a script and a stylesheet pair (`emitPreviewAssets()`, `js/preview-banner.js`, `css/preview-banner.css`). +- `lib/Service/ConfigBundleService.php:241-254` exports the bundle; `openspec/specs/config-portability/spec.md:6-20` lists what the bundle MUST NOT contain and holds the ratchet that every future instance-wide value joins the bundle. +- `templates/settings/admin.php:56` opens the theming section; the environment line goes under its heading. + +## Decisions + +### 1. The environment lives in config.php, not in app config + +`thematiq.environment` is a system value, set with `occ config:system:set thematiq.environment --value=test` or by the deployment. The whole point is to tell copies apart. OTAP copies are usually made by restoring the production database into acceptance or test. An app config value would travel with that copy and label test as production, which is the exact mistake the marker exists to prevent. `config.php` belongs to one server and is not in the database. + +Rejected: an app config value with an admin form. It is easier to set, and wrong after the first database restore. + +### 2. It stays out of the configuration bundle + +The bundle exists to make environments look the same. The environment is the one thing that must differ, like `installed_version` and per-user preview state that the bundle already excludes. The config-portability requirement is modified to say so, so the ratchet rule does not later pull it in. + +### 3. Injected before the per-app guard, on every render context + +The marker is a safety signal, not a theme. It is emitted in `ThemeInjectionListener::handle()` before the exclusion guard, for the login context and for every `BeforeTemplateRenderedEvent`, so excluded apps, the `none` design system and public share pages all show it. It fails open like the preview banner: an error renders no marker and logs a warning, it never breaks the page. + +### 4. Text plus colour, fixed per environment + +Allowed values and their label: `development` "Development environment", `test` "Test environment", `acceptance` "Acceptance environment". `production` and an unset value render nothing. An unknown value renders the label "Unknown environment" with the test styling and logs a warning, because a typo in `config.php` must not silently look like production. + +Each environment has one fixed stripe colour and a label with a text colour that reaches 4.5:1 against it in light and dark mode. The colours are not taken from the house style: a brand whose primary colour happens to be amber must not make test look like the brand. + +### 5. The page title carries the environment too + +`js/environment-marker.js` prefixes `document.title` with the short label (`[Test]`), so browser tabs and bookmarks show it. The stripe itself is a `role="note"` element with the label as text, placed before the header so it is the first thing a screen reader user meets. + +## Risks + +- A deployment that never sets the value shows nothing, which is the same as today. The settings page line says "not set" and how to set it, so the gap is visible to the administrator. +- The stripe takes a few pixels of height. It is fixed and thin, and it must not cover the header's own controls. + +## Out of scope + +- Different house styles per environment. The bundle keeps them identical by design. +- Marking emails sent from a test server. Noted for a later change. diff --git a/openspec/changes/governance-environment-marker/proposal.md b/openspec/changes/governance-environment-marker/proposal.md new file mode 100644 index 00000000..1e0c7fea --- /dev/null +++ b/openspec/changes/governance-environment-marker/proposal.md @@ -0,0 +1,47 @@ +# Environment marker: see at a glance whether you are in test or production + +## Why + +A tender asks for it in so many words. Gemeente Gulpen-Wittem (TenderNed 407031, 2026-01-06) requires one look and feel in the house style for all users, with a clear difference between the test and the production environment. Staff who click through a test copy that looks exactly like production file real work in the wrong place, or test destructive actions on live data. + +Thematiq makes every environment look the same on purpose: the configuration bundle carries the house style from ontwikkeling to productie (OTAP). Nothing marks which environment you are in. The theme preview banner marks a trial session, not an environment. + +#### Row `gov-environment-marker` (thematiq matrix, area governance) + +- Capability: Show a visible difference between the test and the production environment, so nobody works in the wrong one. +- Own rating: no; built.state `none`. Built evidence: the only per-environment feature is promoting the configuration bundle between OTAP stages (lib/Service/ConfigBundleService.php:31); the trial banner (lib/Service/ThemePreviewBannerService.php) marks a session trial, not an environment; no environment label or colour exists +- Demand: tender at https://www.tenderned.nl/aankondigingen/overzicht/407031 (TenderNed 407031 (Gemeente Gulpen-Wittem, Zaaksysteem, 2026-01-06) requires one look-and-feel in the house style for all users with a clear difference between test and production; requirement 23655 in the intelligence database.) +- Nextcloud Theming (built-in app) rated `partial`: nextcloud/server@v35.0.1 theming values are per instance (apps/theming/lib/ThemingDefaults.php:237-258, set via lib/Controller/ThemingController.php:74-130 or occ theming:config), so a test instance can be given its own name, colour and logo; there is no dedicated environment banner or marker setting in apps/theming +- Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) rated `partial`: https://learn.microsoft.com/en-us/entra/fundamentals/how-to-customize-branding: branding is per tenant, so a test tenant can carry a visibly different sign-in and header brand, and https://learn.microsoft.com/en-us/entra/fundamentals/reference-company-branding-css-template advises validating 'in a test tenant first'; no built-in environment banner +- Liferay DXP (style books, themes, client extensions) rated `partial`: https://learn.liferay.com/w/dxp/site-building/publishing-tools/staging : with staging enabled a staging bar lets users toggle Staging and Live; https://learn.liferay.com/w/dxp/sites/publishing-tools/publications/making-and-publishing-changes : a Publications bar shows the active publication and warns on context change; no marker distinguishes a separate test installation from production +- Rated no or unknown: openDesk theming `no`, Tokens Studio (Figma plugin and platform) `no` + +## What changes + +- An administrator declares the environment of a server in `config.php` (`thematiq.environment`: `development`, `test`, `acceptance` or `production`), so the marker never travels with a database copy or a configuration bundle. +- On every page that is not production, including the login page and pages of apps excluded from theming, a stripe and a text label name the environment. Production shows nothing. +- The theming settings page shows the current environment and tells the administrator how to change it. +- The label is text, not only colour, so it meets WCAG 2.1 success criterion 1.4.1 (use of colour). + +## Capabilities + +### New capabilities + +- `environment-marker`: declaring the environment and marking every non-production page. + +### Modified capabilities + +- `config-portability`: the environment is excluded from the bundle, next to the other values that belong to one server. + +## Impact + +- New `lib/Service/EnvironmentMarkerService.php`, called from `lib/Listener/ThemeInjectionListener.php` before the per-app exclusion guard. +- New `js/environment-marker.js` and `css/environment-marker.css`, following `js/preview-banner.js`. +- `templates/settings/admin.php`: a read-only environment line in the theming section. +- `lib/Service/ConfigBundleService.php`: no new key; the spec records why. +- l10n: labels in English and Dutch. +- No database change, no new route. + +## Rows + +- `gov-environment-marker` (thematiq matrix). diff --git a/openspec/changes/governance-environment-marker/specs/config-portability/spec.md b/openspec/changes/governance-environment-marker/specs/config-portability/spec.md new file mode 100644 index 00000000..e54244ff --- /dev/null +++ b/openspec/changes/governance-environment-marker/specs/config-portability/spec.md @@ -0,0 +1,50 @@ +# Spec delta: configuration portability (config-portability) + +The environment a server declares is excluded from the bundle, because it is the one value that must differ between OTAP environments. + +## MODIFIED Requirements + +### Requirement: Complete Configuration Bundle + +The app MUST be able to serialize its COMPLETE configuration into a single JSON bundle with +envelope fields `format: "nldesign-config-bundle"`, `bundleVersion: 1`, `exportedAt` (ISO 8601), +and `app: {id, version}` (informational), containing ALL of: the active token set id +(`token_set`), the hide-slogan toggle, the show-menu-labels toggle, the per-app exclusion list +(`disabled_apps`), the full `custom-overrides.css` content, and every custom token set with its +metadata (id, name, description, theming) and inline CSS content. The bundle MUST NOT contain: +operational counters (`theming_syncs_total` and similar telemetry), `installed_version` +(NC-managed), per-user preview state (session-scoped, see change `theme-preview-workflow`), or +Nextcloud core `theming` app values (owned by the theming app; the theming-sync dialog is the +supported path to re-apply them after import), or the server's declared environment (the +`thematiq.environment` system value, see capability `environment-marker`: it is the one value +that must differ between OTAP environments, and it lives in `config.php`, not in app config). Every future instance-wide nldesign configuration +value MUST be added to the bundle in the same change that introduces the value, with a +`bundleVersion` bump — a configuration value that exists but is not exported is a spec +violation, not an accepted gap. + +@e2e exclude JSON bundle serialisation — the assertions are about envelope fields and which of the six configuration parts a serialised bundle contains, including that telemetry and platform-owned values are absent; that is a document's shape, not a rendered page; proven by tests/Unit/Service/ConfigBundleServiceTest.php. + +#### Scenario: Export captures all six configuration parts + +- GIVEN an instance with token set `amsterdam`, hide-slogan on, menu-labels off, two excluded + apps, three token overrides, and one custom token set `custom-gemeente-x` +- WHEN the bundle is exported +- THEN the JSON MUST contain `config.tokenSet = "amsterdam"`, `config.hideSlogan = true`, + `config.showMenuLabels = false`, `config.disabledApps` with both app ids, + `customOverridesCss` equal to the current `custom-overrides.css` content, and one + `customTokenSets` entry with the set's metadata and full CSS + +#### Scenario: Telemetry and platform-owned values are excluded + +- GIVEN `theming_syncs_total` is `7` and Nextcloud's `theming` app has a primary color set +- WHEN the bundle is exported +- THEN the bundle MUST NOT contain the sync counter, `installed_version`, any `preview_*` user + value, or any `theming` app value + +#### Scenario: The declared environment is not exported + +- GIVEN a server with `thematiq.environment` set to `test` +- WHEN an administrator exports the bundle with `occ nldesign:config:export` +- THEN the bundle MUST NOT contain the environment value or any key naming it + + diff --git a/openspec/changes/governance-environment-marker/specs/environment-marker/spec.md b/openspec/changes/governance-environment-marker/specs/environment-marker/spec.md new file mode 100644 index 00000000..6e8b7ee4 --- /dev/null +++ b/openspec/changes/governance-environment-marker/specs/environment-marker/spec.md @@ -0,0 +1,93 @@ +# Spec delta: environment marker (environment-marker) + +A new capability. A server declares which OTAP environment it is, and every page that is not production says so. + +## ADDED Requirements + +### Requirement: The environment is declared per server + +The app MUST read the environment from the system value `thematiq.environment` in `config.php`, with the allowed values `development`, `test`, `acceptance` and `production`. The app MUST NOT store the environment in app config or in any database table, so that a database copied from one environment to another never carries the source environment's label. + +#### Scenario: A restored production database does not label test as production + +- GIVEN a test server with `thematiq.environment` set to `test` in its `config.php` +- AND a database restored from production +- WHEN a user opens the Files app on the test server +- THEN the page MUST show the "Test environment" label + +#### Scenario: An unset value shows nothing + +- GIVEN a server with no `thematiq.environment` in `config.php` +- WHEN a user opens any page +- THEN the page MUST NOT show an environment stripe or label + +### Requirement: Every non-production page is marked + +When the environment is `development`, `test` or `acceptance`, every page Nextcloud renders MUST show a stripe above the header with a text label naming the environment, and the page title MUST start with the short label. This MUST hold on the login page, on public share pages, on pages of apps excluded from theming, and when the design system is `none`. When the environment is `production` the app MUST NOT render a marker. + +#### Scenario: A user on acceptance sees the label on the login page + +- GIVEN a server with `thematiq.environment` set to `acceptance` +- WHEN a user opens the login page +- THEN the page MUST show the text "Acceptance environment" above the login form +- AND the browser tab title MUST start with "[Acceptance]" + +#### Scenario: An app excluded from theming still shows the marker + +- GIVEN a server with `thematiq.environment` set to `test` +- AND the Calendar app excluded from theming in Settings > Administration > Theming +- WHEN a user opens the Calendar app +- THEN the page MUST show the "Test environment" label +- AND the Calendar page MUST otherwise render unthemed as before + +#### Scenario: Production shows nothing + +- GIVEN a server with `thematiq.environment` set to `production` +- WHEN a user opens the dashboard +- THEN the page MUST NOT contain the environment stripe +- AND the browser tab title MUST NOT carry an environment prefix + +### Requirement: An unknown value is shown, not hidden + +A value outside the allowed list MUST render the label "Unknown environment" with the non-production styling, and the app MUST log a warning naming the value. A typo MUST NOT make a server look like production. + +#### Scenario: A typo in config.php stays visible + +- GIVEN a server with `thematiq.environment` set to `tset` +- WHEN a user opens the dashboard +- THEN the page MUST show the label "Unknown environment" +- AND the Nextcloud log MUST contain a warning naming the value `tset` + +### Requirement: The marker is accessible and independent of the house style + +The label MUST be text inside an element with `role="note"`, placed before the header in the page order. Each environment MUST use one fixed stripe colour that does not come from the active token set, and the label text MUST reach a contrast of 4.5:1 against the stripe in light and in dark mode. + +#### Scenario: A screen reader user hears the environment first + +- GIVEN a server with `thematiq.environment` set to `test` +- WHEN a screen reader user opens the Files app +- THEN the first note in the page order MUST read "Test environment" + +#### Scenario: A brand colour cannot hide the stripe + +- GIVEN the active token set's primary colour is the same colour as the test stripe +- WHEN a user opens the dashboard on a test server +- THEN the stripe MUST keep its fixed colour and label +- AND the label MUST still reach 4.5:1 against the stripe + +### Requirement: The settings page shows the environment + +Settings > Administration > Theming MUST show the environment the server declares, or state that none is set together with the `occ config:system:set thematiq.environment --value=` command. The page MUST NOT offer a control that writes the value. + +#### Scenario: An administrator sees which environment the server is + +- GIVEN a server with `thematiq.environment` set to `acceptance` +- WHEN an administrator opens Settings > Administration > Theming +- THEN the theming section MUST show "Environment: acceptance" + +#### Scenario: An administrator learns how to set it + +- GIVEN a server with no `thematiq.environment` value +- WHEN an administrator opens Settings > Administration > Theming +- THEN the theming section MUST state that no environment is set +- AND it MUST show the occ command that sets it diff --git a/openspec/changes/governance-environment-marker/tasks.md b/openspec/changes/governance-environment-marker/tasks.md new file mode 100644 index 00000000..0c7e4ed8 --- /dev/null +++ b/openspec/changes/governance-environment-marker/tasks.md @@ -0,0 +1,24 @@ +# Tasks: environment marker + +Tick a box when the work is merged to `development`. + +## 1. Service and injection + +- [ ] 1.1 Add `lib/Service/EnvironmentMarkerService.php`: reads `thematiq.environment` through `IConfig::getSystemValueString()`, maps it to a label key and a styling class, fails open. Verify: `tests/Unit/Service/EnvironmentMarkerServiceTest.php` covers each allowed value, unset, `production` and an unknown value. +- [ ] 1.2 Call it from `ThemeInjectionListener::handle()` before the per-app exclusion guard, for the login and the template events. Verify: `tests/Unit/Listener/ThemeInjectionListenerTest.php` asserts the marker is emitted for an excluded app and for the login context, and not emitted when the value is `production`. +- [ ] 1.3 Add `js/environment-marker.js` and `css/environment-marker.css` (stripe with `role="note"`, text label, title prefix), following `js/preview-banner.js`. Verify: the stripe does not overlap header controls at 320 px width and at 200 percent zoom. + +## 2. Settings page + +- [ ] 2.1 `templates/settings/admin.php`: a read-only line under the theming heading showing the current environment, or "not set" with the `occ config:system:set` command. Verify: Playwright scenario "An administrator sees which environment the server is". + +## 3. Configuration bundle + +- [ ] 3.1 Keep `thematiq.environment` out of `ConfigBundleService::export()` and ignore it on import. Verify: `tests/Unit/Service/ConfigBundleServiceTest.php` asserts an exported bundle has no environment key. + +## 4. Quality + +- [ ] 4.1 Contrast: each stripe colour and its label reach 4.5:1 in light and dark mode; record the ratios in the test. Verify: a unit test runs `ContrastService` over the fixed pairs. +- [ ] 4.2 l10n: the three labels, "Unknown environment" and the settings line in `l10n/en.json` and `l10n/nl.json`. Verify: `npm run test:l10n`. +- [ ] 4.3 Docs: a section in `docs/` on declaring the environment, with the Helm or `config.php` example. Verify: the docs build. +- [ ] 4.4 Playwright: on a server with `thematiq.environment=test`, the login page and the Files app show the label and the title prefix. Verify: `tests/e2e/environment-marker.spec.ts`. diff --git a/openspec/changes/governance-theme-as-code/.openspec.yaml b/openspec/changes/governance-theme-as-code/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/governance-theme-as-code/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/governance-theme-as-code/design.md b/openspec/changes/governance-theme-as-code/design.md new file mode 100644 index 00000000..1c1d3ed0 --- /dev/null +++ b/openspec/changes/governance-theme-as-code/design.md @@ -0,0 +1,59 @@ +# Design: theme as code + +## Where it fits (development b4e7568) + +- `lib/Service/ConfigBundleService.php:241` `export()` and `:309` `import(array $bundle, bool $dryRun)`: validate every section, then write; any hard failure writes nothing. Fonts are exported as metadata only and never applied on import (`:50-57`, `binariesIncluded => false` at `:262` and `:865`). +- `lib/Controller/ConfigBundleController.php:56` caps a web upload at 256 KB, which is why fonts were left out. +- `lib/Command/ConfigExport.php:69` `nldesign:config:export [file]` and `lib/Command/ConfigImport.php:73` `nldesign:config:import [--dry-run]`; `openspec/specs/config-portability/spec.md:93-126` requires both to reuse `ConfigBundleService`. +- `lib/Service/FontService.php`: fonts live as `fonts/custom-.woff2` in app data (`:39`, `:77`), with a manifest in app config `custom_fonts` (`:56`) and a revision key (`:63`). +- `lib/Service/TokenSetConverterService.php` converts DTCG and other theme inputs into a token set (open change `nlds-theme-converter` widens it). +- Repair steps are registered in `appinfo/info.xml:182-233`; `lib/BackgroundJob/UpstreamFreshnessJob.php` is the job pattern. +- `lib/Service/ThemingAuditService.php` action `config_imported` is already in the vocabulary. + +## Decisions + +### 1. A package is a directory first + +``` +branding/ + bundle.json the existing bundle, unchanged format + fonts/.woff2 one file per customFonts entry + tokens/.json optional DTCG sources, converted on apply + REVISION optional, the Git commit the directory came from +``` + +A directory diffs well in Git and mounts as a volume. A ZIP of the same tree is accepted wherever a directory is, for people who move files by hand. The web upload keeps its 256 KB cap for a bare bundle; a package goes through occ or declarative mode, which have no such cap. + +`tokens/.json` lets designers keep DTCG in Git instead of generated CSS: on apply, each source runs through `TokenSetConverterService` and replaces the custom set of the same id in the bundle before validation. + +### 2. Fonts are applied when their files are present + +For a package, the `customFonts` section is applied: every font file is checked by the existing font validator, stored through `FontService`, and assigned its role. A manifest entry without a file in the package is a hard error, because it would serve a broken `@font-face` URL. A bare bundle (no package) keeps today's behaviour: font metadata is informational. + +### 3. Declarative mode reads a path, it never pulls + +`thematiq.config_source` in `config.php` names a directory or ZIP. Thematiq never talks to Git or any remote: the deployment tool (Argo CD, Helm with a ConfigMap or an init container, Ansible) puts the checkout on disk. That keeps credentials and network egress out of the app, and keeps the upstream-freshness non-goal intact (no downloads by background jobs). + +Rejected: a Git URL and a ref that thematiq clones itself. It needs Git binaries or a PHP Git client, credentials in app config, and outbound traffic from a background job. + +### 4. Apply on change, validate first + +`ConfigSourceService::applyIfChanged()` hashes the package (sorted file list and contents) and compares it with `config_source_applied_hash`. When they differ it runs the package import. On success it stores the hash and writes a `config_imported` audit entry with actor `system`, context `source: deployment` and the `REVISION` when present. On failure it writes nothing, keeps the old hash, stores the error listing in `config_source_last_error` and logs an error. It runs from a post-migration repair step (so every upgrade applies it), from a background job every 5 minutes, and from `occ nldesign:config:apply` (exit non-zero on failure, for pipelines). + +### 5. Managed notice, drift and an optional lock + +While `thematiq.config_source` is set, the configuration bundle block states the source path, the last applied revision and time, and the last error if any. Drift is shown when `export()` of the running configuration differs from the package's bundle, so an administrator sees that a web change will be replaced on the next apply. `thematiq.config_source_lock = true` makes every configuration setter answer 423 with a message naming the source, and the settings page disables its controls. Lock is off by default, because some teams want the package as a baseline and small web fixes on top. + +### 6. Export for the round trip + +`occ nldesign:config:export --package ` writes the tree above from the running configuration, fonts included, so a team can start their Git repository from what they have today. + +## Risks + +- A failing package on every job run fills the log. The error is logged once per distinct package hash. +- Two replicas applying at once: the apply takes the Nextcloud lock `thematiq-config-source` so one runs at a time. + +## Out of scope + +- Pull request review itself: that is the Git host's job. +- Per-environment differences inside one package. The environment marker (change `governance-environment-marker`) is what differs, and it lives in `config.php`. diff --git a/openspec/changes/governance-theme-as-code/proposal.md b/openspec/changes/governance-theme-as-code/proposal.md new file mode 100644 index 00000000..3a87d3f5 --- /dev/null +++ b/openspec/changes/governance-theme-as-code/proposal.md @@ -0,0 +1,65 @@ +# Theme as code: a branding package in Git, applied from deployment configuration + +## Why + +Operations teams that run Nextcloud with Helm or Ansible version everything in Git and review it through pull requests. The house style is the exception. It lives in the database and moves between environments as a JSON bundle that an administrator downloads and uploads by hand, and that bundle leaves the fonts behind: the administrator re-uploads every font on each environment (`lib/Service/ConfigBundleService.php:50-57`). + +Three rows ask for the same thing from three sides. openDesk and Tokens Studio rate yes on declaring the theme in deployment configuration and on keeping the token source in Git; Nextcloud Theming, Microsoft 365 and Liferay rate partial. Liferay's roadmap asks for one branding package that applies itself. + +#### Row `gov-config-as-code` (thematiq matrix, area governance) + +- Capability: Declare the theme in deployment configuration (for example Helm values) so it is versioned with the infrastructure. +- Own rating: no; built.state `none`. Built evidence: grep -rli 'helm|values.yaml|config-as-code|infrastructure as code' across the repo (docs, openspec, appinfo) found only two openspec files, and reading them showed the 'helm' hit was a substring match inside the unrelated word 'overwhelm' (openspec/specs/lasuite-parity/spec.md:172); no Helm chart, values file, Kubernetes manifest or IaC integration exists anywhere in this repo +- Nextcloud Theming (built-in app) rated `partial`: nextcloud/server@v35.0.1 config/config.sample.php:2463-2484 keys theme, enforce_theme and theming.standalone_window.enabled live in config.php, and occ theming:config (apps/theming/lib/Command/UpdateConfig.php) can be run from Helm or Ansible hooks, but colours, name and images live in the database, not declarative config +- Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) rated `partial`: https://microsoft365dsc.com/resources/sharepoint/SPOTheme/: SPOTheme is a declarative Microsoft365DSC resource (Name, IsInverted, Palette) that can be versioned with infrastructure; Entra sign-in branding and the M365 org theme have Graph APIs but no declarative resource in that list +- openDesk theming rated `yes`: opendesk@v1.18.2 helmfile/environments/default/theme.yaml.gotmpl:13-154 and docs/theming.md:18: the theme is Helm/helmfile values +- Liferay DXP (style books, themes, client extensions) rated `partial`: https://learn.liferay.com/w/dxp/development/customizing-liferays-look-and-feel/using-a-theme-css-client-extension : the theme CSS and token definition are declared in client-extension.yaml and deployed with Gradle or lcp deploy, but assigning it to pages and all style book values are UI or database state; https://learn.liferay.com/w/dxp/development/configuration-as-code/instance-settings-yaml-configuration-reference lists no style book PID +- Tokens Studio (Figma plugin and platform) rated `yes`: tokens-studio/figma-plugin@2.12.1 packages/tokens-studio-for-figma/src/constants/StorageProviderType.ts:5-10: tokens and $themes live as JSON in a git repository; the Studio CLI keeps the consumed version in .studio.json and studio.lock in the codebase (https://documentation-v2.tokens.studio/cli/overview.html) + +#### Row `aut-git-sync` (thematiq matrix, area authoring) + +- Capability: Store and version the token source in a Git repository with pull requests. +- Own rating: no; built.state `none`. Built evidence: grep -rniF for a git-backed token source (pull request workflow, git clone/push in lib/Service or lib/Command) found nothing; token sets are uploaded as files (CustomTokenSetController.php:203) or shipped as static CSS in css/tokens/, never sourced from a Git remote +- Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) rated `partial`: https://microsoft365dsc.com/resources/sharepoint/SPOTheme/: the Microsoft-led open-source Microsoft365DSC declares SharePoint themes (SPOTheme with Palette) as configuration that can be versioned and deployed, with an Azure DevOps integration guide; Entra sign-in branding and the M365 org theme are not among its resources, and there is no built-in git flow +- openDesk theming rated `yes`: opendesk@v1.18.2: the token source is plain files in the helmfile repository (theme.yaml.gotmpl, helmfile/files/theme/**), and docs/enhanced-configuration/gitops.md documents deploying from Git with Argo CD +- Liferay DXP (style books, themes, client extensions) rated `partial`: https://learn.liferay.com/w/dxp/development/customizing-liferays-look-and-feel/using-a-theme-css-client-extension : the theme CSS and its frontend-token-definition.json live in a Liferay Workspace project that can be versioned in Git; style book values live in the database and leave only as ZIP or LAR exports (https://learn.liferay.com/w/dxp/sites/site-appearance/style-books/exporting-and-importing-style-books) +- Tokens Studio (Figma plugin and platform) rated `yes`: tokens-studio/figma-plugin@2.12.1 packages/tokens-studio-for-figma/src/constants/StorageProviderType.ts:5-10 GitHub, GitLab, Azure DevOps, Bitbucket storage (storage/GithubTokenStorage.ts etc.); app/components/PushDialog.tsx:46-60 links to the provider's create-pull-request page +- Rated no or unknown: Nextcloud Theming (built-in app) `no` + +#### Row `app-branding-package` (thematiq matrix, area apply) + +- Capability: Ship the tokens, templates and the theme stylesheet as one branding package that applies itself when a site is connected. +- Own rating: partial; built.state `built`. Built evidence: lib/Service/ConfigBundleService.php (header comment) exports every instance-wide thematiq value as one bundle for OTAP promotion and applies it on import, but font binaries are not embedded (metadata only; the admin re-uploads fonts on the target), so the package is not complete; overlaps gov-otap-bundle, kept for the self-applying package angle +- Demand: roadmap at https://liferay.atlassian.net/browse/LPD-91941 (moving a full house style between environments or tenants in one unit is what OTAP governance needs) +- Rated no or unknown: Nextcloud Theming (built-in app) `unknown`, Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) `unknown`, openDesk theming `unknown`, Liferay DXP (style books, themes, client extensions) `no`, Tokens Studio (Figma plugin and platform) `unknown` + +## What changes + +- A branding package: a directory (or a ZIP of it) holding the existing `bundle.json`, the font files, and optionally DTCG token sources that thematiq converts on apply. `occ nldesign:config:export --package ` writes one; `occ nldesign:config:import ` applies one, fonts included. +- Declarative mode: `thematiq.config_source` in `config.php` names a package path, for example a volume that Argo CD or Helm fills from Git. Thematiq applies it after upgrades, from a background job, and on `occ nldesign:config:apply`, whenever its content hash changes. +- A failed apply changes nothing and says so on the settings page. A successful apply is audited with the package's Git revision when the checkout provides one. +- While a config source is set, the settings page says the house style is managed from deployment configuration and shows whether the running configuration has drifted from it. An optional lock makes the page read-only. + +## Capabilities + +### New capabilities + +- `theme-as-code`: branding packages, declarative apply, drift and lock. + +### Modified capabilities + +- `config-portability`: a package carries font binaries, and an import from a package applies them. + +## Impact + +- `lib/Service/ConfigBundleService.php`: package read and write around the existing `export()` (`:241`) and `import()` (`:309`); fonts through `lib/Service/FontService.php` (app data folder `fonts`, `:77`) and its validator. +- `lib/Command/ConfigExport.php`, `lib/Command/ConfigImport.php` (`:69-140`): package support; new `lib/Command/ConfigApply.php`. +- New `lib/Service/ConfigSourceService.php`, a repair step next to the ones in `appinfo/info.xml:182-233`, and a background job. +- `templates/settings/admin.php`, configuration bundle block (`:587`): managed-by notice, drift, lock. +- Docs: a Helm and Argo CD example. + +## Rows + +- `gov-config-as-code` (thematiq matrix). +- `aut-git-sync` (thematiq matrix). The row had no `built.owner`; this pass fills it as `ConductionNL/thematiq`. +- `app-branding-package` (thematiq matrix): the missing half, fonts in the package and applying it from deployment configuration. diff --git a/openspec/changes/governance-theme-as-code/specs/config-portability/spec.md b/openspec/changes/governance-theme-as-code/specs/config-portability/spec.md new file mode 100644 index 00000000..9eadbe3b --- /dev/null +++ b/openspec/changes/governance-theme-as-code/specs/config-portability/spec.md @@ -0,0 +1,22 @@ +# Spec delta: configuration portability (config-portability) + +A package carries font binaries, so an import from a package applies fonts too. + +## ADDED Requirements + +### Requirement: A package import applies fonts + +When the import source is a branding package, the `customFonts` section MUST be applied: every font file MUST pass the existing font validator and be stored through `FontService` with its role. A `customFonts` entry without a matching file in the package MUST be a hard validation error, so no manifest entry points at a missing file. When the source is a bare bundle file, font metadata MUST stay informational, as before. + +#### Scenario: A package with a missing font file is refused whole + +- GIVEN a package whose bundle names the font `custom-corporate` but has no `fonts/custom-corporate.woff2` +- WHEN an operator runs `occ nldesign:config:import` on it +- THEN the command MUST exit non-zero, name the missing file and write nothing + +#### Scenario: A bare bundle keeps its old font behaviour + +- GIVEN a bundle file uploaded on Settings > Administration > Theming with two fonts in its metadata +- WHEN the import completes +- THEN no font MUST be added or removed +- AND the import result MUST state that fonts in a bare bundle are informational diff --git a/openspec/changes/governance-theme-as-code/specs/theme-as-code/spec.md b/openspec/changes/governance-theme-as-code/specs/theme-as-code/spec.md new file mode 100644 index 00000000..6596aeef --- /dev/null +++ b/openspec/changes/governance-theme-as-code/specs/theme-as-code/spec.md @@ -0,0 +1,73 @@ +# Spec delta: theme as code (theme-as-code) + +A new capability. The house style is a package that can live in Git and is applied from deployment configuration. + +## ADDED Requirements + +### Requirement: A branding package holds the whole house style + +A branding package MUST be a directory, or a ZIP of one, holding `bundle.json` in the existing bundle format, `fonts/.woff2` for every font in the bundle, optional `tokens/.json` DTCG sources, and an optional `REVISION` file. `occ nldesign:config:export --package ` MUST write a package of the running configuration, fonts included. `occ nldesign:config:import` MUST accept a package directory or ZIP as well as a bare bundle file. + +#### Scenario: An operator moves the house style with its fonts + +- GIVEN a test server with a custom heading font and a custom token set +- WHEN an operator runs `occ nldesign:config:export --package /srv/branding` there and `occ nldesign:config:import /srv/branding` on production +- THEN production MUST render headings in the custom font +- AND no administrator MUST re-upload the font + +#### Scenario: A DTCG source in the package is converted on apply + +- GIVEN a package whose `tokens/custom-gemeente.json` holds a DTCG document +- WHEN the package is imported +- THEN the custom set `custom-gemeente` MUST hold the converted values from that document + +### Requirement: The server applies the package named in config.php + +When `thematiq.config_source` in `config.php` names a package path, the app MUST apply the package whenever its content hash differs from the last applied hash: after every upgrade, from a background job that runs at least every five minutes, and when an operator runs `occ nldesign:config:apply`. The app MUST NOT fetch the package from a network location; the deployment puts it on disk. + +#### Scenario: A merged pull request reaches production + +- GIVEN production with `thematiq.config_source` pointing at a volume that Argo CD syncs from a Git repository +- WHEN a pull request changing the primary colour is merged and the volume is updated +- THEN within ten minutes a user who opens the dashboard MUST see the new primary colour +- AND the audit log MUST contain a `config_imported` entry with actor `system` and the commit from `REVISION` + +#### Scenario: An unchanged package is not applied again + +- GIVEN a package that was applied and has not changed +- WHEN the background job runs +- THEN the app MUST NOT write any configuration or audit entry + +### Requirement: A failing package changes nothing + +A package that fails validation MUST change nothing. The app MUST keep the running configuration, log an error once per distinct package hash, and show the error listing on Settings > Administration > Theming. `occ nldesign:config:apply` MUST exit non-zero on a failing package. + +#### Scenario: A typo in the package does not break production + +- GIVEN a running configuration and a new package whose custom token set fails the CSS validation whitelist +- WHEN the background job runs +- THEN users MUST keep seeing the running house style +- AND an administrator on Settings > Administration > Theming MUST see the validation error naming the set + +### Requirement: The settings page shows who manages the house style + +While `thematiq.config_source` is set, the configuration bundle block MUST show the source path, the last applied revision and time, the last error if any, and whether the running configuration differs from the package. When `thematiq.config_source_lock` is true, every configuration setter MUST answer 423 naming the source, and the settings page MUST disable its configuration controls. + +#### Scenario: An administrator sees the house style is managed from Git + +- GIVEN a server with `thematiq.config_source` set and an applied package with revision `3f2a9c1` +- WHEN an administrator opens Settings > Administration > Theming +- THEN the configuration bundle block MUST state that the house style is managed from deployment configuration, with the path and revision `3f2a9c1` + +#### Scenario: A web change shows as drift + +- GIVEN a managed server without the lock +- WHEN an administrator changes the token set on the settings page +- THEN the block MUST show that the running configuration differs from the package and will be replaced on the next apply + +#### Scenario: The lock refuses web changes + +- GIVEN a managed server with `thematiq.config_source_lock` true +- WHEN an administrator calls `POST /apps/thematiq/settings/tokenset` +- THEN the response MUST be 423 with a message naming the source +- AND the active token set MUST be unchanged diff --git a/openspec/changes/governance-theme-as-code/tasks.md b/openspec/changes/governance-theme-as-code/tasks.md new file mode 100644 index 00000000..63859048 --- /dev/null +++ b/openspec/changes/governance-theme-as-code/tasks.md @@ -0,0 +1,27 @@ +# Tasks: theme as code + +Tick a box when the work is merged to `development`. + +## 1. Branding package + +- [ ] 1.1 Package reader and writer in `ConfigBundleService` (directory and ZIP, `bundle.json`, `fonts/`, `tokens/`, `REVISION`). Verify: `tests/Unit/Service/ConfigBundlePackageTest.php` round-trips a package with two fonts and one DTCG source. +- [ ] 1.2 Apply fonts from a package through `FontService` and its validator; a manifest entry without a file is a hard error. Verify: unit tests for both cases, and that a bare bundle still ignores font metadata. +- [ ] 1.3 Convert `tokens/.json` through `TokenSetConverterService` before validation. Verify: unit test with a DTCG fixture. +- [ ] 1.4 `occ nldesign:config:export --package ` and `occ nldesign:config:import `. Verify: command tests. + +## 2. Declarative mode + +- [ ] 2.1 `lib/Service/ConfigSourceService.php`: hash, apply-if-changed, error record, lock `thematiq-config-source`. Verify: unit tests for unchanged, changed and valid, changed and invalid, and a missing path. +- [ ] 2.2 Post-migration repair step, a 5-minute background job, and `occ nldesign:config:apply`. Verify: command test exits non-zero on an invalid package. +- [ ] 2.3 Audit `config_imported` with `source: deployment` and the revision. Verify: unit test. + +## 3. Settings page + +- [ ] 3.1 Managed-by notice, last revision, last error and drift in the configuration bundle block. Verify: Playwright scenario "An administrator sees the house style is managed from Git". +- [ ] 3.2 Optional lock: setters answer 423 and the controls are disabled. Verify: controller tests for two setters and a Playwright check that controls are disabled. + +## 4. Quality + +- [ ] 4.1 Docs: a Helm values example with a ConfigMap, an Argo CD example with a Git source, and the Git repository layout. Verify: the docs build. +- [ ] 4.2 l10n en and nl. Verify: `npm run test:l10n`. +- [ ] 4.3 Apply a package holding an incomplete token set and a dark-mode set. Verify: manual check recorded in the PR. diff --git a/openspec/changes/integration-occ-set-theme/.openspec.yaml b/openspec/changes/integration-occ-set-theme/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/integration-occ-set-theme/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/integration-occ-set-theme/design.md b/openspec/changes/integration-occ-set-theme/design.md new file mode 100644 index 00000000..4820328b --- /dev/null +++ b/openspec/changes/integration-occ-set-theme/design.md @@ -0,0 +1,32 @@ +# Design: set the active theme from the command line + +## Where it fits (development b4e7568) + +- `lib/Controller/SettingsController.php:226-243` `setTokenSet()`: checks `TokenSetService::isValidTokenSet()` (`lib/Service/TokenSetService.php:504`), writes app config `token_set`, logs `token_set_changed` with old and new. +- `lib/Service/TokenSetService.php:226` `getAvailableTokenSets()` merges shipped and custom sets. +- `lib/Service/GroupThemingService.php:172` `setMapping()` holds the group mappings shown by `get`. +- `lib/Service/ThemingService.php:189` `applyColors()` and `:233` `applyImages()` are what the theming-sync dialog calls to push a set's theming metadata into Nextcloud core theming. +- `lib/Service/ConfigBundleService.php:900` sets `token_set` as one field of a full bundle import, the only command-line path today (`lib/Command/ConfigImport.php`). +- Commands are registered in `appinfo/info.xml:235-238` under the `nldesign:` prefix; the open change `rename-nldesign-to-themiq` will rename the prefix for all commands together. + +## Decisions + +### 1. One write path + +`TokenSetService::activate(string $id, string $actor)` takes over the body of `setTokenSet()`: validate, write, audit. The controller and the command both call it. An operator then gets exactly the validation and audit an administrator gets. + +### 2. Core sync is explicit + +The web path shows the theming-sync dialog. A script cannot answer a dialog, so `--sync-core` applies the set's theming metadata (primary colour, background, logo) through `ThemingService`, and without the flag core theming stays as it is. The output says which of the two happened. + +### 3. The prefix follows the other commands + +`nldesign:theme:*` matches `nldesign:config:*` today. The rename change moves all commands at once; this change does not introduce a second prefix. + +## Risks + +- A script that switches the set on every run floods the audit log. `set` to the set that is already active is a no-op that exits 0 and writes no entry. + +## Out of scope + +- Setting group mappings from the command line. `nldesign:config:import` covers them as part of a bundle. diff --git a/openspec/changes/integration-occ-set-theme/proposal.md b/openspec/changes/integration-occ-set-theme/proposal.md new file mode 100644 index 00000000..18da0897 --- /dev/null +++ b/openspec/changes/integration-occ-set-theme/proposal.md @@ -0,0 +1,42 @@ +# Set the active theme from the command line + +## Why + +An operator who wants to switch the house style from a script has to build a whole configuration bundle with the desired token set in it and import that. The bundle import is meant for promoting a complete configuration between environments, not for one field. Microsoft 365 (Graph and PnP PowerShell) and openDesk (Helm values) set the theme with one command; Nextcloud Theming and Liferay do it partly. + +#### Row `int-cli-set-theme` (thematiq matrix, area integration) + +- Capability: Set the active theme from the command line. +- Own rating: partial; built.state `built`. Built evidence: lib/Service/ConfigBundleService.php:900 import() does $this->config->setAppValue(Application::APP_ID, 'token_set', $config['tokenSet']), so occ nldesign:config:import (lib/Command/ConfigImport.php) does set the active token set as one field of a full bundle import; there is no single-purpose 'occ nldesign:set-theme ' command +- Nextcloud Theming (built-in app) rated `partial`: nextcloud/server@v35.0.1 apps/theming/lib/Command/UpdateConfig.php:22-24 sets name, colours, URLs and images from the CLI and config/config.sample.php:2471-2476 enforce_theme picks a built-in theme instance-wide via occ config:system:set; there is no house-style id to set +- Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) rated `yes`: https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.directorymanagement/update-mgorganizationbranding : Update-MgOrganizationBranding (Microsoft Graph PowerShell) and PnP PowerShell cmdlets let you set branding/site theme values from the command line +- openDesk theming rated `yes`: opendesk@v1.18.2 docs/getting-started.md:455 helmfile apply sets the theme from values +- Liferay DXP (style books, themes, client extensions) rated `partial`: https://learn.liferay.com/w/dxp/development/customizing-liferays-look-and-feel/using-a-theme-css-client-extension : theme CSS client extensions deploy from the command line (gradlew deploy, lcp deploy), and style books can be created over the headless API (https://liferay.atlassian.net/browse/LPD-101910), but activating a theme for a site from a CLI is not documented +- Rated no or unknown: Tokens Studio (Figma plugin and platform) `no` + +## What changes + +- `occ nldesign:theme:list` lists the available token sets with id, name and source (shipped or custom). +- `occ nldesign:theme:get` prints the active token set and the group mappings. +- `occ nldesign:theme:set [--sync-core] [--dry-run]` validates the id like the settings dropdown, switches the instance-wide set, optionally applies the set's theming metadata to Nextcloud core theming, and writes an audit entry with actor `cli`. +- Exit codes are fit for scripts: 0 on success, non-zero with a message on an unknown set. + +## Capabilities + +### New capabilities + +- `theme-cli`: the three commands. + +### Modified capabilities + +- None. + +## Impact + +- New `lib/Command/ThemeList.php`, `lib/Command/ThemeGet.php`, `lib/Command/ThemeSet.php`, registered in `appinfo/info.xml` (`:235-238`). +- The token set write moves from `SettingsController::setTokenSet()` (`:226-243`) into a service method both use, so the command cannot drift from the web path. The change `apply-scheduled-theme-switch` needs the same method; whichever lands first adds it. +- `lib/Service/ThemingService.php` `applyColors()` (`:189`) and `applyImages()` (`:233`) for `--sync-core`. + +## Rows + +- `int-cli-set-theme` (thematiq matrix): the missing half, a single-purpose command. diff --git a/openspec/changes/integration-occ-set-theme/specs/theme-cli/spec.md b/openspec/changes/integration-occ-set-theme/specs/theme-cli/spec.md new file mode 100644 index 00000000..65f18ce8 --- /dev/null +++ b/openspec/changes/integration-occ-set-theme/specs/theme-cli/spec.md @@ -0,0 +1,46 @@ +# Spec delta: theme command line (theme-cli) + +A new capability. Operators list, read and switch the active token set with occ. + +## ADDED Requirements + +### Requirement: Operators list and read token sets + +`occ nldesign:theme:list` MUST print every available token set with its id, name and whether it is shipped or custom. `occ nldesign:theme:get` MUST print the active instance-wide token set and every group mapping in priority order. + +#### Scenario: An operator looks up the id of a set + +- GIVEN a server with the shipped sets and one custom set `custom-gemeente-x` +- WHEN an operator runs `occ nldesign:theme:list` +- THEN the output MUST list `custom-gemeente-x` marked as custom +- AND the command MUST exit 0 + +### Requirement: Operators switch the active token set + +`occ nldesign:theme:set ` MUST validate the id exactly as the Design token set dropdown does, make it the active instance-wide set, and write a `token_set_changed` audit entry with actor `cli`. An unknown id MUST exit non-zero, name the id and change nothing. Setting the set that is already active MUST exit 0 and write no audit entry. `--dry-run` MUST validate and print what would change without writing. + +#### Scenario: An operator switches the house style from a script + +- GIVEN `rijkshuisstijl` is active +- WHEN an operator runs `occ nldesign:theme:set amsterdam` +- THEN the command MUST exit 0 +- AND a user who opens the dashboard next MUST see the Amsterdam colours +- AND the audit log on Settings > Administration > Theming MUST show a token set change by `cli` + +#### Scenario: A typo does not change the theme + +- GIVEN `rijkshuisstijl` is active +- WHEN an operator runs `occ nldesign:theme:set amsterdm` +- THEN the command MUST exit non-zero with a message naming `amsterdm` +- AND `rijkshuisstijl` MUST stay active + +### Requirement: Core theming is changed only on request + +Without `--sync-core`, `nldesign:theme:set` MUST NOT change Nextcloud core theming values. With `--sync-core`, it MUST apply the set's theming metadata (primary colour, background, logo) the way the theming-sync dialog does, and the output MUST say which values were applied. + +#### Scenario: An operator also updates the Nextcloud logo and colours + +- GIVEN a token set whose theming metadata has a primary colour and a logo +- WHEN an operator runs `occ nldesign:theme:set --sync-core` +- THEN the Nextcloud core primary colour and logo MUST match the set's metadata +- AND the output MUST list the primary colour and logo as applied diff --git a/openspec/changes/integration-occ-set-theme/tasks.md b/openspec/changes/integration-occ-set-theme/tasks.md new file mode 100644 index 00000000..cf4c20ef --- /dev/null +++ b/openspec/changes/integration-occ-set-theme/tasks.md @@ -0,0 +1,17 @@ +# Tasks: set the active theme from the command line + +Tick a box when the work is merged to `development`. + +## 1. Shared write path + +- [ ] 1.1 Move the body of `SettingsController::setTokenSet()` into `TokenSetService::activate($id, $actor)`; the controller calls it. Verify: `SettingsControllerTest` stays green and a new `TokenSetServiceTest` case covers invalid id, unchanged id (no audit entry) and a switch. + +## 2. Commands + +- [ ] 2.1 `nldesign:theme:list` and `nldesign:theme:get`. Verify: `tests/Unit/Command/ThemeListTest.php` and `ThemeGetTest.php` assert the table columns and the group mapping output. +- [ ] 2.2 `nldesign:theme:set [--sync-core] [--dry-run]`, registered in `appinfo/info.xml`. Verify: `tests/Unit/Command/ThemeSetTest.php` for success, unknown id (non-zero exit), `--dry-run` (no write) and `--sync-core` (calls `ThemingService`). + +## 3. Quality + +- [ ] 3.1 Docs: the three commands in `docs/` with a scripting example. Verify: the docs build. +- [ ] 3.2 Switch to an incomplete custom set from the command line and check the page falls back to defaults. Verify: manual check recorded in the PR. diff --git a/openspec/changes/nlds-theme-converter/design.md b/openspec/changes/nlds-theme-converter/design.md new file mode 100644 index 00000000..67f56695 --- /dev/null +++ b/openspec/changes/nlds-theme-converter/design.md @@ -0,0 +1,301 @@ +# Design: NLDS theme to Nextcloud token set converter + +The vocabulary audit defined *correct* and measured who fails; this change is the +machine that makes them correct, and the admin-facing path that does the same for a theme nobody has +seen before. + +## Baseline (measured 2026-09-09, `npm run audit:token-sets`) + +| Verdict | Count | Sets | +|---|---|---| +| complete | 7 | amsterdam, conduction, denhaag, rotterdam, utrecht, vng, zwolle | +| incomplete | 39 | the allow-list in `tests/Unit/fixtures/token-set-vocabulary-allowlist.json` | +| not audited | 2 | nextcloud (design system `none`), summer-breeze (own design-system layer) | + +Shape of the 39, because it decides the converter's primary input: + +| Missing required tokens | Sets | Note | +|---|---|---| +| 26 of 26 | 25 | raw upstream dumps: palette present, semantic layer absent | +| 25 | 1 | westervoort | +| 24 | 3 | dinkelland, noordwijk, tubbergen | +| 22 | 2 | leiden, noaberkracht | +| 21 | 1 | xxllnc — also the only `primaryMismatch` | +| 15 | 1 | conduction-new | +| 10 | 3 | cunningham, lasuite, frankendesk (non-nldesign design systems) | +| 6 | 1 | hoog-contrast | +| 4 | 1 | opencatalogi | +| 0 | 1 | rijkshuisstijl — 1 foreign name only | + +30 of the 39 declare no `--nldesign-color-primary` at all. None of them are empty files: the foreign +name counts run from 2 (`groningen`) to 959 (`nijmegen`). **The brand data is already in the +repository under the wrong names.** That is why input D (below) is the input that closes the +allow-list, not input A. + +## Decision 1: the mapping table is data, in one file, hashed into the output + +`scripts/mapping/nlds-to-nextcloud.json` holds plan Appendix A as an ordered rule list: + +```json +{ + "version": 1, + "converterVersion": "1.0.0", + "rules": [ + { + "target": "--nldesign-color-primary", + "nextcloud": ["--color-primary", "--color-primary-element"], + "sources": ["--utrecht-button-primary-action-background-color", "--{p}-color-primary"], + "fallback": { "kind": "manifest", "path": "theming.primary_color" }, + "transform": "copy", + "guard": { "kind": "contrast", "against": "--nldesign-color-primary-text", "min": 4.5, + "onFail": "contrast-adjusted" } + } + ], + "never": [ + { "match": "--utrecht-page-max-inline-size", "action": "skip", + "reason": "layout-fixed-by-nextcloud" } + ], + "reasons": { "layout-fixed-by-nextcloud": "Page width and paddings were not applied. ..." } +} +``` + +As built: 43 rules covering all 26 required semantic tokens plus the derived radius, `-rgb`, focus +and manifest targets; 26 `never` entries; 18 reason codes. `nextcloud` is documentation — the +Nextcloud variables the target feeds through `overrides.css` — carried in the table so the report and +stage 5's playground can name them without a second lookup table. + +`fallback` is always an object with a `kind`: `manifest` (a `token-sets.json` path), `literal` (a +fixed value), `derive` (another target plus a transform), or `ramp` (pick a step from the brand's +neutral ramp by `darkest`, `minContrast` or `nearestLuminance`). `never` entries carry an `action` of +`skip` (not emitted) or `keep` (emitted in section 2, counted separately), which is what keeps +"Nextcloud will not do this" distinct from "this is for NLDS components". + +**Why data and not code.** Two runtimes have to agree (decision 2), and a table in two languages +drifts on the first hotfix. The table is also the thing a reviewer argues with — "why did Zwolle's +header end up ice blue" is answered by a diff of one JSON file, not by reading two implementations. +Its SHA-256 is written into every generated file's provenance block, so a set regenerated under an +older table is visible without running anything. + +`{p}` expands to the brand prefix (decision 6). `fallback` is deliberately a different key from +`sources`: a fallback is not a token, it is where the converter goes when the theme said nothing, and +it is always reported as `adapted`, never `applied`. + +## Decision 2: three runtimes, one authority per path + +- **`lib/Service/TokenSetConverterService.php` is authoritative for anything an admin does.** The + upload and the paste both write a file through `CustomTokenSetService` and both must pass + `CustomTokenSetValidator` server-side; a browser-side conversion would either duplicate that gate + or move trust to the client. +- **`js/lib/tokenConverter.js` is authoritative for anything the repository does to itself** — + regenerating shipped sets through `scripts/convert-nlds-theme.mjs`, and the vitest fixtures. + `scripts/` in this repo is Node (`generate-tokens.mjs`, `audit-token-sets.mjs`, + `generate-brand-set.mjs`), and stage 1 already set the precedent of a Node mirror beside a PHP + service. +- **`tests/Unit/Service/TokenSetConverterParityTest.php` is what keeps them honest.** The vitest run + writes `tests/Unit/fixtures/converter/.expected.json` (css + manifest + report); the + PHPUnit test converts the same fixture and asserts byte equality on the CSS and structural equality + on the report. A drift fails both suites, not one. + +The browser module is loaded on the settings page anyway (dual-mode, like `tokenTransforms`), which +stage 5's playground needs for live re-conversion. It is not on the trust path. + +## Decision 3: nothing is sourced from upstream at convert time — the input arrives from a human + +Plan decision 2 is answered: **not sourced and not converted in CI.** No `@conduction/theme` +dependency, no vendored `dist/` copies, no fetch. Consequences, all deliberate: + +- The converter's public interface is content, not a location: a string plus a slug. Every caller + (upload, paste, CLI, playground) hands it bytes it already has. +- `.github/workflows/sync-tokens.yml` keeps doing exactly what it does today. The plan's task + "update the sync workflow to run the converter" is dropped: the nightly sync's input is the + `nl-design-system/themes` clone it already makes, and converting that clone is input C, which is + the same code path as input D over a file already in the tree — worth doing once the paste path is + proven, not before. +- **Fixtures are local.** `css/tokens/openwoo.css` (172 lines, 57 `--nldesign-*` declarations, + hand-resolved from `@conduction/theme` 2.1.0) is the reference the OpenWOO conversion is diffed + against. This originally added that `@gemeente-rotterdam/design-tokens` and + `@nl-design-system-unstable/zwolle-design-tokens` were installed dependencies supplying one DTCG + and one Style Dictionary fixture for free. **Both packages were removed in `c304a57`** — they were + consumed only by `scripts/generate-brand-set.mjs` when the brand CSS is regenerated, and the + generated `css/tokens/rotterdam.css` and `css/tokens/zwolle.css` are committed, so nothing + resolved them at runtime. That takes the free fixtures with it: whoever picks the converter up + needs to commit small hand-authored DTCG and Style Dictionary documents instead, the way + `tests/integration/fixtures/` already does. +- **Acceptance is a human paste.** The definition of done for the admin path is: paste the contents + of a real `design-tokens.css` into the panel, get a set whose header is the brand's header and + whose primary is the brand's primary, plus a report that lists the layout tokens it refused. The + automated fixtures exist to keep that working, not to replace it. + +## Decision 4: detect the input by content, in a fixed order + +The current router branches on the file name (`mapUpload()` reads `.json` / `.tokens.json`). A paste +has no name, so detection moves to the content and the file name becomes a hint only: + +1. Trimmed content starts with `{` or `[` → JSON. Parse once. If any leaf has `$value`, it is + **DTCG (input B)** → `DesignTokensMapper`, which already owns aliases, `$type` dispatch and the + suffix table. Otherwise it is a **Style Dictionary `tokens.json`** → the same walk with + `{a.b.c}` alias resolution. +2. Content contains `--` declarations inside at least one selector block: + - the block selector matches `:root` and the declarations are predominantly `--nldesign-*` → + **input D, an existing token set** (add-only mode, decision 9); + - otherwise → **input A, built theme CSS** (`.{prefix}-theme { }`, `.utrecht-theme`, or any + class-scoped block). +3. Nothing matched → 422 with the four accepted shapes named. An unrecognised paste must never be + stored as an empty set. + +Multiple blocks are merged in source order, later declarations winning, which is what the cascade +would do anyway. Media queries and `@supports` are skipped with `at-rule-not-converted`: the output +is one flat `:root`, enforced by `TokenCssShapeTest`. + +## Decision 5: the output is one file in four sections plus provenance + +Order is fixed so two runs diff cleanly and so a reader can find the layer they care about: + +1. **Brand palette**, verbatim values, renamed to `--{p}-*`. +2. **Component layer**, `--utrecht-*` / `--ams-*` / `--denhaag-*`, `var()` chains resolved to + literals so the file stands on its own. Gaps filled from the VNG role layer re-pointed at the + brand ramp (the logic in `scripts/generate-brand-set.mjs` section C, absorbed here). +3. **Semantic layer**, `--nldesign-*`, produced by the table. This is the only section Nextcloud's + chrome reads. +4. **Provenance comment**: input kind (A/B/C/D), source name and version when the input carried one, + converter version, mapping-table SHA-256, and the applied / adapted / skipped counts. + +Every section is inside the same `:root { }` block with a comment header, because +`TokenCssShapeTest` requires exactly one flat block and no at-rules. + +## Decision 6: brand prefix from the slug, palette steps always prefixed, `var()` always resolved + +The prefix is the slug (`openwoo`, `nijmegen`, `custom-acme`), not the prefix the source used. A +theme that ships `--openwoo-color-green-33` keeps that name when the slug is `openwoo` and is renamed +when it is not, because the file has to be self-consistent for `utrecht-bridge.css` and for anything +reading `--{slug}-*`. + +Raw palette steps found under `--nldesign-*` — the 39 sets' entire content — are renamed to `--{p}-*` +and reported as `adapted` / `palette-reprefixed`. This is what turns `nijmegen`'s 959 foreign names +from a defect into the brand ramp the semantic layer is derived from. + +`var(--x, fallback)` chains are resolved to literals at conversion time, up to a depth limit, with a +cycle guard. A chain that leaves the input's own vocabulary is kept verbatim and reported +`unresolved-var`. Reason: a token set is loaded on the login page and in e-mails, where the source +theme's own variables do not exist. + +## Decision 7: candidate resolution, transforms, and one contrast guard + +First matching source wins, per rule. Transforms are the closed set +`copy`, `darken(fraction)`, `mix(with, weight)`, `rgbTriplet`, `alpha(fraction)`, +`radiusScale(factor, clampPx)`, `onColor(light, dark)` and `literal(value|template)` — the same names +in both runtimes. `darken` and `mix` reuse `js/lib/tokenTransforms.js` `darkenHex()` and the mixing +`CommonThemeTrait` uses, so a derived tint matches what Nextcloud would compute for itself. +`onColor` is Nextcloud's own rule for text on a coloured plate: pick white or black by the +background's luminance, which is how `--nldesign-color-primary-text` and `-header-text` are derived +when the theme names no foreground. + +Guards are separate from transforms and run after them. Two kinds: + +- `contrast` — the target is darkened in 5% steps until the ratio is met, capped at 10 steps, and + reported `adapted` / `contrast-adjusted` with the original value in `value.from`. Applied to the + primary/primary-text pair, the header pair and text-muted on background: the pairs + `ShippedTokenSetAuditService` already audits, so the converter cannot emit a set that fails the + audit it is judged by. +- `fontAvailable` — the family is emitted either way; the guard only decides whether the report + carries `font-not-bundled` (design decision 12). + +A rule may also carry a `when` condition, which decides whether the rule runs at all — `sameColor` +(the header separator, emitted only when header and content are the same colour) and `isDark` (the +app-menu icon filter, `none` on a dark header where the hard-coded black made the icons vanish). +A condition is not a guard: a guard fixes a value, a condition decides that a value belongs in the +file at all. + +Missing `-rgb` triplets are derived, never required from the source: `rgbTriplet` of the resolved +colour. Missing `-light` / `-light-hover` are mixed from the primary. `-hover` prefers the theme's own +hover token and falls back to `darken(0.10)`, reported `adapted`. + +## Decision 8: what is never applied is a rule in the table, not a special case in code + +The `never` list carries a matcher, an action (`skip` or `keep`) and a reason code, and the converter +walks it before the rules, so a source token can only be applied if it survived the policy. 13 of the +18 codes are stage 6's admin-facing list and their copy lives in the same JSON (with `l10n/en.json` +keys for the sentence the admin reads): +`layout-fixed-by-nextcloud`, `typography-scale-locked`, `clickable-area-locked`, +`spacing-not-consumed`, `routed-to-core-theming`, `derived-by-nextcloud`, `radius-scale-derived`, +`font-not-bundled`, `contrast-separator-added`, `contrast-adjusted`, `kept-for-nlds-components`, +`external-url-blocked`, `unmapped`. + +Two of them are not refusals and must not read like one: + +- `routed-to-core-theming` — the page background goes to `token-sets.json` + `theming.background_color`, so Nextcloud paints the login page and the plain background with it, + while `--color-main-background` stays Nextcloud-owned so dark mode keeps working. +- `kept-for-nlds-components` — accordion, breadcrumb, calendar, skip-link, spotlight, data-list and + code tokens stay in section 2 for Conduction's own apps. They are kept, not applied, and are + counted separately from `skipped` so a report does not read as if a third of the theme was thrown + away. + +`unmapped` is the honest bucket: a source token with no rule and no `never` entry is reported so the +table can grow. It is a report line, never a silent drop. + +The five remaining codes describe the conversion's own mechanics rather than a policy about +Nextcloud, and are reported the same way: `palette-reprefixed` (decision 6), `unresolved-var` +(decision 6), `at-rule-not-converted` (decision 4), `kept-existing-value` (decision 9) and +`derived-from-header` (the `when`-conditioned header rules above). + +## Decision 9: hand-authored sets are re-run in add-only mode + +For input D over one of the 7 complete sets (and any custom set an admin re-converts), a value that +already exists wins and the converter may only add what is missing. Every skipped write is reported +`kept-existing-value`. Rationale: those 7 are the sets that look right, three of them were tuned by +hand against a real instance, and a converter that "improves" them turns a regeneration into a +review of somebody's judgement. The plan states the same rule ("diff must show additions only") and +it is the only way the regeneration of all 46 sets is reviewable in one pass. + +## Decision 10: `summer-breeze` gets a semantic layer like every other set + +Plan decision 4 is answered: give it the layer. Its colours live in +`css/systems/summer-breeze/`, which is why the audit reports it as not auditable — the audit reads +the set file alone, on purpose, because layering is what hides a missing brand. Rather than widen the +audit to know about design-system layers (which would also re-hide the 39), the set is converted from +its own design-system layer as input A and emits a normal token file. After that all 47 non-stock sets +are judged by one rule, and `nextcloud` stays the single documented exception because stock *is* its +brand. + +## Decision 11: one entry point, two ways in, one report shape + +`CustomTokenSetController::upload()` accepts `file` **or** `content` (plus optional `sourceName` for +the provenance block) and is unchanged from `readUpload()` onward. Everything else — slug derivation, +conversion, validation, storage, contrast warnings, the audit entry — stays exactly where it is. + +The response gains `report`: an array of `{source, target, action, reason, value}` with +`action ∈ {applied, adapted, skipped, kept}`, plus `counts`. The admin panel renders it with the +existing `groupDiagnosticsByReason()` + `buildDiagnosticsFragment()` pair that already groups DTCG +diagnostics, so the upload result, the paste result and the CLI table are the same information in +three places. The report is written next to the set as `css/tokens/.report.json` — committed for +shipped sets, gitignored for `custom-*` — so stage 5 can show per-component notes without re-running +the conversion. + +## Decision 12: the converter feeds the validator, it never replaces it + +`CustomTokenSetValidator::validateDeclarations()` stays the last gate before anything is written, and +`isForbiddenValue()` is not touched. Two widenings, both required by the emitted file: + +- accepted name prefixes gain `--utrecht-*`, `--ams-*`, `--denhaag-*` (section 2 exists for + `utrecht-bridge.css`), alongside the existing `--nldesign-*` and `--{slug}-*`; +- `url()` values are allowed only when they resolve to a local app path, which is the existing rule; + anything with a host is dropped by the converter first with `external-url-blocked`, so the + validator sees a clean file and the admin still gets told. + +Font families are taken verbatim into `--nldesign-font-family`. When the first family is neither +bundled nor uploaded (`FontService`), the report carries `font-not-bundled` and the stack falls +through to the next family — the theme is not rewritten to a font the instance has. + +## Risks + +- **A regenerated set changes a live instance's appearance.** True for all 39, and it is the bug being + fixed; the 7 complete sets are protected by decision 9. Roll-back is per set: the previous file is + one `git checkout` away and the provenance block says which table produced it. +- **`primaryMismatch` resolution can move a manifest colour.** `xxllnc` is the only current case. The + CSS wins, because the CSS is what paints the app and the audit compares against it. +- **A 959-name palette produces a large section 1.** Accepted: the file is generated, diffed by + machine, and read by section header. +- **PHP/JS parity is a promise, not a mechanism.** Mitigated by the shared table (decision 1) and the + parity test (decision 2), which is the same arrangement stage 1 used for the audit and which caught + drift there. diff --git a/openspec/changes/nlds-theme-converter/proposal.md b/openspec/changes/nlds-theme-converter/proposal.md new file mode 100644 index 00000000..39e6412c --- /dev/null +++ b/openspec/changes/nlds-theme-converter/proposal.md @@ -0,0 +1,135 @@ +--- +kind: code +--- + +## Why + +Stage 1 made "correct" mechanical. It also measured the damage: `npm run audit:token-sets` reports +**39 of the 48 shipped sets incomplete**, 7 complete (`amsterdam`, `conduction`, `denhaag`, +`rotterdam`, `utrecht`, `vng`, `zwolle`) and 2 not auditable (`nextcloud`, which is stock by +definition, and `summer-breeze`, whose colours live in its own design-system layer). Nothing fixes +those 39 today, and nothing can: there is no path from an NL Design System theme to the +`--nldesign-*` vocabulary the app reads, so every set has been hand-authored or dumped raw. + +The raw dumps are the whole problem, and they are also the way out. 25 of the 39 declare **none** of +the 26 required semantic tokens, and 30 declare no primary colour at all — yet they are not empty. +They carry their brand's full palette under names nothing reads: `nijmegen` declares **959** foreign +`--nldesign-*` names, `conduction-new` 189, `epe` 186, `tubbergen` 184, `noordwijk` 168, `xxllnc` 167 +(and its primary contradicts `token-sets.json`). Those files hold `--nldesign-color-blue-40` where +they should hold `--nijmegen-color-blue-40` plus a semantic layer derived from it. The brand data is +present; the mapping is missing. + +The same missing mapping is why the **Custom token sets** upload is narrower than its name suggests. +`CustomTokenSetController::upload()` accepts pre-baked `--nldesign-*` CSS, or W3C DTCG JSON through +`DesignTokensMapper`, and routes on the file extension. Hand an admin the artefact a design system +actually publishes — `dist/design-tokens.css` from a Style Dictionary theme package, one +class-scoped block such as `.openwoo-theme { }` with 697 declarations and `var()` chains — and the +upload has nothing to do with it. The admin's real input is a theme, not a token set. + +This change is the theme converter: one mapping +table, one conversion, four accepted inputs, and a report that names every token that was applied, +adapted or skipped, and why. + +## What Changes + +- **The mapping table becomes data.** `scripts/mapping/nlds-to-nextcloud.json` holds the ordered + rule list (plan Appendix A): `source` candidates in priority order (first match wins), `target`, + `transform`, and the skip rules with their reason code. PHP and JS both load this file, so the two + runtimes cannot drift; its SHA-256 goes into every converted file's provenance block. +- **`lib/Service/TokenSetConverterService.php`** — the runtime the admin path uses. Takes raw content + plus a slug, returns `{css, manifestEntry, report}`. Wired into `CustomTokenSetController::upload()` + so the existing file picker converts any of the four inputs instead of only accepting a finished + token set. +- **A paste surface, because that is how a theme arrives.** The "Custom token sets" section gains a + textarea next to the file picker: paste the contents of a `design-tokens.css` (or a DTCG document), + give it a name, convert. `upload()` accepts either a `file` or a `content` parameter and is + identical from the read onward, so the validator and the store path never fork. +- **Input detection by content, not by file name.** A paste has no extension. The converter sniffs: + DTCG (`$value`/`$type`), Style Dictionary `tokens.json`, class-scoped built CSS, or an existing + `--nldesign-*` token set. `DesignTokensMapper` keeps ownership of the DTCG branch. +- **`js/lib/tokenConverter.js`** — the dual-mode mirror (`module.exports` under Node, + `window.NldesignTokenConverter` in the browser), following `js/lib/tokenTransforms.js`. It is what + `scripts/convert-nlds-theme.mjs` runs and what the vitest fixtures exercise. + `tests/Unit/Service/TokenSetConverterParityTest.php` runs the same fixtures through PHP and + compares against the JSON the vitest suite writes. +- **The 39 incomplete sets are regenerated from their own files.** No upstream package is added, + vendored or fetched (design decision 3): each set's foreign `--nldesign-*` palette steps are + renamed to `--{slug}-*` and the semantic layer is derived from them. `summer-breeze` gets the same + semantic layer as every other set, so the audit judges all of them alike (plan decision 4). The 7 + complete sets are re-run in add-only mode: hand-chosen values win, the converter may only fill + gaps. +- **The theme's logo becomes a file.** A theme carries its wordmark inline, as a `data:` URI on a + logo token; Nextcloud's core theming takes a logo as a FILE. The converter decodes the payload, + hands the caller `img/logos/{set-id}.{ext}` to write, points `--nldesign-logo-url` and + `theming.logo` at it, and rewrites the theme's other logo slots to `var(--nldesign-logo-url)` + instead of repeating ~40 KB of base64 in a file served to every anonymous visitor. This is what + lets a converted theme reach Nextcloud's own logo, name and e-mail branding at all. +- **Every skipped and adapted token gets a reason code** from the stage 6 list, carried in the report + and rendered by the existing `groupDiagnosticsByReason()` / `buildDiagnosticsFragment()` pair, so + the upload result says what a theme asked for that Nextcloud will not do — page width, type scale, + clickable areas, spacing, and the components Nextcloud does not have. +- **`CustomTokenSetValidator` widens to the component prefixes** (`--utrecht-*`, `--ams-*`, + `--denhaag-*`) because the emitted file needs them for `utrecht-bridge.css` and for Conduction's + own apps. `isForbiddenValue()` is untouched and stays the final gate; external `url()` values are + dropped with `external-url-blocked`. +- **The allow-list empties.** `tests/Unit/fixtures/token-set-vocabulary-allowlist.json` goes to `[]` + and `TokenSetVocabularyTest` guards all 46 auditable sets, which is stage 1's stated closing + condition. + +## Capabilities + +### New Capabilities +- `token-set-converter`: converting a published design-system theme into a Nextcloud token set is a + new capability with its own contract — accepted inputs, the four-section output, the provenance + block, the report shape, and the fixed policy about what is never applied. It is not a variation + on uploading a token set (`custom-token-sets` owns that) and not a property of a shipped set + (`token-sets` owns that); both of those consume it. + +### Modified Capabilities +- `custom-token-sets`: the upload accepts theme sources as well as token sets, accepts pasted content + as well as a file, detects the input by content, returns a conversion report, and stores files that + legitimately contain component-prefix tokens. +- `token-sets`: every shipped set that reads the `--nldesign-*` vocabulary is converter output with a + provenance block, the known-incomplete allow-list is empty, and `summer-breeze` is auditable. +- `theming-sync`: `applyImages()` persists the mime type `ImageManager::updateImage()` returns. + Without that app value Nextcloud serves its own logo however successful the sync reported itself, + which is why no converted theme has ever changed the logo. Found by converting a theme end to + end; the sync is otherwise untouched. + +## Impact + +- **An instance already running one of the 39 sets will look different after this change, and that is + the fix.** Those sets render as Rijkshuisstijl today because the cascade falls through to + `css/systems/nldesign/defaults.css`; afterwards they render as their own brand. 30 of them gain a + `--nldesign-color-primary` where they had none, and `xxllnc`'s primary stops contradicting its + manifest entry. The apply dialog's "Incomplete set" warning disappears for all of them. +- **`token-sets.json` `theming.primary_color` changes for the sets whose manifest value was never + reflected in CSS**, which means Nextcloud core theming (login page, e-mails, mobile colour) changes + for those instances on the next sync. Stage 3 owns making that automatic; this change only makes + the two halves agree. +- **Large mechanical diff**: 39 regenerated `css/tokens/*.css`, their `css/tokens/dark/*.css` + variants (`php scripts/generate-dark-variants.php --force`), 39 `token-sets.json` entries and 39 + `css/tokens/.report.json` files. Reviewable because every file carries counts and a mapping + hash, so two runs diff cleanly. +- **Code**: `scripts/mapping/nlds-to-nextcloud.json` (new), `js/lib/tokenConverter.js` (new), + `scripts/convert-nlds-theme.mjs` (new, absorbs `scripts/generate-brand-set.mjs`), + `lib/Service/TokenSetConverterService.php` (new), `lib/Controller/CustomTokenSetController.php` + (content-sniffing router, `content` parameter, report in the response), + `lib/Service/CustomTokenSetValidator.php` (widened vocabulary), + `templates/settings/admin.php` (paste textarea), `js/admin.js` (paste submit, report rendering), + `l10n/*` (reason-code copy), `token-sets.json`, all `css/tokens/*`, the allow-list fixture, and new + tests: `tests/vitest/tokenConverter.spec.js`, `tests/Unit/Service/TokenSetConverterServiceTest.php` + and `tests/Unit/Service/TokenSetConverterParityTest.php`. +- **No new dependency, no network at convert time, no CI sourcing** (design decision 3). The fixtures + are local: `css/tokens/openwoo.css` is the hand-resolved reference the converter's OpenWOO output + is diffed against. This also counted on `@gemeente-rotterdam/design-tokens` and + `@nl-design-system-unstable/zwolle-design-tokens` for the DTCG and Style Dictionary fixtures; + **both were removed in `c304a57`**, because nothing resolved them at runtime — they were inputs to + `scripts/generate-brand-set.mjs` and its output is committed — so those fixtures have to be + hand-authored, see design.md. Final acceptance is a human paste of a real `design-tokens.css`. +- **No OpenRegister schemas, no lifecycle or notification behaviour.** Filesystem and config work + only; ADR-031's declarative/imperative split does not apply. +- **Security**: the conversion runs before `CustomTokenSetValidator`, never instead of it. New value + shapes reaching the validator (component prefixes, `url()` values, `var()` chains) get their own + tests, and the wave-3/wave-5 `url()` policy is enforced inside the converter as a reason code + rather than a silent drop. diff --git a/openspec/changes/nlds-theme-converter/specs/custom-token-sets/spec.md b/openspec/changes/nlds-theme-converter/specs/custom-token-sets/spec.md new file mode 100644 index 00000000..cde3524b --- /dev/null +++ b/openspec/changes/nlds-theme-converter/specs/custom-token-sets/spec.md @@ -0,0 +1,90 @@ +# Spec delta: Custom Token Sets (nlds-theme-converter) + +The upload keeps every guarantee it has — a name, a slug, validation before storage, contrast +warnings, an audit entry — and gains the two things an admin actually has in hand: a theme instead of +a finished token set, and a clipboard instead of a file. Conversion (`token-set-converter`) runs +before the existing validator, never instead of it. + +## ADDED Requirements + +### Requirement: Theme Sources Are Accepted, Not Only Token Sets +The upload MUST accept the four input shapes defined by `token-set-converter` and MUST convert them +to a token set before validation and storage. It MUST NOT require the admin to pre-bake +`--nldesign-*` CSS. + +#### Scenario: A design system's built CSS becomes a selectable token set +- GIVEN an admin picks a `design-tokens.css` whose declarations sit in a `.{prefix}-theme` block +- AND enters the name "OpenWOO" +- WHEN the upload is submitted +- THEN the content MUST be converted before validation +- AND the stored `css/tokens/custom-openwoo.css` MUST declare the required semantic tokens +- AND the response MUST confirm the set was added and is selectable + +#### Scenario: A file whose content contradicts its extension is handled by content +- GIVEN a file named `tokens.json` whose content is CSS +- WHEN the upload is submitted +- THEN the input MUST be detected as CSS from the content +- AND the file name MUST be used only as a provenance hint + +### Requirement: Pasted Content Is A First-Class Input +The "Custom token sets" section MUST offer a textarea beside the file picker, and +`CustomTokenSetController::upload()` MUST accept a `content` parameter as an alternative to `file`, +with an optional `sourceName` for the provenance block. Everything after reading the input MUST be +identical for both paths. + +#### Scenario: Pasting the contents of a theme file creates the same set as uploading it +- GIVEN an admin pastes the full text of a `design-tokens.css` into the textarea +- AND enters a token set name +- WHEN Convert is submitted +- THEN the resulting stored CSS MUST be identical to uploading the same bytes as a file +- AND the validator MUST have run on the emitted CSS + +#### Scenario: An empty paste and an empty file picker are the same error +- GIVEN neither a file nor pasted content is supplied +- WHEN the form is submitted +- THEN the response MUST be a 400 naming that a file or pasted content is required +- AND no set MUST be created + +#### Scenario: Pasted content over the size limit is refused +- GIVEN pasted content larger than `CustomTokenSetValidator::MAX_SIZE` +- WHEN Convert is submitted +- THEN the response MUST refuse it with the same 512 KB limit the file path enforces + +### Requirement: The Conversion Report Is Returned And Rendered +The upload response MUST carry the conversion `report` and its `counts`, and the admin panel MUST +render it grouped by reason using the existing diagnostics grouping, so an admin can see what a theme +asked for that Nextcloud will not do. + +#### Scenario: The report groups skipped tokens by reason +- GIVEN a converted theme that declared page width, font sizes and button paddings +- WHEN the upload response is rendered +- THEN the panel MUST show a grouped block per reason code +- AND each group MUST show the human sentence for that code +- AND the applied / adapted / skipped counts MUST be shown + +#### Scenario: The new set appears in the dropdown without a page reload +- GIVEN a successful conversion +- WHEN the response is handled +- THEN the new set MUST be appended to the token-set dropdown client-side +- AND selecting it MUST NOT require reloading the settings page + +### Requirement: Component-Prefix Tokens Are Accepted In Stored Sets +`CustomTokenSetValidator` MUST accept `--utrecht-*`, `--ams-*` and `--denhaag-*` names beside +`--nldesign-*` and `--{slug}-*`, because the converted file carries a component layer that +`utrecht-bridge.css` and Conduction's own apps read. The value rules MUST NOT be relaxed. + +#### Scenario: A component-prefix declaration is stored +- GIVEN a converted set declaring `--utrecht-button-border-radius: 3px` +- WHEN it is validated +- THEN the declaration MUST be accepted and stored + +#### Scenario: The value gate is unchanged for the widened vocabulary +- GIVEN a declaration `--utrecht-button-border-radius: 3px; background: url(x)` +- WHEN it is validated +- THEN it MUST be rejected by `isForbiddenValue()` exactly as a `--nldesign-*` declaration would be + +#### Scenario: An external url() never reaches the validator +- GIVEN a theme declaring a logo token pointing at a remote host +- WHEN it is converted and validated +- THEN the converter MUST have dropped the declaration with reason `external-url-blocked` +- AND the stored file MUST contain no remote URL diff --git a/openspec/changes/nlds-theme-converter/specs/theming-sync/spec.md b/openspec/changes/nlds-theme-converter/specs/theming-sync/spec.md new file mode 100644 index 00000000..4a234771 --- /dev/null +++ b/openspec/changes/nlds-theme-converter/specs/theming-sync/spec.md @@ -0,0 +1,56 @@ +# Spec delta: Theming Sync (nlds-theme-converter) + +The sync itself is unchanged: the admin still confirms a dialog, the same validation still runs in +the same order, and the same two services are still the only things touched. One requirement is +corrected, because it described a call that silently did nothing. + +`ImageManager::updateImage()` stores the file and RETURNS the mime type it detected; the +`{key}Mime` app value is the caller's job, and it is what `ThemingDefaults::getLogo()` reads to +decide whether a custom image exists at all. `applyImages()` dropped the return value, so a logo +sync looked entirely successful — the endpoint answered `{"status":"ok","updated":["logo"]}`, +`ImageManager::hasImage('logo')` said true, the file was on disk — while every page, e-mail and +login screen kept rendering the stock Nextcloud logo. Core's own +`ThemingController::uploadImage()` pairs the two calls; this one now does too. + +## MODIFIED Requirements + +### Requirement: Apply Images to Nextcloud Theming +The app MUST apply validated image paths to Nextcloud's `ImageManager` service using full +filesystem paths, and MUST persist the mime type `ImageManager::updateImage()` returns, because +Nextcloud reads the image through the `{key}Mime` app value rather than through the file's presence. + +#### Scenario: Logo image applied +- GIVEN a valid request with `logo: "img/logos/amsterdam.svg"` +- AND the file exists at `{appPath}/img/logos/amsterdam.svg` +- WHEN `applyImages()` is called +- THEN `ImageManager::updateImage('logo', '{appPath}/img/logos/amsterdam.svg')` MUST be called with + the full absolute path +- AND `ThemingDefaults::set('logoMime', …)` MUST be called with the mime type that call returned +- AND `"logo"` MUST appear in the list of updated fields + +#### Scenario: Background image applied +- GIVEN a valid request with `background: "img/backgrounds/default.jpg"` +- AND the file exists +- WHEN `applyImages()` is called +- THEN `ImageManager::updateImage('background', '{fullPath}')` MUST be called +- AND `ThemingDefaults::set('backgroundMime', …)` MUST be called with the returned mime type +- AND `"background"` MUST appear in the list of updated fields + +#### Scenario: The synced logo is the one Nextcloud serves +- GIVEN a token set whose `theming.logo` names a file in the app's `img/logos/` +- WHEN the theming sync is confirmed +- THEN `GET /apps/theming/image/logo` MUST return that file's bytes and its mime type +- AND the generated theming stylesheet's `--image-logo` MUST point at that route, not at + `core/img/logo/logo.png` + +#### Scenario: Empty image path ignored +- GIVEN a request where `logo` is empty or not set +- WHEN `applyImages()` is called +- THEN `ImageManager::updateImage()` MUST NOT be called for `logo` +- AND no `{key}Mime` app value MUST be written + +#### Scenario: App path resolved via IAppManager +- GIVEN images need to be applied +- WHEN the full path is constructed +- THEN `IAppManager::getAppPath('thematiq')` MUST be used to resolve the base directory +- AND the relative path MUST be appended to get the full filesystem path diff --git a/openspec/changes/nlds-theme-converter/specs/token-set-converter/spec.md b/openspec/changes/nlds-theme-converter/specs/token-set-converter/spec.md new file mode 100644 index 00000000..774c6b6f --- /dev/null +++ b/openspec/changes/nlds-theme-converter/specs/token-set-converter/spec.md @@ -0,0 +1,224 @@ +# Spec delta: Token Set Converter (nlds-theme-converter) + +New capability. Turning a published design-system theme into a Nextcloud token set is its own +contract: what may be fed in, what comes out, which mapping decides it, and what the caller is told +about everything that did not survive. `custom-token-sets` (an admin upload) and `token-sets` (a +shipped file) both consume this capability and neither owns it. + +## ADDED Requirements + +### Requirement: Accepted Conversion Inputs +The converter MUST accept four input shapes and MUST determine the shape from the CONTENT, not from a +file name, because the primary surface is a paste with no file name. It MUST reject content matching +none of them rather than store an empty or partial set. + +The accepted shapes are: (A) built theme CSS with one or more class-scoped blocks of custom +properties, (B) a W3C DTCG document, (C) a Style Dictionary `tokens.json` tree, and (D) an existing +`:root`-scoped `--nldesign-*` token set. + +#### Scenario: Built theme CSS is converted from a class-scoped block +@e2e exclude Pure conversion — vitest on tokenConverter and PHPUnit on TokenSetConverterService +- GIVEN content whose only selector block is `.openwoo-theme { --utrecht-button-primary-action-background-color: var(--openwoo-color-primary); --openwoo-color-primary: #23845c; }` +- WHEN the content is converted with slug `openwoo` +- THEN the input MUST be detected as input A +- AND `--nldesign-color-primary` MUST be `#23845c` +- AND the `var()` chain MUST be resolved to a literal in the emitted file + +#### Scenario: A DTCG document is routed to the existing mapper +@e2e exclude Pure conversion — vitest and PHPUnit parity fixtures +- GIVEN content that parses as JSON and contains at least one leaf with a `$value` key +- WHEN the content is converted +- THEN the input MUST be detected as input B +- AND the DTCG semantics (alias `{a.b.c}` resolution, `$type` dispatch, the suffix table) MUST be + those of `DesignTokensMapper` +- AND no second DTCG parser MUST be introduced + +#### Scenario: An existing token set is detected as input D +@e2e exclude Pure conversion — vitest and PHPUnit parity fixtures +- GIVEN content whose selector is `:root` and whose declarations are predominantly `--nldesign-*` +- WHEN the content is converted +- THEN the input MUST be detected as input D +- AND the conversion MUST run in add-only mode + +#### Scenario: Unrecognised content is refused +- GIVEN pasted content that is neither parseable JSON nor contains a selector block with custom + properties +- WHEN the conversion is requested +- THEN the converter MUST return an error naming the four accepted shapes +- AND no token set file MUST be written +- AND no `token-sets.json` entry MUST be added + +### Requirement: Converted Output Shape And Provenance +The converter MUST emit exactly one flat `:root { }` block with no at-rules, in four commented +sections in a fixed order — brand palette under `--{slug}-*`, component layer, semantic +`--nldesign-*` layer, provenance — so that the output passes `TokenCssShapeTest` and two runs of the +same input diff cleanly. + +#### Scenario: The four sections are emitted in order with a provenance block +@e2e exclude Generated file shape — vitest and PHPUnit +- GIVEN any accepted input converted with slug `zwolle` +- WHEN the emitted CSS is inspected +- THEN it MUST contain exactly one `:root {` block and no `@media`, `@supports` or `@import` +- AND the brand palette section MUST declare its raw steps as `--zwolle-*`, never as `--nldesign-*` +- AND the provenance comment MUST record the input kind, the source name and version when present, + the converter version, the mapping table SHA-256, and the applied / adapted / skipped counts + +#### Scenario: Palette steps found under the app vocabulary are re-prefixed +@e2e exclude Generated file shape — vitest and PHPUnit +- GIVEN an input that declares `--nldesign-color-blue-40: #1b3d6b` (a raw upstream palette step) +- WHEN it is converted with slug `nijmegen` +- THEN the emitted file MUST declare `--nijmegen-color-blue-40` +- AND MUST NOT declare `--nldesign-color-blue-40` +- AND the report MUST carry the move as `adapted` with reason `palette-reprefixed` + +#### Scenario: A var() chain that leaves the input is not emitted as-is +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN a declaration whose value is `var(--some-foreign-token)` that the input does not define +- WHEN the content is converted +- THEN the value MUST be reported with reason `unresolved-var` +- AND MUST NOT be emitted into the semantic layer, because a token set is also served on the login + page and in e-mails where the source theme's variables do not exist + +### Requirement: Semantic Mapping Is Table-Driven And Shared +The `--nldesign-*` semantic layer MUST be produced by the ordered rule list in +`scripts/mapping/nlds-to-nextcloud.json` — first matching source wins — and BOTH runtimes (the PHP +service and the JS module) MUST load that same file. The transform set MUST be closed: +`copy`, `darken`, `mix`, `rgbTriplet`, `alpha`, `radiusScale`. + +#### Scenario: The first matching source wins +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN a rule whose sources are `--utrecht-button-primary-action-background-color` then `--{p}-color-primary` +- AND an input that declares both with different values +- WHEN the content is converted +- THEN the target MUST take the value of the first source +- AND the report MUST name that source + +#### Scenario: A missing source falls back and is reported as adapted +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN an input that declares no hover colour for its primary action +- WHEN the content is converted +- THEN `--nldesign-color-primary-hover` MUST be derived by `darken` from the primary +- AND the report entry MUST have action `adapted`, not `applied` + +#### Scenario: The contrast guard cannot emit a set that fails the contrast audit +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN a theme whose primary and primary-text pair measures below 4.5:1 +- WHEN the content is converted +- THEN the guarded value MUST be darkened in steps until the ratio is met, to a maximum of 10 steps +- AND the report MUST carry reason `contrast-adjusted` with the original value retained for reference +- AND the emitted set MUST pass the pairs `ShippedTokenSetAuditService` audits + +#### Scenario: Both runtimes produce the same result for the same input +@e2e exclude Parity — vitest writes the expectation, PHPUnit asserts against it +- GIVEN any fixture in `tests/Unit/fixtures/converter/` +- WHEN it is converted by `js/lib/tokenConverter.js` and by `TokenSetConverterService` +- THEN the emitted CSS MUST be byte-equal +- AND the reports MUST be structurally equal + +### Requirement: Nothing Is Dropped Silently +Every source token MUST end up in the report with an action of `applied`, `adapted`, `skipped` or +`kept`, and every non-`applied` entry MUST carry a reason code that exists in the mapping table. +Tokens deliberately not applied to Nextcloud MUST be distinguished from tokens kept in the file for +NL Design System components. + +#### Scenario: Layout, type scale and clickable-area tokens are refused with a reason +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN an input declaring `--utrecht-page-max-inline-size`, `--utrecht-document-font-size` and + `--utrecht-button-padding-block-start` +- WHEN the content is converted +- THEN each MUST be reported as `skipped` with reason `layout-fixed-by-nextcloud`, + `typography-scale-locked` and `clickable-area-locked` respectively +- AND none of them MUST appear in the semantic layer + +#### Scenario: Component tokens Nextcloud does not have are kept, not skipped +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN an input declaring accordion, breadcrumb and skip-link component tokens +- WHEN the content is converted +- THEN they MUST be emitted in the component section +- AND MUST be reported with action `kept` and reason `kept-for-nlds-components` +- AND MUST NOT be counted as skipped + +#### Scenario: The page background is routed to core theming rather than the content background +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN an input declaring `--utrecht-page-background-color: #f5f5f5` +- WHEN the content is converted +- THEN the manifest entry's `theming.background_color` MUST be `#f5f5f5` +- AND `--color-main-background` MUST NOT be targeted, so Nextcloud keeps owning dark mode +- AND the report MUST carry reason `routed-to-core-theming` + +#### Scenario: An external url() value is dropped before the validator sees it +@e2e exclude Security path — PHPUnit on TokenSetConverterService and CustomTokenSetValidator +- GIVEN an input declaring a logo or background token whose value points at a remote host +- WHEN the content is converted +- THEN the declaration MUST NOT be emitted +- AND the report MUST carry reason `external-url-blocked` + +#### Scenario: A source token with no rule is reported rather than forgotten +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN a source token that matches no rule and no policy entry +- WHEN the content is converted +- THEN it MUST be reported with reason `unmapped`, so the mapping table can be extended + +### Requirement: The Theme's Logo Becomes A File, Not A Data URI + +A design-system theme ships its wordmark inline, as a `data:` URI on a logo token. Nextcloud's core +theming takes a logo as a FILE — `ImageManager::updateImage()` copies one, and the `theming-sync` +spec requires `theming.logo` to be a path under `img/logos/` that exists on disk — so the converter +MUST decode that payload into an image the caller can write, and MUST point both the token set and +the manifest entry at the written file. Without this a converted theme can never update the +Nextcloud logo, however complete its colours are. + +#### Scenario: An inline logo is decoded into an asset +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN an input declaring a logo token whose value is `url("data:image/svg+xml;base64,…")` +- WHEN the content is converted with asset name `custom-openwoo` +- THEN the result MUST carry a `logoAsset` of `{path: "img/logos/custom-openwoo.svg", contents}` + whose contents are the decoded bytes +- AND the manifest entry's `theming.logo` MUST be `img/logos/custom-openwoo.svg` +- AND `--nldesign-logo-url` MUST be emitted as `url('../../img/logos/custom-openwoo.svg')` +- AND the report MUST carry reason `logo-extracted` + +#### Scenario: The payload is not repeated across the theme's other logo slots +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN a theme declaring the same artwork on its header, navbar and footer logo tokens +- WHEN the content is converted +- THEN each remaining slot MUST be emitted as `var(--nldesign-logo-url)` +- AND the emitted file MUST NOT contain the base64 payload, which is served to every anonymous + visitor on the login page + +#### Scenario: A logo already stored as a file keeps its path +@e2e exclude Pure conversion — vitest and PHPUnit +- GIVEN an input declaring `--nldesign-logo-url: url('../../img/logos/openwoo.svg')` +- WHEN the content is converted +- THEN `theming.logo` MUST be `img/logos/openwoo.svg` +- AND no `logoAsset` MUST be produced, because there are no bytes to write + +#### Scenario: An unusable logo is reported, never written +@e2e exclude Security path — PHPUnit on TokenSetConverterService +- GIVEN a logo token whose value is an image type Nextcloud does not accept, a payload over + 256 KB, or a path outside `img/logos/` +- WHEN the content is converted +- THEN no `logoAsset` MUST be produced and `theming.logo` MUST be absent +- AND the report MUST carry reason `logo-format-unsupported` +- AND the reported value MUST be truncated, so a rejected data URI never reaches the admin panel + or the audit log + +#### Scenario: An extracted logo cannot overwrite a shipped one +@e2e exclude Security path — PHPUnit on CustomTokenSetService +- GIVEN an admin uploads a theme named "Amsterdam", for which `img/logos/amsterdam.svg` is shipped +- WHEN the upload is stored +- THEN the written file MUST be `img/logos/custom-amsterdam.svg`, named after the set id +- AND `img/logos/amsterdam.svg` MUST be unchanged +- AND deleting the custom set MUST remove the file it wrote + +### Requirement: Re-Conversion Never Overwrites A Chosen Value +When the input is an existing token set (input D), a declaration the file already carries MUST win +and the converter MUST only add what is missing. + +#### Scenario: A hand-authored set gains only additions +@e2e exclude Pure conversion — vitest and PHPUnit over css/tokens/openwoo.css +- GIVEN `css/tokens/openwoo.css`, which declares `--nldesign-color-primary: #23845c` by hand +- WHEN it is re-converted with slug `openwoo` +- THEN `--nldesign-color-primary` MUST still be `#23845c` +- AND every previously declared value MUST be unchanged +- AND each untouched declaration MUST be reported with action `kept` and reason `kept-existing-value` diff --git a/openspec/changes/nlds-theme-converter/specs/token-sets/spec.md b/openspec/changes/nlds-theme-converter/specs/token-sets/spec.md new file mode 100644 index 00000000..0de32c27 --- /dev/null +++ b/openspec/changes/nlds-theme-converter/specs/token-sets/spec.md @@ -0,0 +1,91 @@ +# Spec delta: Token Sets (nlds-theme-converter) + +Stage 1 added "Shipped Token Set Vocabulary Completeness" and an allow-list of the sets that fail it. +This delta closes that allow-list: a shipped set that reads the `--nldesign-*` vocabulary is +converter output, carries its provenance, and has no exemption. The "Token Set CSS Structure" +requirement stays true as written — one flat `:root`, no at-rules, defaults still cover anything a +set omits — the converter is simply what makes a shipped set stop relying on that fallback. + +## ADDED Requirements + +### Requirement: Shipped Sets Are Reproducible Converter Output +Every shipped `css/tokens/{id}.css` for a set whose design system reads the `--nldesign-*` +vocabulary MUST be reproducible by running `token-set-converter` over its recorded source, and MUST +carry a provenance block naming that source, the converter version and the mapping table hash. A set +MUST NOT be edited by hand in a way that the converter cannot reproduce. + +#### Scenario: A shipped set records what produced it +@e2e exclude Filesystem shape — PHPUnit over css/tokens/*.css +- GIVEN any shipped set whose design system is `nldesign` +- WHEN its file is inspected +- THEN it MUST contain a provenance comment with the input kind, the converter version and the + mapping table SHA-256 +- AND the applied / adapted / skipped counts MUST be present + +#### Scenario: Re-running the converter over a shipped set is a no-op +@e2e exclude Regeneration check — `npm run convert:theme:check` +- GIVEN a shipped set that was generated by the current mapping table +- WHEN the converter is re-run over its recorded source in add-only mode +- THEN the emitted file MUST be byte-equal to the file on disk +- AND the check command MUST exit non-zero when it is not + +#### Scenario: A hand-tuned value survives regeneration +@e2e exclude Regeneration check — vitest and PHPUnit +- GIVEN one of the 7 sets that passed the stage 1 audit before this change +- WHEN it is re-converted +- THEN the diff MUST contain additions only +- AND no previously declared value MUST change + +### Requirement: The Known-Incomplete Allow-List Is Empty +`tests/Unit/fixtures/token-set-vocabulary-allowlist.json` MUST hold an empty `sets` array, and +`TokenSetVocabularyTest` MUST guard every auditable shipped set with no exemption. `nextcloud` remains +the single documented non-audited set, because stock Nextcloud is its brand. + +#### Scenario: Every auditable shipped set is complete +@e2e exclude Filesystem audit — PHPUnit on TokenSetVocabularyAuditService and `npm run audit:token-sets` +- GIVEN all 48 shipped sets +- WHEN the vocabulary audit runs over every `css/tokens/*.css` +- THEN 47 sets MUST be audited and MUST report `complete` +- AND only `nextcloud` MUST be reported as not audited +- AND the allow-list MUST be empty + +#### Scenario: A newly added incomplete set fails the gate immediately +@e2e exclude Filesystem audit — PHPUnit gate +- GIVEN a new shipped set that declares none of the required semantic tokens +- WHEN the gate runs +- THEN it MUST fail, naming the set and the missing tokens +- AND the failure MUST NOT be suppressible by adding the set to the allow-list + +### Requirement: Summer Breeze Is Audited Like Every Other Set +`summer-breeze` MUST declare its own semantic layer in `css/tokens/summer-breeze.css` rather than +relying on its design-system layer, so that the audit judges it by the same rule as every other set. + +#### Scenario: Summer Breeze becomes auditable and complete +@e2e exclude Filesystem audit — PHPUnit and the Node CLI +- GIVEN `summer-breeze` after conversion from its design-system layer +- WHEN the vocabulary audit runs +- THEN it MUST be reported as auditable +- AND `missingRequired` MUST be empty +- AND its rendered appearance MUST be unchanged from before the conversion + +## MODIFIED Requirements + +### Requirement: Token Set Manifest Entries Agree With Their CSS +Extends the stage 1 `primaryMismatch` rule from a detection to a guarantee: after conversion, a +shipped set's `token-sets.json` `theming.primary_color` MUST equal its `--nldesign-color-primary` +after hex normalisation, and `theming.background_color` MUST be the page background the converter +routed to core theming. + +#### Scenario: The manifest primary follows the CSS, not the reverse +@e2e exclude Filesystem audit — PHPUnit +- GIVEN `xxllnc`, whose manifest primary contradicted its CSS before this change +- WHEN it is converted +- THEN the manifest entry MUST be updated to the converted CSS primary +- AND `primaryMismatch` MUST be false for every shipped set + +#### Scenario: A set that declares a page background gets a manifest background +@e2e exclude Filesystem audit — PHPUnit +- GIVEN a converted set whose source declared a page background colour +- WHEN its manifest entry is inspected +- THEN `theming.background_color` MUST carry that colour +- AND the CSS MUST NOT target `--color-main-background` diff --git a/openspec/changes/nlds-theme-converter/tasks.md b/openspec/changes/nlds-theme-converter/tasks.md new file mode 100644 index 00000000..f9597506 --- /dev/null +++ b/openspec/changes/nlds-theme-converter/tasks.md @@ -0,0 +1,177 @@ +# Tasks: NLDS theme to Nextcloud token set converter + +Section numbers in brackets were the planning task numbers. +Tick a box when the work is merged to `development`, not when it is started. + +## State on 2026-09-10 + +Both runtimes and the CLI are in and verified against each other; the tests, the admin +surface and the regeneration are not. + +**Parity is measured, not assumed.** The PHP service and `js/lib/tokenConverter.js` were +run over the same two fixtures — a built theme CSS with `var()` chains, inline `data:` +logo URIs and policy-skipped tokens, and a second one whose vendor prefix this app has +never seen — and the emitted CSS is **byte-identical**, provenance hash included, with +structurally equal reports (64 and 53 entries). That is the check §5.4 will automate; it +has been performed by hand, not wired into a suite. + +Three carve-outs in the JS runtime, all deliberate: DTCG (input B) is refused with a 422 +pointing at the server, because `DesignTokensMapper` owns those semantics and a second +parser is exactly the drift this change exists to prevent (input C, Style Dictionary, IS +implemented, which is why §3.4 stays open rather than ticked); `--write` does not call the +dark-variant generator, which needs PHP; and `generate-brand-set.mjs` is not absorbed, so +the nightly sync still emits the raw vocabulary. + +Landed: the mapping table (§2.1–2.3, plus two reason codes the fallbacks needed — +`derived-from-brand` and `nextcloud-default-used` — and one rule reordering, because +`-text-muted`, `-border-dark` and `-header-border-bottom` all measure against +`--nldesign-color-nav-background` and a guard can only see targets already resolved), +`TokenSetConverterService` (§5.1–5.2), the converter ahead of the validator in +`upload()` with `content`/`sourceName` accepted (§6.2), and the widened validator +vocabulary (§7.1). + +Verified by running the service inside the app container against a built theme CSS with +`var()` chains, palette steps, policy-skipped tokens, an external `url()` and a dangling +reference: input A detected from content, chains resolved to literals, palette moved off +`--nldesign-`, 26/26 required tokens emitted, `--nldesign-color-primary` equal to the +manifest `primary_color`, the three audited contrast pairs at 4.64:1 / 11.63:1 / 17.40:1, +one flat `:root` with no at-rules, and every source token in the report with a reason code +that exists in the table. + +Not done, and each one is why stage 4 cannot close: §2.4, §3.4 (DTCG in JS) and §3.9 +(vitest), §4.2–4.4 (dark variant on `--write`, absorbing `generate-brand-set.mjs`, the +npm scripts), §5.3–5.4 and §7.2 (the tests), §6.1/6.3/6.4/6.5 (the paste box, the report +rendering, the no-reload dropdown append, the l10n), §8 (regenerating the 39 sets and +emptying the allow-list) and §9 (the gates). PHPUnit and vitest could not be run at all +on the authoring machine: there is no `vendor/` and no `composer`, so §9.2 stands open +even for the code that is in. + +## 1. Spec and Design (plan 4.1 - 4.4) + +- [x] 1.1 Write this change: `proposal.md`, `design.md`, `tasks.md`, and the spec deltas on + `token-set-converter` (new), `custom-token-sets` and `token-sets`. +- [x] 1.2 Record the measured baseline (39 incomplete / 7 complete / 2 not audited, and the + per-set missing-token shape) in `design.md`, so the before/after is auditable. +- [x] 1.3 Answer plan decision 2 (no upstream sourcing; the input arrives from a human) and + decision 4 (`summer-breeze` gets a semantic layer) in `design.md` decisions 3 and 10. +- [x] 1.4 Deduplication check: confirm nothing already does this. `DesignTokensMapper` owns DTCG + and is reused, not replaced (design decision 4); `scripts/generate-brand-set.mjs` owns the + ramp/role-layer fill and is absorbed (decision 5); `js/lib/tokenTransforms.js` owns + `darkenHex()`/`groupDiagnosticsByReason()` and is reused (decisions 7 and 11); + `CssParserService` owns declaration parsing on the PHP side. + +## 2. Mapping table (plan 4.3) + +- [x] 2.1 Create `scripts/mapping/nlds-to-nextcloud.json`: `version`, `converterVersion`, + `componentPrefixes`, `rules[]` (`target`, `nextcloud`, `sources[]`, `fallback`, `transform`, + `guard`, `when`, `reason`), `never[]` (`match`, `action`, `reason`) and `reasons{}`, + transcribing plan Appendix A in full. 43 rules, 26 policy entries, 18 reason codes. +- [x] 2.2 Cover all 26 required semantic tokens from `TokenSetVocabularyAuditService::REQUIRED_TOKENS` + with a rule, plus the derived `-rgb`, `-light`, `-light-hover`, `-hover`, focus, radius and + `manifest:` targets. Verified: no required token is without a rule. +- [x] 2.3 Add the 13 stage 6 reason codes plus the 5 conversion-mechanics codes with their + one-sentence copy. Verified: no code without copy, no copy without a producer. +- [ ] 2.4 Add the shared SHA-256 helper both runtimes use for the provenance block, and the test + asserting every code the converter can emit exists in the table (and vice versa) as a + permanent gate rather than a one-off check. + +## 3. Browser/Node module (plan 4.3) + +- [x] 3.1 Create `js/lib/tokenConverter.js`, dual-mode exactly like `js/lib/tokenTransforms.js` + (`module.exports` under Node, `window.NldesignTokenConverter` in the browser, no + `import`/`export`). +- [x] 3.2 Input detection in the fixed order of design decision 4, with a 422-equivalent error object + for unrecognised content. +- [x] 3.3 CSS parsing for input A and D: selector blocks, declaration split, `var()` chain resolution + with depth limit and cycle guard, at-rules skipped as `at-rule-not-converted`. +- [ ] 3.4 Style Dictionary / DTCG walk for input B and C, delegating the DTCG semantics that + `DesignTokensMapper` already defines (alias `{a.b.c}`, `$type` dispatch, suffix table). +- [x] 3.5 Rule engine: first-matching-source, the closed transform set + (`copy`, `darken`, `mix`, `rgbTriplet`, `alpha`, `radiusScale`), the single `contrast` guard. +- [x] 3.6 Emit the four-section `:root` file plus the provenance comment (design decision 5) and the + `token-sets.json` manifest entry. +- [x] 3.7 Report builder: `{source, target, action, reason, value}` with + `action in {applied, adapted, skipped, kept}` plus `counts`. +- [x] 3.8 Add-only mode for input D over an existing set (design decision 9), reporting + `kept-existing-value` per untouched declaration. +- [ ] 3.9 `tests/vitest/tokenConverter.spec.js` with local fixtures only: `css/tokens/openwoo.css` + (input D), the installed Rotterdam and Zwolle token packages (inputs A/B), a raw dump set such + as `nijmegen` (the 959-foreign-name case), and a malformed paste. The suite writes + `tests/Unit/fixtures/converter/.expected.json` for the parity test. + +## 4. CLI (plan 4.3) + +- [x] 4.1 Create `scripts/convert-nlds-theme.mjs` over the module: + `node scripts/convert-nlds-theme.mjs --slug zwolle --name "Gemeente Zwolle" + [--write] [--report report.json]`, printing the grouped report table. +- [ ] 4.2 `--write` updates `css/tokens/.css`, the `token-sets.json` entry and + `css/tokens/.report.json`, then calls the dark-variant generator. +- [ ] 4.3 Absorb `scripts/generate-brand-set.mjs` (keep its ramp and role-layer logic, drop the + separate entry point) and leave `scripts/generate-tokens.mjs` untouched for now + (design decision 3). +- [ ] 4.4 Register `npm run convert:theme` and `npm run convert:theme:check` (the latter converts + and fails when the on-disk file would change). + +## 5. PHP runtime (plan 4.3) + +- [x] 5.1 Create `lib/Service/TokenSetConverterService.php` (SPDX docblock, `@spec` tags, PHPCS + `//end` markers) returning `{css, manifestEntry, report}` from raw content plus a slug, loading + the same mapping JSON. +- [x] 5.2 Reuse `CssParserService` for parsing and `DesignTokensMapper` for the DTCG branch; no + second DTCG parser. +- [ ] 5.3 `tests/Unit/Service/TokenSetConverterServiceTest.php`: per-input unit tests, the contrast + guard, the `never` policy, `unresolved-var`, and the unrecognised-input error. +- [ ] 5.4 `tests/Unit/Service/TokenSetConverterParityTest.php`: same fixtures as 3.9, byte-equal CSS + and structurally equal report against the vitest expectations. + +## 6. Admin surface (plan 4.5) + +- [ ] 6.1 `templates/settings/admin.php`: a labelled textarea plus Convert button in the + "Custom token sets" section, next to the existing file picker; the picker keeps its + `accept=".css,.json,.tokens.json"` and gains no new states. +- [x] 6.2 `CustomTokenSetController::upload()` accepts `content` and optional `sourceName` beside + `file`, detects the input by content (not by extension), and returns `report` and `counts` in + the response. +- [ ] 6.3 `js/admin.js`: submit the pasted content, render the report through + `buildDiagnosticsFragment()` grouped by reason, and keep the existing "added and selectable" + confirmation. +- [ ] 6.4 Append the new set to the dropdown client-side from the response, without a page reload. +- [ ] 6.5 Extract the new `t('thematiq', ...)` strings into `l10n/en.json` and translate to Dutch; + run `npm run test:l10n:write` and `npm run test:l10n:completeness:write`. + +## 7. Validator (plan 4.3, security) + +- [x] 7.1 Widen the accepted vocabulary to `--utrecht-*`, `--ams-*`, `--denhaag-*` beside + `--nldesign-*` and `--{slug}-*`; `isForbiddenValue()` unchanged. +- [ ] 7.2 Tests for the widened surface: a component-prefix declaration is accepted, a semicolon or + comment marker in its value is still rejected, and an external `url()` never reaches the + validator (dropped by the converter as `external-url-blocked`). + +## 8. Regeneration and closing stage 1 (plan 4.5) + +- [ ] 8.1 Regenerate the 39 incomplete sets with input D from their own files (design decision 6), + one commit per batch with the report summary in the message. +- [ ] 8.2 Convert `summer-breeze` from its design-system layer (design decision 10). +- [ ] 8.3 Re-run the 7 complete sets in add-only mode; the diff MUST show additions only. +- [ ] 8.4 Regenerate `css/tokens/dark/*.css` with `php scripts/generate-dark-variants.php --force`. +- [ ] 8.5 Update the 39 `token-sets.json` entries (primary, background, provenance) and commit the + per-set `css/tokens/.report.json` files; gitignore `css/tokens/custom-*.report.json`. +- [ ] 8.6 Empty `tests/Unit/fixtures/token-set-vocabulary-allowlist.json` to `[]` and confirm + `npm run audit:token-sets:check` and `TokenSetVocabularyTest` are green with 46 audited sets. + +## 9. Quality gates + +- [ ] 9.1 `php -l` clean on every new/changed PHP file; `node --check` clean on + `js/lib/tokenConverter.js`, `js/admin.js` and `scripts/convert-nlds-theme.mjs`. +- [ ] 9.2 `npm run test:unit` (vitest) and the full `phpunit` suite green, in particular + `TokenSetVocabularyTest`, `TokenCssShapeTest`, `TokenSetContrastAuditTest` and the two new + converter tests. +- [ ] 9.3 `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) over the new/changed PHP files. +- [ ] 9.4 `npm run audit:token-sets:check` green against the emptied allow-list. +- [ ] 9.5 Playwright spec-coverage for the paste path and the report block. +- [ ] 9.6 `CHANGELOG.md` "Unreleased" entries: the converter, the paste surface, the report, the 40 + regenerated sets, and the emptied allow-list. +- [ ] 9.7 Manual acceptance (design decision 3): paste a real `design-tokens.css` into the panel and + confirm the header, primary and report match the theme — including the OpenWOO case, whose + output is diffed against `css/tokens/openwoo.css`. +- [ ] 9.8 Run the hydra gates via WSL on the branch and record the coverage line. diff --git a/openspec/changes/scoping-delegated-group-house-style/.openspec.yaml b/openspec/changes/scoping-delegated-group-house-style/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/scoping-delegated-group-house-style/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/scoping-delegated-group-house-style/design.md b/openspec/changes/scoping-delegated-group-house-style/design.md new file mode 100644 index 00000000..891b82fe --- /dev/null +++ b/openspec/changes/scoping-delegated-group-house-style/design.md @@ -0,0 +1,44 @@ +# Design: delegated group house style + +## Where it fits (development b4e7568) + +- `lib/Service/GroupThemingService.php` keeps the ordered mapping in app config `group_token_sets` (`:66`) with a generation counter (`:73`) that invalidates the resolution cache; `setMapping()` (`:172`) validates every entry before writing (`:177-181`), `validateEntry()` (`:189-200`) returns `{group, tokenSet}`. +- `openspec/specs/per-group-theming/spec.md:43-60`: resolution order is admin preview, first matching group entry, instance default; sessionless pages always get the default (`:56`). +- `lib/Controller/SettingsController.php:906-908` `getGroupTheming()` and `:933-935` `setGroupTheming()`, both `#[AuthorizedAdminSetting(Admin::class)]` as the spec requires (`openspec/specs/per-group-theming/spec.md:167-175`). +- `templates/settings/admin.php:338-360` renders the group theming rows; `js/admin.js` builds them. +- `lib/Settings/Admin.php` implements `IDelegatedSettings`, so the admin section can already be delegated to a group by Nextcloud's admin delegation. That delegates the whole theming section, not one group's choice, which is why it does not answer this row. +- `lib/Service/ThemingAuditService.php` is the single audit write path. + +## Decisions + +### 1. Delegation is a property of a mapping entry + +An entry becomes `{group, tokenSet, delegated: bool, allowedTokenSets: string[]}`. Only a group that has an entry can be delegated, so the administrator decides its priority in the ordered list, and the precedence rules stay unchanged. `allowedTokenSets` must be non-empty, contain the entry's current set, and name only available sets. + +### 2. The subadmin is the group's owner + +Nextcloud already has an owner role per group: the group subadmin (`OCP\Group\ISubAdmin::isSubAdminOfGroup()`). A subadmin of a delegated group may change that entry's `tokenSet`, and nothing else: not the priority, not the allowed list, not another group. + +Rejected: Nextcloud Teams (circles) owners. Teams do not drive the group mapping, and a second ownership model would need its own resolution. + +### 3. A personal settings section, guarded per group + +Subadmins do not see admin settings, so the choice lives in a personal section "House style of my groups", shown only when the user is subadmin of at least one delegated group. Its endpoints are `#[NoAdminRequired]` and every call checks `isSubAdminOfGroup()` for the group in the request and the allowed list for the set, answering 403 otherwise. That per-object check is what keeps a `#[NoAdminRequired]` endpoint from being an IDOR. + +### 4. Locking back keeps the current set + +Turning `delegated` off leaves `tokenSet` as the subadmin set it, so locking never flips a group's look by surprise. The administrator changes it afterwards if needed. + +### 5. Audited like any change + +A delegated choice writes `token_set_changed` with actor the subadmin's uid and `{group}` in context, and bumps the generation counter so the group sees the change on its next page load. + +## Risks + +- A subadmin picks a set that later fails contrast. The warn-only contrast policy applies as for administrators; the section shows the contrast result of each allowed set. +- Deleting an allowed set: validation on read drops it from the allowed list, and resolution already skips entries whose set is gone (`per-group-theming` rule 2). + +## Out of scope + +- Delegating uploads of new sets to subadmins. +- Delegation for sessionless pages, which stay on the instance default by `per-group-theming` (`:56`). diff --git a/openspec/changes/scoping-delegated-group-house-style/proposal.md b/openspec/changes/scoping-delegated-group-house-style/proposal.md new file mode 100644 index 00000000..9e2342d2 --- /dev/null +++ b/openspec/changes/scoping-delegated-group-house-style/proposal.md @@ -0,0 +1,45 @@ +# Delegated group house style: some groups choose, the rest stay locked + +## Why + +On a shared instance, such as a regional cooperation of municipalities, the brand owner wants two things at once. Most groups must keep the organisation's house style and must not drift. A few, such as a municipality in the cooperation or a department with its own public identity, should pick their own style without asking the central administrator each time. + +Thematiq does the first half: only an administrator changes the house style, so every group is locked. It cannot do the second. Microsoft 365 shipped exactly this as site branding governance (roadmap 526794): enforce the enterprise theme, apply themes to chosen sites, disable custom branding on specific sites, audit the changes. + +#### Row `gov-lock-per-space` (thematiq matrix, area governance) + +- Capability: Lock the organisation theme on selected sites or spaces, so their owners cannot change the branding, while other spaces keep their freedom. +- Own rating: partial; built.state `built`. Built evidence: nobody but an administrator can change the house style anywhere (gov-admin-only; SettingsController setters are admin routes), so every space is locked; the reverse, letting chosen spaces keep their own freedom, is limited to excluding whole apps (lib/Service/AppThemingService.php disabled_apps list) +- Demand: roadmap at https://www.microsoft.com/microsoft-365/roadmap?id=526794 (A government brand owner needs to stop departmental sites drifting from the Rijkshuisstijl without locking down the whole instance.) +- Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) rated `yes`: https://www.microsoft.com/microsoft-365/roadmap?id=526794 (launched 2026-02): admins 'enforce consistent branding, apply enterprise themes to individual sites, disable custom branding on specific sites, and audit branding changes'; https://learn.microsoft.com/en-us/powershell/module/microsoft.online.sharepoint.powershell/set-sposite: -DisableSiteBranding +- Liferay DXP (style books, themes, client extensions) rated `partial`: https://learn.liferay.com/w/dxp/sites/site-appearance/design-libraries: connecting a site needs update permission on the library, 'a site administrator cannot attach or detach their own site', and a published library style book 'a site can neither override'; but a connected site 'can still define its own style books', and https://learn.liferay.com/w/dxp/security-and-administration/administration/configuring-liferay/virtual-instances/instance-configuration 'Allow site administrators to use their own logo?' is one instance-wide switch, not per site +- Rated no or unknown: Nextcloud Theming (built-in app) `no`, openDesk theming `no`, Tokens Studio (Figma plugin and platform) `no` + +## What changes + +- An administrator can mark a group mapping as delegated and give it a list of allowed token sets. Groups without the mark stay locked, as today. +- A subadmin of a delegated group chooses that group's set from the allowed list in a new personal settings section, "House style of my groups". +- Every delegated choice is validated against the allowed list, applied through the existing group mapping, and written to the audit log with the subadmin as actor. +- The administrator can lock a delegated group again at any time; the group then keeps the set it has until the administrator changes it. + +## Capabilities + +### New capabilities + +- None. + +### Modified capabilities + +- `per-group-theming`: delegated entries, the subadmin section and its endpoints. + +## Impact + +- `lib/Service/GroupThemingService.php`: entries gain `delegated` and `allowedTokenSets` (`setMapping()` at `:172`, entry validation at `:189-200`); a new `setDelegatedTokenSet()`. +- `lib/Controller/SettingsController.php` (`:907`, `:934`): admin read and write carry the new fields. A new controller for the subadmin endpoints, `#[NoAdminRequired]` with a per-group subadmin check through `OCP\Group\ISubAdmin`. +- New `lib/Settings/Personal.php` and `templates/settings/personal.php`, registered in `appinfo/info.xml` next to the admin section (`:241`), shown only to subadmins of a delegated group. +- `templates/settings/admin.php` (`:338-360`) and `js/admin.js`: a delegate toggle and an allowed sets picker per mapping row. +- Audit: `token_set_changed` entries with the group in context. + +## Rows + +- `gov-lock-per-space` (thematiq matrix): the missing half, letting chosen groups keep their own choice while the others stay locked. diff --git a/openspec/changes/scoping-delegated-group-house-style/specs/per-group-theming/spec.md b/openspec/changes/scoping-delegated-group-house-style/specs/per-group-theming/spec.md new file mode 100644 index 00000000..10b6a72d --- /dev/null +++ b/openspec/changes/scoping-delegated-group-house-style/specs/per-group-theming/spec.md @@ -0,0 +1,67 @@ +# Spec delta: per-group theming (per-group-theming) + +Chosen groups can pick their own house style from a list the administrator allows; all other groups stay locked. + +## ADDED Requirements + +### Requirement: An administrator delegates a group mapping + +A group mapping entry MUST be able to carry `delegated` and `allowedTokenSets`. An administrator MUST be able to mark an entry as delegated on Settings > Administration > Theming and choose its allowed token sets. The allowed list MUST NOT be empty, MUST contain the entry's current set and MUST name only available sets. Entries stored before this change MUST read as not delegated. Delegation MUST NOT change the resolution order. + +#### Scenario: An administrator delegates a group + +- GIVEN an administrator on Settings > Administration > Theming with a mapping for group `gemeente-a` to `rijkshuisstijl` +- WHEN they mark it as delegated with allowed sets `rijkshuisstijl` and `gemeente-a-huisstijl` and save +- THEN the mapping MUST show the row as delegated with both allowed sets +- AND members of `gemeente-a` MUST still see `rijkshuisstijl` until a choice is made + +#### Scenario: Undelegated groups stay locked + +- GIVEN a mapping for group `concern` that is not delegated +- WHEN a subadmin of `concern` opens their personal settings +- THEN they MUST NOT see a house style choice for `concern` + +### Requirement: A group subadmin chooses the house style of a delegated group + +A subadmin of a delegated group MUST be able to choose that group's token set from its allowed list in the personal settings section "House style of my groups". The section MUST appear only for users who are subadmin of at least one delegated group. The endpoints MUST be `#[NoAdminRequired]` and MUST check, on every call, that the user is subadmin of the named group and that the set is on its allowed list, answering 403 otherwise. A choice MUST bump the mapping generation so the group sees it on its next page load. + +#### Scenario: A subadmin picks the house style of their municipality + +- GIVEN group `gemeente-a` delegated with allowed sets `rijkshuisstijl` and `gemeente-a-huisstijl` +- AND a user who is subadmin of `gemeente-a` +- WHEN the subadmin opens Settings > Personal > House style of my groups and chooses `gemeente-a-huisstijl` +- THEN a member of `gemeente-a` who opens the dashboard next MUST see the `gemeente-a-huisstijl` colours + +#### Scenario: A subadmin cannot change another group + +- GIVEN a subadmin of `gemeente-a` only +- WHEN they call `POST /apps/thematiq/api/my-groups/gemeente-b/house-style` +- THEN the response MUST be 403 +- AND the mapping of `gemeente-b` MUST be unchanged + +#### Scenario: A set outside the allowed list is refused + +- GIVEN a subadmin of delegated group `gemeente-a` whose allowed list does not contain `amsterdam` +- WHEN they request `amsterdam` for `gemeente-a` +- THEN the response MUST be 403 and the mapping MUST be unchanged + +### Requirement: Delegated choices are audited + +Every delegated choice MUST write a `token_set_changed` audit entry with the subadmin's user id as actor, the old and new set, and the group in its context. + +#### Scenario: The brand owner sees who changed a group's look + +- GIVEN a subadmin of `gemeente-a` changed the group's set +- WHEN an administrator opens the theming audit log +- THEN the entry MUST show the subadmin as user, the old and new set, and the group `gemeente-a` + +### Requirement: Locking a group back keeps its current set + +Turning delegation off MUST keep the entry's current token set and MUST remove the group from the subadmin's section. + +#### Scenario: An administrator locks a group again + +- GIVEN delegated group `gemeente-a` currently on `gemeente-a-huisstijl` +- WHEN an administrator turns delegation off for it +- THEN members of `gemeente-a` MUST keep seeing `gemeente-a-huisstijl` +- AND the subadmin's personal section MUST no longer offer `gemeente-a` diff --git a/openspec/changes/scoping-delegated-group-house-style/tasks.md b/openspec/changes/scoping-delegated-group-house-style/tasks.md new file mode 100644 index 00000000..06fcd6fe --- /dev/null +++ b/openspec/changes/scoping-delegated-group-house-style/tasks.md @@ -0,0 +1,24 @@ +# Tasks: delegated group house style + +Tick a box when the work is merged to `development`. + +## 1. Mapping model + +- [ ] 1.1 `delegated` and `allowedTokenSets` on mapping entries, validated in `GroupThemingService::validateEntry()`; old entries read as not delegated. Verify: `tests/Unit/Service/GroupThemingServiceTest.php` for a delegated entry, an allowed list missing the current set (refused), and a stored mapping without the new fields. +- [ ] 1.2 `setDelegatedTokenSet(string $uid, string $group, string $tokenSet)`: subadmin check, allowed list check, generation bump, audit. Verify: unit tests for a subadmin of the group, a subadmin of another group (refused), a set outside the allowed list (refused), and a locked group (refused). + +## 2. Endpoints + +- [ ] 2.1 Admin endpoints carry the new fields. Verify: `SettingsController` tests. +- [ ] 2.2 `GET /api/my-groups/house-style` and `POST /api/my-groups/{group}/house-style`, `#[NoAdminRequired]`, per-group subadmin check. Verify: controller tests including a plain user (403) and the hydra no-admin-idor gate. + +## 3. Screens + +- [ ] 3.1 Admin: delegate toggle and allowed sets picker per mapping row, with a label for each control. Verify: Playwright scenario "An administrator delegates a group". +- [ ] 3.2 Personal section "House style of my groups" (`lib/Settings/Personal.php`, `templates/settings/personal.php`), shown only to subadmins of a delegated group, with the contrast result per allowed set. Verify: Playwright scenario "A subadmin picks the house style of their municipality". + +## 4. Quality + +- [ ] 4.1 l10n en and nl. Verify: `npm run test:l10n`. +- [ ] 4.2 Docs: "Let a group choose its own house style" in `docs/`. Verify: the docs build. +- [ ] 4.3 Check an allowed incomplete set and dark mode for a delegated group. Verify: manual check recorded in the PR. diff --git a/openspec/changes/surfaces-assistant-approved-mark/.openspec.yaml b/openspec/changes/surfaces-assistant-approved-mark/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/surfaces-assistant-approved-mark/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/surfaces-assistant-approved-mark/design.md b/openspec/changes/surfaces-assistant-approved-mark/design.md new file mode 100644 index 00000000..a5156ac5 --- /dev/null +++ b/openspec/changes/surfaces-assistant-approved-mark/design.md @@ -0,0 +1,37 @@ +# Design: approved mark in the AI assistant + +## Where it fits (development b4e7568) + +- `lib/Capabilities.php` publishes `logos.default` for the active set (`openspec/specs/theming-capability/spec.md:37-60`). That capability is public and limited to "branding facts already observable by anyone loading the themed login page" with an exact key list, so the mark does not go there. +- `lib/Service/EmailThemingService.php:161-167` holds the organisation name used in the email footer. +- `lib/Controller/CatalogController.php:33,82-83` is the `#[NoAdminRequired]`, session-only read pattern for fleet apps. +- `lib/Service/ConfigBundleService.php:241` and `openspec/specs/config-portability/spec.md:6-20`: every new instance-wide value joins the bundle. +- Sibling: `@conduction/nextcloud-vue` development `c8aa8586` ships `src/components/CnAiCompanion/` (`CnAiCompanion.vue`, `CnAiChatPanel.vue`, `CnAiInput.vue` and others). ADR-034 (hydra) puts the companion in nextcloud-vue, mounted through `CnAppRoot`. + +## Decisions + +### 1. Thematiq owns the statement, nextcloud-vue draws it + +Whether the organisation sanctions the assistant, and with which name and logo, is a branding decision taken where the house style is managed. Drawing it is the panel's job. The endpoint is the contract between the two. + +### 2. A session-only endpoint, not the public capability + +`GET /apps/thematiq/api/assistant-mark` returns `{enabled, label, organisation, logo: {url, alt}|null}` for signed-in users. `label` is "Approved by {organisation}" translated into the requesting user's language on the server, so the panel needs no translation of its own. When the mark is off the response is `{enabled: false}` and nothing else. + +### 3. Off by default, defaults filled from what exists + +`assistant_mark_enabled` defaults to off. `assistant_mark_organisation` defaults to the email footer organisation name; `assistant_mark_logo` defaults to the active set's `logos.default`. The toggle cannot be turned on while the organisation name is empty, because "Approved by" with no name says nothing. + +### 4. The mark is informative, not a security control + +It tells honest users which assistant is the sanctioned one. It does not prove anything to an attacker who controls the page. The settings hint says so, so nobody mistakes it for a control. + +## Risks + +- A panel that fetches the endpoint on every open adds a request. The contract allows caching for the session; the nextcloud-vue half decides. +- Apps on an older nextcloud-vue simply show no mark. + +## Out of scope + +- Marks in the Nextcloud Assistant app (`nextcloud/assistant`), which the fleet does not own. +- A mark per assistant agent. diff --git a/openspec/changes/surfaces-assistant-approved-mark/proposal.md b/openspec/changes/surfaces-assistant-approved-mark/proposal.md new file mode 100644 index 00000000..5b90d582 --- /dev/null +++ b/openspec/changes/surfaces-assistant-approved-mark/proposal.md @@ -0,0 +1,41 @@ +# Approved mark in the AI assistant + +## Why + +Public servants are told to use only the AI assistant their organisation sanctioned. Inside the workspace every assistant panel looks alike, and a look-alike is one browser extension away. Microsoft 365 answers this in Copilot: the logo from the organisation's theming settings sits in the chat footer with a fixed label, "Approved by". The fleet's own assistant, `CnAiCompanion` in `@conduction/nextcloud-vue` (ADR-034), shows no organisation mark. + +#### Row `sur-assistant-logo` (thematiq matrix, area surfaces) + +- Capability: Show the organisation's logo as an approved mark inside the AI assistant, so users can see the assistant is the one their organisation sanctioned. +- Own rating: no; built.state `none`. Built evidence: grep -rniF assistant lib templates js finds no hook into the Nextcloud Assistant; thematiq restyles its colours through the shared CSS variables like any app (sur-all-pages) but shows no organisation mark inside it +- Demand: roadmap at https://www.microsoft.com/microsoft-365/roadmap?id=555852 (Public servants must be able to tell a sanctioned assistant from a look-alike; a brand mark in the assistant is a trust signal.) +- Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) rated `yes`: https://www.microsoft.com/microsoft-365/roadmap?id=555852 (rolling out, GA 2026-08): the Microsoft 365 Copilot app shows the logo 'already managed in the Microsoft Admin Center theming settings' in the chat footer 'paired with a fixed label: Approved by' +- Rated no or unknown: Nextcloud Theming (built-in app) `no`, openDesk theming `no`, Liferay DXP (style books, themes, client extensions) `unknown`, Tokens Studio (Figma plugin and platform) `no` + +## What changes + +- A small "AI assistant" block in Settings > Administration > Theming: a toggle to show the approved mark, the organisation name (defaulting to the email footer organisation name), and the logo (defaulting to the active house style logo). +- `GET /api/assistant-mark` gives signed-in users the mark: whether it is on, the label "Approved by " in the user's language, and the logo URL with its alternative text. +- The mark is off by default. Turning it on is a statement by the organisation, not a default of the software. +- Sibling half in `ConductionNL/nextcloud-vue`: `CnAiChatPanel` renders the mark in its footer when thematiq is installed and the mark is on, and renders nothing otherwise. + +## Capabilities + +### New capabilities + +- `assistant-approved-mark`: the setting, the endpoint and the contract with the assistant panel. + +### Modified capabilities + +- None. The public theming capability stays as it is, because the mark is for signed-in users only. + +## Impact + +- New `lib/Service/AssistantMarkService.php` and a controller method on a new `AssistantMarkController` (`#[NoAdminRequired]` read, `#[AuthorizedAdminSetting]` write). +- Reads `lib/Service/EmailThemingService.php` `getFooterConfig()` (`:161`) and the active set's `logos.default` as published by `lib/Capabilities.php`. +- `templates/settings/admin.php` and `js/admin.js`: the AI assistant block. Three app config keys, all in the configuration bundle. +- Sibling: `ConductionNL/nextcloud-vue` `src/components/CnAiCompanion/CnAiChatPanel.vue` (development `c8aa8586`). + +## Rows + +- `sur-assistant-logo` (thematiq matrix). diff --git a/openspec/changes/surfaces-assistant-approved-mark/specs/assistant-approved-mark/spec.md b/openspec/changes/surfaces-assistant-approved-mark/specs/assistant-approved-mark/spec.md new file mode 100644 index 00000000..549c631c --- /dev/null +++ b/openspec/changes/surfaces-assistant-approved-mark/specs/assistant-approved-mark/spec.md @@ -0,0 +1,48 @@ +# Spec delta: assistant approved mark (assistant-approved-mark) + +A new capability. An organisation marks the fleet's AI assistant as the one it sanctioned. + +## ADDED Requirements + +### Requirement: An administrator turns the approved mark on + +Settings > Administration > Theming MUST offer an AI assistant block with a toggle for the approved mark, an organisation name that defaults to the email footer organisation name, and a logo that defaults to the active house style logo. The mark MUST be off by default. The app MUST refuse to turn it on while the organisation name is empty. + +#### Scenario: An administrator turns on the approved mark + +- GIVEN an administrator on Settings > Administration > Theming with the email footer organisation "Gemeente Voorbeeld" +- WHEN they turn on the approved mark and save +- THEN the block MUST preview "Approved by Gemeente Voorbeeld" with the house style logo + +#### Scenario: An empty name keeps the mark off + +- GIVEN no organisation name in the email footer and none entered in the block +- WHEN an administrator tries to turn on the approved mark +- THEN the save MUST fail with a message asking for the organisation name +- AND the mark MUST stay off + +### Requirement: Signed-in users read the mark + +`GET /apps/thematiq/api/assistant-mark` MUST answer signed-in users with whether the mark is on and, when it is, the label "Approved by " translated into the user's language, the organisation name, and the logo URL with alternative text. When the mark is off, the response MUST say only that it is off. The endpoint MUST NOT answer without a session. + +#### Scenario: A Dutch user gets a Dutch label + +- GIVEN the mark is on for "Gemeente Voorbeeld" +- WHEN a signed-in user whose language is Dutch requests `GET /apps/thematiq/api/assistant-mark` +- THEN the label MUST be the Dutch translation of "Approved by Gemeente Voorbeeld" + +#### Scenario: The mark is off + +- GIVEN the mark is off +- WHEN a signed-in user requests the endpoint +- THEN the response MUST state `enabled: false` and carry no label or logo + +### Requirement: The assistant panel shows the mark when it is on + +When thematiq is installed and the mark is on, the fleet's AI assistant panel MUST show the logo and the label in its footer; when thematiq is not installed or the mark is off, the panel MUST show nothing in that place. This requirement is met by the nextcloud-vue half of this change. + +#### Scenario: A user recognises the sanctioned assistant + +- GIVEN the mark is on for "Gemeente Voorbeeld" and a user opens a Conduction app +- WHEN they open the AI assistant panel +- THEN the panel footer MUST show the organisation logo and "Approved by Gemeente Voorbeeld" diff --git a/openspec/changes/surfaces-assistant-approved-mark/tasks.md b/openspec/changes/surfaces-assistant-approved-mark/tasks.md new file mode 100644 index 00000000..66782b6f --- /dev/null +++ b/openspec/changes/surfaces-assistant-approved-mark/tasks.md @@ -0,0 +1,23 @@ +# Tasks: approved mark in the AI assistant + +Tick a box when the work is merged to `development`. + +## 1. Setting and endpoint + +- [ ] 1.1 `lib/Service/AssistantMarkService.php` with the three app config keys and their defaults. Verify: `tests/Unit/Service/AssistantMarkServiceTest.php` for off, on with defaults, on with an own name and logo, and refusing on with an empty name. +- [ ] 1.2 `GET /api/assistant-mark` (`#[NoAdminRequired]`) and `POST /settings/assistant-mark` (`#[AuthorizedAdminSetting]`). Verify: controller tests, anonymous refused, non-admin write 403, label in Dutch for a Dutch user. +- [ ] 1.3 The three keys in the configuration bundle. Verify: `ConfigBundleServiceTest` round trip. + +## 2. Settings page + +- [ ] 2.1 AI assistant block with toggle, name, logo and a preview of the mark. Verify: Playwright scenario "An administrator turns on the approved mark". + +## 3. Sibling handover + +- [ ] 3.1 Document the endpoint contract in `docs/reference/assistant-mark.md`. Verify: the docs build. +- [ ] 3.2 Open an issue in `ConductionNL/nextcloud-vue` for rendering the mark in `CnAiChatPanel` when thematiq is installed and the mark is on. Verify: issue link recorded here. + +## 4. Quality + +- [ ] 4.1 The logo in the preview has the alternative text " logo"; the label meets 4.5:1 against the panel footer in light and dark mode. Verify: Playwright axe run on the settings block. +- [ ] 4.2 l10n en and nl for the label and the block. Verify: `npm run test:l10n`. diff --git a/openspec/changes/surfaces-document-house-style/.openspec.yaml b/openspec/changes/surfaces-document-house-style/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/surfaces-document-house-style/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/surfaces-document-house-style/design.md b/openspec/changes/surfaces-document-house-style/design.md new file mode 100644 index 00000000..f140f7ee --- /dev/null +++ b/openspec/changes/surfaces-document-house-style/design.md @@ -0,0 +1,42 @@ +# Design: document house style + +## Where it fits (development b4e7568) + +- `lib/Service/EmailThemingService.php:161-167` `getFooterConfig()` returns the organisation name, accessibility URL and privacy URL used in the email footer. +- `lib/Service/FontService.php` stores custom fonts in app data (`fonts/custom-.woff2`) with roles; `appinfo/routes.php:83-84` serve them (`font#serve`, `font#css`). +- `lib/Capabilities.php` and `openspec/specs/theming-capability/spec.md:37-60` already publish the active set id, name, WCAG level and `logos.default` of the active set. +- `lib/Service/GroupThemingService.php` resolves a user's set by group priority; `openspec/specs/per-group-theming/spec.md:56` keeps sessionless pages on the instance default. +- `lib/Controller/CatalogController.php:82-83` is the `#[NoAdminRequired]` read pattern for leaf apps (app-token-set-selection). +- Siblings: filinq's `huisstijl` schema has `name`, `logo`, `primaryColor`, `headerHtml`, `footerHtml`, `defaultMargins`, loaded by `DocumentRenderPipeline::loadHuisstijl()` (filinq development `7af2f955`). OpenRegister renders PDF exports with Dompdf in `ExportService::exportToPdf()` (openregister development `555af721`, `:332`), with no logo, font or footer. + +## Decisions + +### 1. A profile, not a template + +Thematiq does not render documents. It publishes the values a document needs and leaves layout to the app that generates the document. That keeps one owner per concern: thematiq for the house style, filinq for templates, OpenRegister for exports. + +Profile shape (`DocumentStyleService::forUser(?string $uid): array`): +`{ tokenSet: {id, name}, organisation, logo: {url, mime}, cover: {url, mime}|null, colours: {primary, primaryText, text, background, accent}, fonts: {heading: {family, url|null}, body: {family, url|null}}, footer: {lines: [..], accessibilityUrl, privacyUrl} }`. +Colours come from the resolved declarations of the user's set, so a group-mapped set gives its own colours. Font URLs point at `font#serve` for custom fonts and are null for system fonts. + +### 2. Two ways to read it + +In-process: fleet apps resolve `OCA\Thematiq\Service\DocumentStyleService` from the server container when thematiq is installed (a duck-typed lookup that returns null when it is not). Over HTTP: `GET /apps/thematiq/api/document-style` for the signed-in user, `#[NoAdminRequired]`. Both return the same array. + +### 3. Document-specific assets are optional + +A document logo (often a print version of the logo) and a cover image are uploaded in the Documents block, stored in app data `documents/`, validated like the converter's logo asset (type and size limits). One extra footer line is free text, escaped. Without uploads the profile uses `logos.default` of the active set and the email footer values. + +### 4. In the bundle + +The footer line joins the bundle as a value; the logo and cover join as metadata only, like fonts, and travel as files in a branding package when change `governance-theme-as-code` lands. + +## Risks + +- A sibling that never reads the profile leaves the tender unmet. The proposal names both sibling halves so their lanes can pick them up. +- Font licences: the profile exposes font URLs to signed-in users only, the same audience that already downloads them to render pages. + +## Out of scope + +- Rendering, pagination, templates: filinq's and OpenRegister's. +- Office documents created by users (Collabora templates): the office editor's. diff --git a/openspec/changes/surfaces-document-house-style/proposal.md b/openspec/changes/surfaces-document-house-style/proposal.md new file mode 100644 index 00000000..af0c268f --- /dev/null +++ b/openspec/changes/surfaces-document-house-style/proposal.md @@ -0,0 +1,43 @@ +# Document house style: generated letters and exports follow the workspace house style + +## Why + +Three tenders ask that documents the system generates carry the house style: logo, cover, footer and fonts. Hilversum (TenderNed 404703, VTH), the FUMO (415897) and the BUCH municipalities (298070, several house styles, one per municipality) all name it. In the fleet, documents are generated by other apps: filinq renders letters and templates with its own `huisstijl` object in OpenRegister, and OpenRegister exports lists to PDF with Dompdf. Thematiq owns the house style, but nothing hands it to them, so an organisation configures its logo and colours twice and they drift apart. + +#### Row `sur-generated-documents` (thematiq matrix, area surfaces) + +- Capability: Have documents the system generates (letters, PDF exports, reports) carry the house style: logo, cover, footer and fonts. +- Own rating: no; built.state `none`. Built evidence: grep -rliE 'pdf|docx|documenttemplate' lib/ src/ finds no document generation; thematiq styles the web interface and emails only (sur-email), document templates belong to a document app +- Demand: tender at https://www.tenderned.nl/aankondigingen/overzicht/404703 (Demand from tenders: TenderNed 404703 (Hilversum VTH, 2025-12-12), 415897 (FUMO, 2026-03-13) and 298070 (BUCH, several house styles per municipality) require generated documents in the house style. openDesk added OpenProject PDF export branding in 1.18.0.) +- Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) rated `partial`: https://learn.microsoft.com/en-us/sharepoint/organization-assets-library: organization assets libraries publish Office templates (.dotx, .xltx, .potx) so new documents start in the house style; https://learn.microsoft.com/en-us/sharepoint/brand-center-overview: organisation fonts in PowerPoint for the web. System-generated exports are not covered +- openDesk theming rated `partial`: partial: only OpenProject PDF exports are branded. CHANGELOG 1.18.0 (2026-08-19) 'openproject: Add more theming options'; theme.yaml.gotmpl:128-145 pdfExportLogoPath, pdfExportCoverPath, pdfExportFooterPath and pdfExportFont*Url, wired in helmfile/apps/openproject/values.yaml.gotmpl:103-116; overwritten on every deployment (theme.yaml.gotmpl:130-131). +- Rated no or unknown: Nextcloud Theming (built-in app) `no`, Liferay DXP (style books, themes, client extensions) `unknown`, Tokens Studio (Figma plugin and platform) `no` + +## What changes + +- Thematiq publishes a document house style profile: organisation name, document logo, primary and text colours from the active token set, the heading and body fonts with their font file URLs, and footer lines (organisation name, accessibility statement link, privacy link) from the email footer settings. +- An administrator can upload a separate document logo and a cover image, and add a free footer line, in a "Documents" block on Settings > Administration > Theming. Without them the profile falls back to the house style logo and the email footer. +- Fleet apps read the profile through a PHP service they can resolve from the container and through `GET /api/document-style` for signed-in users. +- Per-group house styles carry through: the profile for a user resolves the token set of that user's group, so the BUCH case (one house style per municipality) produces letters in the right style. + +## Capabilities + +### New capabilities + +- `document-house-style`: the profile, its settings and how fleet apps read it. + +### Modified capabilities + +- None. + +## Impact + +- New `lib/Service/DocumentStyleService.php` and a `DocumentStyleController` (`#[NoAdminRequired]`, like `CatalogController`). +- Reads `lib/Service/EmailThemingService.php` `getFooterConfig()` (`:161`), `lib/Service/FontService.php` and the font routes (`appinfo/routes.php:83-84`), `lib/Service/GroupThemingService.php` for the user's set. +- `templates/settings/admin.php` and `js/admin.js`: a Documents block. +- The profile joins the configuration bundle (document logo and cover as metadata, like fonts; footer line as a value). +- Sibling halves, not in this repository: filinq seeds and refreshes its `huisstijl` object from the profile (`lib/Service/DocumentRenderPipeline.php` `loadHuisstijl()` at filinq `7af2f955`); OpenRegister adds the profile's logo, fonts and footer to `ExportService::exportToPdf()` (`lib/Service/ExportService.php:332` at openregister `555af721`). + +## Rows + +- `sur-generated-documents` (thematiq matrix). diff --git a/openspec/changes/surfaces-document-house-style/specs/document-house-style/spec.md b/openspec/changes/surfaces-document-house-style/specs/document-house-style/spec.md new file mode 100644 index 00000000..c9fe06f2 --- /dev/null +++ b/openspec/changes/surfaces-document-house-style/specs/document-house-style/spec.md @@ -0,0 +1,54 @@ +# Spec delta: document house style (document-house-style) + +A new capability. Thematiq publishes the house style values that generated documents need, for the fleet apps that generate them. + +## ADDED Requirements + +### Requirement: Thematiq publishes a document house style profile + +The app MUST provide a document house style profile with the organisation name, a logo, an optional cover image, the primary, primary text, text, background and accent colours, the heading and body fonts with a font file URL for custom fonts, and footer lines with the accessibility and privacy links. Colours MUST come from the token set that applies to the requesting user, so a group-mapped house style yields its own profile. + +#### Scenario: A letter for a group-mapped municipality gets its own colours + +- GIVEN group `gemeente-bussum` mapped to set `bussum` and the instance default `rijkshuisstijl` +- WHEN a member of `gemeente-bussum` requests `GET /apps/thematiq/api/document-style` +- THEN the profile MUST carry the Bussum primary colour and token set id `bussum` + +#### Scenario: A set without fonts reports system fonts + +- GIVEN an active set with no custom fonts uploaded +- WHEN a signed-in user requests the profile +- THEN the font entries MUST name the family and carry no font file URL + +### Requirement: Fleet apps read the profile in-process or over HTTP + +The profile MUST be available to other apps on the same server through `OCA\Thematiq\Service\DocumentStyleService`, and to signed-in users through `GET /apps/thematiq/api/document-style` (`#[NoAdminRequired]`). Both MUST return the same values. The endpoint MUST NOT answer without a session. + +#### Scenario: A document app renders a letter in the house style + +- GIVEN filinq resolves `DocumentStyleService` for the signed-in user +- WHEN it renders a letter +- THEN the logo, primary colour, fonts and footer lines it uses MUST equal the profile's values + +#### Scenario: No session, no profile + +- GIVEN no session +- WHEN a request is made to `GET /apps/thematiq/api/document-style` +- THEN the response MUST NOT contain the profile + +### Requirement: An administrator sets document assets + +The Documents block on Settings > Administration > Theming MUST let an administrator upload a document logo and a cover image and add one footer line. Uploads MUST be checked for type and size, and an SVG containing script MUST be refused. Without uploads the profile MUST fall back to the active set's logo and the email footer settings. The endpoints MUST carry `#[AuthorizedAdminSetting(OCA\Thematiq\Settings\Admin::class)]`. + +#### Scenario: An administrator sets a print logo for documents + +- GIVEN an administrator on Settings > Administration > Theming +- WHEN they upload a PNG print logo in the Documents block +- THEN the profile MUST name that logo +- AND the header logo on web pages MUST be unchanged + +#### Scenario: No document logo falls back to the house style logo + +- GIVEN no document logo uploaded and an active set with a logo +- WHEN a signed-in user requests the profile +- THEN the profile logo MUST be the active set's logo diff --git a/openspec/changes/surfaces-document-house-style/tasks.md b/openspec/changes/surfaces-document-house-style/tasks.md new file mode 100644 index 00000000..48298117 --- /dev/null +++ b/openspec/changes/surfaces-document-house-style/tasks.md @@ -0,0 +1,24 @@ +# Tasks: document house style + +Tick a box when the work is merged to `development`. + +## 1. Profile + +- [ ] 1.1 `lib/Service/DocumentStyleService.php::forUser()` from the resolved set, fonts and footer config. Verify: `tests/Unit/Service/DocumentStyleServiceTest.php` for the instance default, a group-mapped user, a set without a logo, and system fonts. +- [ ] 1.2 `GET /api/document-style`, `#[NoAdminRequired]`. Verify: controller test, anonymous refused. + +## 2. Settings + +- [ ] 2.1 Documents block: document logo, cover image, extra footer line, with a preview of the profile. Verify: Playwright scenario "An administrator sets a print logo for documents". +- [ ] 2.2 Store assets in app data `documents/` with type and size checks; endpoints `#[AuthorizedAdminSetting]`. Verify: controller tests for an SVG with script refused, a too-large file refused, non-admin 403. +- [ ] 2.3 Bundle: footer line as a value, assets as metadata. Verify: `ConfigBundleServiceTest` round trip. + +## 3. Sibling handover + +- [ ] 3.1 Document the PHP and HTTP contract in `docs/reference/document-style.md` with the profile shape. Verify: the docs build. +- [ ] 3.2 Open issues in filinq and OpenRegister naming their half (seed `huisstijl` from the profile; logo, fonts and footer in `exportToPdf()`). Verify: issue links recorded in this task. + +## 4. Quality + +- [ ] 4.1 l10n en and nl. Verify: `npm run test:l10n`. +- [ ] 4.2 Colours in the profile meet 4.5:1 for text on background, or the profile carries the warning. Verify: unit test. diff --git a/openspec/changes/surfaces-per-app-brand/.openspec.yaml b/openspec/changes/surfaces-per-app-brand/.openspec.yaml new file mode 100644 index 00000000..7f2ad572 --- /dev/null +++ b/openspec/changes/surfaces-per-app-brand/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/surfaces-per-app-brand/design.md b/openspec/changes/surfaces-per-app-brand/design.md new file mode 100644 index 00000000..250ad93e --- /dev/null +++ b/openspec/changes/surfaces-per-app-brand/design.md @@ -0,0 +1,39 @@ +# Design: per-app brand + +## Where it fits (development b4e7568) + +- `lib/Listener/ThemeInjectionListener.php:188-196` resolves the rendered app id from `TemplateResponse::getApp()`, falling back to `AppThemingService::resolveAppIdFromPath()` (`lib/Service/AppThemingService.php:182`), to run the exclusion guard (`openspec/specs/per-app-theming/spec.md:28-40`). The login page never has an app id. +- `lib/Service/CssInjectionService.php:247` `inject(string $context)`; at `:261` it resolves the token set through `GroupThemingService::resolveTokenSetForRequest()`. +- The header logo is drawn from `--nldesign-logo-url` (`css/systems/nldesign/theme.css:135`, `:303`), which `CssInjectionService` sets per request in an inline layer (`:725-735`), choosing between an uploaded core logo and core's `logo.svg`. +- `openspec/specs/per-group-theming/spec.md:43-60` fixes the resolution order: preview, group, default. +- `lib/Service/AppThemingService.php:53` protects `thematiq`, `settings` and `theming` from exclusion. + +## Decisions + +### 1. Stored next to the exclusion list + +`app_brands` is a JSON object keyed by app id: `{tokenSet, logoLarge|null, logoSmall|null}`. The app must be installed and not excluded; the protected ids cannot be branded, so the settings pages always look like the organisation. + +### 2. The app brand sits between preview and group + +On a page of a branded app, the app's set wins over the user's group set: the brand belongs to the app, whoever opens it. An active admin preview still wins, so an administrator can try a set on any page. Sessionless pages keep the instance default, as `per-group-theming` requires, because public share pages and the login page have no app brand. + +Rejected: group over app. Then a branded participation platform would change colour per municipality, which defeats the purpose. + +### 3. The logo goes through the existing variable + +For a branded app with a large logo, the inline logo layer sets `--nldesign-logo-url` to that logo instead of the core logo. The small logo is used under the narrow-screen breakpoint that Nextcloud's header already uses. No new selector is added. + +### 4. The name stays core's + +The app menu label and the page title come from Nextcloud and the app's own `info.xml`. Changing them needs a script that rewrites core pages, which breaks on every release. The admin block says so. + +## Risks + +- Contrast: an app set with poor contrast next to the instance header. The block shows each mapped set's contrast result, warn-only as everywhere. +- An app renamed or removed: its entry is ignored at resolution and flagged in the block. + +## Out of scope + +- Per-app favicons (the favicon is core theming's). +- Per-app brands on public pages. diff --git a/openspec/changes/surfaces-per-app-brand/proposal.md b/openspec/changes/surfaces-per-app-brand/proposal.md new file mode 100644 index 00000000..95bf53e6 --- /dev/null +++ b/openspec/changes/surfaces-per-app-brand/proposal.md @@ -0,0 +1,47 @@ +# Per-app brand: an app of the suite gets its own colours and logo + +## Why + +Some apps in a government workspace have a public identity of their own. An internal social network, a participation platform or a knowledge base often carries its own name, logo and colour, decided centrally. Microsoft 365 is rolling this out for Viva Engage from its brand center (roadmap 568364). openDesk gives each app its own favicon from central values; Liferay gives each connected site its own colours from a design library. + +Thematiq applies one house style to the whole instance, per group if needed, and can only exclude an app. The archived change `app-token-set-selection` lets a Conduction app's own picker choose a set for its own users, which is a different thing: the administrator cannot give an app its own brand. + +#### Row `sur-per-app-brand` (thematiq matrix, area surfaces) + +- Capability: Give a collaboration or social app of the suite its own brand name, large and small logo and theme colour from a central brand center. +- Own rating: no; built.state `none`. Built evidence: thematiq applies one house style instance wide (or per group, sco-per-group) and can only exclude an app (lib/Service/AppThemingService.php disabled_apps); no app can carry its own brand name, logo and colour +- Demand: roadmap at https://www.microsoft.com/microsoft-365/roadmap?id=568364 (Shows branding spreading per app with its own brand center, the fragmentation a single instance-wide theme avoids.) +- Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center) rated `yes`: https://www.microsoft.com/microsoft-365/roadmap?id=568364 (rolling out, 2026-08): Viva Engage admins 'configure a brand name, add large and small logos, and a theme color that is applied consistently for all users', managed from a Brand Center in the Engage admin center +- openDesk theming rated `partial`: opendesk@v1.18.2 helmfile/environments/default/theme.yaml.gotmpl:55-80 gives each app its own favicon (chat, files, groupware, knowledge, notes) from the central theme values, but one productName (:17) and one colour set (:27-47) serve every app; a per-app name or colour needs a per-release customization file that openDesk does not support (customization.yaml.gotmpl:4-24) +- Liferay DXP (style books, themes, client extensions) rated `partial`: https://learn.liferay.com/w/dxp/sites/site-appearance/design-libraries: a library style book gives each connected site its colours and fonts from one central place, and https://learn.liferay.com/w/dxp/security-and-administration/administration/configuring-liferay/virtual-instances/instance-configuration lets site administrators upload their own site logo when allowed; the library holds no brand name or logo, so logo and name stay per-site settings rather than set from the brand center +- Rated no or unknown: Nextcloud Theming (built-in app) `no`, Tokens Studio (Figma plugin and platform) `no` + +## What changes + +- An administrator maps an app to a token set and, optionally, a large and a small logo, in a new "Brand per app" block on Settings > Administration > Theming. +- On that app's pages the mapped set and logo replace the house style set and logo. Every other page is unchanged. +- The order of authority becomes: an active admin preview, then the app's brand, then the user's group mapping, then the instance default. +- The app's name stays Nextcloud's, because the app menu and page titles are core's. +- Mappings travel in the configuration bundle; logos as metadata. + +## Capabilities + +### New capabilities + +- None. + +### Modified capabilities + +- `per-app-theming`: brand per app next to the exclusion list. +- `per-group-theming`: the resolution order gains the app brand step. + +## Impact + +- `lib/Service/AppThemingService.php` (exclusion list at `:46`, `resolveAppIdFromPath()` at `:182`): stores `app_brands` next to `disabled_apps`. +- `lib/Listener/ThemeInjectionListener.php` resolves the app id already (`:192-194`) and passes it to `CssInjectionService::inject()` (`:247`), which uses it where it resolves the token set (`:261`) and the logo layer (`--nldesign-logo-url`, `:725-735`). +- `templates/settings/admin.php` and `js/admin.js`: the Brand per app block under Theming per app. +- Logos in app data `app-brands/`, validated like the converter's logo asset. + +## Rows + +- `sur-per-app-brand` (thematiq matrix). diff --git a/openspec/changes/surfaces-per-app-brand/specs/per-app-theming/spec.md b/openspec/changes/surfaces-per-app-brand/specs/per-app-theming/spec.md new file mode 100644 index 00000000..e596ee47 --- /dev/null +++ b/openspec/changes/surfaces-per-app-brand/specs/per-app-theming/spec.md @@ -0,0 +1,32 @@ +# Spec delta: per-app theming (per-app-theming) + +Next to excluding an app, an administrator can give an app its own brand. + +## ADDED Requirements + +### Requirement: An administrator gives an app its own brand + +Settings > Administration > Theming MUST offer a Brand per app block in which an administrator maps an installed app to a token set and, optionally, a large and a small logo. The app MUST NOT be excluded from theming and MUST NOT be one of the protected ids (`thematiq`, `settings`, `theming`). The block MUST state that the app's name stays Nextcloud's. The endpoints MUST carry `#[AuthorizedAdminSetting(OCA\Thematiq\Settings\Admin::class)]`, and logo uploads MUST be checked for type and size. + +#### Scenario: An administrator gives the knowledge base its own brand + +- GIVEN an administrator on Settings > Administration > Theming with the Collectives app installed +- WHEN they map Collectives to set `kennisbank` with a large and a small logo and save +- THEN a user who opens Collectives MUST see the `kennisbank` colours and the large logo in the header +- AND a user who opens Files MUST see the house style as before + +#### Scenario: A protected app cannot be branded + +- GIVEN an administrator in the Brand per app block +- WHEN they try to map the `settings` app +- THEN the save MUST fail with a message that the settings pages always follow the house style + +### Requirement: The small logo is used on narrow screens + +For a branded app with a small logo, the header MUST show the small logo below Nextcloud's narrow-screen breakpoint and the large logo above it. Without a small logo the large logo MUST be used at every width. + +#### Scenario: A phone shows the small logo + +- GIVEN Collectives branded with a large and a small logo +- WHEN a user opens Collectives on a 360 px wide screen +- THEN the header MUST show the small logo diff --git a/openspec/changes/surfaces-per-app-brand/specs/per-group-theming/spec.md b/openspec/changes/surfaces-per-app-brand/specs/per-group-theming/spec.md new file mode 100644 index 00000000..78e41721 --- /dev/null +++ b/openspec/changes/surfaces-per-app-brand/specs/per-group-theming/spec.md @@ -0,0 +1,22 @@ +# Spec delta: per-group theming (per-group-theming) + +The resolution order gains one step: an app's own brand, between an admin preview and the group mapping. + +## ADDED Requirements + +### Requirement: An app brand wins over the group mapping on that app's pages + +On a page of an app that has a brand mapping, the effective token set MUST be the app's set, unless an admin theme preview is active for the session, which MUST still win. On every other page, resolution MUST follow the existing order (preview, group, instance default). Sessionless pages MUST keep resolving to the instance default. + +#### Scenario: A branded app looks the same for every municipality + +- GIVEN group `gemeente-a` mapped to `gemeente-a-huisstijl` and the Collectives app branded with `kennisbank` +- WHEN a member of `gemeente-a` opens Collectives +- THEN the page MUST render in `kennisbank` +- AND when they open Files, it MUST render in `gemeente-a-huisstijl` + +#### Scenario: A preview still wins + +- GIVEN an administrator with an active theme preview of `amsterdam` +- WHEN they open the branded Collectives app +- THEN the page MUST render in `amsterdam` diff --git a/openspec/changes/surfaces-per-app-brand/tasks.md b/openspec/changes/surfaces-per-app-brand/tasks.md new file mode 100644 index 00000000..c8e0faf3 --- /dev/null +++ b/openspec/changes/surfaces-per-app-brand/tasks.md @@ -0,0 +1,21 @@ +# Tasks: per-app brand + +Tick a box when the work is merged to `development`. + +## 1. Model and resolution + +- [ ] 1.1 `app_brands` storage and validation in `AppThemingService` (installed app, not excluded, not protected, available set). Verify: `tests/Unit/Service/AppThemingServiceTest.php`. +- [ ] 1.2 Pass the resolved app id from `ThemeInjectionListener` into `CssInjectionService::inject()`; resolution order preview, app brand, group, default. Verify: `tests/Unit/Service/CssInjectionServiceTest.php` for each step and for the login page. +- [ ] 1.3 Logo layer uses the app's large and small logo. Verify: unit test on the inline layer output. + +## 2. Settings + +- [ ] 2.1 Brand per app block: app picker, set picker, logo uploads, contrast result, and the note that the app name stays Nextcloud's. Verify: Playwright scenario "An administrator gives the knowledge base its own brand". +- [ ] 2.2 Endpoints `#[AuthorizedAdminSetting]`, logo type and size checks. Verify: controller tests, non-admin 403, SVG with script refused. +- [ ] 2.3 Bundle: `appBrands` with logos as metadata, `bundleVersion` bump. Verify: `ConfigBundleServiceTest` round trip. + +## 3. Quality + +- [ ] 3.1 l10n en and nl. Verify: `npm run test:l10n`. +- [ ] 3.2 Docs: "Give an app its own brand" in `docs/`. Verify: the docs build. +- [ ] 3.3 Check a branded app in dark mode and with an incomplete set. Verify: manual check recorded in the PR. diff --git a/openspec/changes/token-set-vocabulary-audit/design.md b/openspec/changes/token-set-vocabulary-audit/design.md new file mode 100644 index 00000000..87739889 --- /dev/null +++ b/openspec/changes/token-set-vocabulary-audit/design.md @@ -0,0 +1,296 @@ +## Context + +Three things already guard the shipped token sets, and none of them can see the defect this change +targets: + +- `tests/Unit/TokenCssShapeTest.php` guards the file's **shape**: exactly one flat `:root { }` block, + no at-rules, no other selector. A raw upstream dump satisfies it perfectly. +- `tests/Unit/TokenSetContrastAuditTest.php` (via `ShippedTokenSetAuditService`) guards the + **contrast** of the resolved colours. It resolves `css/tokens/{id}.css` **layered over** + `css/systems/nldesign/defaults.css`, because a contrast ratio must be computed on the value that + actually renders. A set that declares nothing therefore audits the Rijkshuisstijl defaults, which + are WCAG-compliant — so it passes. +- `tests/validate-manifest.js` guards `token-sets.json`'s **structure**, not its agreement with the + CSS. + +The missing guard is the one about **vocabulary**: does the set declare the names the design +system's own stylesheets read? `css/systems/nldesign/theme.css`, `overrides.css` and +`element-overrides.css` read a fixed `--nldesign-*` set. When a token set declares none of it, the +cascade falls through to `defaults.css` and Zwolle renders as Rijkshuisstijl. That is invisible to +every existing gate, which is why this audit comes before the converter. + +## Goals / Non-Goals + +**Goals:** +- Turn "the examples are correct" into a mechanical, per-set, per-token statement. +- Record the baseline so stage 2's progress is auditable rather than claimed. +- Give a token-set author a one-command local answer (`npm run audit:token-sets`) with no PHP + runtime, no vendor install, no PHPUnit. +- Tell the admin, at the point of selection, that a set is incomplete and what is missing. +- Keep CI green today while making it impossible for the known-broken list to grow, or to silently + stop shrinking. + +**Non-Goals:** +- Fixing any token set. Not one `css/tokens/*.css` file is modified by this change; regeneration is + stage 2. +- Re-auditing contrast. `ShippedTokenSetAuditService` owns that and is not touched. +- Judging values. The audit asks whether a token is declared, never whether the declared colour is + the right one for the brand. +- Blocking an admin from applying an incomplete set. The warning is non-blocking, exactly like the + existing contrast warning. + +## Decisions + +### 1. The required-token list is a fixed 26-name constant, not derived + +`TokenSetVocabularyAuditService::REQUIRED_TOKENS` is the plan's stage-1 list, spelled out: + +``` +--nldesign-color-primary, -primary-text, -primary-hover, -primary-light, -primary-light-hover, +--nldesign-color-header-background, -header-text, -nav-background, +--nldesign-color-text, -text-muted, -border, -border-dark, -link, -link-hover, +--nldesign-color-error(+ -rgb), -warning(+ -rgb), -success(+ -rgb), -info(+ -rgb), +--nldesign-font-family, --nldesign-border-radius, -small, -large +``` + +It could have been derived from "every name `theme.css` reads" (118 names). It is not, for two +reasons: a derived list would silently grow whenever someone adds a `var()` to a theme layer, +turning an unrelated edit into 48 test failures; and many of those 118 names have a genuinely +sensible Rijkshuisstijl default that a brand has no opinion about (`--nldesign-animation-quick`, +`--nldesign-component-table-cell-padding-block`). The 26 are the ones where inheriting the default +means inheriting *someone else's brand*. A separate test +(`testRequiredTokensAreThemselvesInTheVocabulary`) asserts all 26 are names the app actually reads, +so the constant can never demand a token nothing consumes. + +`--nldesign-color-primary-rgb` is deliberately **not** required: unlike the status colours, no layer +in the app reads it, so requiring it would fail that same subset test. + +### 2. `missingRequired` is evaluated against the set file alone — the opposite of the contrast audit + +`ShippedTokenSetAuditService::resolveDeclarations()` layers `defaults.css` under the set on purpose. +This service must not, and therefore deliberately does not reuse it: layering is the mechanism that +hides the defect. `stage 1`'s question is "what does this file itself say?", so the audit parses only +`css/tokens/{id}.css`. Both services share `CssParserService` so that "what counts as a declaration" +still has exactly one definition. + +Comments are stripped **before** `parseDeclarations()` is called, because that parser does not strip +them — without this a commented-out `/* --nldesign-color-primary: red; */` would count as a +declaration and hide a missing token. + +### 3. The vocabulary is wider than the two files the plan names + +The plan says a set must declare no `--nldesign-*` name "outside the vocabulary declared in +`css/systems/nldesign/defaults.css` and `utrecht-bridge.css`". Measured against exactly those two +files, `amsterdam` — one of the 17 good sets — has 2 foreign names, because `theme.css` and +`element-overrides.css` read names those two files never mention (`--nldesign-logo-url`, +`--nldesign-color-background`, `--nldesign-color-nav-text`, `--nldesign-color-footer-*`, ...), and +hand-authored sets legitimately set them. + +So `declaredVocabulary()` scans **every `.css` file under `css/` except `css/tokens/`** and collects +every `--nldesign-*` name, declared or referenced. A name is in the vocabulary iff *something can +consume it*; anything else is dead weight, which is precisely what the rule is looking for. The two +runtime-generated admin-data files (`custom-overrides.css`, `custom-css.css`) are excluded by name so +a value an admin typed into the theme editor can never widen the accepted vocabulary. + +Under this vocabulary `rijkshuisstijl` has exactly one foreign name, +`--nldesign-color-logo-text` — a genuinely dead token nothing has ever read. The rule found a real +defect in the default set on its first run, which is the evidence that the widening did not +declaw it. + +### 4. Names are matched with `[A-Za-z0-9_-]+`, not `[a-z0-9-]+` + +`CssParserService::parseDeclarations()` matches `--[\w-]+`, so it parses camelCase names. An earlier +lowercase-only vocabulary scan combined with that parser produced an asymmetry: `nijmegen`'s three +upstream `--nldesign-tokenSetOrder-*` metadata tokens were reported as foreign by the PHP service and +not by the Node CLI. Both now use the same character class, and the finding is kept — those three +names are real dead weight, an upstream generator artefact leaking into a shipped file. + +### 5. Sets whose design system reads no `--nldesign-*` name are "not auditable", not "failing" + +`design-systems.json` lists each system's stylesheets. `nldesignConsumingSystems()` marks a system as +consuming when at least one of its own stylesheets references any `--nldesign-*` name. That yields: + +| Design system | Consumes the vocabulary | Why | +|---|---|---| +| `nldesign` | yes | `theme.css`/`overrides.css`/`element-overrides.css` | +| `high-contrast` | yes | its `theme.css` reads 39 of them | +| `lasuite`, `cunningham` | yes | `systems/lasuite/bridge.css` reads 54 | +| `summer-breeze` | **no** | its `theme.css` and `element-overrides.css` reference none | +| `none` | **no** | loads no stylesheet at all | + +`nextcloud` (`none`) and `summer-breeze` are therefore reported `auditable: false, complete: true` +with empty finding arrays, rather than as 26-token failures. This is why the measured baseline +excludes `summer-breeze` even though Appendix B lists it — its set file legitimately declares no +`--nldesign-*` token, because nothing in its stack would read one. (Plan decision 4 already flagged +`summer-breeze` as a special case; this is the mechanical form of that.) + +`lasuite`, `cunningham` and `hoog-contrast` get no such exemption: their bridges *do* read the +vocabulary, so their gaps (status colours and links, mostly) are real fall-throughs to Rijkshuisstijl. + +### 6. `primaryMismatch` never double-counts a missing primary + +A set with no `--nldesign-color-primary`, or one whose value is not a hex literal, already appears in +`missingRequired`. `primaryMismatch` is therefore true only when **both** values normalise to a hex +and they differ — one defect, one finding. Normalisation is lowercase + 3-to-6-digit expansion, and a +manifest entry without `theming.primary_color` (`conduction`) is not a mismatch, it is simply +unconstrained. + +Exactly one set fails this rule today: `xxllnc` declares `--nldesign-color-primary: #000000` while +`token-sets.json` says `#333333`. + +### 7. One allow-list file, read by both gates + +`tests/Unit/fixtures/token-set-vocabulary-allowlist.json` holds `{ "$comment": [...], "sets": [...] }`. +Both `tests/Unit/TokenSetVocabularyTest.php` and `scripts/audit-token-sets.mjs --check` read it. Two +copies of the list — one per runtime — would drift, and the whole point of the fixture is to be the +single record of what is known-broken. + +The gate is bidirectional on purpose: + +- a set that is incomplete and **not** listed fails (a new broken set cannot be added); +- a listed set that has started **passing** fails (stage 2 progress must be recorded, not hidden). + +So the list can only shrink, and it must be `[]` when stage 2 closes. + +### 8. The Node CLI is a mirror, and the mirroring is guarded + +`npm run audit:token-sets` exists because a token-set author fixing `zwolle.css` should not need +`composer install` to see whether the fix worked. It re-implements the three rules rather than +shelling out to PHP, which means the two lists can drift — +`testNodeMirrorRequiresTheSameTokens()` parses `REQUIRED_TOKENS` straight out of the `.mjs` and +asserts equality with the PHP constant. Both implementations were run against all 48 sets during +this change and agree field-for-field on every one. + +The CLI exits 0 by default (it is an informational audit; the PHPUnit test is the gate) and +non-zero under `--check`, which applies the same bidirectional allow-list logic. + +### 9. The warning rides the existing `warnings` channel + +`TokenSetService::applyWarnings()` already carries contrast findings to the admin UI through +`IInitialState`. The vocabulary finding is appended to the same array as a single entry with +`kind: 'incomplete'`, carrying `missing[]`, `foreign[]`, `primaryMismatch`, `declaredPrimary` and +`cssPrimary`. Contrast entries have no `kind`, so `admin.js` separates the two by that field alone: +`buildContrastWarningHtml()` filters `kind === 'incomplete'` out, `buildIncompleteWarningHtml()` +renders it, and `buildTokenSetWarningsHtml()` (the renamed apply-dialog entry point) emits both. + +The dropdown badge is a third, hidden-by-default element next to the design-system badge, so a clean +catalogue stays visually quiet. In the custom-set list "Incomplete set" outranks "Contrast warning": +a set that never defines the vocabulary is broken in a way no contrast ratio can reveal. + +**Cost**: `applyWarnings()` runs for every discovered set, so `getAvailableTokenSets()` now performs +one CSS-tree walk (29 files, memoised per service instance) plus one token-file read per set on top +of the contrast audit's existing two-file-per-set reads. Same order of magnitude as what was already +there, on an admin page and a catalogue endpoint. Not cached across requests in this change; stage 6 +reworks this surface and is the right place for it. + +## Baseline (measured 2026-09-07, `npm run audit:token-sets`) + +48 shipped sets: **46 audited, 5 complete, 41 incomplete, 2 not auditable**. + +Complete: `amsterdam`, `conduction`, `denhaag`, `utrecht`, `vng`. +Not auditable: `nextcloud` (`none`), `summer-breeze` (`summer-breeze`). + +The 41 incomplete ids are the initial contents of the allow-list. 30 of them are Appendix B entries; +the 11 that Appendix B does not list are `conduction-new`, `cunningham`, `frankendesk`, +`hoog-contrast`, `lasuite`, `leiden`, `noaberkracht`, `opencatalogi`, `rijkshuisstijl`, `rotterdam`, +`xxllnc`. Appendix B's 31st entry, `summer-breeze`, is not auditable (decision 5). + + + +| Set | Design system | Missing required | Foreign names | Primary | Verdict | In Appendix B | +|-----|---------------|-----------------:|--------------:|---------|---------|---------------| +| `amsterdam` | nldesign | 0 | 0 | ok | complete | — | +| `bodegraven-reeuwijk` | nldesign | 26 | 118 | (no CSS value) | incomplete | yes | +| `borne` | nldesign | 26 | 74 | (no CSS value) | incomplete | yes | +| `buren` | nldesign | 26 | 117 | (no CSS value) | incomplete | yes | +| `conduction` | nldesign | 0 | 0 | (unset) | complete | — | +| `conduction-new` | nldesign | 15 | 189 | (no CSS value) | incomplete | — | +| `cunningham` | cunningham | 10 | 0 | ok | incomplete | — | +| `demodam` | nldesign | 26 | 52 | (no CSS value) | incomplete | yes | +| `denhaag` | nldesign | 0 | 0 | ok | complete | — | +| `dinkelland` | nldesign | 24 | 183 | (no CSS value) | incomplete | yes | +| `drechterland` | nldesign | 26 | 93 | (no CSS value) | incomplete | yes | +| `duiven` | nldesign | 26 | 24 | (no CSS value) | incomplete | yes | +| `duo` | nldesign | 26 | 85 | (no CSS value) | incomplete | yes | +| `enkhuizen` | nldesign | 26 | 97 | (no CSS value) | incomplete | yes | +| `epe` | nldesign | 26 | 186 | (no CSS value) | incomplete | yes | +| `frankendesk` | lasuite | 10 | 81 | ok | incomplete | — | +| `groningen` | nldesign | 26 | 2 | (no CSS value) | incomplete | yes | +| `haarlem` | nldesign | 26 | 120 | (no CSS value) | incomplete | yes | +| `haarlemmermeer` | nldesign | 26 | 117 | (no CSS value) | incomplete | yes | +| `hoog-contrast` | high-contrast | 6 | 0 | ok | incomplete | — | +| `hoorn` | nldesign | 26 | 85 | (no CSS value) | incomplete | yes | +| `horstaandemaas` | nldesign | 26 | 98 | (no CSS value) | incomplete | yes | +| `lasuite` | lasuite | 10 | 0 | ok | incomplete | — | +| `leiden` | nldesign | 22 | 163 | ok | incomplete | — | +| `leidschendam-voorburg` | nldesign | 26 | 91 | (no CSS value) | incomplete | yes | +| `nextcloud` | none | — | — | — | not auditable | — | +| `nijmegen` | nldesign | 26 | 959 | (no CSS value) | incomplete | yes | +| `noaberkracht` | nldesign | 22 | 159 | ok | incomplete | — | +| `noordoostpolder` | nldesign | 26 | 128 | (no CSS value) | incomplete | yes | +| `noordwijk` | nldesign | 24 | 168 | (no CSS value) | incomplete | yes | +| `opencatalogi` | nldesign | 4 | 28 | ok | incomplete | — | +| `provincie-zuid-holland` | nldesign | 26 | 83 | (no CSS value) | incomplete | yes | +| `riddeliemers` | nldesign | 26 | 60 | (no CSS value) | incomplete | yes | +| `ridderkerk` | nldesign | 26 | 9 | (no CSS value) | incomplete | yes | +| `rijkshuisstijl` | nldesign | 0 | 1 | ok | incomplete | — | +| `rotterdam` | nldesign | 4 | 28 | ok | incomplete | — | +| `stedebroec` | nldesign | 26 | 94 | (no CSS value) | incomplete | yes | +| `summer-breeze` | summer-breeze | — | — | — | not auditable | yes | +| `tilburg` | nldesign | 26 | 119 | (no CSS value) | incomplete | yes | +| `tubbergen` | nldesign | 24 | 184 | (no CSS value) | incomplete | yes | +| `utrecht` | nldesign | 0 | 0 | ok | complete | — | +| `venray` | nldesign | 26 | 105 | (no CSS value) | incomplete | yes | +| `vng` | nldesign | 0 | 0 | ok | complete | — | +| `vught` | nldesign | 26 | 110 | (no CSS value) | incomplete | yes | +| `westervoort` | nldesign | 25 | 33 | (no CSS value) | incomplete | yes | +| `xxllnc` | nldesign | 21 | 167 | mismatch | incomplete | — | +| `zevenaar` | nldesign | 26 | 25 | (no CSS value) | incomplete | yes | +| `zwolle` | nldesign | 26 | 94 | (no CSS value) | incomplete | yes | + +Reading the table: + +- **26 missing + a high foreign count** is the raw-upstream-dump signature: the set declares a full + brand palette under the `--nldesign-` prefix and none of the semantic vocabulary. `nijmegen` (956 + foreign names) is the extreme case; `groningen` (2 foreign, 18 lines) and `ridderkerk` (9 foreign) + are the near-empty ones the plan already flagged as needing a hand-authored brand file. +- **A small missing count with a high foreign count** (`conduction-new` 15/189, `leiden` 22/163, + `xxllnc` 21/167, `noaberkracht` 22/159) is the woo-website shape: real semantic values plus a large + `--nldesign-footer-*`/`-hero-*`/`-card-*` vocabulary that only `css/public-bridge.css` partially + reads. +- **A small missing count with zero foreign names** (`cunningham` 10/0, `lasuite` 10/0, + `hoog-contrast` 6/0) is a clean set with a genuine gap: all of them omit `--nldesign-color-link`, + `-link-hover` and the four status `-rgb` triplets, so those fall through to Rijkshuisstijl. +- **`rijkshuisstijl` 0/1** is the one-name case from decision 3. + +## Risks / Trade-offs + +- **The allow-list is 41 entries, not the plan's 31.** Stage 2's scope is larger than + the planning notes assumed. The alternative — narrowing the required list until exactly Appendix B + failed — would have meant dropping links, status colours, `-rgb` triplets, `font-family` and every + `border-radius` from the definition, which is most of what makes a brand look like itself. The + measurement stands and the plan's Appendix B line is corrected here. +- **Every admin now sees a warning on 41 of 48 sets.** Intended ("no silent drops"), but noisy until + stage 2 lands. The badge is hidden for complete sets and the warning is non-blocking, so nothing is + prevented; stage 6 owns the copy and grouping. +- **The vocabulary is derived from the CSS tree, so deleting a `var()` reference can create a foreign + name.** That is the intended direction of the rule (a name nothing reads is dead weight), but it + means an unrelated cleanup in `theme.css` can add allow-list pressure. The per-set failure message + names the exact token, so the cause is never a mystery. +- **The Node mirror can drift in the rules, not just the token list.** The token list is guarded by a + test; the rule bodies are not. They were verified equal across all 48 sets during this change. If + they diverge later, the PHPUnit gate is authoritative and the CLI is the convenience. + +## Migration Plan + +None. No data, no config key, no CSS file changes; the audit is additive and read-only. The one +user-visible change (the badge/warning) appears on the next admin-page load. + +## Open Questions + +- Does `--nldesign-color-logo-text` (rijkshuisstijl's single foreign name) want a consumer in + `theme.css`, or should the declaration be deleted? Deleting it is the smaller change and takes + `rijkshuisstijl` to complete; stage 2 can do either. +- `summer-breeze` is not auditable under decision 5, which means its set file is unguarded by + anything. Should the `summer-breeze` system get its own required vocabulary in a later stage, or + should the set be folded into the nldesign stack? diff --git a/openspec/changes/token-set-vocabulary-audit/proposal.md b/openspec/changes/token-set-vocabulary-audit/proposal.md new file mode 100644 index 00000000..0246654d --- /dev/null +++ b/openspec/changes/token-set-vocabulary-audit/proposal.md @@ -0,0 +1,113 @@ +--- +kind: code +--- + +## Why + +Selecting `zwolle`, `tubbergen` or `haarlem` in the admin dropdown produces a Nextcloud that looks +like Rijkshuisstijl, not like Zwolle, Tubbergen or Haarlem. This is the "the examples do not look +correct" symptom, and it is not a styling opinion — it is a mechanical fact about the shipped files. + +`css/systems/nldesign/theme.css`, `overrides.css` and `element-overrides.css` consume a fixed +`--nldesign-*` vocabulary (`--nldesign-color-primary`, `-primary-text`, `-header-background`, +`--nldesign-font-family`, `--nldesign-border-radius`, ...). The 17 hand-authored sets define it. The +other 31 are raw dumps from `nl-design-system/themes`: palette steps such as +`--nldesign-color-blue-40` plus `--nldesign-typography-*` and `--nldesign-space-*` names **nothing +in the app reads**. For those sets the cascade falls straight through to +`css/systems/nldesign/defaults.css`, whose values are Rijkshuisstijl's — so every one of them +renders as Rijkshuisstijl with, at best, a different header. + +Nothing detects this today. `tests/Unit/TokenCssShapeTest.php` checks the file's *shape* (one flat +`:root` block, no at-rules) and `tests/Unit/TokenSetContrastAuditTest.php` checks the *contrast* of +the colours a set does define — but a set that defines nothing at all passes both: it is +structurally perfect and its (inherited Rijkshuisstijl) colours are WCAG-compliant. This change +makes "correct" a mechanical statement, enforced by a test, before any file is regenerated. It is +the first step of the theming makeover, and the NLDS→Nextcloud converter has no definition of done +without it. + +## What Changes + +- Add `lib/Service/TokenSetVocabularyAuditService.php`: given an app root and a set id, it returns + `{missingRequired[], foreignNldesignNames[], primaryMismatch}` plus the resolved primary values and + a `complete` verdict. Three rules, no colour judgement: + 1. **`missingRequired`** — the 26 required semantic tokens the set file itself does not declare, + evaluated against the set file **alone** (layering it over `defaults.css` is exactly what hides + the defect). + 2. **`foreignNldesignNames`** — `--nldesign-*` names the set declares that no CSS layer in the app + declares a default for or reads. Raw palette steps belong under the brand prefix + (`--zwolle-color-blue-40`), never under `--nldesign-`. + 3. **`primaryMismatch`** — `--nldesign-color-primary` disagrees with `token-sets.json`'s + `theming.primary_color` after hex normalisation. One value, one source of truth. +- Add `tests/Unit/TokenSetVocabularyTest.php`: runs the audit over every `css/tokens/*.css` and fails + with a per-set list of what is missing/foreign/mismatched. Ships with an explicit allow-list of the + sets that fail today so CI stays green; the gate also fails on an allow-list entry that has started + passing, so the list can only shrink. +- Add `tests/Unit/fixtures/token-set-vocabulary-allowlist.json`, read by **both** the PHPUnit gate and + the Node CLI so they can never disagree about what is known-broken. It must be an empty array when + stage 2 closes. +- Add `scripts/audit-token-sets.mjs` + `npm run audit:token-sets` (and `audit:token-sets:check`): a + dependency-free Node mirror of the same three rules that prints a per-set table (set, design + system, missing count, foreign count, primary match, verdict), so a token-set author can run the + audit without a PHP runtime. A test asserts the two required-token lists have not drifted. +- Surface the verdict in the admin UI as a third badge state, "Incomplete set", next to the existing + design-system and WCAG badges, with a tooltip listing exactly what is missing — carried on the + existing `warnings` channel from `TokenSetService::applyWarnings()`, so the apply dialog raises it + too. `TokenSetService` gains one constructor dependency. +- Gitignore `css/custom-css.css` next to `custom-overrides.css`: it is admin-generated runtime data + written by `CustomCssService::write()` on demand, not app source. + +## Capabilities + +### New Capabilities +- None. This change adds requirements to the existing `token-sets` capability rather than a new one: + the subject is what a shipped token set MUST contain, which is that spec's "Token Set CSS Structure" + requirement, and the audit has no user-facing surface of its own beyond one badge on the existing + dropdown. + +### Modified Capabilities +- `token-sets`: two added requirements — "Shipped Token Set Vocabulary Completeness" (the mechanical + definition of correct, the three rules, the not-auditable cases, and the allow-list contract) and + "Incomplete Sets Are Surfaced In The Admin Dropdown" (the badge, the tooltip, and the reuse of the + `warnings` channel). + +## Impact + +- **Measured baseline diverges from the planning estimate (31 sets) — 41 sets fail, and + `summer-breeze` is not auditable at all.** Appendix B counted the sets that define *none* of the + vocabulary; the plan's own stage-1 definition is stricter than that, and 11 further sets + (`conduction-new`, `cunningham`, `frankendesk`, `hoog-contrast`, `lasuite`, `leiden`, + `noaberkracht`, `opencatalogi`, `rijkshuisstijl`, `rotterdam`, `xxllnc`) fail it on partial gaps. + See design.md "Baseline" for the full table and the per-set reasons. The allow-list therefore + starts at 41 entries, not 31; the planning estimate ("exactly the 31 sets") is superseded by the + measurement, and the converter's scope is correspondingly larger. + + **The 41 is the baseline, not the current count.** The list shrinks as sets are repaired and may + never grow — that is the fixture's whole contract. `rotterdam` and `zwolle` were repaired within + this change and left the list on the same commit that fixed them, so + `tests/Unit/fixtures/token-set-vocabulary-allowlist.json` now holds **39**. Read a number here as + what was measured on 2026-09-07; the fixture is the only live count. + + The 41 above is the figure measured when this change was written. The fixture now holds 39, and + the audit reports 7 complete where this measurement found 5: 41 + 5 and 39 + 7 are both 46, the + audited total, so exactly two sets moved from incomplete to complete while the branch went on. + The list is shrink-only and the gate fails on a listed set that has started passing, so that is + the mechanism working rather than drift. The fixture is the authority; a number written into + prose is a snapshot, which is why the test's own docblock no longer quotes one. +- **Behavioural change for shipped sets**: the apply dialog and the dropdown now raise a + non-blocking warning for 41 of the 48 shipped sets. That is the point ("no silent drops"), but it + is visible to every admin from this release on; stage 6 refines the copy and grouping. +- **Code**: `lib/Service/TokenSetVocabularyAuditService.php` (new), + `lib/Service/TokenSetService.php` (one new constructor dependency, `applyWarnings()` merges the + vocabulary warnings after the contrast ones), `js/admin.js` (badge, tooltip, banner; the apply + dialog's `buildContrastWarningHtml()` becomes `buildTokenSetWarningsHtml()` over two banner types), + `templates/settings/admin.php` (one badge element), `scripts/audit-token-sets.mjs` (new), + `package.json` (two scripts), `.gitignore` (one entry), `l10n/*` (5 new keys, Dutch translated), + `tests/Unit/TokenSetVocabularyTest.php` + fixture (new), and the six existing tests that construct + `TokenSetService` directly. +- **No token set file is modified by stage 1.** Fixing the measured 41 is stage 2's job; stage 1 only + makes the failure visible and mechanical. (Two of them, `rotterdam` and `zwolle`, were repaired by + hand in a later commit of this same pull request and are no longer allow-listed.) +- **No OpenRegister schemas, no lifecycle/aggregation/notification behaviour** — the audit is pure + filesystem work over `css/`, `token-sets.json` and `design-systems.json`. No Seed Data section + applies and the ADR-031 declarative-vs-imperative distinction does not. +- **Dependencies**: none beyond the existing `CssParserService`. diff --git a/openspec/changes/token-set-vocabulary-audit/specs/token-sets/spec.md b/openspec/changes/token-set-vocabulary-audit/specs/token-sets/spec.md new file mode 100644 index 00000000..c60c31b2 --- /dev/null +++ b/openspec/changes/token-set-vocabulary-audit/specs/token-sets/spec.md @@ -0,0 +1,32 @@ +# Spec delta: Token Sets (token-set-vocabulary-audit) + +Adds the mechanical definition of a *correct* shipped token set — one that declares the +`--nldesign-*` vocabulary its design system actually reads — and the admin surface that says so when +a set does not. The existing "Token Set CSS Structure" requirement stays true exactly as written +(a set MAY override any `--nldesign-*` variable, and an incomplete set MUST still render); this delta +adds the separate statement that a *shipped* set MUST NOT rely on that fallback for the tokens that +carry a brand's identity. + + +## Status: LANDED + +This delta has no ADDED Requirements section any more, because it has none left to add. Both +requirements now live in `openspec/specs/token-sets/spec.md`, copied there verbatim by `8e57d62` / +`f80c126` so the `@spec` anchors +`#requirement-shipped-token-set-vocabulary-completeness` and +`#requirement-incomplete-sets-are-surfaced-in-the-admin-dropdown` resolve against the +source-of-truth spec rather than against an unmerged change. + +The text is deliberately not repeated here. Two live normative copies of one requirement drift +apart, and the canonical spec is the one every `@spec` tag points at. + +- `### Requirement: Shipped Token Set Vocabulary Completeness` — the three mechanical rules, the + 26 required semantic tokens, and the allow-list's shrink-only contract. +- `### Requirement: Incomplete Sets Are Surfaced In The Admin Dropdown` — the badge, the tooltip, + and the reuse of the existing `warnings` channel. + +**Not archived yet.** `tasks.md` still carries three unchecked items — 7.4 (`composer check:strict` +over the changed PHP), 7.5 (the full PHPUnit suite) and 7.6 (a Playwright spec for the "Incomplete +set" badge and tooltip). 7.4 and 7.5 have since been run green in CI, so 7.6 is the only one with +work left in it; tick the other two and archive this change once that spec exists. Nothing further +needs to land in the spec by then. diff --git a/openspec/changes/token-set-vocabulary-audit/tasks.md b/openspec/changes/token-set-vocabulary-audit/tasks.md new file mode 100644 index 00000000..5de08add --- /dev/null +++ b/openspec/changes/token-set-vocabulary-audit/tasks.md @@ -0,0 +1,116 @@ +Note: no OpenRegister schemas are involved in this change — the audit is pure filesystem work over +`css/`, `token-sets.json` and `design-systems.json`, and it persists nothing. There is no Seed Data +section and no seed task. There is no lifecycle/aggregation/notification behaviour either, so the +ADR-031 declarative-vs-imperative notification-dialect distinction does not apply. + +Task numbering (1.1–1.7) follows the planning notes this change was written from. + +## 1. Spec and Design (plan task 1.1, 1.7) + +- [x] 1.1 Write this change: `proposal.md`, `design.md`, `tasks.md`, and the spec delta on + `openspec/specs/token-sets/spec.md` (two ADDED requirements — vocabulary completeness and the + admin-dropdown surface; no MODIFIED requirement, the existing "Token Set CSS Structure" + requirement stays true as written). +- [x] 1.7 Record the measured baseline table (48 sets, per-set missing/foreign/primary/verdict) in + `design.md`, alongside the reconciliation against the planning estimate — 41 sets fail, + not 31, and `summer-breeze` is not auditable. Regeneratable with + `node scripts/audit-token-sets.mjs --json`. That 41 is the baseline as measured on + 2026-09-07; the allow-list fixture is the live count and may only shrink from it. + +## 2. Audit Service (plan task 1.2) + +- [x] 2.1 Create `lib/Service/TokenSetVocabularyAuditService.php` (SPDX docblock, `@spec` tags + referencing the two new requirements) with `auditSet()`, `auditAll()`, `warningsFor()`, + `declaredVocabulary()` and `nldesignConsumingSystems()`. `auditSet()` returns + `{id, designSystem, auditable, missingRequired[], foreignNldesignNames[], primaryMismatch, + declaredPrimary, cssPrimary, complete}`. +- [x] 2.2 Declare `REQUIRED_TOKENS` as a public 26-name constant (design.md decision 1) and reuse + `CssParserService` for declaration parsing, stripping comments first because that parser does + not (design.md decision 2). +- [x] 2.3 Memoise the vocabulary scan and the consuming-systems scan per app root, so auditing all + 48 sets walks the CSS tree once rather than 48 times. + +## 3. PHPUnit Gate (plan task 1.3) + +- [x] 3.1 Create `tests/Unit/fixtures/token-set-vocabulary-allowlist.json` holding the measured + known-incomplete ids plus a `$comment` block stating the shrink-only contract and that the + array MUST be empty when stage 2 closes. Created with the 41 measured on 2026-09-07; it holds + 39 since `rotterdam` and `zwolle` were repaired, which is the shrink-only contract working + rather than a discrepancy. +- [x] 3.2 Create `tests/Unit/TokenSetVocabularyTest.php` (no Nextcloud runtime, mirroring the + `TokenSetContrastAuditTest.php` static-inventory pattern) with: + `testEveryShippedSetIsCompleteOrAllowListed` (fails with a per-set list of missing/foreign/ + mismatched names), `testAllowlistHasNoStaleEntries` (a listed set that now passes fails the + gate, so the list can only shrink), `testAuditDistinguishesCompleteFromIncompleteSets` + (non-vacuity), `testSetsOfNonConsumingDesignSystemsAreNotAudited`, + `testNodeMirrorRequiresTheSameTokens` (drift guard against `scripts/audit-token-sets.mjs`), and + `testRequiredTokensAreThemselvesInTheVocabulary` (the required list can never demand a token + nothing reads). + +## 4. Node CLI (plan task 1.4) + +- [x] 4.1 Create `scripts/audit-token-sets.mjs`: dependency-free Node mirror of the same three rules, + reading the same allow-list fixture. Prints a table (set, design system, missing count, foreign + count, primary match, verdict) plus a summary; `--verbose` lists every offending token name, + `--json` emits machine-readable results, `--check` exits non-zero on an unlisted failure or a + stale allow-list entry. +- [x] 4.2 Register `npm run audit:token-sets` and `npm run audit:token-sets:check` in `package.json`. +- [x] 4.3 Verify the Node and PHP implementations agree: both were run over all 48 sets and match + field-for-field (the run surfaced and fixed the `[a-z0-9-]` vs `[\w-]` asymmetry, design.md + decision 4). + +## 5. Admin Surface (plan task 1.5) + +- [x] 5.1 Add `TokenSetVocabularyAuditService::warningsFor()` returning a single + `kind: 'incomplete'` entry, and merge it into `TokenSetService::applyWarnings()` after the + contrast warnings (new constructor dependency; the six existing tests that construct + `TokenSetService` directly are updated). +- [x] 5.2 Add the hidden-by-default `#nldesign-token-set-completeness-badge` element to + `templates/settings/admin.php`, next to the design-system badge. +- [x] 5.3 In `js/admin.js`: `incompleteWarningFor()`, `incompleteWarningLines()` and + `updateCompletenessBadge()` (called from both `change` and initial-paint paths); split the apply + dialog's banner into `buildContrastWarningHtml()` (now filtering `kind === 'incomplete'` out) + and `buildIncompleteWarningHtml()`, both emitted by the renamed + `buildTokenSetWarningsHtml()`; add "Incomplete set" as the top-priority third state in + `renderCustomSetList()`'s badge. +- [x] 5.4 Extract the 5 new `t('thematiq', ...)` strings into `l10n/en.json` + (`node tests/l10n/check-l10n.js --write`) — the only l10n file CI gates + (`code-quality.yml` runs `test:l10n`, NOT `test:l10n:completeness`) — translate the Dutch side + by hand in `l10n/nl.json`, and rebuild those two browser catalogues. + The other 35 locale files are deliberately NOT touched: backfilling them writes the English + source as a placeholder value, which is a 70-file diff carrying no translation. `.prettierignore` + already records `l10n/` as "written by the translation workflow", so those files belong to that + workflow, not to a feature commit. `node tests/l10n/check-l10n-completeness.js` therefore + reports 5 missing keys per locale until the translation workflow next runs — expected, and not + a CI failure. + +## 6. Repository Hygiene (plan task 1.6) + +- [x] 6.1 Add `/css/custom-css.css` to `.gitignore` next to `/css/custom-overrides.css`, with the + reason recorded inline. +- [x] 6.2 Confirm the file is created on demand and its absence is harmless: `CustomCssService::write()` + writes it atomically (temp file + rename) and `CssInjectionService::injectOverrideStyles()` + emits the stylesheet only when the feature is enabled AND the file has content — so unlike + `custom-overrides.css` nothing needs an `ensureExists()` on boot. The stray dump that was in + this worktree is gone. + +## 7. Quality Gates + +- [x] 7.1 `php -l` clean on every new/changed PHP file, and `node --check js/admin.js` clean. +- [x] 7.2 `node tests/l10n/check-l10n.js`, `node tests/l10n/check-l10n-completeness.js` and + `node scripts/build-l10n-js.js --check` all green. +- [x] 7.3 `npm run audit:token-sets:check` green against the committed allow-list. +- [ ] 7.4 Run `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) over the new/changed PHP files + and fix any findings. **Not run: no `composer`/`vendor/` and no PHP CLI on the authoring + machine** — the PHP was syntax-checked inside the running `nextcloud` container instead. +- [ ] 7.5 Run the full `phpunit` suite (in particular `TokenSetVocabularyTest`, `TokenCssShapeTest`, + `TokenSetContrastAuditTest`, and the six updated `TokenSetService` tests). **Not run: no + `vendor/`, so PHPUnit cannot be invoked locally.** The audit rules themselves were verified by + running the PHP service directly against all 48 sets and diffing the result against the Node + CLI. +- [ ] 7.6 Add or extend a Playwright spec-coverage test for the "Incomplete set" badge and tooltip, + or apply a reason-bearing `@e2e exclude` to the backend-only scenarios. +- [x] 7.7 Add the `CHANGELOG.md` "Unreleased" entries (Added: the audit, the npm CLI, the badge; + Changed: the `css/custom-css.css` gitignore). `appinfo/info.xml` `` is deliberately + NOT bumped by hand — every commit that has ever touched it is a `chore(release)` from the + release workflow, which is also what moves the `?v=` cache-buster for the changed `js/admin.js`. diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json new file mode 100644 index 00000000..aaf8cbb1 --- /dev/null +++ b/openspec/parity/capabilities.json @@ -0,0 +1,6226 @@ +{ + "comparedOn": "2026-09-26", + "category": "Runtime brand and design-token theming for a government collaboration workspace. Thematiq applies a government house style, expressed as NL Design System design tokens (and the French Cunningham/La Suite system), to a whole Nextcloud instance at runtime: every app, the login page, emails and the header, scoped per app or per group, with WCAG contrast evidence, a trial before publishing, an audit trail and OTAP portability. The buyer is a government IT or communications team that must make a self-hosted workplace look like the organisation's own. It therefore competes with the branding layer of other workplace suites (Nextcloud's own Theming app, Microsoft 365 organisational branding, openDesk) and with the design-token tooling a team uses to maintain a house style for government portals (Liferay DXP style books, Tokens Studio). It is not a design system or a component library: NL Design System, GOV.UK, USWDS and the like are its inputs, not its competitors.", + "corpus": { + "repo": "ConductionNL/thematiq", + "file": "openspec/parity/capabilities.json" + }, + "readAt": { + "repo": "ConductionNL/thematiq", + "branch": "development", + "sha": "fa7117c18e5c03755e77d342b497078cd0b54db9" + }, + "systems": [ + { + "key": "thematiq", + "name": "Thematiq", + "vendor": "Conduction", + "isSelf": true, + "readOn": "2026-09-26", + "columnAddedOn": "2026-09-26" + }, + { + "key": "nextcloud-theming", + "name": "Nextcloud Theming (built-in app)", + "vendor": "Nextcloud GmbH", + "readOn": "2026-09-26", + "columnAddedOn": "2026-09-26", + "evidenceGrade": "docs-only", + "unknownReason": "Each unknown cell carries its own not-checked reason: rows the v35.0.1 source read could not settle without a live drive, and rows mined on 2026-09-26 after the column was read.", + "sources": { + "docs": "https://docs.nextcloud.com/server/stable/admin_manual/configuration_server/theming.html", + "sourceRepo": "https://github.com/nextcloud/server/tree/v35.0.1/apps/theming (tag v35.0.1, commit 3c012e6)", + "featurePage": "https://nextcloud.com/blog/branding-theming-nextcloud-hub/", + "featureRequests": "https://github.com/nextcloud/server/issues?q=is%3Aissue+is%3Aopen+label%3A%22feature%3A+theming%22+label%3Aenhancement", + "issueTracker": { + "url": "https://github.com/nextcloud/server/issues?q=is%3Aissue+is%3Aopen+label%3A%22feature%3A+theming%22", + "featureLabel": "feature: theming" + }, + "roadmap": null, + "changelog": [ + "https://github.com/nextcloud/server/releases/tag/v35.0.1", + "https://nextcloud.com/changelog/" + ], + "apiReference": [ + "https://github.com/nextcloud/server/blob/v35.0.1/apps/theming/openapi.json", + "https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html" + ], + "marketplace": "https://apps.nextcloud.com/apps/theming_customcss", + "pricing": null, + "accessibilityStatement": null, + "securityDocs": "https://github.com/nextcloud/server/blob/v35.0.1/SECURITY.md", + "demoInstance": "https://try.nextcloud.com/", + "community": "https://help.nextcloud.com/", + "reviews": null, + "videos": "https://nextcloud.com/blog/webinar/webinar-branding-theming-nextcloud-hub/", + "caseStudies": null, + "partnerDirectory": null, + "trainingCurriculum": null, + "jobPostings": null, + "tenders": [ + "opentender AT ocds-70d2nz-e3a2e012-3572-3e76-abe8-62471096b8de (Bundesministerium fuer Arbeit und Wirtschaft, Nextcloud Subscriptions, https://opentender.eu/at/tender/ocds-70d2nz-e3a2e012-3572-3e76-abe8-62471096b8de)", + "opentender AT ocds-70d2nz-6ff0aa94-6e12-3651-b25e-efe4f4e7cd2e (Universitaet Innsbruck, Nextcloud Groupware Campus Lizenz, 2024-12-12, https://opentender.eu/at/tender/ocds-70d2nz-6ff0aa94-6e12-3651-b25e-efe4f4e7cd2e)" + ], + "nullReasons": { + "roadmap": "2026-09-26: no public roadmap found; github.com/nextcloud/roadmap returns 404 and nextcloud.com could not be reached from this sandbox (connection refused), so a roadmap page there was not checked", + "pricing": "2026-09-26: theming is free in the AGPL server; nextcloud.com/pricing could not be reached from this sandbox (connection refused), so enterprise branding prices were not read", + "accessibilityStatement": "2026-09-26: nextcloud.com/accessibility could not be reached (connection refused); no accessibility statement file in the v35.0.1 source tree", + "reviews": "2026-09-26: g2.com/products/nextcloud/reviews returned 403; no other review source checked", + "caseStudies": "2026-09-26: nextcloud.com/customers could not be reached (connection refused); no theming-specific case study found in web search", + "partnerDirectory": "2026-09-26: nextcloud.com/partners could not be reached (connection refused)", + "trainingCurriculum": "2026-09-26: nextcloud.com/training could not be reached (connection refused); the admin manual theming page is the only training material found", + "jobPostings": "2026-09-26: nextcloud.com/jobs could not be reached (connection refused); not relevant to a shipped server component" + }, + "notes": { + "featurePage": "URL found by web search on 2026-09-26; nextcloud.com itself refused connections from this sandbox, so its current content was not read", + "changelog": "GitHub release list read with gh; nextcloud.com/changelog found by web search but not loadable here", + "marketplace": "theming_customcss is a separate App Store app that adds a custom CSS field; it is not part of the built-in theming app", + "videos": "found by web search 2026-09-26, not loaded" + } + }, + "readingNotes": "Grade is docs-only: this reading was of the local server source (read-only) plus the published admin manual, not of a running instance, per the brief. Key structural facts a matrix author needs: (1) there is no house-style *catalogue* at all in stock Nextcloud - admin theming is exactly two colours (primary, background) plus four uploadable images (logo, logoheader, background, favicon) plus text fields (name, url, slogan, imprint/privacy URLs); most 'catalogue', 'authoring' (token-set upload/DTCG/CSS/Figma/Git) and 'scoping' (group/tenant/domain) rows are structurally out of scope, not merely unimplemented. (2) The 'themes' the app does ship are accessibility/appearance variants - default, light, dark, high-contrast, dark+high-contrast, a dyslexia font, and a reduced-motion toggle - selectable per-user in Personal settings, auto-applied via prefers-color-scheme/prefers-contrast/prefers-reduced-motion media queries, and instance-enforceable via config.php's enforce_theme. (3) Its one real strength versus thematiq's likely gaps: Util::elementColor() bakes a minimum-contrast guarantee (3.2:1, 5.6:1 under high-contrast) into every derived element colour automatically, and DefaultTheme/DarkTheme hand-tune status colours to a documented 4.5:1 target - a built-in contrast-safety net thematiq would need to demonstrate matching. (4) Governance: this settings section already supports Nextcloud's native admin-delegation (IDelegatedSettings), so 'delegate theme editing to a non-admin' is a real yes, not a gap. (5) No custom CSS field, no font upload, no audit log, no OTAP config bundle (only per-key occ theming:config), no marketplace, no desktop/mobile client branding (Enterprise-only per Nextcloud's own tiering, not present here).", + "readSources": [ + "server repo @ 175c7e6be47 (git -C ~/nextcloud-docker-dev/workspace/server log -1 --format=%h), apps/theming/lib/Controller/ThemingController.php", + "apps/theming/lib/ThemingDefaults.php", + "apps/theming/lib/Capabilities.php", + "apps/theming/lib/IconBuilder.php", + "apps/theming/lib/ImageManager.php", + "apps/theming/lib/Util.php", + "apps/theming/lib/ConfigLexicon.php", + "apps/theming/lib/Command/UpdateConfig.php", + "apps/theming/lib/Settings/Admin.php", + "apps/theming/lib/Settings/Personal.php", + "apps/theming/lib/Themes/DefaultTheme.php", + "apps/theming/lib/Themes/DarkTheme.php", + "apps/theming/lib/Themes/LightTheme.php", + "apps/theming/lib/Themes/HighContrastTheme.php", + "apps/theming/lib/Themes/DarkHighContrastTheme.php", + "apps/theming/lib/Themes/DyslexiaFont.php", + "apps/theming/lib/Themes/ReducedMotion.php", + "apps/theming/lib/Themes/CommonThemeTrait.php", + "apps/theming/lib/Service/ThemesService.php", + "apps/theming/lib/Service/BackgroundService.php", + "apps/theming/lib/Controller/UserThemeController.php", + "apps/theming/src/components/admin/ColorPickerField.vue", + "apps/theming/src/utils/refreshStyles.ts", + "apps/theming/fonts/ (OpenDyslexic-Regular.otf, OpenDyslexic-Bold.otf, no in-repo LICENSE found)", + "lib/private/Mail/EMailTemplate.php", + "config/config.sample.php (enforce_theme, theming.standalone_window.enabled)", + "https://docs.nextcloud.com/server/stable/admin_manual/configuration_server/theming.html (fetched 2026-09-26, thinner than the source: covers name/url/slogan, primary+background colour, logo, background/login image, favicon, legal links, disable-user-theming, occ theming:config; does not mention fonts, custom CSS, group theming, accessibility statements, dark/high-contrast modes, or a marketplace)" + ], + "readNote": "source read at nextcloud/server v35.0.1 (3c012e6, latest stable, 2026-09-24), not driven; replaces the 2026-09-26 read at dev commit 175c7e6be47" + }, + { + "key": "m365-branding", + "name": "Microsoft 365 organisational branding (Entra company branding, Microsoft 365 themes, SharePoint brand center)", + "vendor": "Microsoft", + "readOn": "2026-09-26", + "columnAddedOn": "2026-09-26", + "evidenceGrade": "docs-only", + "unknownReason": "not checked beyond Entra ID sign-in branding, Microsoft 365 organization theme (suite header), SharePoint site theming/brand center and the SharePoint organization assets library documentation read on 2026-09-26; Teams/Outlook client-internal theming, notification-email templates, monitoring/metrics for the branding subsystem, and any NL-government-specific presets were not covered by that documentation.", + "sources": { + "docs": [ + "https://learn.microsoft.com/en-us/entra/fundamentals/how-to-customize-branding", + "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/customize-your-organization-theme", + "https://learn.microsoft.com/en-us/sharepoint/brand-center-overview", + "https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/site-theming/sharepoint-site-theming-overview", + "https://learn.microsoft.com/en-us/microsoftteams/meeting-themes" + ], + "sourceRepo": null, + "featurePage": "https://learn.microsoft.com/en-us/sharepoint/brand-center-overview", + "featureRequests": "https://feedbackportal.microsoft.com/", + "issueTracker": null, + "roadmap": "https://www.microsoft.com/microsoft-365/roadmap", + "changelog": [ + "https://learn.microsoft.com/en-us/entra/fundamentals/whats-new", + "https://learn.microsoft.com/en-us/entra/fundamentals/whats-new-archive" + ], + "apiReference": [ + "https://learn.microsoft.com/en-us/graph/api/resources/organizationalbranding?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/site-theming/sharepoint-site-theming-rest-api", + "https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/site-theming/sharepoint-site-theming-powershell" + ], + "marketplace": null, + "pricing": null, + "accessibilityStatement": null, + "securityDocs": "https://learn.microsoft.com/en-us/entra/architecture/security-operations-introduction", + "demoInstance": null, + "community": [ + "https://techcommunity.microsoft.com/category/microsoft-entra", + "https://techcommunity.microsoft.com/category/sharepoint", + "https://learn.microsoft.com/en-us/answers/tags/455/entra-id" + ], + "reviews": null, + "videos": "https://learn.microsoft.com/en-us/shows/", + "caseStudies": null, + "partnerDirectory": null, + "trainingCurriculum": "https://learn.microsoft.com/en-us/credentials/certifications/identity-and-access-administrator/", + "jobPostings": "https://jobs.careers.microsoft.com/global/en/search?q=branding", + "tenders": [ + "130 tenders in the intelligence database carry a requirement naming Microsoft 365 or Office 365 (integration, not branding); two tie it to house style: TenderNed 399455 (Zaaksysteem, https://www.tenderned.nl/aankondigingen/overzicht/399455) and TenderNed 415112 (Leermanagementsysteem, https://www.tenderned.nl/aankondigingen/overzicht/415112)" + ], + "nullReasons": { + "sourceRepo": "closed source: Entra ID, Microsoft 365 and SharePoint Online are proprietary hosted services; the only related repo is the community tool github.com/microsoft/Microsoft365DSC, not the product (checked 2026-09-26)", + "issueTracker": "no public issue tracker for these services; defects go through admin-center support requests, and feature ideas through feedbackportal.microsoft.com (checked 2026-09-26)", + "marketplace": "no theme marketplace; appsource.microsoft.com (apps, not themes) returned 403 to curl on 2026-09-26", + "pricing": "not verified: www.microsoft.com Entra and Microsoft 365 pricing pages returned 403 to curl on 2026-09-26; licence prerequisites (Entra ID P1/P2, M365 Business Standard, SharePoint Plan 1, Teams Premium) are stated in the feature docs", + "accessibilityStatement": "not verified: https://www.microsoft.com/en-us/accessibility/conformance-reports returned 403 to curl on 2026-09-26, and no branding-specific accessibility statement was found on learn.microsoft.com", + "demoInstance": "none: no public demo tenant; trial accounts were out of scope for this read (2026-09-26)", + "reviews": "not verified: Gartner Peer Insights and G2 pages returned 403 to curl on 2026-09-26", + "caseStudies": "not verified: customers.microsoft.com and www.microsoft.com/en-us/customers returned 403 to curl on 2026-09-26", + "partnerDirectory": "not verified: partner.microsoft.com find-a-partner timed out and appsource partner directory returned 403 on 2026-09-26" + } + }, + "readingNotes": "Custom CSS for Entra ID sign-in pages needs Entra ID P1/P2, Microsoft 365 Business Standard or SharePoint Plan 1 licensing plus the Organizational Branding Administrator role, and it is being retired: tenants created after 2026-01-05 no longer get custom CSS, and layout/positioning CSS properties (position, margin, transform, overflow) are being removed fleet-wide under Microsoft's Secure Future Initiative, with full retirement planned. SharePoint brand center features (custom fonts, org-wide brand asset libraries) require enabling a Public CDN and are unavailable on 21Vianet (China) and Microsoft 365 US Government plans. Ratings score only the organisational/company-branding surfaces named in the brief (Entra sign-in, M365 org theme, SharePoint site themes/brand center); general per-user Office personalization (e.g. the personal 'colorful/dark/light' Office theme) is a separate feature, scored here only where the org-branding docs themselves mention user override (see sco-user-choice).", + "readSources": [ + "https://learn.microsoft.com/en-us/entra/fundamentals/how-to-customize-branding", + "https://learn.microsoft.com/en-us/microsoft-365/admin/setup/customize-your-organization-theme", + "https://learn.microsoft.com/en-us/sharepoint/dev/declarative-customization/site-theming/sharepoint-site-theming-overview", + "https://learn.microsoft.com/en-us/sharepoint/brand-center-overview", + "https://learn.microsoft.com/en-us/sharepoint/organization-assets-library", + "https://learn.microsoft.com/en-us/sharepoint/brand-fonts", + "https://learn.microsoft.com/en-us/graph/api/organizationalbranding-update", + "https://learn.microsoft.com/en-us/powershell/module/microsoft.graph.identity.directorymanagement/update-mgorganizationbranding", + "https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-deploy-a-custom-theme-in-sharepoint", + "https://learn.microsoft.com/en-us/answers/questions/5985270/microsoft-365-organisational-theme-no-longer-appli" + ], + "readNote": "public vendor documents read on 2026-09-26 (learn.microsoft.com, the Microsoft 365 roadmap feed); closed source, not driven" + }, + { + "key": "opendesk", + "name": "openDesk theming", + "vendor": "ZenDiS", + "readOn": "2026-09-26", + "columnAddedOn": "2026-09-26", + "evidenceGrade": "docs-only", + "unknownReason": "Each unknown cell carries its own not-checked reason: stock component behaviour (focus, forced colours, reduced motion, clients) not audited in the v1.18.2 source read, and rows mined on 2026-09-26 after the column was read.", + "sources": { + "docs": [ + "https://docs.opendesk.eu/operations/theming/", + "https://docs.opendesk.eu/" + ], + "sourceRepo": "https://gitlab.opencode.de/bmi/opendesk/deployment/opendesk (tag v1.18.2, commit eab2ee7; also read opendesk-nextcloud chart v4.10.6, opendesk-nextcloud image v2.17.2, opendesk-jitsi chart v3.10.0, opendesk-impress-customization v1.0.0 under gitlab.opencode.de/bmi/opendesk/components/platform-development/)", + "featurePage": "https://www.opendesk.eu/en/product", + "featureRequests": "https://gitlab.opencode.de/bmi/opendesk/deployment/opendesk/-/issues/?label_name%5B%5D=type%3A%20Improvement", + "issueTracker": { + "url": "https://gitlab.opencode.de/bmi/opendesk/deployment/opendesk/-/issues", + "featureLabel": "type: Improvement (feature requests also use the 'Feature Request' issue template)" + }, + "roadmap": "https://www.opendesk.eu/en/roadmap", + "changelog": "https://gitlab.opencode.de/bmi/opendesk/deployment/opendesk/-/blob/v1.18.2/CHANGELOG.md", + "apiReference": "https://gitlab.opencode.de/bmi/opendesk/deployment/opendesk/-/blob/v1.18.2/docs/architecture/apis.md", + "marketplace": null, + "pricing": null, + "accessibilityStatement": [ + "https://docs.opendesk.eu/legal/barrierefreiheit/", + "https://www.opendesk.eu/en/accessibility" + ], + "securityDocs": [ + "https://docs.opendesk.eu/operations/security/", + "https://docs.opendesk.eu/legal/it-grundschutz/" + ], + "demoInstance": "https://portal.demo.opendesk.eu/univention/portal/", + "community": "https://gitlab.opencode.de/bmi/opendesk", + "reviews": null, + "videos": null, + "caseStudies": null, + "partnerDirectory": "https://www.opendesk.eu/en/partner-werden", + "trainingCurriculum": null, + "jobPostings": "https://www.zendis.de/karriere", + "tenders": null, + "nullReasons": { + "marketplace": "openDesk ships as a helmfile deployment; opendesk.eu/en and docs.opendesk.eu link no app store or marketplace listing, and it is not a Nextcloud app store app (searched 2026-09-26)", + "pricing": "https://www.opendesk.eu/en/operating-models (read 2026-09-26) describes Enterprise and Community editions and operating models but publishes no prices", + "reviews": "no review platform linked from opendesk.eu or docs.opendesk.eu; not searched on third-party review sites (2026-09-26)", + "videos": "opendesk.eu/en footer links LinkedIn, GitLab, opencode.de and docs only; no video channel found (2026-09-26)", + "caseStudies": "opendesk.eu/en has a blog (news, partner programme, security incident) but no case study section (2026-09-26)", + "trainingCurriculum": "docs.opendesk.eu has user and admin guides (/user/overview/erste-schritte/, /administration/erste-schritte/) but no training curriculum; searched site navigation 2026-09-26", + "tenders": "intelligence database searched 2026-09-26: no tender name, description or requirement mentions openDesk" + }, + "notes": { + "roadmap": "page exists but lists no items: 'We are currently coordinating our upcoming development cycles. More information about future releases will be available here shortly.' (read 2026-09-26)", + "demoInstance": "https://demo.opendesk.eu/ redirects to the portal login; https://www.opendesk.eu/en/demo-session books a guided demo", + "docs": "docs.opendesk.eu/operations/theming/ renders docs/theming.md, whose link to portalStylesheets.css names a file that does not exist at v1.18.2" + } + }, + "readingNotes": "openDesk's theming is fundamentally different in shape from thematiq: it is ONE deployment-configured brand (set via Helm/helmfile values, requiring `helmfile apply` to change) applied consistently across Nextcloud (via a separate opendesk-nextcloud-management chart), Element, OX App Suite, Xwiki, Jitsi and the Portal/Keycloak login screens - not a runtime catalogue of switchable house styles with an admin UI. There is no in-app theming admin screen, preview, trial, diff, or rollback UI documented anywhere; every 'apply'-area capability that assumes a live admin UI is 'no' by construction, not because the feature is missing but because the whole model is config-as-code. Nextcloud's own values-nextcloud.yaml file was confirmed to carry NO theming keys itself - branding reaches Nextcloud only via the separate opendesk-nextcloud-management chart. The shipped default colour table itself documents several colour tokens (primary35, primary65, secondaryBlue, secondaryBlueHighcontrast, secondaryRed, secondaryYellow, secondaryGreen, secondaryGrey) as 'Not yet implemented' by any component - a concrete, vendor-acknowledged incompleteness worth flagging to a matrix author.", + "readSources": [ + "https://docs.opendesk.eu/operations/theming/", + "https://docs.opendesk.eu/operations/functional/", + "https://docs.opendesk.eu/operations/getting-started/", + "https://gitlab.opencode.de/bmi/opendesk/deployment/opendesk (helmfile/environments/default/theme.yaml.gotmpl, raw fetch: productName/slogan text keys, full colour table with per-component \"used by\" column including several colours marked \"Not yet implemented\", imagery/favicon keys per module, portalStylesheets.css)", + "https://gitlab.opencode.de/bmi/opendesk/deployment/opendesk (helmfile/apps/nextcloud/values-nextcloud.yaml, raw fetch: confirms this file itself carries NO theming keys - Nextcloud branding is not set here)", + "https://gitlab.opencode.de/bmi/opendesk/components/platform-development/charts/opendesk-nextcloud (charts/opendesk-nextcloud-management/README.md, raw fetch: theme.colors.primary, theme.texts.productName/slogan, theme.logo.svgBase64, theme.favicon.pngBase64, theme.background.color/imgBase64, theme.urls.main/privacy/imprint)", + "https://www.opendesk.eu/en/accessibility (accessibility statement scope)", + "https://www.openproject.org/blog/accessibility-high-contrast-mode/ (search snippet on BMI-commissioned high-contrast mode)", + "https://github.com/opentelekomcloud-blueprints/openDesk-deployment (search snippet: environments/prod/values.yaml.gotmpl, confirms multi-environment values structure)", + "docs.nextcloud.com theming admin manual (search snippet only, used solely to support that Nextcloud's own theming app also colours emails; not independently fetched)" + ], + "readNote": "source read at openDesk deployment v1.18.2 (eab2ee7), with opendesk-nextcloud chart v4.10.6, opendesk-nextcloud-image v2.17.2 (inferred from the 33.0.9 pin at images.yaml.gotmpl:351, not a git-tag pin), opendesk-jitsi v3.10.0, opendesk-impress-customization v1.0.0 and nextcloud/server v33.0.9 for stock behaviour; not driven" + }, + { + "key": "liferay-dxp", + "name": "Liferay DXP (style books, themes, client extensions)", + "vendor": "Liferay", + "readOn": "2026-09-26", + "columnAddedOn": "2026-09-26", + "evidenceGrade": "docs-only", + "unknownReason": "not checked beyond style books, theme CSS/favicon client extensions, staging/publications, style book export-import, virtual instances, the audit framework, roles/permissions, the accessibility menu and the DXP Sites Experience VPAT read on 2026-09-26; headless REST API coverage for style books specifically, font-file upload for style book typography tokens, email templating, and Prometheus/health-check integration were not found in that documentation.", + "sources": { + "docs": "https://learn.liferay.com/w/dxp", + "sourceRepo": "https://github.com/liferay/liferay-portal/tree/2026.q3.0 (LGPL source per https://liferay.dev/b/one-platform-one-liferay-the-2026-release-model; tag 2026.q3.0 exists; this column was rated from public docs, the source was not read)", + "featurePage": "https://learn.liferay.com/w/dxp/sites/site-appearance/style-books", + "featureRequests": "https://liferay.atlassian.net/jira/software/c/projects/LPD/issues", + "issueTracker": { + "url": "https://liferay.atlassian.net/jira/software/c/projects/LPD/issues", + "featureLabel": "issue types Epic, Story and Spike in project LPD (no separate feature label; the public REST search /rest/api/3/search/jql works anonymously)" + }, + "roadmap": "https://www.liferay.com/roadmap", + "changelog": [ + "https://liferay.dev/b/liferay-dxp-2026-q3-release-webinar-replay", + "https://learn.liferay.com/w/dxp/self-hosted-installation-and-upgrades/upgrading-liferay/deprecations-and-breaking-changes-reference/2026-deprecations-and-breaking-changes/2026-q3-default-setting-and-feature-flag-changes", + "https://liferay.dev/b/liferay-dxp-2026-q2-is-now-available-" + ], + "apiReference": "https://learn.liferay.com/w/dxp/integration/headless-apis", + "marketplace": "https://marketplace.liferay.com/", + "pricing": "https://www.liferay.com/pricing", + "accessibilityStatement": [ + "https://www.liferay.com/accessibility-compliance/digital-experience-platform", + "https://www.liferay.com/documents/d/guest/vpat2-5rev_int_february2025_liferay_dxp_sites_experience" + ], + "securityDocs": "https://learn.liferay.com/w/dxp/security-and-administration/security", + "demoInstance": "https://learn.liferay.com/w/dxp/getting-started/liferay-dxp-free-tier", + "community": [ + "https://liferay.dev/", + "https://liferay.dev/ask" + ], + "reviews": null, + "videos": "https://www.youtube.com/@liferay", + "caseStudies": "https://www.liferay.com/resources/case-studies", + "partnerDirectory": "https://www.liferay.com/partners", + "trainingCurriculum": "https://learn.liferay.com/education/index", + "jobPostings": "https://www.liferay.com/careers", + "tenders": [ + "34 tenders name Liferay, all Spanish PLACSP portal maintenance or subscription renewals, e.g. ES-PLACSP-19525683 (renewal of Liferay DXP subscription and support, https://contrataciondelestado.es/wps/poc?uri=deeplink:detalle_licitacion&idEvl=0sX1dCzTZxzECtSnloz%2BZQ%3D%3D) and ES-PLACSP-19931298 (2026-07-16)" + ], + "nullReasons": { + "reviews": "2026-09-26: https://www.g2.com/products/liferay-dxp/reviews returned 403 to a plain request and no other review site was checked, so no review page was confirmed" + }, + "notes": "www.liferay.com pages answer a bot challenge to curl on 2026-09-26; roadmap, pricing, accessibility, case studies, partners and careers URLs were confirmed by web search results or HTTP status, not by reading the page body; demoInstance is the free tier self-install, Liferay offers no public demo instance" + }, + "readingNotes": "Since Liferay DXP 2025.Q2, every style book is tied to one specific theme (Token Isolation) and style books explicitly 'cannot be used in administrative themes', so the login/control-panel look still needs a theme or a theme CSS client extension (a developer-built and Gradle-deployed artifact), not the no-code style book editor. Style book export/import is documented as UI-only ('both methods appear limited to the user interface'), which is the direct source for several 'no CLI' ratings. Liferay DXP is Liferay's commercial/enterprise edition; the Apache-licensed open-source core is the separate Liferay Portal Community Edition product, not DXP itself.", + "readSources": [ + "https://learn.liferay.com/w/dxp/sites/site-appearance/style-books/using-a-style-book-to-standardize-site-appearance", + "https://learn.liferay.com/w/dxp/sites/site-appearance/style-books/developer-guide/style-book-token-definitions", + "https://learn.liferay.com/w/dxp/development/customizing-liferays-look-and-feel/using-a-theme-css-client-extension", + "https://learn.liferay.com/w/dxp/sites/publishing-tools/staging", + "https://learn.liferay.com/w/dxp/sites/site-appearance/style-books/exporting-and-importing-style-books", + "https://learn.liferay.com/w/dxp/security-and-administration/administration/configuring-liferay/virtual-instances", + "https://learn.liferay.com/w/dxp/development/customizing-liferays-look-and-feel/using-a-theme-favicon-client-extension", + "https://learn.liferay.com/w/dxp/security-and-administration/administration/audit-framework", + "https://learn.liferay.com/w/dxp/security-and-administration/administration/audit-framework/searching-and-exporting-audit-events", + "https://learn.liferay.com/w/dxp/users-and-permissions/roles-and-permissions/creating-and-managing-roles", + "https://learn.liferay.com/w/dxp/sites/creating-pages/page-settings/updating-page-permissions", + "https://learn.liferay.com/w/dxp/personalization/experiences/using-the-accessibility-menu", + "https://learn.liferay.com/kb-article/creating-high-contrast-color-themes-in-liferay-dxp-7.0", + "https://marketplace.liferay.com/p/materialized-theme", + "https://www.liferay.com/documents/d/guest/vpat2-5rev_int_february2025_liferay_dxp_sites_experience", + "https://liferay.dev/b/from-theme-to-theme-less-the-smart-path-to-faster-future-ready-liferay-dxp-upgrades", + "https://github.com/liferay/liferay-frontend-projects/blob/master/guidelines/dxp/how_to_use_css_custom_properties.md", + "https://help.liferay.com/hc/en-us/articles/360018171691-Page-Set-Look-and-Feel" + ], + "readNote": "public vendor documents read on 2026-09-26 (learn.liferay.com, liferay.dev, public LPD issues on liferay.atlassian.net); several ratings rest on public Jira issues rather than product docs; www.liferay.com answered a bot challenge, so its roadmap, pricing, accessibility, case study, partner and careers source fields are confirmed by status code or search result, not by reading the page; closed edition not driven, source not read" + }, + { + "key": "tokens-studio", + "name": "Tokens Studio (Figma plugin and platform)", + "vendor": "Tokens Studio", + "readOn": "2026-09-26", + "columnAddedOn": "2026-09-26", + "evidenceGrade": "docs-only", + "unknownReason": "Each unknown cell carries its own not-checked reason. The plugin cells were read from source at 2.12.1; cells resting on the paid Studio platform are docs-only (docs.tokens.studio), and the evidence of each such cell says so.", + "sources": { + "docs": [ + "https://docs.tokens.studio/", + "https://documentation-v2.tokens.studio/" + ], + "sourceRepo": "https://github.com/tokens-studio/figma-plugin/tree/2.12.1 (tag 2.12.1, commit a28cadd)", + "featurePage": "https://tokens.studio/studio-platform", + "featureRequests": [ + "https://feedback.tokens.studio/", + "https://github.com/tokens-studio/figma-plugin/issues?q=is%3Aopen+label%3A%22%F0%9F%92%A1request%22" + ], + "issueTracker": { + "url": "https://github.com/tokens-studio/figma-plugin/issues", + "featureLabel": "💡request" + }, + "roadmap": "https://feedback.tokens.studio/roadmap", + "changelog": [ + "https://github.com/tokens-studio/figma-plugin/blob/2.12.1/packages/tokens-studio-for-figma/CHANGELOG.md", + "https://github.com/tokens-studio/figma-plugin/releases" + ], + "apiReference": "https://tokens-studio.github.io/studio-app/", + "marketplace": "https://www.figma.com/community/plugin/843461159747178978/tokens-studio-for-figma", + "pricing": "https://tokens.studio/pricing", + "accessibilityStatement": null, + "securityDocs": null, + "demoInstance": "https://tokens.studio/trial", + "community": [ + "https://tokens.studio/slack", + "https://github.com/tokens-studio/figma-plugin/discussions" + ], + "reviews": null, + "videos": [ + "https://www.youtube.com/@tokensstudio" + ], + "caseStudies": null, + "partnerDirectory": null, + "trainingCurriculum": "https://tokens.studio/coaching", + "jobPostings": null, + "tenders": null, + "nullReasons": { + "accessibilityStatement": "searched tokens.studio nav links, tokens.studio/accessibility (404) and both doc sites for accessib/wcag on 2026-09-26: none published", + "securityDocs": "tokens.studio/security and /trust return 404 and the docs nav has no security page on 2026-09-26; pricing only lists 'Security & Compliance support' in the Organization plan", + "reviews": "G2 (g2.com/products/tokens-studio/reviews) returned 403 to a scripted read on 2026-09-26; no other review site found", + "caseStudies": "tokens.studio/case-studies and /customers return 404 on 2026-09-26; the homepage has only a logo wall", + "partnerDirectory": "tokens.studio/partners returns 404 on 2026-09-26; pricing says an agency partner programme is being worked on", + "jobPostings": "tokens.studio/jobs redirects to careers.tokens.studio, which did not answer (no connection) on 2026-09-26", + "tenders": "intelligence database searched 2026-09-26: no tender name, description or requirement mentions Tokens Studio" + } + }, + "readingNotes": "Several genuinely strong capabilities are Pro/Platform-gated, not free: Themes/multi-brand switching, Modified Colors (dark-variant derivation), multi-file git sync and branch switching. The core plugin (github.com/tokens-studio/figma-plugin) is open source; the Tokens Studio Platform (GraphQL API/CLI, multi-file sync for non-Pro teammates) is a separate commercial product. Multi-platform export (CSS/iOS/Android/JSON) always requires an external Style Dictionary build step with the @tokens-studio/sd-transforms package - there is no direct no-build CSS-variable export.", + "readSources": [ + "https://docs.tokens.studio/ (overview)", + "https://docs.tokens.studio/manage-tokens/token-types/ (24 token types: color, dimension, typography composites, asset, boolean, etc.)", + "https://docs.tokens.studio/manage-tokens/token-types/asset (Asset token = external URL reference, no hosting)", + "https://docs.tokens.studio/manage-tokens/token-types/color/modified (Modified Colors pro: lighten/darken/mix/alpha, no live contrast display)", + "https://docs.tokens.studio/manage-tokens/token-values/references (aliasing/semantic references, unlimited layers)", + "https://docs.tokens.studio/manage-tokens/token-values/math (math expressions in token values)", + "https://docs.tokens.studio/manage-settings/token-format (W3C DTCG vs legacy format, conversion)", + "https://docs.tokens.studio/manage-themes/themes-overview (Themes are a Pro feature; multi-brand/multi-dimensional theming)", + "https://docs.tokens.studio/token-storage/remote/sync-git-github (GitHub sync: push/pull, branches, \"Create a Pull Request\" button, multi-file sync and branch switching are Pro-gated)", + "https://docs.tokens.studio/token-storage-and-sync/sync-provider-add (full provider list: GitHub, GitLab, Bitbucket, Azure DevOps, JSONBin, Supernova, Tokens Studio Platform, URL/Generic Versioned Storage)", + "https://docs.tokens.studio/token-storage/remote-push-pull-changes", + "https://docs.tokens.studio/token-storage/troubleshooting-common-sync-provider-errors (schema validation errors on pull)", + "https://docs.tokens.studio/transform-tokens/style-dictionary (Style Dictionary + @tokens-studio/sd-transforms for CSS/iOS/Android/JSON export, build-step required, no direct no-build CSS export)", + "https://docs.tokens.studio/manage-tokens/token-description", + "https://github.com/tokens-studio/figma-plugin (\"proudly open source at its core\")", + "https://tokens-studio.github.io/studio-app/ (search snippet: Tokens Studio Platform GraphQL API + CLI, a separate paid product from the core plugin)", + "https://www.figma.com/community/plugin/843461159747178978/tokens-studio-for-figma (distribution channel)" + ], + "readNote": "plugin source read at tokens-studio/figma-plugin 2.12.1 (a28cadd, 2026-09-23), not driven; cells resting on the paid Studio platform are docs-only from docs.tokens.studio, plugin cells are source-read" + } + ], + "areas": [ + { + "key": "catalogue", + "name": "Ready-made government house styles", + "name_nl": "Kant-en-klare overheidshuisstijlen" + }, + { + "key": "apply", + "name": "Applying and switching a theme", + "name_nl": "Een thema toepassen en wisselen" + }, + { + "key": "authoring", + "name": "Bringing your own brand", + "name_nl": "Je eigen huisstijl meebrengen" + }, + { + "key": "typography", + "name": "Fonts and typography", + "name_nl": "Lettertypen en typografie" + }, + { + "key": "assets", + "name": "Logos, icons and imagery", + "name_nl": "Logo's, iconen en beeldmateriaal" + }, + { + "key": "surfaces", + "name": "Where the theme reaches", + "name_nl": "Waar het thema zichtbaar is" + }, + { + "key": "scoping", + "name": "Scoping a theme", + "name_nl": "Een thema afbakenen" + }, + { + "key": "accessibility", + "name": "Accessibility and contrast", + "name_nl": "Toegankelijkheid en contrast" + }, + { + "key": "governance", + "name": "Governance and change control", + "name_nl": "Beheer en wijzigingscontrole" + }, + { + "key": "sync", + "name": "Staying current with upstream", + "name_nl": "Actueel blijven met de bron" + }, + { + "key": "integration", + "name": "Integration and operations", + "name_nl": "Integratie en beheer" + } + ], + "providers": [ + { + "key": "thematiq", + "name": "Thematiq", + "kind": "self" + }, + { + "key": "nextcloud", + "name": "Nextcloud server and its built-in Theming app", + "kind": "platform" + }, + { + "key": "openregister", + "name": "OpenRegister", + "kind": "app" + }, + { + "key": "nextcloud-vue", + "name": "@conduction/nextcloud-vue", + "kind": "app" + }, + { + "key": "nl-design-system", + "name": "NL Design System community (upstream token sources)", + "kind": "external" + } + ], + "capabilities": [ + { + "id": "cat-rijkshuisstijl", + "area": "catalogue", + "name": "Apply the Dutch national government house style (Rijkshuisstijl) without designing anything.", + "thematiq": "yes", + "nextcloud-theming": "no", + "m365-branding": "no", + "opendesk": "no", + "liferay-dxp": "no", + "tokens-studio": "no", + "built": { + "state": "built", + "evidence": "token-sets.json id=rijkshuisstijl; templates/settings/admin.php:66-76 renders it as an /g)).toHaveLength( + 2, + ) + expect(markup).not.toContain('') + }) +}) + +describe('the login card, against the page it stands for', () => { + // Transcribed from the rendered DOM of a real Nextcloud 34 login page. The + // class names are the contract: they are what core's stylesheet, the + // component stylesheets and Thematiq's own overrides all match on, and a + // specimen missing one of them is a specimen one of those three stops + // reaching. + const markup = playground.STAGES['login-card']( + null, + inventory.components.find((entry) => entry.id === 'login-card'), + ) + + it('keeps the guest layout nesting core styles against', () => { + // `.wrapper` is what separates the card from the footer, and + // `.v-align` and `.guest-content` are what core centres it with. + expect(markup).toContain('class="wrapper"') + expect(markup).toContain('class="v-align"') + expect(markup).toContain('class="guest-content"') + expect(markup.indexOf(' { + // Including the `vue-` infix: that is what this Nextcloud's login page + // emits, and it is the only name Thematiq's element-overrides.css knows. + expect(markup).toContain('button-vue--vue-primary') + expect(markup).toContain('button-vue--icon-and-text') + expect(markup).toContain('button-vue--wide') + expect(markup).toContain('class="button-vue__icon"') + }) + + it('puts the log-in button in the login button scope, and only that one', () => { + // On the real page `#body-login .button-vue--primary` does this; the + // specimen cannot carry that id, so it is named instead. Without it the + // card's button read the primary button's token, not the login one. + const submit = markup.slice(markup.indexOf('button-vue--vue-primary')) + expect(submit.slice(0, submit.indexOf('>'))).toContain( + 'data-thematiq-component="login-button"', + ) + expect(markup.split('data-thematiq-component="login-button"')).toHaveLength( + 2, + ) + }) + + it('gives a text button no icon span and an icon button no text span', () => { + // NcButton's own `:empty` and `:has()` rules are what turn an icon-only + // button square and close up a text-only one; emitting both spans + // regardless would defeat them. + const tertiary = markup.slice(markup.indexOf('button-vue--text-only')) + expect(tertiary.slice(0, tertiary.indexOf(''))).not.toContain( + 'button-vue__icon', + ) + + const reveal = markup.slice(markup.indexOf('button-vue--icon-only')) + expect(reveal.slice(0, reveal.indexOf(''))).not.toContain( + 'button-vue__text', + ) + }) + + it('renders real controls rather than pictures of them', () => { + expect(markup).toContain('class="input-field__input"') + expect(markup).toContain('type="password"') + expect(markup).toContain('class="checkbox-radio-switch__input"') + }) + + it('gives the checkbox the sizes the component v-binds onto itself', () => { + // Those two custom properties are declared under build-hash names no + // specimen can carry; the properties they feed resolve to nothing + // without this, and the control collapses. + expect(markup).toContain('--icon-size:24px') + expect(markup).toContain('--icon-height:24px') + }) + + it('ties every label to the input it names, under an id of its own', () => { + const ids = [...markup.matchAll(/ m[1]) + const fors = [...markup.matchAll(/