|
| 1 | +--- |
| 2 | +kind: code |
| 3 | +--- |
| 4 | + |
| 5 | +## Why |
| 6 | + |
| 7 | +nldesign already owns the entire NL Design token-set surface: discovery |
| 8 | +(`TokenSetService::getAvailableTokenSets()`, ~lib/Service/TokenSetService.php:135-189), the shipped |
| 9 | +catalogue (`token-sets.json`, `design-systems.json`, `css/tokens/<id>.css` — 43 sets + dark |
| 10 | +variants), admin custom-set upload including W3C DTCG JSON |
| 11 | +(`CustomTokenSetService::store()`, ~lib/Service/CustomTokenSetService.php:175-236), and WCAG |
| 12 | +contrast math (`ContrastService` — relative luminance, fixed pairs primary/text @4.5:1 and |
| 13 | +primary/background @3:1 — invoked from `CustomTokenSetService::store()` at ~:196; |
| 14 | +`ShippedTokenSetAuditService`; `Capabilities::computeWcagLevel()`, |
| 15 | +~lib/Capabilities.php:219-281). Every existing consumption path — the admin dropdown |
| 16 | +(`/settings/tokensets`), the public capabilities document (`Capabilities`, `IPublicCapability`), |
| 17 | +and the whole-instance CSS injector (`CssInjectionService` + `ThemeInjectionListener`) — is either |
| 18 | +**admin-only** or **instance-wide**. None of it lets a **leaf app's own picker** (a builder tool |
| 19 | +inside another Conduction app, e.g. OpenBuild) enumerate the catalogue for its own users, and none |
| 20 | +of it gives leaf apps a shared place to evaluate WCAG contrast instead of re-implementing the math. |
| 21 | + |
| 22 | +### The gap is not hypothetical — OpenBuild already hit it and built around it |
| 23 | + |
| 24 | +OpenBuild's `nldesign-theme-selection` feature (archived 2026-06-15, |
| 25 | +`../../../openbuild/openspec/specs/nldesign-theme-selection/spec.md`) lets a builder pick an |
| 26 | +nldesign token set for a virtual app. Its own spec (REQ-NTS-002, REQ-NTS-006) documents, verbatim, |
| 27 | +the exact gap this change closes: |
| 28 | + |
| 29 | +> "(a) `GET /apps/nldesign/settings/tokensets` when the session is admin — the route is |
| 30 | +> `AuthorizedAdminSetting` today ... (b) the flagged non-admin nldesign list endpoint **once it |
| 31 | +> exists** ... (c) a validated free-text fallback." / "A Codeberg issue against `Conduction/nldesign` |
| 32 | +> MUST be filed ... requesting (a) a `#[NoAdminRequired]` read-only token-set list endpoint and (b) |
| 33 | +> documentation of `css/tokens/*.css` as a consumable contract." |
| 34 | +
|
| 35 | +`SettingsController::getAvailableTokenSets()` (~lib/Controller/SettingsController.php:261) and |
| 36 | +every other `settings#*` GET are `#[AuthorizedAdminSetting(Admin::class)]` — confirmed by reading |
| 37 | +the controller, not just OpenBuild's note. `openbuild/src/dialogs/ThemePickerDialog.vue` (lines |
| 38 | +1-19) ships a **feature-probe stub** for leg (b) today: it always falls through to the free-text |
| 39 | +`css/tokens/<id>.css` fallback because the non-admin endpoint does not exist. |
| 40 | + |
| 41 | +OpenBuild also independently built a **second, unrelated** feature, `app-theming` (archived |
| 42 | +2026-07-24, `../../../openbuild/openspec/specs/app-theming/spec.md`), that **duplicates nldesign's |
| 43 | +WCAG math** in `openbuild/src/services/checkThemeContrast.js` — the same relative-luminance |
| 44 | +formula, re-typed in JS, checked against a **hardcoded `#FFFFFF` background** rather than the real |
| 45 | +active background — and enforces it as a **hard, non-bypassable save-blocking gate** (`"There SHALL |
| 46 | +be no override or bypass of this check"`), unlike nldesign's own upload-time policy, which is |
| 47 | +**warn-only** (`custom-token-sets` spec, "WCAG AA Contrast Warnings on Upload": *"Failures MUST be |
| 48 | +returned as non-blocking warnings"*). |
| 49 | + |
| 50 | +Finally, `app-theming`'s own spec already documents an ad hoc scoped-CSS **rewriter** contract |
| 51 | +(REQ-NTS-003, reused verbatim by `app-theming`'s Requirement "Theme applies via the existing scoped |
| 52 | +CSS-variable mechanism"): fetch `css/tokens/<id>.css`, rewrite every `:root` selector to |
| 53 | +`[data-openbuild-theme-scope="<appSlug>"]`, inject as one managed `<style>` element, and — critically |
| 54 | +— **bail out and inject nothing** if the CSS contains a construct the rewriter does not positively |
| 55 | +recognise (nested at-rules etc.). This is homegrown, OpenBuild-owned (`data-openbuild-*`), and would |
| 56 | +have to be reinvented by every other leaf app that wants the same trick. Verified empirically |
| 57 | +(`grep` over all 43 `css/tokens/*.css`): every shipped set is already exactly one flat `:root { }` |
| 58 | +block with no at-rules or other selectors — the invariant OpenBuild's rewriter silently depends on |
| 59 | +already holds, it has just never been published as an nldesign contract. The dark variants |
| 60 | +(`css/tokens/dark/<id>.css`, generated by `DarkPaletteService`) are the opposite shape — |
| 61 | +`@media (prefers-color-scheme: dark) { body:not(...) { ... } }` — confirming the rewriter's bail-out |
| 62 | +rule is load-bearing and that dark-mode scoped application is a distinct, unsolved problem (out of |
| 63 | +scope here, see design.md). |
| 64 | + |
| 65 | +### What this change is (and is not) |
| 66 | + |
| 67 | +This is **nldesign's side of the contract only**. A companion `nextcloud-vue` change will add the |
| 68 | +generic, shared, scoped client-side applier (replacing OpenBuild's homegrown |
| 69 | +`data-openbuild-theme-scope` rewriter with a design-system-owned one). A companion OpenBuild change |
| 70 | +will reduce `ThemePickerDialog`'s three-tier fallback to a direct call against the new endpoint, and |
| 71 | +will evaluate whether `checkThemeContrast.js` can be deleted in favour of the new shared contrast |
| 72 | +endpoint. Neither of those changes is proposed, designed, or implemented here — this change adds |
| 73 | +**no Vue, no scoped applier, no OpenBuild edits** — it only defines and documents nldesign's PHP |
| 74 | +contract surface: a read-only catalogue endpoint, a shared contrast-evaluation entry point, the |
| 75 | +scoped-application contract nc-vue implements against, and the blocking-policy resolution between |
| 76 | +nldesign's existing warn-only model and OpenBuild's existing hard-block model. |
| 77 | + |
| 78 | +### Evidence |
| 79 | + |
| 80 | +- `lib/Service/TokenSetService.php:135-189` — `getAvailableTokenSets()`, the single discovery path |
| 81 | + this change reuses without duplication. |
| 82 | +- `lib/Service/CustomTokenSetService.php:175-236` — `store()`, the existing warn-only contrast |
| 83 | + policy for admin uploads. |
| 84 | +- `lib/Service/ContrastService.php` — the existing fixed-pair WCAG relative-luminance math |
| 85 | + (`PAIRS` const: primary/text @4.5:1, primary/background @3:1). |
| 86 | +- `lib/Service/ShippedTokenSetAuditService.php`, `lib/Capabilities.php:219-281` — the existing |
| 87 | + per-set audit + cached `wcagLevel` computation (`ICache` prefix `nldesign_wcag_level`, TTL 3600s) |
| 88 | + this change's catalogue endpoint reuses rather than re-implements. |
| 89 | +- `lib/Controller/SettingsController.php:242,261,439` — confirms every `settings#*` GET is |
| 90 | + `#[AuthorizedAdminSetting(Admin::class)]` today (no non-admin read path exists). |
| 91 | +- `appinfo/routes.php` — confirms the `/settings/*` URL prefix is this app's own convention for |
| 92 | + admin-gated routes, while `/api/*` (metrics, health) is the existing non-`/settings/*` prefix for |
| 93 | + focused, non-admin-config endpoints. |
| 94 | +- `token-sets.json` — confirms the `theming.{primary_color,background_color,logo}` shape already |
| 95 | + exists verbatim on every shipped entry (e.g. `rijkshuisstijl`), unchanged since 2026-02. |
| 96 | +- `../../../openbuild/openspec/specs/nldesign-theme-selection/spec.md` (REQ-NTS-002, REQ-NTS-003, |
| 97 | + REQ-NTS-006) and `../../../openbuild/src/dialogs/ThemePickerDialog.vue:1-19` — the live, |
| 98 | + already-shipped consumer whose feature-probe stub and filed-issue request this change answers. |
| 99 | +- `../../../openbuild/openspec/specs/app-theming/spec.md` (Requirements "WCAG contrast guardrail |
| 100 | + blocks saving a non-compliant theme", "Theme applies via the existing scoped CSS-variable |
| 101 | + mechanism") and `../../../openbuild/src/services/checkThemeContrast.js` — the duplicated WCAG math |
| 102 | + and hard-block policy this change's contrast entry point and blocking-policy decision resolve. |
| 103 | +- `docs/GOVERNMENT-FEATURES.md` F-03 ("Token set selectie per organisatie"), F-04 ("Aangepaste |
| 104 | + token sets uploaden"), F-13 ("Theming voor alle Conduction-apps") — the existing government |
| 105 | + features this change extends to a new class of consumer (a leaf app's own builder-facing picker) |
| 106 | + without adding a new feature row itself. |
| 107 | + |
| 108 | +## What Changes |
| 109 | + |
| 110 | +- **NEW non-admin, read-only token-set catalogue endpoint** — `GET /api/token-sets` |
| 111 | + (`#[NoAdminRequired]`, authenticated non-admin user; not `#[PublicPage]`). Reuses |
| 112 | + `TokenSetService::getAvailableTokenSets()` for discovery (zero duplication) and projects each |
| 113 | + entry to a closed 5-field contract: `id`, `name`, `design_system`, `theming` (`primary_color`, |
| 114 | + `background_color`, `logo`), `wcagLevel`. `wcagLevel` is computed and cached exactly as |
| 115 | + `Capabilities::computeWcagLevel()` already does for the active set, extended to every catalogue |
| 116 | + entry and sharing the same `ICache` prefix. Deliberately narrower than the admin |
| 117 | + `getAvailableTokenSets()` response (no `description`, `custom`, `warnings`, |
| 118 | + `upstreamVersion`/`upstreamRef`) — a minimal, independently-versionable public contract. |
| 119 | +- **NEW shared contrast-evaluation endpoint** — `POST /api/contrast/evaluate` |
| 120 | + (`#[NoAdminRequired]`). Generalises `ContrastService` beyond its current two fixed pairs: accepts |
| 121 | + an arbitrary list of candidate colors each tagged `text` (4.5:1) or `ui` (3:1), evaluated against |
| 122 | + a background (explicit or defaulted), and returns ratio/threshold/level/pass — diagnostic data |
| 123 | + only, **never** a block/allow verdict. This is the shape a caller like OpenBuild's |
| 124 | + `checkThemeContrast.js` needs (arbitrary `primaryColor`/`secondaryColor`/`accentColor`, not just |
| 125 | + nldesign's own fixed primary/text and primary/background pairs) so that JS file can be deleted in |
| 126 | + the companion OpenBuild change in favour of one shared, server-verified implementation. |
| 127 | +- **NEW published scoped-application contract** — documents (does not implement) the contract a |
| 128 | + shared client-side applier (the companion `nextcloud-vue` change) implements against: token |
| 129 | + namespace `--nldesign-*` (existing, reaffirmed), a design-system-owned scope attribute |
| 130 | + `data-nldesign-theme-scope="<scopeId>"` (superseding leaf-app-owned attributes like OpenBuild's |
| 131 | + `data-openbuild-theme-scope`), the `:root` → `[data-nldesign-theme-scope="<scopeId>"]` |
| 132 | + selector-rewrite rule, and the defensive rules already proven necessary by OpenBuild's own |
| 133 | + rewriter (flat-`:root`-only, bail-and-degrade on any other construct, applies to the base/light |
| 134 | + `css/tokens/<id>.css` only — the dark variant is explicitly out of scope). Backed by a **new |
| 135 | + structural-invariant regression test** guaranteeing every shipped token CSS file stays in this |
| 136 | + shape (custom uploads are already guaranteed by `CustomTokenSetValidator`). |
| 137 | +- **NEW documented blocking-policy resolution** — states explicitly that this endpoint's contrast |
| 138 | + data is always non-blocking, and that a leaf app **selecting** one of nldesign's curated/shipped |
| 139 | + or already-uploaded custom sets from the new catalogue MUST treat a contrast result as a warning, |
| 140 | + never a hard block — consistent with nldesign's own upload-time policy. A leaf app's own |
| 141 | + **free-hand custom-color authoring** (a distinct concern, e.g. OpenBuild's `appTheme` raw color |
| 142 | + picker) remains free to apply its own local blocking policy on top of the same shared data; this |
| 143 | + change resolves the *selection* path only. |
| 144 | +- **No Vue, no OpenBuild changes, no new database/appconfig storage** — read-only projections and |
| 145 | + computations over existing state. |
| 146 | + |
| 147 | +## Impact |
| 148 | + |
| 149 | +- `lib/Service/TokenSetService.php` — new projection method for the catalogue's closed 5-field |
| 150 | + shape (reuses `getAvailableTokenSets()`; no change to its existing return shape/consumers). |
| 151 | +- `lib/Service/ContrastService.php` — new `evaluate()`-style method generalising `check()` to |
| 152 | + arbitrary candidate/role pairs against a background; `check()` (fixed-pair, upload-time) is |
| 153 | + unchanged. |
| 154 | +- `lib/Capabilities.php` — no change; `computeWcagLevel()`'s per-set audit path is reused, not |
| 155 | + modified, by the new catalogue projection. |
| 156 | +- New controller(s) (proposed: `CatalogController`, `ContrastController` — small, single-purpose, |
| 157 | + mirroring the existing `MetricsController`/`HealthController` pattern of focused non-`/settings/*` |
| 158 | + controllers) and two new `appinfo/routes.php` entries outside the `/settings/*` prefix. |
| 159 | +- `openspec/specs/token-sets/spec.md`, `openspec/specs/theming-capability/spec.md`, |
| 160 | + `openspec/specs/custom-token-sets/spec.md` — referenced, not modified (this is a NEW capability |
| 161 | + spec; no existing requirement changes). |
| 162 | +- New PHPUnit structural-invariant test over `css/tokens/*.css` (excluding `dark/`). |
| 163 | +- Docs: a new consumable-contract reference page (scope attribute, rewrite rule, defensive rules, |
| 164 | + endpoint shapes) for leaf-app authors — the same role `openbuild`'s own spec plays for its |
| 165 | + consumers today, but published from nldesign's side. |
0 commit comments