feat(app-theming): logo + 3-color + header-style theming with WCAG contrast guardrail - #15
Merged
Merged
Conversation
added 2 commits
July 24, 2026 17:29
…ntrast guardrail Adds the runtime.appTheme manifest block (logoRef, primaryColor, secondaryColor, accentColor, headerStyle) as a lightweight, non-nldesign theming layer for virtual apps, reusing nldesign-theme-selection's scoped [data-openbuild-theme-scope] applier mechanism and app-icon-management's icon/iconDark fields unmodified. - appTheme manifest validation (services/manifestValidation/appTheme.js) - checkThemeContrast.js — pure WCAG relative-luminance contrast guardrail (4.5:1 text, 3:1 UI element), no override/bypass - AppCustomThemeSection.vue — theme editor, sibling of ThemeSection.vue in PageDesignerHost.vue; Save blocked at the actual persist boundary when contrast fails - useAppCustomTheme.js — scoped CSS-variable applier; maps colors onto --color-primary/--color-primary-element via a var(--nldesign-color-primary, <fallback>) chain (verified against the ACTUAL fetched nldesign token CSS, which only ever sets --nldesign-* names) so an active nldesign theme genuinely takes precedence - AppBrandedHeader.vue — OpenBuild-side branded-header binding (CnAppRoot has no logo/branding slot), wired into BuilderHost.vue See the PR description for the full list of deviations from design.md's implementation-time open questions and their rationale.
…rage gaps - Fixed the "Logo defaults..." requirement anchor (apostrophe slugifies to "-s-", not "s") across all three files that reference it. - Added missing @SPEC tags to every changed method/computed flagged by hydra gate-16 (spec-coverage): AppBrandedHeader.vue, AppCustomThemeSection.vue (including the headerStyleOption get/set pair), useAppCustomTheme.js, checkThemeContrast.js helper functions. Result: 38/39 hydra gates green (--scope-to-diff); the sole remaining failure (gate-46 spec-anchor-existence) is pre-existing dangling @SPEC references to archived change directories, verified identical on origin/development before this change.
This was referenced Jul 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements
app-theming(OpenSpec change, PURELY ADDITIVE): a lightweight logo + 3-color + header-style theming layer for OpenBuild virtual apps that are not on a mandated NL Design System token set, with a hard, non-bypassable WCAG contrast guardrail. Reusesnldesign-theme-selection's scoped[data-openbuild-theme-scope]CSS-variable applier mechanism andapp-icon-management'sicon/iconDarkfields unmodified.What's implemented (13 tasks, all checked in
tasks.md)runtime.appThememanifest block —logoRef,primaryColor,secondaryColor,accentColor,headerStyle(default|compact|branded), validated insrc/services/manifestValidation/appTheme.js(mirrorstheme.js's conventions exactly). Additive underruntime's existingadditionalProperties: true— no schema change needed, no new OR register (matchesnldesign-theme-selectionprecedent — nothing added underlib/Settings/register.d/).checkThemeContrast.js— pure WCAG relative-luminance + contrast-ratio function. Checks primary-as-text (≥4.5:1) and primary/secondary/accent-as-UI-element (≥3:1) against a pinned#FFFFFFbackground (NC's--color-main-backgroundlight default). No override/bypass anywhere in the call chain.AppCustomThemeSection.vue— theme editor: native<input type=color>+ labeledNcTextFieldhex value per color,NcSelectheader-style picker (inputLabelset), logo picker (defaults to the app icon, opt-in dedicated upload), live swatch preview, inline per-pair contrast-failure list (pair, computed ratio, required threshold).useAppCustomTheme.js— scoped CSS-variable applier, same[data-openbuild-theme-scope]mechanism, managed<style data-openbuild-app-theme>element, torn down on leave.AppBrandedHeader.vue— theheaderStyle: "branded"logo strip.PageDesignerHost.vue(editor + Save-time hard gate) andBuilderHost.vue(runtime render + injection-order coordination + lazy Application fetch for the branded header).CSS variables targeted (pinned against the REAL fetched nldesign token CSS, not guessed)
Inspected the actual
nldesign/css/tokens/*.cssfiles (e.g.amsterdam.css) andnldesign/css/systems/nldesign/{theme,overrides}.cssin the sibling repo. Finding that changed the plan: nldesign's scoped per-app token files (the exact assetnldesign-theme-selection's applier fetches and rewrites) declare only--nldesign-*-prefixed properties — never--color-*directly. The--color-primary: var(--nldesign-color-primary) !importantmapping only exists in nldesign's global, unscoped, instance-widetheme.css/overrides.css— a separate mechanism this per-app feature doesn't touch.So:
primaryColor→--color-primary,--color-primary-element,--color-primary-element-hover,--color-primary-element-text,--color-primary-text— the real Nextcloud-standard names everyNcButton/etc. reads — via avar(--nldesign-color-primary, <literal>)fallback chain, not a bare literal.secondaryColor/accentColor→ app-scoped--ob-theme-secondary/--ob-theme-accent(no native NC var exists for these roles; used only byAppBrandedHeader.vue).Deviation: precedence mechanism (design.md D3)
Design.md's D3 assumed pure DOM injection order ("later declaration wins by cascade") would make "nldesign wins for any shared variable name" true. Against the real fetched CSS this doesn't hold — nldesign's scoped applier never sets
--color-primaryin-scope, so there's no shared property name for injection order to act on. The applier instead uses thevar(--nldesign-color-primary, <fallback>)chain above: when nldesign's scoped<style>(still injected second, per D3, for future-proofing) also defines--nldesign-color-primaryin the same scope, thevar()resolves to its value; otherwise it falls back to the appTheme literal. Proven with a dedicated integration test (tests/composables/useAppCustomTheme.spec.js"nldesign precedence" suite) using a small CSS-cascade+var()resolver against a realamsterdam.css-shaped fixture (jsdom'sgetComputedStyledoesn't resolve custom properties, so this is the closest honest proxy for a unit test).Deviation: theme editor location
Design.md guessed
AppSettingsModal.vue. That modal only owns publish/permissions/data-registers and has no manifest access at all. The established, actually-reused pattern forruntime.*theme editing in this codebase is a manifest-driven, controlled sibling section wired intoPageDesignerHost.vue(see the existingThemeSection.vue). Followed that precedent instead —AppCustomThemeSection.vuesits right besideThemeSection.vue.Deviation:
headerStyle: "branded"bindingCnAppRoot(installed@conduction/nextcloud-vuebeta.219, confirmed by readingsrc/components/CnAppRoot/CnAppRoot.vuein the sibling checkout) exposes no dedicated top-bar logo/branding slot — only#header-actions(toolbar buttons beside the router-view) and#menu(left-rail nav). ImplementedAppBrandedHeader.vueas an OpenBuild-side binding, rendered above the nestedCnAppRootinsideBuilderHost.vue's existing[data-openbuild-theme-scope]wrapper — never touching the library.WCAG guardrail — no bypass
AppCustomThemeSection.vueas the developer edits.PageDesignerHost.save()re-checkscheckThemeContrast()directly and refuses to call the API when it fails (belt-and-braces against any future caller ofsave()that bypasses the button). The "Save pages" button is also disabled with an explanatory title.checkThemeContrastruns all three (primary/secondary/accent) as UI-element pairs, not only the primary palette.Test results (exact commands)
Unverified / deferred
tests/e2e/spec-coverage/app-theming.spec.tsis written with@e2e app-theming::*tags buttest.skip()'d with the same Conduction/openbuild#41 quarantine reason the siblingnldesign-theme-selection.spec.tsalready carries (the page-designer/builder admin UI doesn't render in this build) — logic coverage delegated to the vitest suites listed above, matching established precedent.runtime.*block already uses), not a live round-trip test.Vendor hazard
vendor/is.gitignore'd in this repo (confirmed viagit check-ignore -v vendor) —git status vendorstayed clean throughout, andgit show --stat HEADon both commits confirms novendor/path was ever staged or committed.🤖 Generated with Claude Code