Skip to content

Commit daa3236

Browse files
authored
Merge pull request #185 from ConductionNL/chore/archive-lasuite-token-completeness
docs(openspec): archive lasuite-token-completeness
2 parents 27afec1 + 6846b96 commit daa3236

18 files changed

Lines changed: 1083 additions & 38 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-07-25

openspec/changes/app-token-set-selection/design.md

Lines changed: 258 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

Comments
 (0)