+ *
+ * @spec openspec/changes/apply-without-reload/specs/theming-sync-dialog/spec.md
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+function installInitialState(state) {
+ global.OCP = Object.assign(global.OCP || {}, {
+ InitialState: {
+ loadState: (app, key, fallback) =>
+ Object.prototype.hasOwnProperty.call(state, key)
+ ? state[key]
+ : fallback,
+ },
+ })
+}
+
+function installGlobals() {
+ global.t = (app, text, params) => {
+ if (params === undefined) {
+ return text
+ }
+ return Object.keys(params).reduce(
+ (acc, key) => acc.replace('{' + key + '}', params[key]),
+ text,
+ )
+ }
+ global.n = (app, singular, plural, count) => (count === 1 ? singular : plural)
+ global.OC = {
+ generateUrl: (url) => url,
+ linkTo: (app, path) => path,
+ imagePath: (app, path) => '/' + app + '/' + path,
+ filePath: (app, type, file) => '/' + app + '/' + type + '/' + file,
+ requestToken: 'test-token',
+ Notification: { showTemporary: vi.fn() },
+ dialogs: { confirm: vi.fn() },
+ }
+}
+
+/**
+ * Route fetches by URL fragment, and optionally by method.
+ *
+ * The method matters for `/settings/theming`, which is one URL with two
+ * answers: GET returns the snapshot, POST returns `{status: 'ok'}`. Without
+ * the distinction the POST was answered with the snapshot, `applyThemingPlan()`
+ * saw no `status` and threw "Theming sync failed", and every assertion about
+ * the panel failed on a defect of this stub rather than of admin.js.
+ *
+ * @param {Array<[string, object, string?]>} routes `[urlFragment, body, method?]`; a route with a method only matches that method, and is tried before the method-less ones.
+ * @param {Array?} postLog Collects every request that carried options.
+ */
+function installFetchRouter(routes, postLog) {
+ global.fetch = vi.fn((url, options) => {
+ const method = (options && options.method) || 'GET'
+ if (options && postLog) {
+ postLog.push({ url, method, body: options.body })
+ }
+ const matches = (route) => url.indexOf(route[0]) !== -1
+ const byMethod = routes.filter((route) => route[2] === method).find(matches)
+ const route =
+ byMethod
+ || routes.filter((route) => route[2] === undefined).find(matches)
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(route ? route[1] : {}),
+ })
+ })
+}
+
+async function flush(rounds = 10) {
+ for (let i = 0; i < rounds; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+}
+
+async function loadAdminScript() {
+ vi.resetModules()
+ await import('../../js/admin.js?t=' + Math.random())
+ await flush()
+}
+
+const OPENWOO = {
+ id: 'custom-openwoo',
+ name: 'OpenWOO',
+ design_system: 'nldesign',
+ theming: {
+ primary_color: '#23845c',
+ background_color: '#ffffff',
+ logo: 'img/logos/custom-openwoo.svg',
+ },
+}
+
+/**
+ * The settings page plus a stand-in for core's Theming panel, built the way
+ * the compiled Vue writes it: the colour lives ONLY in an inline custom
+ * property whose name is a build hash.
+ *
+ * @param {string} primaryHash Hash core's build gave `value` on the primary field.
+ * @param {string} primaryTextHash Hash it gave `usedTextColor`.
+ */
+function buildDom(primaryHash, primaryTextHash) {
+ installInitialState({
+ tokenSets: [OPENWOO],
+ currentTokenSet: 'nextcloud',
+ activePreview: null,
+ iconPackSource: '',
+ })
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+ `
+}
+
+/**
+ * Select OpenWOO and confirm the apply dialog, whose theming section is
+ * checked by default — the one-confirm path an admin takes.
+ *
+ * @param {object} themingSnapshot What GET /settings/theming answers AFTER the sync.
+ */
+async function applyOpenwoo(themingSnapshot, postLog) {
+ installFetchRouter(
+ [
+ // A token diff exists, so the apply dialog (with its theming
+ // section) is what opens — not the standalone sync dialog.
+ ['tokenset-preview', { resolved: { '--color-primary': '#23845c' } }],
+ [
+ 'tokenset-stylesheets',
+ { tokenSet: 'custom-openwoo', designSystem: 'nldesign', layers: [] },
+ ],
+ ['/settings/overrides', { overrides: {}, status: 'ok' }],
+ ['/settings/tokenset', { status: 'ok' }],
+ // The sync itself; the GET below then answers with the post-sync
+ // snapshot, which is what the panel is rebuilt from.
+ ['/settings/theming', { status: 'ok' }, 'POST'],
+ ['/settings/theming', themingSnapshot],
+ ],
+ postLog,
+ )
+
+ await loadAdminScript()
+
+ const select = document.getElementById('nldesign-token-set-select')
+ select.value = 'custom-openwoo'
+ select.dispatchEvent(new window.Event('change', { bubbles: true }))
+ await flush()
+
+ const overlay = document.getElementById('nldesign-apply-dialog-overlay')
+ expect(overlay, 'the apply dialog should open').not.toBeNull()
+ overlay
+ .querySelector('.nldesign-dialog-confirm')
+ .dispatchEvent(new window.Event('click', { bubbles: true }))
+ await flush(20)
+}
+
+describe('admin.js — core Theming panel after a sync', () => {
+ beforeEach(() => {
+ installGlobals()
+ })
+
+ afterEach(() => {
+ document.body.innerHTML = ''
+ vi.restoreAllMocks()
+ })
+
+ it('rebinds the compiled v-bind custom property, so the picker button changes colour', async () => {
+ buildDom('6cc639bc', '6fa57444')
+ await applyOpenwoo({
+ primary_color: '#23845c',
+ background_color: '#ffffff',
+ logo_url: '/apps/theming/image/logo?v=9',
+ has_custom_logo: true,
+ has_custom_background: false,
+ default_primary_color: '#00679e',
+ default_background_color: '#00679e',
+ })
+
+ const field = document.querySelector(
+ '[data-admin-theming-setting-primary-color]',
+ )
+ // The colour, not just the label.
+ expect(field.style.getPropertyValue('--6cc639bc').trim()).toBe('#23845c')
+ // #23845c is dark, so core would put white text on it.
+ expect(field.style.getPropertyValue('--6fa57444').trim()).toBe('#ffffff')
+ expect(field.querySelector('.button-vue__text').textContent).toBe('#23845c')
+ expect(
+ field.querySelector('[data-admin-theming-setting-color]').style
+ .backgroundColor,
+ ).toBe('rgb(35, 132, 92)')
+ })
+
+ it('discovers the hash at runtime — a different build still works', async () => {
+ buildDom('deadbeef', 'cafed00d')
+ await applyOpenwoo({
+ primary_color: '#23845c',
+ background_color: '#ffffff',
+ has_custom_logo: false,
+ has_custom_background: false,
+ default_primary_color: '#00679e',
+ default_background_color: '#00679e',
+ })
+
+ const field = document.querySelector(
+ '[data-admin-theming-setting-primary-color]',
+ )
+ expect(field.style.getPropertyValue('--deadbeef').trim()).toBe('#23845c')
+ expect(field.style.getPropertyValue('--cafed00d').trim()).toBe('#ffffff')
+ })
+
+ it('puts black text on a light colour, as core would', async () => {
+ buildDom('6cc639bc', '6fa57444')
+ await applyOpenwoo({
+ primary_color: '#23845c',
+ background_color: '#ffffff',
+ has_custom_logo: false,
+ has_custom_background: false,
+ default_primary_color: '#00679e',
+ default_background_color: '#00679e',
+ })
+
+ // The background field's synced value is #ffffff — white on white
+ // would leave the label invisible, which is what the first attempt did.
+ const field = document.querySelector(
+ '[data-admin-theming-setting-background-color]',
+ )
+ expect(field.style.getPropertyValue('--aaa11122').trim()).toBe('#ffffff')
+ expect(field.style.getPropertyValue('--bbb33344').trim()).toBe('#000000')
+ })
+
+ it('falls back to core defaults when a reset emptied the values', async () => {
+ buildDom('6cc639bc', '6fa57444')
+ await applyOpenwoo({
+ // What GET /settings/theming answers after DELETE: nothing is set.
+ primary_color: '',
+ background_color: '',
+ has_custom_logo: false,
+ has_custom_background: false,
+ default_primary_color: '#00679e',
+ default_background_color: '#00679e',
+ })
+
+ const field = document.querySelector(
+ '[data-admin-theming-setting-primary-color]',
+ )
+ expect(field.style.getPropertyValue('--6cc639bc').trim()).toBe('#00679e')
+ expect(field.querySelector('.button-vue__text').textContent).toBe('#00679e')
+ })
+
+ it('updates the logo preview from the snapshot URL', async () => {
+ buildDom('6cc639bc', '6fa57444')
+ await applyOpenwoo({
+ primary_color: '#23845c',
+ background_color: '#ffffff',
+ logo_url: '/apps/theming/image/logo?v=42',
+ has_custom_logo: true,
+ has_custom_background: false,
+ default_primary_color: '#00679e',
+ default_background_color: '#00679e',
+ })
+
+ const preview = document.querySelector('[data-admin-theming-preview-logo]')
+ expect(preview.style.backgroundImage).toContain(
+ '/apps/theming/image/logo?v=42',
+ )
+ })
+
+ it("offers nothing when the slot already holds this set's logo", async () => {
+ // The bug this pins: `if (proposed.logo)` never compared, so a set with
+ // a logo always looked changed and the dialog could never stop
+ // appearing — measured live, with primary, background AND logo already
+ // synced. `synced_logo` is what core cannot tell us.
+ buildDom('6cc639bc', '6fa57444')
+ installFetchRouter([
+ ['tokenset-preview', { error: 'not applicable' }],
+ [
+ 'tokenset-stylesheets',
+ { tokenSet: 'custom-openwoo', designSystem: 'nldesign', layers: [] },
+ ],
+ ['/settings/tokenset', { status: 'ok' }],
+ [
+ '/settings/theming',
+ {
+ primary_color: '#23845c',
+ background_color: '#ffffff',
+ has_custom_logo: true,
+ has_custom_background: false,
+ synced_logo: 'img/logos/custom-openwoo.svg',
+ synced_background: '',
+ default_primary_color: '#00679e',
+ default_background_color: '#00679e',
+ },
+ ],
+ ])
+
+ await loadAdminScript()
+ const select = document.getElementById('nldesign-token-set-select')
+ select.value = 'custom-openwoo'
+ select.dispatchEvent(new window.Event('change', { bubbles: true }))
+ await flush(20)
+
+ expect(document.getElementById('nldesign-theming-dialog-overlay')).toBeNull()
+ })
+
+ it('offers the logo when the slot holds a different one', async () => {
+ buildDom('6cc639bc', '6fa57444')
+ installFetchRouter([
+ ['tokenset-preview', { error: 'not applicable' }],
+ [
+ 'tokenset-stylesheets',
+ { tokenSet: 'custom-openwoo', designSystem: 'nldesign', layers: [] },
+ ],
+ ['/settings/tokenset', { status: 'ok' }],
+ [
+ '/settings/theming',
+ {
+ primary_color: '#23845c',
+ background_color: '#ffffff',
+ has_custom_logo: true,
+ has_custom_background: false,
+ synced_logo: 'img/logos/amsterdam.svg',
+ synced_background: '',
+ default_primary_color: '#00679e',
+ default_background_color: '#00679e',
+ },
+ ],
+ ])
+
+ await loadAdminScript()
+ const select = document.getElementById('nldesign-token-set-select')
+ select.value = 'custom-openwoo'
+ select.dispatchEvent(new window.Event('change', { bubbles: true }))
+ await flush(20)
+
+ const overlay = document.getElementById('nldesign-theming-dialog-overlay')
+ expect(overlay).not.toBeNull()
+ expect(overlay.textContent).toContain('custom-openwoo.svg')
+ })
+
+ it('syncs on the apply confirm, with no second dialog', async () => {
+ const postLog = []
+ buildDom('6cc639bc', '6fa57444')
+ await applyOpenwoo(
+ {
+ // A PRE-sync state: stock primary, no custom logo. The set's
+ // #23845c and its wordmark therefore both belong in the POST.
+ // (Handing this test the POST-sync values would mean nothing
+ // differed, and the body would carry neither.)
+ primary_color: '#00679e',
+ background_color: '#ffffff',
+ has_custom_logo: false,
+ has_custom_background: false,
+ default_primary_color: '#00679e',
+ default_background_color: '#00679e',
+ },
+ postLog,
+ )
+
+ const sync = postLog.find(
+ (entry) =>
+ entry.method === 'POST'
+ && entry.url.indexOf('/settings/theming') !== -1,
+ )
+ expect(sync, 'the apply confirm should POST the theming sync').toBeTruthy()
+ expect(sync.body).toContain('primary_color')
+ expect(sync.body).toContain('logo')
+ // The whole point: no follow-up modal.
+ expect(document.getElementById('nldesign-theming-dialog-overlay')).toBeNull()
+ })
+})
diff --git a/tests/vitest/auditFormat.spec.js b/tests/vitest/auditFormat.spec.js
new file mode 100644
index 00000000..daed9064
--- /dev/null
+++ b/tests/vitest/auditFormat.spec.js
@@ -0,0 +1,125 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The audit log's value formatting.
+ *
+ * These four were written inside a closure in admin.js with no export, so the
+ * only way to exercise them was to open the settings page and read it — which
+ * is how "1 entries" reached review. They live in js/lib/auditFormat.js now,
+ * and every shape ThemingAuditService can store has a case here.
+ *
+ * @spec openspec/specs/theming-audit/spec.md
+ */
+
+import { describe, it, expect } from 'vitest'
+import format from '../../js/lib/auditFormat.js'
+
+describe('a stored value, as the panel renders it', () => {
+ it('says so when there is no before-side', () => {
+ // A first write or an upload has no `old`. An empty cell reads as a
+ // rendering failure; the dash is a statement.
+ expect(format.formatAuditValue(null)).toBe('—')
+ expect(format.formatAuditValue(undefined)).toBe('—')
+ })
+
+ it('renders a toggle as a state, not as a literal', () => {
+ expect(format.formatAuditValue(true)).toBe('On')
+ expect(format.formatAuditValue(false)).toBe('Off')
+ })
+
+ it('counts one entry in the singular', () => {
+ // summarizeValue() reduces ANY array to {count}, so a one-key override
+ // map is routine — and read "1 entries" until this.
+ expect(format.formatAuditValue({ count: 1 })).toBe('1 entry')
+ expect(format.formatAuditValue({ count: 0 })).toBe('0 entries')
+ expect(format.formatAuditValue({ count: 12 })).toBe('12 entries')
+ })
+
+ it('renders a CSS payload as a size and the digest identifying it', () => {
+ expect(format.formatAuditValue({ hash: 'sha256:a1b2c3', bytes: 1536 })).toBe(
+ '1.5 kB · sha256:a1b2c3',
+ )
+ // The digest is the evidence half; a payload without one still sizes.
+ expect(format.formatAuditValue({ bytes: 412 })).toBe('412 B')
+ })
+
+ it('scales bytes to something a reader can compare', () => {
+ expect(format.formatAuditBytes(0)).toBe('0 B')
+ expect(format.formatAuditBytes(1023)).toBe('1023 B')
+ expect(format.formatAuditBytes(1024)).toBe('1.0 kB')
+ expect(format.formatAuditBytes(2359296)).toBe('2.3 MB')
+ })
+
+ it('passes a scalar through', () => {
+ expect(format.formatAuditValue('rotterdam')).toBe('rotterdam')
+ expect(format.formatAuditValue(42)).toBe('42')
+ })
+
+ it('falls back to JSON for a shape it does not know', () => {
+ expect(format.formatAuditValue({ weird: 1 })).toBe('{"weird":1}')
+ })
+})
+
+describe('a stored timestamp, as the panel renders it', () => {
+ it('drops the machine punctuation and keeps UTC', () => {
+ // Kept in UTC deliberately: the exported log is the artefact that gets
+ // filed, and a panel showing a different hour than the export cannot be
+ // lined up against it.
+ expect(format.formatAuditTimestamp('2026-09-17T09:14:02Z')).toBe(
+ '2026-09-17 09:14:02 UTC',
+ )
+ })
+
+ it('passes anything not in that exact shape through untouched', () => {
+ expect(format.formatAuditTimestamp('not-a-date')).toBe('not-a-date')
+ expect(format.formatAuditTimestamp('2026-09-17 09:14:02')).toBe(
+ '2026-09-17 09:14:02',
+ )
+ expect(format.formatAuditTimestamp('')).toBe('')
+ expect(format.formatAuditTimestamp(null)).toBe('')
+ })
+})
+
+describe('which identities changed', () => {
+ it('names them, because two counts do not answer the question', () => {
+ // Both sides of a sync entry are snapshots reduced to a count, so From
+ // and To both read "10 entries" — this column is the only one that says
+ // what actually moved.
+ expect(
+ format.formatAuditChanged({ changed: ['primary_color', 'logo'] }),
+ ).toBe('primary_color, logo')
+ })
+
+ it('caps a long list, because one write can name forty tokens', () => {
+ // Printed in full this cell was taller than the rest of the row and,
+ // before the table layout was fixed, wider than the page. The count is
+ // what a reader acts on; the names are in the exported log.
+ const many = Array.from({ length: 40 }, (unused, i) => '--token-' + i)
+ const out = format.formatAuditChanged({ changed: many })
+
+ expect(out).toContain('--token-0')
+ expect(out).toContain('--token-5')
+ expect(out).not.toContain('--token-6')
+ expect(out).toContain('+34 more')
+ })
+
+ it('does not cap a list that already fits', () => {
+ const six = Array.from({ length: 6 }, (unused, i) => '--token-' + i)
+
+ expect(format.formatAuditChanged({ changed: six })).toBe(six.join(', '))
+ expect(format.formatAuditChanged({ changed: six })).not.toContain('more')
+ })
+
+ it('counts the remainder, not the whole list', () => {
+ const seven = Array.from({ length: 7 }, (unused, i) => '--token-' + i)
+
+ expect(format.formatAuditChanged({ changed: seven })).toContain('+1 more')
+ })
+
+ it('distinguishes "nothing changed" from "not applicable"', () => {
+ expect(format.formatAuditChanged({ changed: [] })).toBe('Nothing')
+ expect(format.formatAuditChanged({})).toBe('—')
+ expect(format.formatAuditChanged({ changed: 'not-an-array' })).toBe('—')
+ })
+})
diff --git a/tests/vitest/componentTokenReach.spec.js b/tests/vitest/componentTokenReach.spec.js
new file mode 100644
index 00000000..77a4d1c9
--- /dev/null
+++ b/tests/vitest/componentTokenReach.spec.js
@@ -0,0 +1,394 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Does a chip's token actually reach its component?
+ *
+ * A component token is worth nothing unless something paints from it. There
+ * are two ways it can:
+ *
+ * 1. `css/component-scopes.css` redirects the Nextcloud variable the
+ * component consumes, and a NEXTCLOUD rule paints from that variable; or
+ * 2. a thematiq rule paints the property and reads a token that CHAINS to
+ * the component's own — directly, through `defaults.css`, or through an
+ * `aliases` entry in the mapping.
+ *
+ * It fails when a thematiq rule paints the property with `!important` while
+ * reading some OTHER token. The rule wins, the chip's control does nothing,
+ * and nothing says so: the row still renders, still saves, still shows a
+ * colour. That is exactly how the login button shipped — its background and
+ * label came from the PRIMARY button's tokens, so an admin could set a colour,
+ * see it stored, and watch the button stay blue.
+ *
+ * This is the guard for that class of defect. It is deliberately stricter than
+ * "the token exists": existence was never the problem.
+ *
+ * @spec openspec/specs/component-tokens/spec.md
+ */
+
+import { describe, it, expect } from 'vitest'
+import * as fs from 'fs'
+import * as path from 'path'
+
+const ROOT = path.resolve(__dirname, '../..')
+
+const mapping = JSON.parse(
+ fs.readFileSync(
+ path.join(ROOT, 'scripts/mapping/component-tokens.json'),
+ 'utf8',
+ ),
+)
+
+const SHEETS = [
+ 'css/systems/nldesign/theme.css',
+ 'css/systems/nldesign/element-overrides.css',
+]
+
+/**
+ * Tokens that are allowed to paint over a component token, with the reason.
+ *
+ * `--nldesign-color-on-surface` is not a component token and is not meant to
+ * be one. It is the WCAG pairing mechanism in element-overrides.css: a
+ * container declares which foreground belongs on its surface and every
+ * descendant inherits it, so text stays legible on a saturated fill. Making it
+ * per-component would defeat the point — the pairing has to travel.
+ */
+const BY_DESIGN = new Set(['--nldesign-color-on-surface'])
+
+/**
+ * Properties a component has no row for, and the token that paints them.
+ *
+ * Each entry is a real gap: the component simply offers no control for that
+ * property, so a rule painting it from elsewhere is not shadowing anything.
+ * Adding a row is the fix; until then this list is the record of what an admin
+ * cannot reach, and it must not grow silently.
+ */
+const NO_ROW_YET = new Set([
+ // The navigation panel's own surface. Its Nextcloud variable is
+ // `--color-main-background`, which overrides.css deliberately leaves alone
+ // because overriding it breaks dark mode — so a row here needs a token the
+ // nav rule reads directly, not a re-scope.
+ 'app-navigation: background-color <- --nldesign-color-nav-background',
+ // Text colour inside a field. The chips offer border, placeholder, corner,
+ // focus and invalid, but not the typed text itself.
+ 'text-input: color <- --nldesign-component-textbox-color',
+ 'textarea: color <- --nldesign-component-textbox-color',
+ // The primary button's border. It tracks the background in every shipped
+ // set, so it reads as one edge — but it is a separate token with no row.
+ 'primary-button: border-color <- --nldesign-component-button-primary-action-border-color',
+])
+
+/**
+ * Rules whose selector names one component but whose subject is another,
+ * nested inside it — a button under `#content`, an avatar under `#header`.
+ * The marker match cannot tell those apart; the owning component covers them.
+ */
+const NESTED = new Set([
+ 'header-bar: background-color <- --nldesign-color-primary',
+ 'avatar: color <- --nldesign-component-header-color',
+ 'content-card: color <- --nldesign-component-button-primary-action-color',
+ 'link: color <- --nldesign-component-header-color',
+ 'link: color <- --nldesign-color-primary',
+])
+
+/* ---------------------------------------------------- defaults.css chains */
+
+const defaults = new Map()
+{
+ const css = fs
+ .readFileSync(path.join(ROOT, 'css/systems/nldesign/defaults.css'), 'utf8')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ for (const m of css.matchAll(/(--nldesign-[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
+ defaults.set(m[1], m[2].trim())
+ }
+}
+
+/**
+ * Every token `name` resolves through, itself included.
+ *
+ * @param {string} name A `--nldesign-*` token name.
+ * @param {Set} [seen] Accumulator for the recursion.
+ *
+ * @return {Set} The chain.
+ */
+function chainOf(name, seen) {
+ const out = seen || new Set()
+ if (out.has(name)) {
+ return out
+ }
+ out.add(name)
+ const value = defaults.get(name)
+ if (value === undefined) {
+ return out
+ }
+ for (const m of value.matchAll(/var\(\s*(--nldesign-[a-z0-9-]+)/g)) {
+ chainOf(m[1], out)
+ }
+ return out
+}
+
+/* ------------------------------------------------------- the shipped rules */
+
+const rules = []
+for (const file of SHEETS) {
+ const css = fs
+ .readFileSync(path.join(ROOT, file), 'utf8')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
+ const selectorList = m[1].trim()
+ if (selectorList.startsWith('@') || selectorList.includes(':root')) {
+ continue
+ }
+ const decls = []
+ for (const declaration of m[2].split(';')) {
+ const at = declaration.indexOf(':')
+ if (at < 0) {
+ continue
+ }
+ const prop = declaration.slice(0, at).trim()
+ if (prop.startsWith('--')) {
+ continue
+ }
+ const tokens = [
+ ...declaration
+ .slice(at + 1)
+ .matchAll(/var\(\s*(--nldesign-[a-z0-9-]+)/g),
+ ].map((x) => x[1])
+ if (tokens.length === 0) {
+ continue
+ }
+ // The FIRST is the primary; the rest are var() fallbacks, which are
+ // what a chain is supposed to end in and never shadow anything.
+ decls.push({ prop, token: tokens[0] })
+ }
+ if (decls.length > 0) {
+ rules.push({
+ selectors: selectorList
+ .split(',')
+ .map((s) => s.trim().replace(/\s+/g, ' ')),
+ decls,
+ })
+ }
+ }
+}
+
+/* ------------------------------------------- attributing a rule to a chip */
+
+/** Only the LAST compound of a selector identifies the component. */
+function markersFor(component) {
+ const marks = new Set()
+ for (const selector of component.selectors) {
+ const last = selector.trim().split(/\s+/).pop()
+ const classes = last.match(/\.[\w-]+/g) || []
+ const ids = last.match(/#[\w-]+/g) || []
+ classes.forEach((c) => marks.add(c))
+ ids.forEach((i) => marks.add(i))
+ if (classes.length > 0 || ids.length > 0) {
+ continue
+ }
+ // An element compound, attribute included: `input[type='submit']` must
+ // stay whole, or it matches every typed field in the sheet.
+ if (/^[a-z][\w-]*(\[[^\]]*\])?$/.test(last)) {
+ marks.add(last)
+ }
+ }
+ return [...marks]
+}
+
+function hasMarker(selector, marker) {
+ const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ if (/^[.#]/.test(marker)) {
+ // A class or id may sit anywhere in a compound: `.a.b` does carry `.a`.
+ return new RegExp(escaped + '(?![\\w-])').test(selector)
+ }
+ // An element compound must be the whole compound, or `button` claims
+ // `button.secondary`.
+ return new RegExp('(?:^|[\\s>+~])' + escaped + '(?![\\w.#[-])').test(selector)
+}
+
+/** The properties a chip's rows are understood to control. */
+function claimedProps(component) {
+ const props = new Set()
+ for (const name of Object.keys(component.tokens)) {
+ if (/-background-color$/.test(name)) {
+ props.add('background-color')
+ props.add('background')
+ }
+ if (/(^|-)color$/.test(name)) {
+ props.add('color')
+ }
+ if (/-border-color$/.test(name)) {
+ props.add('border-color')
+ }
+ if (/-border-radius$/.test(name)) {
+ props.add('border-radius')
+ }
+ if (/-font-family$/.test(name)) {
+ props.add('font-family')
+ }
+ if (/-font-weight$/.test(name)) {
+ props.add('font-weight')
+ }
+ }
+ return props
+}
+
+/** States a chip has a row for; a `:disabled` rule is not its business. */
+function statesOf(component) {
+ const states = new Set([''])
+ for (const name of Object.keys(component.tokens)) {
+ if (/-hover(-|$)/.test(name)) {
+ states.add('hover')
+ }
+ if (/-active-/.test(name)) {
+ states.add('active')
+ }
+ if (/-focus-/.test(name)) {
+ states.add('focus')
+ }
+ if (/-disabled-/.test(name)) {
+ states.add('disabled')
+ }
+ }
+ return states
+}
+
+function stateOf(selector) {
+ const m = selector.match(
+ /:(hover|active|focus-visible|focus|disabled|checked|visited|invalid)/,
+ )
+ if (m === null) {
+ return ''
+ }
+ return m[1] === 'focus-visible' ? 'focus' : m[1]
+}
+
+/* ------------------------------------------------------------------ the test */
+
+function findInert() {
+ const found = []
+ for (const [id, component] of Object.entries(mapping.components)) {
+ const own = new Set(Object.keys(component.tokens))
+ const aliased = new Set(Object.keys(component.aliases ?? {}))
+ const marks = markersFor(component)
+ const claims = claimedProps(component)
+ const states = statesOf(component)
+ if (marks.length === 0 || claims.size === 0) {
+ continue
+ }
+
+ for (const rule of rules) {
+ const matches = rule.selectors.some(
+ (s) =>
+ marks.some((mk) => hasMarker(s, mk)) && states.has(stateOf(s)),
+ )
+ if (matches === false) {
+ continue
+ }
+ for (const decl of rule.decls) {
+ if (claims.has(decl.prop) === false) {
+ continue
+ }
+ if (aliased.has(decl.token) || BY_DESIGN.has(decl.token)) {
+ continue
+ }
+ if ([...chainOf(decl.token)].some((t) => own.has(t))) {
+ continue
+ }
+ const key = id + ': ' + decl.prop + ' <- ' + decl.token
+ if (found.includes(key) === false) {
+ found.push(key)
+ }
+ }
+ }
+ }
+ return found
+}
+
+describe('component tokens: does the chip reach its component', () => {
+ it('has no chip row a shipped rule paints over', () => {
+ // Anything here is a control that renders, saves, and does nothing. If
+ // this fails after adding a rule, chain that rule to the component's own
+ // token — `var(--nldesign-component-x-y, var(--what-it-read-before))` —
+ // or add an `aliases` entry in the mapping when the rule legitimately
+ // reads another component's token.
+ const unreachable = findInert().filter(
+ (key) => NO_ROW_YET.has(key) === false && NESTED.has(key) === false,
+ )
+
+ expect(unreachable).toEqual([])
+ })
+
+ it('keeps the known-gap list honest', () => {
+ // A gap that has been closed must leave this list, or the list stops
+ // describing the code and starts excusing it.
+ const current = findInert()
+ const stale = [...NO_ROW_YET, ...NESTED].filter(
+ (key) => current.includes(key) === false,
+ )
+
+ expect(stale).toEqual([])
+ })
+})
+
+/*
+ * The capture block's ELEMENT, which decides which palette the fallbacks see.
+ *
+ * `--thematiq-global-*` copies a Nextcloud global so a scope rule can fall back
+ * to it without referring to itself. Which element that copy is taken on is the
+ * whole behaviour: every design system declares the globals on `body`
+ * (css/systems/nldesign/theme.css and the high-contrast, summer-breeze and
+ * lasuite sheets), while Nextcloud core declares its own on `:root`, because
+ * ThemeInjectionService serves the default theme with `plain=true`.
+ *
+ * Taken on `:root`, the copies held CORE's palette, so every component whose
+ * token nobody had set — 91 of the 123 in the mapping — was painted Nextcloud
+ * blue instead of the brand colour, and `primary-lock.css` drove every locked
+ * component to core's primary rather than the set's.
+ *
+ * `body` sees both: what it declares itself, and what `:root` declares, by
+ * inheritance. So this is not a style preference, and a regenerate that moves
+ * it back to `:root` has to fail here.
+ */
+describe('component tokens: the capture block is taken on body', () => {
+ const GENERATED = ['css/component-scopes.css', 'css/primary-lock.css']
+
+ it.each(GENERATED)('%s declares --thematiq-global-* on body', (file) => {
+ const css = fs.readFileSync(path.join(ROOT, file), 'utf8')
+
+ // The selector list of the first block that mentions a capture name —
+ // read off the raw text back to the end of the previous rule or
+ // comment, so a list spanning several lines is read whole.
+ const open = css.search(/\{[^}]*--thematiq-global-/)
+ expect(open, 'no block declaring or reading a capture').toBeGreaterThan(-1)
+
+ const head = css.slice(0, open)
+ const start = Math.max(head.lastIndexOf('}') + 1, head.lastIndexOf('*/') + 2)
+ const selectors = head
+ .slice(start)
+ .split(',')
+ .map((s) => s.trim())
+
+ expect(selectors[0]).toBe('body')
+ // The token editor's preview is captured too: an unsaved edit is
+ // declared there and nowhere else. Only the scopes read captures
+ // inside it; the lock is about the live page.
+ if (file === 'css/component-scopes.css') {
+ expect(selectors).toEqual(['body', '#nldesign-preview'])
+ } else {
+ expect(selectors).toEqual(['body'])
+ }
+ })
+
+ it('leaves no capture stranded on :root', () => {
+ for (const file of GENERATED) {
+ const css = fs.readFileSync(path.join(ROOT, file), 'utf8')
+ const rootBlocks = [...css.matchAll(/(^|\n):root\s*\{([^}]*)\}/g)]
+ const stranded = rootBlocks.filter((m) =>
+ m[2].includes('--thematiq-global-'),
+ )
+
+ expect(stranded.map(() => file)).toEqual([])
+ }
+ })
+})
diff --git a/tests/vitest/layerSwap.spec.js b/tests/vitest/layerSwap.spec.js
new file mode 100644
index 00000000..a31878a8
--- /dev/null
+++ b/tests/vitest/layerSwap.spec.js
@@ -0,0 +1,157 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Unit tests for js/lib/layerSwap.js — the pure parts of applying a token set
+ * to the page without a reload: how a page element and a manifest layer are
+ * matched (by pathname, never by cache-busting query), what a swap changes,
+ * and how a stylesheet is re-requested. The DOM half (swap, findLayerElements,
+ * refreshStylesheets) is covered by the Playwright workflow
+ * tests/e2e/workflows/apply-without-reload.workflow.spec.ts, because it needs
+ * real stylesheet `load` events.
+ */
+
+import { describe, it, expect } from 'vitest'
+import layerSwap from '../../js/lib/layerSwap.js'
+
+const { pathnameOf, layerKey, diffLayers, bumpVersion } = layerSwap
+
+describe('pathnameOf', () => {
+ it('strips origin, query and hash', () => {
+ expect(
+ pathnameOf(
+ 'https://cloud.example/custom_apps/thematiq/css/tokens/x.css?v=abc-36#top',
+ ),
+ ).toBe('/custom_apps/thematiq/css/tokens/x.css')
+ })
+
+ it('leaves a root-relative path alone apart from the query', () => {
+ expect(pathnameOf('/custom_apps/thematiq/css/tokens/x.css?v=1.1.12')).toBe(
+ '/custom_apps/thematiq/css/tokens/x.css',
+ )
+ })
+
+ it('tolerates empty input', () => {
+ expect(pathnameOf('')).toBe('')
+ expect(pathnameOf(undefined)).toBe('')
+ })
+})
+
+describe('layerKey', () => {
+ it('keys a file layer by pathname so two cache-busters are the same file', () => {
+ const server = {
+ kind: 'file',
+ href: '/custom_apps/thematiq/css/tokens/a.css?v=d98319d8-36',
+ }
+ const manifest = {
+ kind: 'file',
+ href: '/custom_apps/thematiq/css/tokens/a.css?v=1.1.12',
+ }
+ expect(layerKey(server)).toBe(layerKey(manifest))
+ expect(layerKey(server)).toBe('file:/custom_apps/thematiq/css/tokens/a.css')
+ })
+
+ it('keys an inline layer by its element id', () => {
+ expect(
+ layerKey({ kind: 'inline', id: 'nldesign-logo-url', css: ':root{}' }),
+ ).toBe('inline:nldesign-logo-url')
+ })
+})
+
+describe('diffLayers', () => {
+ const designSystem = [
+ {
+ kind: 'file',
+ layer: 'design-system',
+ href: '/a/css/systems/nldesign/theme.css?v=1',
+ },
+ {
+ kind: 'file',
+ layer: 'design-system',
+ href: '/a/css/systems/nldesign/overrides.css?v=1',
+ },
+ ]
+ const amsterdam = designSystem.concat([
+ { kind: 'file', layer: 'tokens', href: '/a/css/tokens/amsterdam.css?v=1' },
+ {
+ kind: 'inline',
+ layer: 'logo-url',
+ id: 'nldesign-logo-url',
+ css: ':root{--nldesign-logo-url:url(/a/img/logos/amsterdam.svg)}',
+ },
+ ])
+ const zwolle = designSystem.concat([
+ { kind: 'file', layer: 'tokens', href: '/a/css/tokens/zwolle.css?v=2' },
+ {
+ kind: 'inline',
+ layer: 'logo-url',
+ id: 'nldesign-logo-url',
+ css: ':root{--nldesign-logo-url:none}',
+ },
+ ])
+
+ it('reports the set-specific file as removed and added, the shared ones as unchanged', () => {
+ const diff = diffLayers(amsterdam, zwolle)
+ expect(diff.removed.map(layerKey)).toEqual([
+ 'file:/a/css/tokens/amsterdam.css',
+ ])
+ expect(diff.added.map(layerKey)).toEqual(['file:/a/css/tokens/zwolle.css'])
+ expect(diff.unchanged.map(layerKey)).toEqual([
+ 'file:/a/css/systems/nldesign/theme.css',
+ 'file:/a/css/systems/nldesign/overrides.css',
+ 'inline:nldesign-logo-url',
+ ])
+ })
+
+ it('treats stock Nextcloud (no layers) as removing everything', () => {
+ const diff = diffLayers(amsterdam, [])
+ expect(diff.added).toEqual([])
+ expect(diff.unchanged).toEqual([])
+ expect(diff.removed).toHaveLength(amsterdam.length)
+ })
+
+ it('treats leaving stock Nextcloud as adding everything', () => {
+ const diff = diffLayers([], zwolle)
+ expect(diff.removed).toEqual([])
+ expect(diff.added).toHaveLength(zwolle.length)
+ })
+
+ it('ignores cache-busting differences', () => {
+ const rebusted = amsterdam.map((layer) =>
+ layer.kind === 'file'
+ ? { ...layer, href: layer.href.replace(/v=\d/, 'v=9') }
+ : layer,
+ )
+ const diff = diffLayers(amsterdam, rebusted)
+ expect(diff.removed).toEqual([])
+ expect(diff.added).toEqual([])
+ })
+
+ it('accepts null or undefined lists', () => {
+ expect(diffLayers(null, undefined)).toEqual({
+ removed: [],
+ added: [],
+ unchanged: [],
+ })
+ })
+})
+
+describe('bumpVersion', () => {
+ it('replaces an existing v= whatever its value', () => {
+ expect(
+ bumpVersion('/apps/theming/theme/default.css?plain=1&v=11cfaf9b', '42'),
+ ).toBe('/apps/theming/theme/default.css?plain=1&v=42')
+ expect(bumpVersion('/x.css?v=1.1.12-unstable.20260831125624', 'now')).toBe(
+ '/x.css?v=now',
+ )
+ })
+
+ it('appends v= when there is none', () => {
+ expect(bumpVersion('/x.css', '7')).toBe('/x.css?v=7')
+ expect(bumpVersion('/x.css?plain=1', '7')).toBe('/x.css?plain=1&v=7')
+ })
+
+ it('does not touch a hash fragment after the version', () => {
+ expect(bumpVersion('/x.css?v=1#frag', '2')).toBe('/x.css?v=2#frag')
+ })
+})
diff --git a/tests/vitest/layerSwapDom.spec.js b/tests/vitest/layerSwapDom.spec.js
new file mode 100644
index 00000000..cd78ca66
--- /dev/null
+++ b/tests/vitest/layerSwapDom.spec.js
@@ -0,0 +1,121 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * What `swap()` does to the document when a new stylesheet does NOT arrive.
+ *
+ * The happy path is covered end to end by the Playwright workflow, which has a
+ * real server and real `load` events. This file pins the failure path, which a
+ * browser test cannot produce on demand: a 404, a dropped connection, or a sheet
+ * that simply never answers. The rule is that the page must be left on a theme
+ * that works — the previous one — rather than on half of the new one.
+ *
+ * jsdom does not fetch a ``, so the events are dispatched by hand. That is
+ * the point here: the test decides whether the sheet "arrived".
+ */
+
+import { beforeEach, describe, it, expect } from 'vitest'
+import layerSwap from '../../js/lib/layerSwap.js'
+
+const { swap } = layerSwap
+
+/** A one-file manifest for a set, the shape the server's endpoint returns. */
+function manifestFor(set) {
+ return {
+ layers: [
+ {
+ layer: 'tokens',
+ kind: 'file',
+ href: '/apps/thematiq/css/tokens/' + set + '.css?v=1',
+ },
+ ],
+ }
+}
+
+/** The set names of the token stylesheets currently in the head, in order. */
+function tokenSheets() {
+ return Array.from(
+ document.head.querySelectorAll('link[rel="stylesheet"][href]'),
+ ).map((link) =>
+ link.getAttribute('href').replace(/^.*\/tokens\/(.*)\.css.*$/, '$1'),
+ )
+}
+
+/**
+ * Answer for the sheet of `set` once swap has inserted it: `load` for a sheet
+ * that arrives, `error` for one that does not.
+ */
+function answerFor(set, type) {
+ setTimeout(() => {
+ document.head
+ .querySelectorAll('link[rel="stylesheet"][href]')
+ .forEach((link) => {
+ if (
+ link.getAttribute('href').indexOf('/tokens/' + set + '.css')
+ !== -1
+ ) {
+ link.dispatchEvent(new window.Event(type))
+ }
+ })
+ }, 0)
+}
+
+describe('swap — when the new stylesheet does not arrive', () => {
+ beforeEach(() => {
+ document.head.innerHTML =
+ ''
+ })
+
+ it('replaces the run once the new sheet has loaded', async () => {
+ answerFor('zwolle', 'load')
+
+ const result = await swap(
+ document,
+ manifestFor('amsterdam'),
+ manifestFor('zwolle'),
+ )
+
+ expect(result.ok).toBe(true)
+ expect(tokenSheets()).toEqual(['zwolle'])
+ expect(result.removed).toHaveLength(1)
+ })
+
+ it('keeps the working run and rolls back when the new sheet 404s', async () => {
+ answerFor('zwolle', 'error')
+
+ const result = await swap(
+ document,
+ manifestFor('amsterdam'),
+ manifestFor('zwolle'),
+ )
+
+ // The page is still on a theme that works, not on half of a new one.
+ expect(result.ok).toBe(false)
+ expect(tokenSheets()).toEqual(['amsterdam'])
+ expect(result.added).toEqual([])
+ expect(result.removed).toEqual([])
+ })
+
+ it('rolls back the whole run when only one of several sheets fails', async () => {
+ const next = {
+ layers: [
+ ...manifestFor('zwolle').layers,
+ {
+ layer: 'dark-variant',
+ kind: 'file',
+ href: '/apps/thematiq/css/tokens/zwolle-dark.css?v=1',
+ },
+ ],
+ }
+
+ answerFor('zwolle', 'load')
+ answerFor('zwolle-dark', 'error')
+
+ const result = await swap(document, manifestFor('amsterdam'), next)
+
+ expect(result.ok).toBe(false)
+ expect(tokenSheets()).toEqual(['amsterdam'])
+ })
+})
diff --git a/tests/vitest/playgroundInventory.spec.js b/tests/vitest/playgroundInventory.spec.js
new file mode 100644
index 00000000..ec3cea11
--- /dev/null
+++ b/tests/vitest/playgroundInventory.spec.js
@@ -0,0 +1,631 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The component inventory, held to the code it describes.
+ *
+ * `js/playground/components.json` is data, and data is exactly the kind of
+ * thing that goes stale quietly: a token renamed in the registry, a class name
+ * Nextcloud stopped using, a component whose stage markup was never written.
+ * Each of those leaves an instrument that still renders and no longer tells the
+ * truth — a chip that silently shows an unstyled specimen looks like a broken
+ * theme rather than a stale data file. That is the failure this file exists to
+ * make loud, and it is the drift guard a Vue build would have given for free.
+ *
+ * @spec openspec/changes/component-playground/specs/component-playground/spec.md
+ */
+
+import { describe, it, expect } from 'vitest'
+import * as fs from 'fs'
+import * as path from 'path'
+import playground from '../../js/playground.js'
+
+const ROOT = path.resolve(__dirname, '../..')
+
+const inventory = JSON.parse(
+ fs.readFileSync(path.join(ROOT, 'js/playground/components.json'), 'utf8'),
+)
+
+/**
+ * The component-token table, which TokenRegistry.php reads at runtime and
+ * scripts/generate-component-scopes.mjs turns into CSS. Reading it here is what
+ * keeps this guard checking the same registry the editor actually renders.
+ */
+const componentTokens = JSON.parse(
+ fs.readFileSync(
+ path.join(ROOT, 'scripts/mapping/component-tokens.json'),
+ 'utf8',
+ ),
+)
+
+const registryPhp = fs.readFileSync(
+ path.join(ROOT, 'lib/Service/TokenRegistry.php'),
+ 'utf8',
+)
+
+/**
+ * The BRAND layer: Nextcloud's own globals, hand-listed in the PHP.
+ *
+ * No chip owns these, and that is deliberate — moving one is MEANT to move
+ * every component that has not been given a value of its own, so they are
+ * edited from the four-tab list rather than from a component chip.
+ */
+const brandTokens = new Set(
+ [...registryPhp.matchAll(/'(--[a-z0-9-]+)'\s*=>\s*\[/g)].map(
+ (match) => match[1],
+ ),
+)
+
+/** Every variable the token editor can write: the brand layer plus the components. */
+const registry = new Set([
+ ...brandTokens,
+ ...Object.values(componentTokens.components).flatMap((component) =>
+ Object.keys(component.tokens),
+ ),
+])
+
+/** The tab each registry token is filed under, which is where its row renders. */
+const registryTabs = new Map([
+ ...[
+ ...registryPhp.matchAll(
+ /'(--[a-z0-9-]+)'\s*=>\s*\['tab'\s*=>\s*'([a-z]+)'/g,
+ ),
+ ].map((match) => [match[1], match[2]]),
+ ...Object.values(componentTokens.components).flatMap((component) =>
+ Object.keys(component.tokens).map((name) => [name, component.tab]),
+ ),
+])
+
+/**
+ * The THEMING stylesheets, as one string, for the class-name check.
+ *
+ * The playground's own sheets are excluded on purpose, and that exclusion is
+ * the whole point of the guard. A class name here is meant to prove the theme
+ * still reaches the component; counting css/playground.css lets the specimen's
+ * fallback floor answer for the theme, and a floor defines every class the
+ * specimen draws by construction. That is exactly how three wrong names —
+ * .menutoggle, .unified-search__button and .app-content-detail — survived this
+ * guard until they were read off Nextcloud's own source instead.
+ *
+ * So: a name has to appear in a stylesheet that paints the REAL component, not
+ * in the one that paints the drawing of it.
+ */
+const SELF_PAINTED = /playground/
+
+const stylesheets = (function read(dir) {
+ return fs.readdirSync(dir, { withFileTypes: true }).reduce((text, entry) => {
+ const full = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ return text + read(full)
+ }
+ if (entry.name.endsWith('.css') === false) {
+ return text
+ }
+ if (SELF_PAINTED.test(entry.name) === true) {
+ return text
+ }
+ return text + fs.readFileSync(full, 'utf8')
+ }, '')
+})(path.join(ROOT, 'css'))
+
+/** Every token of every component, flattened. */
+const tokens = inventory.components.flatMap((component) =>
+ component.tokens.map((token) => ({ component: component.id, ...token })),
+)
+
+describe('component inventory: the tokens', () => {
+ it('names only tokens the editor actually renders a row for', () => {
+ // The instrument CLONES the editor's own row for each token. A name the
+ // registry does not carry has no row to clone, so the component would
+ // list a control that does nothing.
+ const unknown = tokens
+ .filter((token) => registry.has(token.name) === false)
+ .map((token) => `${token.component}: ${token.name}`)
+
+ expect(unknown).toEqual([])
+ })
+
+ it('reaches every component token the editor can write', () => {
+ // The chips are the visual way into the same file the four-tab list
+ // edits. A component token no chip reads is one an admin can only find by
+ // scrolling the full list, which is the thing this instrument exists to
+ // make unnecessary.
+ //
+ // The brand globals are exempt, and only they: they have no component to
+ // belong to, because moving one is meant to move everything that has not
+ // opted out. Exempting them here is what lets the chips stop naming them.
+ const covered = new Set(tokens.map((token) => token.name))
+ const unreachable = [...registry].filter(
+ (name) => covered.has(name) === false && brandTokens.has(name) === false,
+ )
+
+ expect(unreachable).toEqual([])
+ })
+
+ it('names no Nextcloud global from a chip', () => {
+ // The regression this whole layer exists to prevent. A chip that names a
+ // global writes a value every other component sharing it also receives —
+ // which is how `Primary button` used to move the navigation, the sidebar,
+ // the checkbox, the progress bar, the dialog and the counter bubble.
+ const globals = tokens
+ .filter((token) => brandTokens.has(token.name) === true)
+ .map((token) => `${token.component}: ${token.name}`)
+
+ expect(globals).toEqual([])
+ })
+
+ it('gives every chip token a scope rule that applies it', () => {
+ // A token the editor can write and no stylesheet consumes is a control
+ // that does nothing. css/component-scopes.css is generated from the same
+ // table, so the check is that the table and the chips agree.
+ const scoped = new Set(
+ Object.values(componentTokens.components).flatMap((component) =>
+ Object.keys(component.tokens),
+ ),
+ )
+ const unapplied = tokens
+ .filter((token) => scoped.has(token.name) === false)
+ .map((token) => `${token.component}: ${token.name}`)
+
+ expect(unapplied).toEqual([])
+ })
+
+ it('points every token at a state the stage actually draws', () => {
+ const dangling = inventory.components.flatMap((component) => {
+ // Callout 0 is the shared group: a token every state reads, such
+ // as the note cards' corner, listed ahead of the states.
+ const states = new Set(
+ [0].concat(component.states.map((state) => state.n)),
+ )
+ return component.tokens
+ .concat(component.fixed)
+ .filter((entry) => states.has(entry.callout) === false)
+ .map(
+ (entry) =>
+ `${component.id}: ${entry.name || entry.what} → ${entry.callout}`,
+ )
+ })
+
+ expect(dangling).toEqual([])
+ })
+
+ it('says what every token paints', () => {
+ const undescribed = tokens
+ .filter((token) => !token.paints)
+ .map((token) => `${token.component}: ${token.name}`)
+
+ expect(undescribed).toEqual([])
+ })
+
+ it('gives every token-less row a reason code', () => {
+ const unexplained = inventory.components.flatMap((component) =>
+ component.fixed
+ .filter((entry) => !entry.code || !entry.why)
+ .map((entry) => `${component.id}: ${entry.what}`),
+ )
+
+ expect(unexplained).toEqual([])
+ })
+
+ it('uses reason codes the converter defines', () => {
+ // Same vocabulary as an import report, so the same fact is never
+ // explained two different ways.
+ const known = new Set(
+ Object.keys(
+ JSON.parse(
+ fs.readFileSync(
+ path.join(ROOT, 'scripts/mapping/nlds-to-nextcloud.json'),
+ 'utf8',
+ ),
+ ).reasons,
+ ),
+ )
+ const invented = inventory.components.flatMap((component) =>
+ component.fixed
+ .filter((entry) => known.has(entry.code) === false)
+ .map((entry) => `${component.id}: ${entry.code}`),
+ )
+
+ expect(invented).toEqual([])
+ })
+})
+
+describe('component inventory: the components', () => {
+ it('has stage markup for every component and a component for every stage', () => {
+ const declared = inventory.components.map((component) => component.id).sort()
+ const built = Object.keys(playground.STAGES).sort()
+
+ expect(built).toEqual(declared)
+ })
+
+ it('draws every state its stage markup is asked for', () => {
+ // A cell-per-state builder is called once per state id; one that returns
+ // nothing would leave an empty cell under a numbered marker.
+ const empty = inventory.components
+ .filter((component) => component.layout !== 'wide')
+ .flatMap((component) =>
+ component.states
+ .filter(
+ (state) =>
+ String(
+ playground.STAGES[component.id](state.id, component)
+ || '',
+ ) === '',
+ )
+ .map((state) => `${component.id}: ${state.id}`),
+ )
+
+ expect(empty).toEqual([])
+ })
+
+ it('draws no frozen copy of a state the admin can produce by pointing', () => {
+ // A frozen hover beside the live specimen is two components an admin
+ // cannot tell apart, one of which does not respond to being hovered.
+ // The real pseudo-class is wired on every specimen, so the drawing shows
+ // the component once and lets it be hovered.
+ const frozen = inventory.components.flatMap((component) => {
+ // Only the states the stage actually draws: renderStage skips
+ // the pointable ones, so asking a builder for `hover` and then
+ // objecting to what comes back tests nothing that ships.
+ const states =
+ component.layout === 'wide'
+ ? [null]
+ : component.states.filter(
+ (state) => !playground.POINTABLE.includes(state.id),
+ )
+ const markup = states
+ .map((state) =>
+ String(
+ playground.STAGES[component.id](
+ state === null ? null : state.id,
+ component,
+ ) || '',
+ ),
+ )
+ .join('')
+ return playground.POINTABLE.filter((name) =>
+ [...markup.matchAll(/class="([^"]*)"/g)].some((attribute) =>
+ attribute[1].split(/\s+/).includes(`is-${name}`),
+ ),
+ ).map((name) => `${component.id}: is-${name}`)
+ })
+
+ expect(frozen).toEqual([])
+ })
+
+ it('draws a wide component at a size worth judging', () => {
+ // The reason a component is wide at all: a table you can only see three
+ // cells of, or a list with one row, tells an admin nothing about rhythm
+ // — the rules between rows, the zebra against the hover, the spacing.
+ // A token count is a poor proxy for "big enough", but an empty or
+ // near-empty specimen is unambiguously not it.
+ const thin = inventory.components
+ .filter((component) => component.layout === 'wide')
+ .map((component) => ({
+ id: component.id,
+ length: String(
+ playground.STAGES[component.id](null, component) || '',
+ ).length,
+ }))
+ .filter((entry) => entry.length < 200)
+
+ expect(thin).toEqual([])
+ })
+
+ it('says which surface every component is drawn on', () => {
+ // "Will this look good" is a question about a component AND its
+ // background; neither answers it alone. A component with no ground would
+ // be judged against whatever the stage happens to be, which is the one
+ // surface it never actually lands on.
+ const grounds = ['content', 'plain', 'login', 'header', 'overlay']
+ const unplaced = inventory.components
+ .filter((component) => grounds.includes(component.ground) === false)
+ .map((component) => `${component.id}: ${component.ground}`)
+
+ expect(unplaced).toEqual([])
+ })
+
+ it('draws every ground it declares', () => {
+ // A ground named in the data with no rule in the stylesheet is a
+ // transparent box: the specimen would look like it sits on the stage.
+ const stylesheet = fs.readFileSync(
+ path.join(ROOT, 'css/playground.css'),
+ 'utf8',
+ )
+ const undrawn = [
+ ...new Set(inventory.components.map((component) => component.ground)),
+ ].filter(
+ (ground) =>
+ stylesheet.includes(`.nldesign-pg-ground--${ground}`) === false,
+ )
+
+ expect(undrawn).toEqual([])
+ })
+
+ it('names only class names a stylesheet in this app styles', () => {
+ // The drift guard: when Nextcloud renames a class out from under a
+ // copied fragment, the specimen stops being styled and looks like a
+ // broken theme rather than a stale copy.
+ const unstyled = inventory.components.flatMap((component) =>
+ component.classes
+ .filter(
+ (className) =>
+ new RegExp(
+ '\\.'
+ + className.replace(/[-_]/g, (char) => '\\' + char)
+ + '(?![A-Za-z0-9_])',
+ ).test(stylesheets) === false,
+ )
+ .map((className) => `${component.id}: .${className}`),
+ )
+
+ expect(unstyled).toEqual([])
+ })
+
+ it('gives every component a unique id, a title and a subtitle', () => {
+ const ids = inventory.components.map((component) => component.id)
+ const incomplete = inventory.components
+ .filter((component) => !component.title || !component.subtitle)
+ .map((component) => component.id)
+
+ expect(ids.length).toBe(new Set(ids).size)
+ expect(incomplete).toEqual([])
+ })
+
+ it('names a radius token the registry carries, where it names one', () => {
+ const unknown = inventory.components
+ .filter((component) => component.radiusToken)
+ .filter((component) => registry.has(component.radiusToken) === false)
+ .map((component) => `${component.id}: ${component.radiusToken}`)
+
+ expect(unknown).toEqual([])
+ })
+
+ it('fills every tab with at least one component', () => {
+ const empty = inventory.tabs
+ .filter(
+ (tab) => playground.componentsFor(inventory, tab.id).length === 0,
+ )
+ .map((tab) => tab.id)
+
+ expect(empty).toEqual([])
+ })
+
+ it("files every chip token under the chip's own tab", () => {
+ // This used to assert the OPPOSITE — that at least one chip read a token
+ // filed under another tab — and the example it pinned was the primary
+ // button, which lives under Buttons & Status and was painted by
+ // `--color-primary-element`, filed under Login page & Branding.
+ //
+ // That was a symptom, not a feature. The button read that token because
+ // chips named Nextcloud's globals, which is the same reason moving the
+ // button moved seven other components. Now each chip owns component
+ // tokens that take the chip's own tab, so no chip reaches across.
+ //
+ // The instrument still clones from the whole editor rather than from the
+ // open panel, so a cross-tab token would keep working if one ever came
+ // back. What is pinned here is that none is needed.
+ const crossTab = tokens
+ .filter((token) => {
+ const component = playground.componentById(
+ inventory,
+ token.component,
+ )
+ return registryTabs.get(token.name) !== component.tab
+ })
+ .map((token) => `${token.component}: ${token.name}`)
+
+ expect(crossTab).toEqual([])
+ })
+})
+
+describe('the header specimen across Nextcloud versions', () => {
+ // The header is the one component whose MARKUP changes between the versions
+ // this app supports. 34 deleted core/src/components/AppMenuEntry.vue — it is
+ // present in v32.0.0 and v33.0.0 and a 404 in v34.0.0 — and replaced the
+ // entry row with a waffle, a popover grid and a current-app button. An admin
+ // on 32 asking "what happens when I upgrade" is the whole point of the
+ // switch, so these assert each version draws the shape that version ships.
+ const header = (version) => playground.STAGES['header-bar'](null, null, version)
+
+ const classesOf = (markup) =>
+ new Set(
+ [...markup.matchAll(/class="([^"]*)"/g)].flatMap((attribute) =>
+ attribute[1].split(/\s+/),
+ ),
+ )
+
+ it('draws the entry row on 32 and 33, and never the 34 shape', () => {
+ for (const version of [32, 33]) {
+ const classes = classesOf(header(version))
+
+ expect(classes.has('app-menu-entry')).toBe(true)
+ expect(classes.has('app-menu__list')).toBe(true)
+ expect(classes.has('app-menu__waffle')).toBe(false)
+ expect(classes.has('app-menu__current-app')).toBe(false)
+ }
+ })
+
+ it('draws the waffle and the current app on 34, and never the entry row', () => {
+ const classes = classesOf(header(34))
+
+ expect(classes.has('app-menu__waffle')).toBe(true)
+ expect(classes.has('app-menu__current-app')).toBe(true)
+ expect(classes.has('app-menu-entry')).toBe(false)
+ expect(classes.has('app-menu__list')).toBe(false)
+ })
+
+ it('moves the search from a glyph on the right to a field in the middle', () => {
+ // 34 did not merely rename the search trigger: it replaced the magnifier
+ // among the account glyphs with UnifiedSearchInput, a element
+ // carrying the placeholder, between the app menu and the glyphs. A
+ // specimen that only renamed the class would put the new search in the
+ // old place.
+ //
+ // The 32/33 side is asserted on the names UnifiedSearch.vue really
+ // emits — .unified-search-menu around an NcHeaderButton, whose visible
+ // element is .header-menu__trigger. The name this test used to assert,
+ // .unified-search__button, is pre-Vue and is emitted by NEITHER
+ // release, so it proved only that the specimen still said it.
+ expect(classesOf(header(33)).has('unified-search-menu')).toBe(true)
+ expect(classesOf(header(33)).has('header-menu__trigger')).toBe(true)
+ expect(classesOf(header(33)).has('unified-search__button')).toBe(false)
+ expect(header(33)).not.toContain(' {
+ for (const version of [34, 35]) {
+ expect(header(version)).toContain('Thematiq')
+ expect(
+ classesOf(header(version)).has('app-menu__current-app-icon'),
+ ).toBe(true)
+ }
+
+ expect(header(32)).not.toContain('Thematiq')
+ })
+
+ it('draws an app menu in every version it can be drawn as', () => {
+ // Whichever shape is showing, the menu itself has to be there: a version
+ // branch that returns nothing would leave the bar with a logo and the
+ // account glyphs and nothing between them.
+ for (const version of [32, 33, 34, 35]) {
+ expect(classesOf(header(version)).has('app-menu')).toBe(true)
+ }
+ })
+
+ it('falls back to the newest header when the version is unknown', () => {
+ // playgroundVersion is 0 when the server cannot be asked.
+ expect(classesOf(header(0)).has('app-menu__waffle')).toBe(true)
+ expect(classesOf(header(undefined)).has('app-menu__waffle')).toBe(true)
+ })
+})
+
+describe('the header specimen shows the instance, not a mock-up', () => {
+ const header = (version) => playground.STAGES['header-bar'](null, null, version)
+
+ it('never hardcodes a stand-in account', () => {
+ // The bar an admin is judging is THEIR bar, and a stranger's initials in
+ // the corner is the one detail that makes the whole drawing read as
+ // somebody else's screenshot. The avatar comes from
+ // OC.getCurrentUser(); these initials were literal.
+ for (const version of [32, 33, 34, 35]) {
+ expect(header(version)).not.toContain('RB')
+ }
+ })
+
+ it('degrades to a placeholder where there is no session to ask', () => {
+ // This file is loaded under Node by these very tests, so `OC` is absent
+ // and accountPlate() must not throw — a builder that crashes takes the
+ // whole stage down, not just the avatar.
+ expect(header(34)).toContain('nldesign-pg-avatarwrap')
+ expect(header(34)).toContain('user-status-icon')
+ })
+
+ it('draws the logo through core class, not a shape of its own', () => {
+ // `.logo` is what core's own header rule paints, so the specimen
+ // resolves --image-logoheader / --image-logo the same way the real bar
+ // does instead of drawing a disc that shows an admin nothing about
+ // their own branding.
+ for (const version of [32, 33, 34, 35]) {
+ expect(header(version)).toContain('nldesign-pg-header-logo logo')
+ }
+ })
+})
+
+describe('the 34 search field matches the structure core gives it', () => {
+ const header34 = () => playground.STAGES['header-bar'](null, null, 34)
+
+ it('nests the button inside the search element, not beside it', () => {
+ // UnifiedSearchInput is two elements doing two jobs: is an
+ // absolutely centred, click-through TRACK spanning the bar, and
+ // .unified-search-input__button inside it is the thing you see and
+ // click. Flattening them into one element is what made the specimen a
+ // left-aligned pill in the middle of the leftover space rather than a
+ // centred field in the middle of the bar.
+ const markup = header34()
+ const search = markup.indexOf('')
+
+ expect(search).toBeGreaterThan(-1)
+ expect(button).toBeGreaterThan(search)
+ expect(button).toBeLessThan(close)
+ })
+
+ it('carries the icon and the label the real field carries', () => {
+ expect(header34()).toContain('unified-search-input__icon')
+ expect(header34()).toContain('unified-search-input__label')
+ })
+})
+
+describe('the 35 header draws what 35 changed, not 34 again', () => {
+ // 35 is not a relabelled 34. `core/templates/layout.user.php` is byte-identical
+ // across 32, 33 and 34 and gains a `.header-center` in 35;
+ // UnifiedSearchInput.vue turns its button into a field with a shortcut hint;
+ // AppMenu.vue wraps the waffle and the current app in one `.app-menu__trigger`.
+ // A 35 button that drew the 34 bar would answer the upgrade question wrongly,
+ // which is worse than not offering it.
+ const header = (version) => playground.STAGES['header-bar'](null, null, version)
+
+ const classesOf = (markup) =>
+ new Set(
+ [...markup.matchAll(/class="([^"]*)"/g)].flatMap((attribute) =>
+ attribute[1].split(/\s+/),
+ ),
+ )
+
+ it('gives the bar a start column so the centre has something to centre between', () => {
+ expect(classesOf(header(35)).has('nldesign-pg-header-start')).toBe(true)
+ expect(classesOf(header(34)).has('nldesign-pg-header-start')).toBe(false)
+ })
+
+ it('groups the waffle and the current app under one trigger', () => {
+ expect(classesOf(header(35)).has('app-menu__trigger')).toBe(true)
+ expect(classesOf(header(35)).has('app-menu__waffle')).toBe(true)
+ expect(classesOf(header(35)).has('app-menu__current-app')).toBe(true)
+ expect(classesOf(header(34)).has('app-menu__trigger')).toBe(false)
+ })
+
+ it('nests the app icon one level deeper than 34 does', () => {
+ // 34 renders an . 35 keeps that
+ // class on an outer element and puts the shape on a __glyph inside it,
+ // so the header fade lands on the glyph rather than on a box behind it.
+ expect(classesOf(header(35)).has('app-menu__current-app-glyph')).toBe(true)
+ expect(classesOf(header(34)).has('app-menu__current-app-glyph')).toBe(false)
+ })
+
+ it('replaces the search button with a field, not a renamed button', () => {
+ const classes = classesOf(header(35))
+
+ expect(header(35)).toContain(' {
+ // NcKbd renders , and core's own rule for the hint selects that
+ // element rather than a class — a span styled to look like a key would
+ // be reached by neither core nor a theme.
+ expect(header(35)).toContain('unified-search-input__shortcut')
+ expect(header(35)).toContain('Ctrl')
+ expect(header(35)).toContain('K')
+ expect(header(34)).not.toContain('')
+ })
+
+ it('uses the placeholder 35 ships, not 34s', () => {
+ expect(header(35)).toContain('Apps, files, messages, and more')
+ expect(header(34)).toContain('Search apps, files, tags, messages')
+ })
+
+ it('is what an unknown version falls back to', () => {
+ // headerMajor() answers with the newest supported major, which is now
+ // 35 — an instance the server cannot be asked about is far likelier to
+ // be new than to be 34.
+ expect(classesOf(header(0)).has('unified-search-input__field')).toBe(true)
+ })
+})
diff --git a/tests/vitest/playgroundSelection.spec.js b/tests/vitest/playgroundSelection.spec.js
new file mode 100644
index 00000000..23fe482e
--- /dev/null
+++ b/tests/vitest/playgroundSelection.spec.js
@@ -0,0 +1,809 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The component instrument's selection logic and its token-set export.
+ *
+ * Everything the instrument does to the DOM is judged by looking at it — that
+ * is what it is for. These are the parts where looking would not tell you
+ * whether they are right: which chips a tab offers, what a shared link
+ * reopens, and what leaves the panel as a file.
+ *
+ * @spec openspec/changes/component-playground/specs/component-playground/spec.md
+ */
+
+import { describe, it, expect } from 'vitest'
+import * as fs from 'fs'
+import * as path from 'path'
+import playground from '../../js/playground.js'
+
+const ROOT = path.resolve(__dirname, '../..')
+
+const inventory = JSON.parse(
+ fs.readFileSync(path.join(ROOT, 'js/playground/components.json'), 'utf8'),
+)
+
+/**
+ * The required semantic vocabulary, read from the audit CLI rather than
+ * restated here, so this test cannot disagree with the gate it appeals to.
+ */
+const REQUIRED_TOKENS = [
+ ...fs
+ .readFileSync(path.join(ROOT, 'scripts/audit-token-sets.mjs'), 'utf8')
+ .split('const REQUIRED_TOKENS = [')[1]
+ .split(']')[0]
+ .matchAll(/'(--nldesign-[a-z0-9-]+)'/g),
+].map((match) => match[1])
+
+/** The --nldesign-* declarations of a CSS file, the way the server parses them. */
+function parseTokens(css) {
+ const tokens = {}
+ for (const match of css.matchAll(/^\s*(--nldesign-[\w-]+)\s*:\s*([^;]+);/gm)) {
+ tokens[match[1]] = match[2].trim()
+ }
+ return tokens
+}
+
+/** The merged --nldesign-* layer of a shipped set: the defaults, then the set. */
+function resolvedTokens(setId) {
+ return {
+ ...parseTokens(
+ fs.readFileSync(
+ path.join(ROOT, 'css/systems/nldesign/defaults.css'),
+ 'utf8',
+ ),
+ ),
+ ...parseTokens(
+ fs.readFileSync(path.join(ROOT, `css/tokens/${setId}.css`), 'utf8'),
+ ),
+ }
+}
+
+/** The --color-* to --nldesign-* map, the way the server parses overrides.css. */
+function tokenSources() {
+ const sources = {}
+ const css = fs.readFileSync(
+ path.join(ROOT, 'css/systems/nldesign/overrides.css'),
+ 'utf8',
+ )
+ for (const match of css.matchAll(
+ /^\s*(--[\w-]+)\s*:\s*var\((--[\w-]+)\)\s*(?:!important)?\s*;/gm,
+ )) {
+ sources[match[1]] = match[2]
+ }
+ return sources
+}
+
+/** Whether the vocabulary audit would rate a token map complete. */
+function ratesComplete(tokens) {
+ return REQUIRED_TOKENS.every((token) =>
+ Object.prototype.hasOwnProperty.call(tokens, token),
+ )
+}
+
+describe('component instrument: the chips', () => {
+ it('offers a tab only the components filed under it', () => {
+ const status = playground.componentsFor(inventory, 'status')
+
+ expect(status.length).toBeGreaterThan(0)
+ expect(status.every((component) => component.tab === 'status')).toBe(true)
+ })
+
+ it('offers nothing for a tab the editor does not have', () => {
+ expect(playground.componentsFor(inventory, 'nonsense')).toEqual([])
+ })
+
+ it('finds a component by id', () => {
+ expect(playground.componentById(inventory, 'primary-button').title).toBe(
+ 'Primary button',
+ )
+ expect(playground.componentById(inventory, 'no-such-thing')).toBe(null)
+ })
+})
+
+describe('component instrument: the rows under a component', () => {
+ const component = playground.componentById(inventory, 'primary-button')
+
+ it('groups the rows under the state each one paints, in callout order', () => {
+ const groups = playground.rowsByState(component)
+
+ expect(groups.map((group) => group.state.n)).toEqual([1, 2, 3, 4])
+ // The button's OWN background token, not `--color-primary-element`. The
+ // chip named that global until the component-token layer landed, which is
+ // why moving this row also moved the navigation, the sidebar, the
+ // checkbox, the progress bar, the dialog and the counter bubble.
+ expect(groups[0].tokens.map((token) => token.name)).toContain(
+ '--nldesign-component-button-primary-action-background-color',
+ )
+ })
+
+ it('leads with the tokens every variant shares, apart from any one variant', () => {
+ // The note cards' corner used to sit among the info rows, where it read
+ // as belonging to the info card alone.
+ const notes = playground.componentById(inventory, 'note-cards')
+ const groups = playground.rowsByState(notes)
+
+ expect(groups[0].state.id).toBe('all')
+ expect(groups[0].tokens.map((token) => token.name)).toEqual([
+ '--nldesign-component-notecard-border-radius',
+ ])
+ expect(
+ groups
+ .slice(1)
+ .flatMap((group) => group.tokens.map((token) => token.name)),
+ ).not.toContain('--nldesign-component-notecard-border-radius')
+ })
+
+ it('puts a token-less fact under its own state and nowhere else', () => {
+ const groups = playground.rowsByState(component)
+ const disabled = groups.find((group) => group.state.id === 'disabled')
+
+ expect(disabled.fixed.map((entry) => entry.code)).toEqual([
+ 'derived-by-nextcloud',
+ ])
+ expect(disabled.tokens).toEqual([])
+ })
+
+ it('lists every token of the component exactly once across the states', () => {
+ const listed = playground
+ .rowsByState(component)
+ .flatMap((group) => group.tokens.map((token) => token.name))
+
+ expect(listed.sort()).toEqual(
+ component.tokens.map((token) => token.name).sort(),
+ )
+ })
+})
+
+describe('component instrument: the URL hash', () => {
+ it('round-trips a selection', () => {
+ const hash = playground.hashFor('status', 'primary-button')
+
+ expect(hash).toBe('#preview=status/primary-button')
+ expect(playground.parseHash(hash, inventory)).toEqual({
+ tab: 'status',
+ component: 'primary-button',
+ })
+ })
+
+ it('round-trips the full view of a tab', () => {
+ const hash = playground.hashFor('content', playground.FULL_VIEW)
+
+ expect(playground.parseHash(hash, inventory)).toEqual({
+ tab: 'content',
+ component: playground.FULL_VIEW,
+ })
+ })
+
+ it('refuses a component that is not under the tab the link names', () => {
+ // A link is shared and then the inventory moves a chip. Opening the tab
+ // and ignoring the stale component is recoverable; opening a component
+ // under a tab whose token rows it cannot filter is not.
+ expect(
+ playground.parseHash('#preview=typography/primary-button', inventory),
+ ).toBe(null)
+ })
+
+ it('refuses a tab or a component this build does not have', () => {
+ expect(
+ playground.parseHash('#preview=nonsense/primary-button', inventory),
+ ).toBe(null)
+ expect(
+ playground.parseHash('#preview=status/no-such-thing', inventory),
+ ).toBe(null)
+ expect(playground.parseHash('', inventory)).toBe(null)
+ expect(playground.parseHash('#something-else', inventory)).toBe(null)
+ })
+})
+
+describe('component instrument: the token set export', () => {
+ const tokens = resolvedTokens('rijkshuisstijl')
+ const sources = tokenSources()
+
+ it('round-trips the active set when nothing is overridden', () => {
+ const result = playground.exportCss(tokens, {}, sources)
+
+ expect(parseTokens(result.css)).toEqual(tokens)
+ expect(result.unexpressed).toEqual([])
+ })
+
+ it('is rated by the vocabulary audit exactly as the set it came from', () => {
+ const exported = parseTokens(playground.exportCss(tokens, {}, sources).css)
+
+ expect(ratesComplete(exported)).toBe(ratesComplete(tokens))
+ expect(ratesComplete(exported)).toBe(true)
+ })
+
+ it('writes an override back to the token the variable reads', () => {
+ const exported = parseTokens(
+ playground.exportCss(tokens, { '--color-primary': '#a90061' }, sources)
+ .css,
+ )
+
+ expect(exported['--nldesign-color-primary']).toBe('#a90061')
+ })
+
+ it('reports an override no token in the vocabulary can carry', () => {
+ // `--color-scrollbar` is editable and `overrides.css` maps it to no
+ // --nldesign-* token, so a token set file cannot express it. Saying so is
+ // the difference between an export the admin can trust and one that
+ // quietly lost a change.
+ const result = playground.exportCss(
+ tokens,
+ { '--color-scrollbar': '#111111' },
+ sources,
+ )
+
+ expect(result.unexpressed).toEqual(['--color-scrollbar'])
+ expect(parseTokens(result.css)).toEqual(tokens)
+ })
+
+ it('writes a component token the admin set by name', () => {
+ // The bug this guards: `sources` runs Nextcloud variable => token, and
+ // every override used to BE a Nextcloud variable. The component layer
+ // added rows named for the token itself, those names matched nothing in
+ // a map keyed by `--color-*`, and every one of them was reported as
+ // inexpressible and left out. An admin who set the header colours and
+ // saved the result as a theme got a theme with no header colours in it.
+ const result = playground.exportCss(
+ tokens,
+ {
+ '--nldesign-component-header-background-color': '#e8eff6',
+ '--nldesign-component-header-color': '#11304e',
+ },
+ sources,
+ )
+ const exported = parseTokens(result.css)
+
+ expect(exported['--nldesign-component-header-background-color']).toBe(
+ '#e8eff6',
+ )
+ expect(exported['--nldesign-component-header-color']).toBe('#11304e')
+ expect(result.unexpressed).toEqual([])
+ })
+
+ it('lets a token set by name beat the same token reached through a variable', () => {
+ const result = playground.exportCss(
+ tokens,
+ {
+ '--color-primary': '#ff0000',
+ '--nldesign-color-primary': '#00ff00',
+ },
+ sources,
+ )
+
+ expect(parseTokens(result.css)['--nldesign-color-primary']).toBe('#00ff00')
+ })
+
+ it('keeps the defining variable when two overrides read one token', () => {
+ // The map is many-to-one: `overrides.css` points both --color-primary
+ // and --color-primary-element at --nldesign-color-primary, and
+ // TokenRegistry makes both editable — so an admin can override both and
+ // the file has one line to carry them. The token's own name decides,
+ // the same rule StockTokensService::canonical() applies to the same map
+ // in the other direction.
+ const result = playground.exportCss(
+ tokens,
+ {
+ '--color-primary-element': '#00ff00',
+ '--color-primary': '#ff0000',
+ },
+ sources,
+ )
+
+ expect(parseTokens(result.css)['--nldesign-color-primary']).toBe('#ff0000')
+ })
+
+ it('reports the override the other one took the token from', () => {
+ // The bug this guards: the loser used to vanish from the file AND from
+ // the report, so an admin re-imported a set that had silently lost a
+ // change they had made and saved.
+ const result = playground.exportCss(
+ tokens,
+ {
+ '--color-primary': '#ff0000',
+ '--color-primary-element': '#00ff00',
+ },
+ sources,
+ )
+
+ expect(result.overruled).toEqual([
+ {
+ name: '--color-primary-element',
+ token: '--nldesign-color-primary',
+ winner: '--color-primary',
+ },
+ ])
+ expect(result.unexpressed).toEqual([])
+ })
+
+ it('does not depend on the order the overrides arrive in', () => {
+ const forwards = playground.exportCss(
+ tokens,
+ { '--color-primary': '#ff0000', '--color-primary-element': '#00ff00' },
+ sources,
+ )
+ const backwards = playground.exportCss(
+ tokens,
+ { '--color-primary-element': '#00ff00', '--color-primary': '#ff0000' },
+ sources,
+ )
+
+ expect(forwards.css).toBe(backwards.css)
+ expect(forwards.overruled).toEqual(backwards.overruled)
+ })
+
+ it('falls back to sorted order when no variable carries the token name', () => {
+ // --nldesign-color-main-background is read by variables none of which is
+ // called --color-main-background, so the candidates are interchangeable
+ // and the file must still not depend on iteration order.
+ const competing = Object.keys(sources).filter(
+ (name) =>
+ sources[name] === '--nldesign-color-primary'
+ && name !== '--color-primary',
+ )
+
+ const result = playground.exportCss(
+ tokens,
+ Object.fromEntries(competing.map((name, i) => [name, '#00000' + i])),
+ sources,
+ )
+
+ const winner = [...competing].sort()[0]
+ expect(parseTokens(result.css)['--nldesign-color-primary']).toBe(
+ '#00000' + competing.indexOf(winner),
+ )
+ expect(result.overruled).toHaveLength(competing.length - 1)
+ })
+
+ it('emits one flat :root block, sorted, in the shape the upload accepts', () => {
+ const lines = playground.exportCss(tokens, {}, sources).css.split('\n')
+ const names = lines
+ .filter((line) => line.startsWith(' --'))
+ .map((line) => line.split(':')[0].trim())
+
+ expect(lines[1]).toBe(':root {')
+ expect(lines[lines.length - 2]).toBe('}')
+ expect(names).toEqual([...names].sort())
+ })
+})
+
+describe('component instrument: the values the export carries', () => {
+ // The server publishes what it parsed out of a file. The browser knows what
+ // the page is actually wearing. These differ for the `nextcloud` set by
+ // construction — it is resolved from the running instance and has no file
+ // values to publish — and they can differ for any set whose file drifts
+ // from the cascade.
+ it('prefers the live value over the one the server parsed', () => {
+ const live = playground.liveTokens(
+ { '--nldesign-color-primary': '#0082c9' },
+ () => '#00679e',
+ )
+
+ expect(live).toEqual({ '--nldesign-color-primary': '#00679e' })
+ })
+
+ it('falls back to the server value when the cascade has none', () => {
+ const live = playground.liveTokens(
+ { '--nldesign-color-primary': '#00679e' },
+ (name, fallback) => fallback,
+ )
+
+ expect(live).toEqual({ '--nldesign-color-primary': '#00679e' })
+ })
+
+ it('keeps the server map as the key set, so no token is dropped', () => {
+ // A token the cascade cannot answer for still belongs in an exported
+ // set: the file defines what the set is MADE of, and a set that
+ // silently loses a token is a set that cannot be re-imported whole.
+ const live = playground.liveTokens(
+ {
+ '--nldesign-color-primary': '#00679e',
+ '--nldesign-color-error': '#FFE7E7',
+ },
+ (name, fallback) => fallback,
+ )
+
+ expect(Object.keys(live).sort()).toEqual([
+ '--nldesign-color-error',
+ '--nldesign-color-primary',
+ ])
+ })
+
+ it('invents no token the server did not publish', () => {
+ const live = playground.liveTokens({}, () => '#ffffff')
+
+ expect(live).toEqual({})
+ })
+})
+
+describe('component instrument: the specimens can be used', () => {
+ // The content area once rendered as a wall of drawings that ignored every
+ // click, because the rule then in force froze any element documenting a
+ // state — and that is the selected row in almost every component, so the row
+ // an admin reaches for was exactly the row that refused. These assert the
+ // hooks are in the markup, so a rewritten specimen cannot quietly go inert
+ // again.
+
+ // Read-only indicators. An avatar and a progress bar report state; there is
+ // nothing a click could mean on either, and inventing something would be
+ // worse than leaving them alone.
+ const STATIC = ['avatar', 'progress']
+
+ /**
+ * Match a selector against markup the way a browser would, near enough.
+ * `.foo` has to appear in a class attribute as a whole word and `tr` as a
+ * real tag — matching either as a bare substring makes `` answer
+ * for `tr`, which is how the first draft of this test passed everything.
+ */
+ const matches = (selector) => {
+ const last = selector.split(' ').pop()
+ if (last.startsWith('.')) {
+ const name = last.slice(1)
+ return (markup) =>
+ [...markup.matchAll(/class="([^"]*)"/g)].some((attribute) =>
+ attribute[1].split(/\s+/).includes(name),
+ )
+ }
+ return (markup) => markup.includes(`<${last}`)
+ }
+
+ const HOOKS = [
+ ...playground.PICKABLE.map((group) => group.row),
+ '.nldesign-pg-choice',
+ '.nldesign-pg-option',
+ '.nldesign-pg-action',
+ '.action-item__menutoggle',
+ // The text field is a real `` now, rendered in NcInputField's own
+ // DOM so the shipped stylesheet reaches it; the drawn one is what is
+ // left behind for the select and the textarea.
+ '.input-field__input',
+ '.nldesign-pg-input',
+ '.nldesign-pg-textarea',
+ '.nldesign-pg-btn',
+ ].map(matches)
+
+ const render = (component) => {
+ const build = playground.STAGES[component.id]
+ const states = component.layout === 'wide' ? [null] : component.states
+ return states
+ .map((state) => build(state === null ? null : state.id, component))
+ .join('')
+ }
+
+ it('gives every content-area component something to interact with', () => {
+ const inert = inventory.components
+ .filter((entry) => entry.tab === 'content')
+ .filter((entry) => !STATIC.includes(entry.id))
+ .filter((entry) => {
+ const markup = render(entry)
+ return !HOOKS.some((hook) => hook(markup))
+ })
+ .map((entry) => entry.id)
+
+ expect(inert).toEqual([])
+ })
+
+ it('puts every pickable row inside the container its group names', () => {
+ // pick() finds the siblings with closest(group), so a row whose named
+ // container is not actually an ancestor would silently never move its
+ // selection — the failure this whole change was about.
+ const orphaned = []
+ playground.PICKABLE.forEach((group) => {
+ const hasRow = matches(group.row)
+ const hasGroup = matches(group.group.split(' ')[0])
+ inventory.components.forEach((entry) => {
+ const markup = render(entry)
+ if (hasRow(markup) && !hasGroup(markup)) {
+ orphaned.push(`${entry.id}: ${group.row}`)
+ }
+ })
+ })
+
+ expect(orphaned).toEqual([])
+ })
+
+ it('leaves no specimen drawn from the removed placeholder bars', () => {
+ const placeheld = inventory.components
+ .filter((entry) => render(entry).includes('nldesign-pg-line'))
+ .map((entry) => entry.id)
+
+ expect(placeheld).toEqual([])
+ })
+})
+
+describe('the shipped stylesheets, reached', () => {
+ // The specimens are drawn in Nextcloud's own component DOM so that the CSS
+ // this page ALREADY loads paints them. Every one of those rules is
+ // Vue-scoped, so the whole arrangement hangs on finding the right attribute
+ // in the loaded sheets and putting it on. When that stops working the
+ // specimens do not look subtly wrong — the components lose their styling
+ // entirely, and an admin reads that as a broken theme.
+
+ /**
+ * A style rule, shaped the way a real one is.
+ *
+ * The `cssRules` is the point. Since CSS Nesting shipped, CSSStyleRule
+ * inherits it from CSSGroupingRule, so a real style rule carries an empty
+ * list — and an empty CSSRuleList is an object, so it is TRUTHY. A helper
+ * that left it out modelled a CSSOM no browser has produced for years, and
+ * it is what let the walker ship testing `cssRules` before
+ * `selectorText`: every real style rule took the grouping branch and no
+ * specimen was stamped, while these tests stayed green.
+ */
+ const styleRule = (selectorText, nested = []) => ({
+ selectorText,
+ cssRules: nested,
+ })
+
+ /** A stylesheet, shaped the way the CSSOM hands one over. */
+ const sheet = (...selectors) => ({
+ cssRules: selectors.map((s) => (typeof s === 'string' ? styleRule(s) : s)),
+ })
+
+ /** A grouping rule — @media, @supports — which has no selector of its own. */
+ const group = (...children) => ({ cssRules: children })
+
+ /** An element, shaped the way applyScopes uses one. */
+ const element = (...names) => {
+ const attributes = {}
+ return {
+ classList: names,
+ attributes,
+ setAttribute(name, value) {
+ attributes[name] = value
+ },
+ }
+ }
+
+ const tree = (...nodes) => ({ querySelectorAll: () => nodes })
+
+ it('finds the scope attribute each class is styled under', () => {
+ const scopes = playground.componentScopes({
+ styleSheets: [
+ sheet(
+ '.button-vue[data-v-00a99684]',
+ '.button-vue--wide[data-v-00a99684]',
+ '.input-field__label[data-v-8e16cbb5]',
+ '.plain-old-class',
+ ),
+ ],
+ })
+
+ expect(scopes['button-vue']).toEqual(['data-v-00a99684'])
+ expect(scopes['button-vue--wide']).toEqual(['data-v-00a99684'])
+ expect(scopes['input-field__label']).toEqual(['data-v-8e16cbb5'])
+ expect(scopes['plain-old-class']).toBeUndefined()
+ })
+
+ it('reads a style rule that carries an empty cssRules of its own', () => {
+ // The regression guard for the walker's rule order. Every rule here is
+ // shaped like a real CSSStyleRule, so a walker that descends before it
+ // reads the selector finds nothing at all.
+ const scopes = playground.componentScopes({
+ styleSheets: [sheet('.notecard[data-v-11112222]')],
+ })
+
+ expect(scopes.notecard).toEqual(['data-v-11112222'])
+ })
+
+ it('reads a nested style rule as well as the one holding it', () => {
+ // CSS Nesting again, from the other side: a style rule can be BOTH a
+ // selector and a container, so finding the parent must not stop the
+ // descent and descending must not skip the parent.
+ const scopes = playground.componentScopes({
+ styleSheets: [
+ sheet(
+ styleRule('.list-item[data-v-aaaabbbb]', [
+ styleRule('.list-item__name[data-v-aaaabbbb]'),
+ ]),
+ ),
+ ],
+ })
+
+ expect(scopes['list-item']).toEqual(['data-v-aaaabbbb'])
+ expect(scopes['list-item__name']).toEqual(['data-v-aaaabbbb'])
+ })
+
+ it('still descends into a grouping rule, which has no selector', () => {
+ const scopes = playground.componentScopes({
+ styleSheets: [
+ { cssRules: [group(styleRule('.avatardiv[data-v-ccccdddd]'))] },
+ ],
+ })
+
+ expect(scopes.avatardiv).toEqual(['data-v-ccccdddd'])
+ })
+
+ it('follows an @import, which is how these sheets actually arrive', () => {
+ // Not a corner case, and the reason the first version of this stamped
+ // nothing at all: `dist/theming-settings-admin.css` is one whose
+ // entire body is a list of @imports, one per component chunk. In the
+ // CSSOM each of those is a CSSImportRule and the imported sheet hangs
+ // off `styleSheet` — a property a walker looking for `cssRules` steps
+ // straight past, finding an empty map and leaving every specimen bare.
+ const scopes = playground.componentScopes({
+ styleSheets: [
+ {
+ cssRules: [
+ { styleSheet: sheet('.button-vue[data-v-00a99684]') },
+ {
+ styleSheet: sheet(
+ '.input-field__input[data-v-8e16cbb5]',
+ ),
+ },
+ ],
+ },
+ ],
+ })
+
+ expect(scopes['button-vue']).toEqual(['data-v-00a99684'])
+ expect(scopes['input-field__input']).toEqual(['data-v-8e16cbb5'])
+ })
+
+ it('descends into media and supports blocks', () => {
+ // The dark-mode halves of these components live inside one, and a
+ // component styled only in the light half would go unstamped and lose
+ // its dark rules with it.
+ const scopes = playground.componentScopes({
+ styleSheets: [
+ {
+ cssRules: [
+ {
+ cssRules: sheet('.input-field__input[data-v-8e16cbb5]')
+ .cssRules,
+ },
+ ],
+ },
+ ],
+ })
+
+ expect(scopes['input-field__input']).toEqual(['data-v-8e16cbb5'])
+ })
+
+ it('keeps every scope a class is styled under, not just the first', () => {
+ // `material-design-icon` is scoped separately in every component that
+ // draws an icon, and one element may legitimately carry several — which
+ // is what Vue itself does at the root of a child component.
+ const scopes = playground.componentScopes({
+ styleSheets: [
+ sheet(
+ '.material-design-icon[data-v-00a99684]',
+ '.material-design-icon[data-v-5ca1e30f]',
+ ),
+ ],
+ })
+
+ expect(scopes['material-design-icon']).toEqual([
+ 'data-v-00a99684',
+ 'data-v-5ca1e30f',
+ ])
+ })
+
+ it('survives a stylesheet it is not allowed to read', () => {
+ // A sheet from another origin throws on `cssRules`. Nextcloud serves all
+ // of its own from this one, so the answer is to skip it rather than to
+ // give up and leave every specimen unstamped.
+ const scopes = playground.componentScopes({
+ styleSheets: [
+ {
+ get cssRules() {
+ throw new Error('SecurityError')
+ },
+ },
+ sheet('.button-vue[data-v-00a99684]'),
+ ],
+ })
+
+ expect(scopes['button-vue']).toEqual(['data-v-00a99684'])
+ })
+
+ it('stamps every scope a specimen carries a styled class for', () => {
+ const node = element('button-vue', 'button-vue--wide', 'nldesign-pg-btn')
+ playground.applyScopes(tree(node), {
+ 'button-vue': ['data-v-00a99684'],
+ 'button-vue--wide': ['data-v-00a99684'],
+ 'input-field': ['data-v-8e16cbb5'],
+ })
+
+ expect(node.attributes).toEqual({ 'data-v-00a99684': '' })
+ })
+})
+
+describe('the link specimen', () => {
+ it("draws real links, so hover and focus are the browser's own", () => {
+ // A span has no hover or focus of its own: the stage told the admin to
+ // hover the link for its hover colour, and nothing happened.
+ const markup = playground.STAGES.link(
+ null,
+ inventory.components.find((entry) => entry.id === 'link'),
+ )
+ expect(markup.match(//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('