App Token-Set Selection: Public Catalogue, Contrast, and Scoped-Application Contract
This page documents Thematiq's read-only, non-admin consumption surface for a leaf app's own picker — a builder tool inside another Conduction app (OpenBuild's virtual-app theme picker is the live example) — to enumerate the Thematiq token-set catalogue, evaluate WCAG contrast for arbitrary candidate colors, and (via a shared client-side applier built elsewhere) apply a token set scoped to one element on the page rather than instance-wide.
This is Thematiq's side of the contract only. The scoped applier itself is
implemented in nextcloud-vue, shared across every leaf app — this page
documents the contract it implements against; it ships no Vue and no client
code.
GET /api/token-sets — non-admin catalogue
Authenticated non-admin user (#[NoAdminRequired], not #[PublicPage]):
any logged-in user of the instance, not anonymous internet traffic. This is
deliberately narrower than admin-only (any logged-in user can read it) and
narrower than public (an anonymous visitor cannot).
GET /apps/thematiq/api/token-sets
Response:
{
"tokenSets": [
{
"id": "rijkshuisstijl",
"name": "Rijkshuisstijl",
"design_system": "nldesign",
"theming": {
"primary_color": "#154273",
"background_color": "#F5F6F7",
"logo": "img/logos/rijkshuisstijl.svg"
},
"wcagLevel": "AA"
}
]
}
Each entry is exactly the 5 fields above (theming.logo present only when
the set declares one — omitted, never null, otherwise). This is
deliberately narrower than the admin GET /settings/tokensets response: no
description, custom, warnings, upstreamVersion, or upstreamRef.
Every entry TokenSetService::getAvailableTokenSets() discovers is included
— shipped sets and admin-uploaded custom sets alike, no filtering by origin.
wcagLevel is one of AAA, AA, fail, or null (stock/custom/unknown
sets), computed via the same audit path the public capabilities document
(capabilities.nldesign.wcagLevel) uses for the active set, and cached under
the same ICache prefix (nldesign_wcag_level) — a set that is both the
active theme and a catalogue entry is audited at most once per cache TTL
window (3600s), not twice.
POST /api/contrast/evaluate — shared contrast evaluation
Authenticated non-admin user (#[NoAdminRequired]). No CSRF exemption: a
same-origin, authenticated browser POST carries the Nextcloud request token
automatically.
POST /apps/thematiq/api/contrast/evaluate
Content-Type: application/json
{
"candidates": [
{ "name": "primary", "value": "#154273", "role": "text" },
{ "name": "accent", "value": "#e8f0f8", "role": "ui" }
],
"background": "#F5F6F7"
}
Response:
{
"results": [
{ "name": "primary", "ratio": 8.99, "threshold": 4.5, "level": "AA", "pass": true },
{ "name": "accent", "ratio": 1.05, "threshold": 3.0, "level": "AA", "pass": false }
]
}
role: "text" candidates are evaluated at the 4.5:1 WCAG AA threshold,
role: "ui" candidates at 3.0:1. A candidate (or the background) that is
not a parseable literal color (hex or rgb()/rgba() — e.g. var(--token))
is reported unevaluated: true, ratio: null, and is never reported as
pass: true.
The response never contains a blocked, allowed, or verdict field.
This endpoint reports facts only — ratio, threshold, level, pass — and the
calling app's own UI decides what to do with them. See
Selection contrast is non-blocking
below for the resolved policy on what a caller is expected to do with this
data.
Selection contrast is non-blocking
Evaluating contrast for a leaf app's selection of an existing catalogue
entry (any set listed by GET /api/token-sets — shipped or already-uploaded
custom) MUST always be treated as a warning, never a hard block — consistent
with Thematiq's own upload-time policy
(custom-token-sets: non-blocking WCAG AA
warnings). Neither endpoint above exposes a mechanism for a caller to
condition a hard block on Thematiq's own contrast data for a selection flow
— there is no blocked/allowed field to even wire a hard block off of.
This policy governs selection only. A leaf app's own free-hand
custom-color authoring (a distinct concern — e.g. a leaf app's own raw
color-picker feature, not a catalogue selection) remains that app's own
decision: it may keep, relax, or drop its own local blocking policy on top of
the same shared ratio data from POST /api/contrast/evaluate.
Scoped-application contract for base token CSS
A shared client-side applier (built in nextcloud-vue, not this app)
implements against the following contract to apply a token set scoped to one
element on a page, rather than instance-wide:
- Token namespace:
--nldesign-*(existing, unchanged) — the stable vocabulary a scoped applier rewrites verbatim, never renaming or reinterpreting a property. - Scope attribute:
data-nldesign-theme-scope="<scopeId>"— owned by nldesign (the design system), not by any individual leaf app.<scopeId>is minted and validated entirely by the consuming applier; nldesign only requires the attribute exists on the scope root. Value-bearing (not presence-only) so more than one differently-themed scope can coexist on one page. - Selector-rewrite rule:
:root→[data-nldesign-theme-scope="<scopeId>"]— a 1:1 selector-prefix transform. No property name, value, or declaration order is ever altered. - Scope of coverage: base/light
css/tokens/<id>.cssonly. The generated dark variant (css/tokens/dark/<id>.css) uses an unrelated@media (prefers-color-scheme: dark) { body:not(...) { ... } }shape and is explicitly out of scope — scoped per-app dark-mode theming is a distinct, unsolved problem. - Defensive rule (bail, never partially rewrite): a consuming applier
MUST first verify the fetched CSS is exactly one flat
:root { }block with no at-rules and no other selector, and MUST inject nothing (degrading to default/unstyled) if it is not — partial rewriting must never occur.
Thematiq guarantees every shipped css/tokens/*.css file (excluding
dark/) satisfies this flat :root-only shape — enforced by an automated
regression test (tests/Unit/TokenCssShapeTest.php) over every shipped set.
Custom-uploaded sets already satisfy this shape by construction: the
existing CustomTokenSetValidator rejects any non-:root selector or
at-rule at upload time.
What this contract is not
- No Vue, no scoped applier implementation ships from Thematiq — the
applier is
nextcloud-vue's responsibility. - No new appconfig/storage — both endpoints are pure read/compute projections over existing state.
- No change to any existing endpoint's shape, auth posture, or behaviour —
the admin
GET /settings/tokensets,POST /settings/tokensets/upload, and the public capabilities document are all unchanged.