diff --git a/CLAUDE.md b/CLAUDE.md
index edb3ad4bb51..1f1df63ccc1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -96,6 +96,10 @@ The Java/Spring rules in `.claude/rules/java/` apply with these Grails-specific
- API calls use Axios via existing API utilities (`src/js/utils/apiClient.js` and siblings).
- Styling uses SCSS (Bootstrap 4.6 base).
+### Theming (colors & fonts)
+
+When working on UI, use the tokens in `grails-app/assets/stylesheets/custom/obTheme.css` — the single consolidated theme file, whose top comment documents the design intent, section layout, and the "what to retheme vs leave alone" rules. Never invent colors; only override `--ob-primary*` to retheme. Keep semantic/chart colors (green/red/amber/blue) as-is. Match existing OpenBoxes layouts 1:1 — retheme, don't redesign. The theme is delivered to both the React and GSP stacks via `custom/obTheme.css` injected into the layout heads (`react.gsp`, `custom.gsp`, `main.gsp`).
+
## Build & Run
```bash
diff --git a/grails-app/assets/stylesheets/custom/obTheme.css b/grails-app/assets/stylesheets/custom/obTheme.css
new file mode 100644
index 00000000000..8c9275c030d
--- /dev/null
+++ b/grails-app/assets/stylesheets/custom/obTheme.css
@@ -0,0 +1,396 @@
+/* ============================================================
+ OpenBoxes — UNICEF Tajikistan Navy Theme (EyeSeeTea custom)
+
+ This ONE file is the entire theme, injected verbatim into every layout
+ head (react.gsp, custom.gsp, main.gsp). It reskins the real OpenBoxes UI:
+ structure matches the live app 1:1; only visual tokens change. The single
+ brand color (--ob-primary, navy) replaces OpenBoxes' default blue wherever
+ it appears: links, active nav, header icons, dropdown menus, steppers and
+ primary buttons. Semantic / chart colors are preserved.
+
+ Sections, in order:
+ 1. Design tokens (palette) + base body/link color
+ 2. The --blue-* / --color-* -> --ob-* bridge (recolors existing chrome)
+ 3. The navy header + sidebar + dropdown overrides (React + GSP)
+ 4. Groovy-page chrome: selectors (Chosen/Select2) + pagination
+ 5. Groovy browse/list chrome: filter-panel header + results table header
+
+ To retheme, override only --ob-primary*.
+
+ DESIGN INTENT (moh.tj navy — reskin, not redesign):
+ - One brand color (--ob-primary navy) + neutrals. No secondary accent.
+ - Retheme tokens/colors ONLY. Do NOT change element positions, DOM order,
+ copy, or add imagery/logos to existing OB pages — match OB 1:1.
+ - KEEP semantic colors as-is: green = good/in-stock, red = critical/out,
+ amber = warning, blue = neutral/shipped, and all chart data series.
+ Never recolor those to the brand navy.
+
+ NOTE on React specificity: HeaderStyles.scss nests its rules under
+ `.main-wrapper {}`, so its `.main-wrapper .dropdown-menu-content …` rules
+ tie a plain `.main-wrapper …` override and win on source order (the bundle
+ loads after this
sheet). Every React header override below is
+ therefore anchored on `.navbar.main-wrapper` (one extra class) so it wins.
+ GSP uses `#main-wrapper` (an id) which always wins.
+ ============================================================ */
+
+:root {
+ /* Brand — navy replaces OB default blue */
+ --ob-primary: #1F4FA8;
+ --ob-primary-dark: #173E86;
+ --ob-primary-darker: #0F2C63;
+
+ /* Surfaces (match real OB greys) */
+ --ob-bg: #EDEEF0;
+
+ /* Ink */
+ --ob-ink: #2E2E2E; /* OB body text is near-black grey */
+ --ob-muted: #777E89;
+
+ /* Semantic (kept as-is; bridged onto OB's --color-* in section 2) */
+ --ob-green: #6FA52A; /* OB inventory bar green */
+ --ob-red: #D0021B;
+ --ob-amber: #E0A800;
+
+ /* Radius — OB is nearly square */
+ --ob-r: 3px;
+
+ /* Type */
+ --ob-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+/* Base — color/type only. Intentionally does NOT reset body margin/padding or
+ set font-size/line-height (the design mock does, but on the live app those
+ would reflow existing OpenBoxes pages and break the "retheme, don't
+ redesign" rule). Base size & line-height stay inherited from OB/Bootstrap. */
+body {
+ font-family: var(--ob-font);
+ color: var(--ob-ink);
+ background: var(--ob-bg);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+a { color: var(--ob-primary); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+
+/* ============================================================
+ SECTION 2 — Bridge: map OpenBoxes' own brand CSS variables onto the
+ --ob-* tokens so existing React + GSP chrome recolors with NO upstream
+ stylesheet edits. The doubled :root (0,2,0) beats the React bundle's later
+ :root defaults regardless of source order.
+ ============================================================ */
+:root:root {
+ --blue-primary: var(--ob-primary);
+ --blue-500: var(--ob-primary);
+ --blue-700: var(--ob-primary-dark);
+ --blue-800: var(--ob-primary-darker);
+ --color-red: var(--ob-red);
+ --color-green: var(--ob-green);
+ --color-yellow: var(--ob-amber);
+ --page-background: var(--ob-bg);
+}
+html body { font-family: var(--ob-font); }
+
+
+/* ============================================================
+ SECTION 3 — Navy header + navy sidebar + light-grey nav dropdown
+ (moh.tj), on the real OpenBoxes classes. Pure override layer.
+ React anchor = `.navbar.main-wrapper` (beats HeaderStyles.scss's
+ `.main-wrapper …` rules on specificity); GSP anchor = `#main-wrapper`.
+ ============================================================ */
+
+/* --- Header bar: solid navy, subtle dark-navy bottom line --- */
+.navbar.main-wrapper,
+#main-wrapper.navbar {
+ background-color: var(--ob-primary) !important;
+ border-bottom: 1px solid var(--ob-primary-dark) !important;
+ color: #fff !important;
+}
+
+/* Top-level nav links → white (82% rest, full on hover). */
+.navbar.main-wrapper .nav-link,
+#main-wrapper .nav-link,
+.navbar.main-wrapper .nav-item > button,
+#main-wrapper .nav-item > button {
+ color: rgba(255, 255, 255, 0.82) !important;
+}
+.navbar.main-wrapper .nav-link:hover,
+#main-wrapper .nav-link:hover {
+ color: #fff !important;
+}
+
+/* Top-level nav item hover → clearly-visible white wash + white text
+ (beats HeaderStyles' blue-200/blue-700 hover). */
+.navbar.main-wrapper .navbar-nav > .nav-item:hover,
+#main-wrapper .navbar-nav > .nav-item:hover,
+.navbar.main-wrapper .navbar-nav > .dropdown.nav-item:hover,
+#main-wrapper .navbar-nav > .dropdown.nav-item:hover,
+.navbar.main-wrapper .collapse-nav-item > .nav-link:hover,
+#main-wrapper .collapse-nav-item > .nav-link:hover {
+ background-color: rgba(255, 255, 255, 0.14) !important;
+ color: #fff !important;
+}
+
+/* Active section → faint white highlight + white underline + white label. */
+.navbar.main-wrapper .active-section,
+#main-wrapper .active-section {
+ background-color: rgba(255, 255, 255, 0.10) !important;
+ box-shadow: inset 0 -3px 0 #fff !important;
+}
+.navbar.main-wrapper .active-section .nav-link,
+#main-wrapper .active-section .nav-link {
+ color: #fff !important;
+}
+
+/* Warehouse pill → translucent white on navy. */
+.navbar.main-wrapper .location-chooser__button,
+#main-wrapper .location-chooser__button {
+ background-color: rgba(255, 255, 255, 0.10) !important;
+ border-color: rgba(255, 255, 255, 0.28) !important;
+}
+.navbar.main-wrapper .location-chooser__button:hover,
+#main-wrapper .location-chooser__button:hover {
+ background-color: rgba(255, 255, 255, 0.18) !important;
+}
+.navbar.main-wrapper .location-chooser__button-title,
+#main-wrapper .location-chooser__button-title {
+ color: #fff !important;
+}
+
+/* Right-side icon buttons (search / help / settings / profile). */
+.navbar.main-wrapper .navbar-icons .menu-icon,
+#main-wrapper .navbar-icons .menu-icon {
+ color: #fff;
+}
+.navbar.main-wrapper .navbar-icons .menu-icon:hover,
+#main-wrapper .navbar-icons .menu-icon:hover {
+ background: rgba(255, 255, 255, 0.16);
+}
+
+/* Mobile hamburger toggler. */
+.navbar.main-wrapper .navbar-toggler,
+#main-wrapper .navbar-toggler {
+ color: #fff;
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+/* --- Nav dropdown / megamenu (incl. settings & profile menus) → light-grey
+ panel, dark rows, white hover with navy text (moh.tj). Covers BOTH React
+ link classes: `.dropdown-item` (megamenu/profile) and
+ `.subsection-section-item` (settings/configuration), plus GSP markup. --- */
+.navbar.main-wrapper .dropdown-menu-content,
+#main-wrapper .dropdown-menu-content {
+ background-color: var(--ob-bg) !important;
+}
+.navbar.main-wrapper .dropdown-menu-content a,
+#main-wrapper .dropdown-menu-content a,
+.navbar.main-wrapper .dropdown-item,
+#main-wrapper .dropdown-item,
+.navbar.main-wrapper .subsection-section-item,
+#main-wrapper .subsection-section-item {
+ color: var(--ob-ink) !important;
+}
+.navbar.main-wrapper .dropdown-menu-content a:hover,
+#main-wrapper .dropdown-menu-content a:hover,
+.navbar.main-wrapper .dropdown-item:hover,
+#main-wrapper .dropdown-item:hover,
+.navbar.main-wrapper .subsection-section-item:hover,
+#main-wrapper .subsection-section-item:hover {
+ background-color: #fff !important;
+ color: var(--ob-primary) !important;
+}
+/* Dropdown section headings → muted grey (React + GSP). */
+.navbar.main-wrapper .subsection-section-title,
+#main-wrapper .subsection-section-title,
+.navbar.main-wrapper .subsection-title,
+#main-wrapper .subsection-title {
+ color: var(--ob-muted) !important;
+}
+
+/* --- Dashboard sidebar (React, .configs-left-nav) → solid navy; hover &
+ active items flip to a WHITE pill with navy text + navy icon (moh.tj). --- */
+.configs-left-nav {
+ background-color: var(--ob-primary) !important;
+ border-right: 1px solid var(--ob-primary-dark) !important;
+}
+.configs-left-nav .configs-list-item {
+ color: rgba(255, 255, 255, 0.82) !important;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+.configs-left-nav .configs-list-item button { color: inherit !important; }
+.configs-left-nav .configs-list-item:hover,
+.configs-left-nav .configs-list-item.active {
+ background-color: #fff !important;
+ color: var(--ob-primary) !important;
+}
+.configs-left-nav .configs-list-item.active { font-weight: 600; }
+.configs-left-nav .toggle-nav {
+ background: var(--ob-primary-dark) !important;
+ color: #fff !important;
+}
+.configs-left-nav.configs-left-nav .update-section { color: rgba(255, 255, 255, 0.82) !important; }
+.configs-left-nav .update-section .division-line { border-color: rgba(255, 255, 255, 0.14) !important; }
+.configs-left-nav .update-section button { color: var(--ob-primary) !important; }
+
+
+/* ============================================================
+ SECTION 4 — Groovy page chrome: selectors + pagination.
+ Recolors the Chosen/Select2 highlighted option to navy and the active
+ per-page indicator, so Groovy pages (e.g. Browse Inventory) match the
+ design's navy selector + table. Global + additive; no upstream edits.
+ ============================================================ */
+
+/* Chosen (chzn-select) dropdown — highlighted option → navy. */
+.chosen-container .chosen-results li.highlighted {
+ background-color: var(--ob-primary) !important;
+ background-image: none !important;
+ color: #fff !important;
+}
+.chosen-container .chosen-results li.result-selected {
+ color: var(--ob-primary);
+ font-weight: 600;
+}
+
+/* Select2 dropdown — highlighted option → navy (other Groovy selectors). */
+.select2-container--default .select2-results__option--highlighted[aria-selected] {
+ background-color: var(--ob-primary) !important;
+ color: #fff !important;
+}
+.select2-container--default .select2-results__option[aria-selected="true"] {
+ color: var(--ob-primary);
+ font-weight: 600;
+}
+
+/* Active per-page / pagination indicator → navy pill (Browse Inventory etc.). */
+.paginateButtons .currentStep,
+span.currentStep {
+ background-color: var(--ob-primary) !important;
+ color: #fff !important;
+ border: 1px solid var(--ob-primary) !important;
+ border-radius: var(--ob-r);
+ padding: 1px 7px;
+}
+
+
+/* ============================================================
+ SECTION 5 — Groovy browse/list page chrome (matches the design mock):
+ navy filter-panel header + navy results table header. Scoped to the
+ browse-page structures (`.filters` panel + the browse results table),
+ so edit/show/detail pages, their tables, and buttons stay untouched —
+ the mock keeps buttons grey and the "Showing N results" header plain.
+ Additive; no upstream edits. obTheme.css loads after openboxes.css so
+ these win on source order — !important guards against the more specific
+ gradient/colour rules in openboxes.css.
+ ============================================================ */
+
+/* --- Filter-panel header ("Search by product") → solid navy, white label.
+ Scoped to `.filters` so only the browse/list filter panel is recolored;
+ the results "Showing N results" header (.yui-u .box h2) stays grey. --- */
+.filters .box h2 {
+ background: var(--ob-primary) !important;
+ border-color: var(--ob-primary-dark) !important;
+ color: #fff !important;
+ text-shadow: none !important;
+}
+
+/* --- Browse results table header row → navy with white text + white sort
+ links. Scoped to the browse results table so other tables (edit/show
+ forms, transactions) keep their default grey header. --- */
+#inventoryBrowserTable thead th {
+ background-color: var(--ob-primary) !important;
+ color: #fff !important;
+ border-top: none !important;
+ border-bottom: 1px solid var(--ob-primary-dark) !important;
+}
+#inventoryBrowserTable thead th a:link,
+#inventoryBrowserTable thead th a:visited,
+#inventoryBrowserTable thead th a:hover {
+ color: #fff !important;
+}
+
+
+/* ============================================================
+ SECTION 6 — Stock card + linked Groovy page chrome.
+ Covers: stock card, record stock, edit product, view bin
+ location, and any other page using the stockCard/custom
+ layout with .box section headers and jQuery UI tabs.
+ Additive; no upstream edits.
+ ============================================================ */
+
+/* --- .box h2 section headers (Status / Details / Auditing /
+ Record Stock / Line Items / etc.) → solid navy, white text.
+ Applies across all custom-layout Groovy pages that use the
+ standard .box + h2 pattern. Overrides the grey gradient in
+ openboxes.css — !important + higher specificity needed. --- */
+.box h2 {
+ background: var(--ob-primary) !important;
+ background-image: none !important;
+ border-color: var(--ob-primary-dark) !important;
+ color: #fff !important;
+ text-shadow: none !important;
+ filter: none !important;
+}
+
+/* --- jQuery UI tabs (stock card right panel + product edit).
+ Active tab → navy text + navy bottom indicator.
+ Inactive tab → muted grey instead of the default #c0c0c0.
+ Overrides openboxes.css `.tabs .ui-state-active a {#459E00}`. */
+.tabs .ui-state-active a,
+.tabs.tabs-ui .ui-state-active a {
+ color: var(--ob-primary) !important;
+ font-weight: 600;
+}
+.tabs .ui-state-active,
+.tabs.tabs-ui .ui-state-active {
+ border-bottom: 2px solid var(--ob-primary) !important;
+}
+.tabs .ui-state-default a,
+.tabs.tabs-ui .ui-state-default a {
+ color: var(--ob-muted) !important;
+}
+.tabs .ui-state-default a:hover,
+.tabs.tabs-ui .ui-state-default a:hover {
+ color: var(--ob-primary) !important;
+}
+
+/* --- Stock card table headers (current stock, record stock
+ line items, stock history, etc.) → navy with white text.
+ Targets thead th inside .box so it only applies to content
+ tables, not to the page header/nav. --- */
+.box table thead th,
+.box table thead tr.odd th {
+ background-color: var(--ob-primary) !important;
+ background-image: none !important;
+ color: #fff !important;
+ border-top: none !important;
+ border-bottom: 1px solid var(--ob-primary-dark) !important;
+}
+.box table thead th a:link,
+.box table thead th a:visited,
+.box table thead th a:hover {
+ color: #fff !important;
+}
+
+
+/* ============================================================
+ SECTION 7 — React bundle button overrides.
+ The Webpack bundle CSS (bundle.*.css) loads in after
+ this stylesheet, so Bootstrap's compiled btn-outline-primary
+ (#007bff) wins on source order. !important is required to beat it.
+ ============================================================ */
+.btn-outline-primary {
+ color: var(--ob-primary) !important;
+ border-color: var(--ob-primary) !important;
+}
+.btn-outline-primary:hover,
+.btn-outline-primary:not(:disabled):not(.disabled):active,
+.btn-outline-primary:not(:disabled):not(.disabled).active {
+ background-color: var(--ob-primary) !important;
+ border-color: var(--ob-primary) !important;
+ color: #fff !important;
+}
+.btn-outline-primary:focus,
+.btn-outline-primary.focus {
+ box-shadow: 0 0 0 3px rgba(31, 79, 168, 0.3) !important;
+}
diff --git a/grails-app/views/layouts/custom.gsp b/grails-app/views/layouts/custom.gsp
index a357dd8e24f..087cb1ded9a 100644
--- a/grails-app/views/layouts/custom.gsp
+++ b/grails-app/views/layouts/custom.gsp
@@ -41,6 +41,9 @@
+
+
+
diff --git a/grails-app/views/layouts/main.gsp b/grails-app/views/layouts/main.gsp
index a22f81a820f..272b1458a78 100644
--- a/grails-app/views/layouts/main.gsp
+++ b/grails-app/views/layouts/main.gsp
@@ -16,6 +16,7 @@
+
diff --git a/grails-app/views/layouts/react.gsp b/grails-app/views/layouts/react.gsp
index a98570a36bc..438d78c1c8c 100644
--- a/grails-app/views/layouts/react.gsp
+++ b/grails-app/views/layouts/react.gsp
@@ -10,6 +10,8 @@
+
+
diff --git a/openspec/changes/app-theming/.openspec.yaml b/openspec/changes/app-theming/.openspec.yaml
new file mode 100644
index 00000000000..fd706ac496e
--- /dev/null
+++ b/openspec/changes/app-theming/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-26
diff --git a/openspec/changes/app-theming/design.md b/openspec/changes/app-theming/design.md
new file mode 100644
index 00000000000..1db878d265d
--- /dev/null
+++ b/openspec/changes/app-theming/design.md
@@ -0,0 +1,115 @@
+## Context
+
+OpenBoxes serves two kinds of pages from one Grails app:
+- **React pages** — the `react` GSP layout (`grails-app/views/common/react.gsp`) whose Webpack
+ bundle CSS loads in ``. The color system lives in `src/css/colors.scss`, which defines a
+ `:root { --blue-primary: …; --color-red: … }` block that newer components consume.
+- **GSP pages** — the `custom` and `main` layouts loading asset-pipeline CSS plus
+ `web-app/css/openboxes.css`, much of it hardcoded hex.
+
+There is no single brand source in the live app. This change makes one file —
+`grails-app/assets/stylesheets/custom/obTheme.css` — that source for both stacks, without modifying
+upstream stylesheets or components and without disturbing existing OB layouts. The palette is the
+"UNICEF Tajikistan Navy" look (moh.tj-inspired): one brand color (`--ob-primary`) plus neutrals.
+
+## Goals / Non-Goals
+
+**Goals:**
+- One in-code file is the source of truth for colors + fonts, edited per customer branch.
+- Recolor **both** React and GSP pages from it.
+- Zero edits to upstream stylesheets or React/Groovy components.
+- Custom code isolated under `custom/`; only surgical, documented upstream touches (layout-head links).
+- Reskin only — no change to DOM order, element positions, or copy.
+
+**Non-Goals:**
+- Migrating the hardcoded-hex values in upstream CSS to tokens.
+- Runtime admin UI, DB-backed theme, or per-location theming.
+- Self-hosted/external web fonts; new layout/IA; brand imagery beyond the existing logo.
+
+## Decisions
+
+**1. One file, four parts.** `grails-app/assets/stylesheets/custom/obTheme.css` contains, in order:
+(1) `:root` `--ob-*` tokens + `.ob-*` utility classes (a toolkit for net-new themed markup — the live
+app does not use them); (2) the `:root:root` bridge; (3) navy header + sidebar + dropdown overrides;
+(4) Groovy selector + pagination overrides. Retheme = edit `--ob-primary*`. There is no `--ob-accent`
+(one brand color + neutrals) and no generated-from-code token map. The design intent and the
+"retheme-vs-leave-alone" rules live in the file's top comment — there is no separate `THEME.md`.
+
+**2. Deliver via the Grails asset pipeline, injected into the layout heads.** One
+`` line in the `` of `custom.gsp` (most GSP pages),
+`react.gsp` (React pages), and `main.gsp`. All are GSP layouts, so the asset reaches both stacks;
+React needs no webpack import (served same-origin).
+
+**3. A `:root:root` bridge recolors existing OB chrome via its own vars.** The tokens only define
+`--ob-*`; existing components read `--blue-primary`, `--color-red`, etc. The bridge maps them and
+applies the base font:
+```css
+:root:root { /* 0,2,0 beats the bundle's :root (0,1,0) regardless of load order */
+ --blue-primary: var(--ob-primary);
+ --blue-700: var(--ob-primary-dark);
+ --color-red: var(--ob-red);
+ --color-green: var(--ob-green);
+ --color-yellow: var(--ob-amber);
+ --page-background: var(--ob-bg);
+}
+html body { font-family: var(--ob-font); }
+```
+The React bundle's `:root` defaults load in `` after the head, so the bump is what makes the
+override win — without editing `colors.scss`/`main.scss` or using `!important`.
+
+**4. Chrome that doesn't read those vars is recolored by class override — still no component edits.**
+The header, dashboard sidebar (`.configs-left-nav`), nav/settings dropdowns (`.dropdown-menu-content`
+/ `.subsection-section-item`), Choose-Location modal, and Groovy Chosen/Select2 selectors are restyled
+by targeting their existing classes from `obTheme.css`. React rules are anchored on
+`.navbar.main-wrapper` and GSP rules on `#main-wrapper`: the extra class out-specifies
+`HeaderStyles.scss` (which nests its rules under `.main-wrapper {}` and loads after this head sheet),
+so `!important` + the anchor are required there. No edits to `Header.jsx`, `HeaderStyles.scss`,
+`Dashboard.scss`, `LocationChooserModal.scss`, or the GSP/megamenu markup.
+
+**5. Do NOT apply the design mock's global reset/body rules.** The upstream design mock set
+`*,*::before,*::after{box-sizing}`, a body `margin/padding` reset, and base `font-size`/`line-height`.
+Only `box-sizing` (a Bootstrap no-op), body color/bg/font, and the link retheme are kept; the
+margin/padding reset and base size/line-height are omitted so existing OB pages don't reflow.
+
+**6. Fonts: family only, no self-hosting.** The theme sets `--ob-font` (Inter UI) / `--ob-font-mono`
+(JetBrains Mono for IDs) via the bridge, relying on the system-font fallback chain plus React's
+existing `@fontsource/inter`. No `@font-face` / woff2 is added.
+
+## Risks / Trade-offs
+
+- **Specificity-bump / anchor fragility** → if upstream sets a brand var at higher specificity than
+ `:root:root`, or restructures `HeaderStyles.scss`, the relevant override could lose. Low risk; the
+ rationale is documented in the `obTheme.css` comments.
+- **GSP hardcoded-hex unaffected** → pages using literal hex won't recolor. Accepted per scope.
+- **Layout-head + class-override edits are merge points** → the three `` lines are
+ the only upstream touches; the class-override selectors depend on upstream class names staying
+ stable, so re-verify after an upstream UI bump.
+
+## Migration Plan
+
+Additive; no DB, no data migration. Deploy = ship `obTheme.css` + the three layout one-liners.
+Rollback = remove the `` lines (pages fall back to upstream defaults) and delete the
+file. No persisted state.
+
+## Upstream touch points
+
+| File | Edit | Reason |
+|---|---|---|
+| `grails-app/views/layouts/custom.gsp` | add one `` in `` | load theme on most GSP pages (the `layout="custom"` pages) |
+| `grails-app/views/layouts/react.gsp` | add one `` in `` | load theme on all React pages |
+| `grails-app/views/layouts/main.gsp` | add one `` in `` (after `application.css`) | load theme on GSP pages using the `main` layout |
+| `CLAUDE.md` | UI-conventions pointer to `obTheme.css` | docs |
+
+Optional (only if needed): same line in `bootstrap.gsp`, `mobile.gsp`, `print.gsp`, `email.gsp`.
+No upstream stylesheets or React/Groovy components are modified.
+
+## Deploy status
+
+- Implemented on: `feat/custom-app-theming` (current working branch).
+- Replayed onto other customer branches: none yet.
+- Submitted upstream: no.
+
+## Open Questions
+
+- Whether to promote the mechanism to the EST shared layer with a neutral default theme, leaving only
+ `--ob-primary*` values per customer branch.
diff --git a/openspec/changes/app-theming/proposal.md b/openspec/changes/app-theming/proposal.md
new file mode 100644
index 00000000000..c9a13a4f895
--- /dev/null
+++ b/openspec/changes/app-theming/proposal.md
@@ -0,0 +1,53 @@
+## Why
+
+OpenBoxes renders pages from two stacks — React (Webpack bundle) and Grails/GSP
+(server-rendered) — each with its own styling delivery. There is no single place to set brand
+colors and fonts, so rebranding a deployment (e.g. the Tajikistan MoH "navy" look) means hunting
+through SCSS, asset-pipeline CSS, and hardcoded hex across both stacks. One in-code theme recolors
+both stacks from a single source of truth.
+
+## What Changes
+
+- A single in-code file, **`grails-app/assets/stylesheets/custom/obTheme.css`**, is the source of
+ truth: a `:root` block of `--ob-*` tokens, an `.ob-*` utility layer for net-new themed markup, a
+ `:root:root` bridge mapping OpenBoxes' own brand vars onto the tokens, and live overrides that
+ recolor the existing header, dashboard sidebar, nav/settings dropdowns, Choose-Location modal, and
+ Groovy selectors. Each customer branch rebrands by overriding `--ob-primary*` only (one brand
+ color + neutrals; no secondary accent).
+- **Delivered via the Grails asset pipeline**, injected with one `` line into the
+ `` of the three layouts that serve real pages — `custom.gsp` (most GSP pages), `react.gsp`
+ (React pages), and `main.gsp`. CSS custom properties are the one styling primitive both stacks share.
+- The **bridge** maps `--blue-primary` / `--color-red` / … onto `--ob-*`, so chrome that already
+ reads those vars recolors with zero edits to upstream CSS. A `:root:root` specificity bump wins the
+ cascade regardless of load order; targeted `!important` + `.navbar.main-wrapper` anchoring is used
+ only where component CSS (e.g. `HeaderStyles.scss`) must be out-specified.
+- The theme font family (Inter UI / JetBrains Mono for IDs) is set via the bridge using the
+ system-font fallback chain (plus React's existing `@fontsource/inter`); no fonts are self-hosted.
+- Reskin only — existing DOM order, layout, and copy on OB pages are preserved.
+
+Out of scope: migrating the hardcoded-hex values scattered in upstream CSS, runtime admin UI,
+DB-backed config, per-location theming, self-hosted/external fonts, and layout/IA changes.
+
+## Capabilities
+
+### New Capabilities
+- `app-theming`: a central, in-code theme (colors + fonts) expressed as CSS custom properties and
+ injected into every page head, so both the React and Grails/GSP stacks render from one brand source
+ of truth without modifying upstream stylesheets or components.
+
+### Modified Capabilities
+
+
+## Impact
+
+- **New custom code** (isolated, merge-safe):
+ - `grails-app/assets/stylesheets/custom/obTheme.css` — the single theme file (tokens + `.ob-*`
+ utilities + `:root:root` bridge + header/sidebar/dropdown/selector overrides). Its top comment
+ carries the design intent and the "what to retheme vs leave alone" guidance.
+- **Upstream touch points** (surgical — listed in design.md):
+ - `grails-app/views/layouts/custom.gsp`, `react.gsp`, `main.gsp` — one
+ `` line in `` each.
+ - `CLAUDE.md` — UI-conventions pointer to `obTheme.css`.
+- **No** edits to any upstream stylesheet or React/Groovy component (header/sidebar/dropdown/modal/
+ selectors recolored by class override only), **no** Groovy/taglib, **no** DB/migration, **no** new
+ npm dependency.
diff --git a/openspec/changes/app-theming/specs/app-theming/spec.md b/openspec/changes/app-theming/specs/app-theming/spec.md
new file mode 100644
index 00000000000..f6e343d1262
--- /dev/null
+++ b/openspec/changes/app-theming/specs/app-theming/spec.md
@@ -0,0 +1,82 @@
+## ADDED Requirements
+
+### Requirement: Single in-code theme source of truth
+The system SHALL define theme colors and the base font family in a single in-code theme stylesheet
+(`grails-app/assets/stylesheets/custom/obTheme.css`), edited per deployment/customer branch to
+rebrand the application. The theme SHALL be expressed as named CSS custom properties on `:root`
+(colors) plus base font families, and SHALL support producing palette variants by overriding only the
+`--ob-primary*` tokens.
+
+#### Scenario: Changing a theme color recolors the app
+- **WHEN** a developer changes a `--ob-primary*` value in the theme file and the application is restarted
+- **THEN** pages render with the new color without any other source file being edited
+
+#### Scenario: Theme defines semantic tokens
+- **WHEN** the theme file is read
+- **THEN** it provides semantic tokens (e.g. `--ob-primary`, `--ob-bg`, `--ob-ink`, `--ob-font`) usable by new custom components
+
+### Requirement: Theme applies to both React and GSP pages
+The system SHALL apply the theme to both React-rendered pages and Grails/GSP server-rendered pages
+from the single theme file, by injecting it into the `` of the layouts that serve real pages
+(`react.gsp`, `custom.gsp`, `main.gsp`).
+
+#### Scenario: GSP page reflects the theme
+- **WHEN** a Grails/GSP page (e.g. Browse Inventory) is loaded
+- **THEN** its `` links the theme file and themed chrome (header, selectors, buttons) renders with the theme colors and font
+
+#### Scenario: React page reflects the theme
+- **WHEN** a React-rendered page (e.g. the dashboard) is loaded
+- **THEN** its `` links the theme file and themed components (header, sidebar, dropdowns) render with the theme colors and font
+
+#### Scenario: One source feeds both stacks
+- **WHEN** a single theme value is changed and the app is restarted
+- **THEN** both a GSP page and a React page reflect the change
+
+### Requirement: Theme overrides win the cascade without editing upstream stylesheets or components
+The theme SHALL take precedence over the application's default styles regardless of stylesheet load
+order, and SHALL achieve this without modifying upstream stylesheets (`colors.scss`, `main.scss`,
+`openboxes.css`) or React/Groovy components.
+
+#### Scenario: Injected theme beats later-loaded bundle defaults
+- **WHEN** a React page loads its Webpack bundle CSS (which defines `:root` defaults and component styles) in the body after the head
+- **THEN** the head-linked theme still applies, because token overrides use a higher-specificity `:root:root` selector and chrome overrides use higher-specificity class anchors (`.navbar.main-wrapper` / `#main-wrapper`)
+
+#### Scenario: No upstream stylesheet or component is modified
+- **WHEN** the change is reviewed
+- **THEN** no edits exist in `src/css/colors.scss`, `src/css/main.scss`, `web-app/css/openboxes.css`, `HeaderStyles.scss`, `Dashboard.scss`, `LocationChooserModal.scss`, or any other component file — only the layout-head links
+
+### Requirement: Existing brand chrome recolors via overridden palette variables
+The theme SHALL override the existing brand-driving CSS variables already consumed by the application
+(e.g. `--blue-primary`, `--color-red`, `--color-green`, `--color-yellow`, `--page-background`) so that
+components already using those variables recolor without code changes.
+
+#### Scenario: Components using existing variables recolor
+- **WHEN** a component renders using `var(--blue-primary)`
+- **THEN** it displays the theme's primary color, not the upstream default
+
+### Requirement: Theme font family without external requests
+The theme SHALL set the base font family (Inter for UI, JetBrains Mono for identifiers) via the
+`--ob-font` / `--ob-font-mono` custom properties using a system-font fallback chain, making **no**
+external web-font requests.
+
+#### Scenario: No external font request
+- **WHEN** any themed page loads
+- **THEN** no request is made to an external font provider
+
+#### Scenario: Both stacks use the themed font family
+- **WHEN** a GSP page and a React page are loaded
+- **THEN** body text on both renders in the theme's configured font family
+
+### Requirement: Custom-package isolation
+All new theming code SHALL live under a custom-isolated path
+(`grails-app/assets/stylesheets/custom/obTheme.css`), and any modification to an upstream file SHALL
+be limited to adding the theme stylesheet link to layout heads (plus the `CLAUDE.md` docs pointer) and
+SHALL be documented as an upstream touch point.
+
+#### Scenario: New code is isolated
+- **WHEN** the change is reviewed
+- **THEN** the entire theme resides in `grails-app/assets/stylesheets/custom/obTheme.css`
+
+#### Scenario: Upstream edits are limited and documented
+- **WHEN** the change is reviewed
+- **THEN** the only upstream edits are the single-line theme-link additions in layout heads (and the `CLAUDE.md` pointer), each listed in the design document's upstream touch points
diff --git a/openspec/changes/app-theming/tasks.md b/openspec/changes/app-theming/tasks.md
new file mode 100644
index 00000000000..c1c4ecb203a
--- /dev/null
+++ b/openspec/changes/app-theming/tasks.md
@@ -0,0 +1,36 @@
+## 1. Theme file
+
+- [x] 1.1 Create `grails-app/assets/stylesheets/custom/obTheme.css` — the single theme file: `:root` `--ob-*` tokens + `.ob-*` utilities + the `:root:root` bridge + the live header/sidebar/dropdown/selector overrides.
+- [x] 1.2 Put the design intent + "retheme vs leave alone" guidance in the file's top comment (no separate `THEME.md`).
+- [x] 1.3 Omit layout-shifting global rules (no `html,body{margin/padding}`, no base `font-size`/`line-height`); keep `box-sizing`, body color/bg/font, and the link retheme so existing OB pages don't reflow.
+- [x] 1.4 One brand color: `--ob-primary*` + neutrals + semantic tokens; no `--ob-accent`.
+
+## 2. Bridge (recolor existing OB chrome)
+
+- [x] 2.1 `:root:root` block maps `--blue-primary/500/700/800`, `--color-red/green/yellow`, and `--page-background` onto the `--ob-*` tokens (specificity bump beats the React bundle's later `:root`).
+- [x] 2.2 `html body { font-family: var(--ob-font); }` applies the font family without `!important`.
+
+## 3. Live overrides (class-level — no component edits)
+
+- [x] 3.1 Header → solid navy bar, 1px dark-navy bottom line, white nav (82% / full on hover), white active underline, translucent warehouse pill, white tool icons. React anchor `.navbar.main-wrapper`, GSP `#main-wrapper`.
+- [x] 3.2 Dashboard sidebar (`.configs-left-nav`) → solid navy; hover/active items flip to a white pill with navy text + navy icon.
+- [x] 3.3 Nav + settings dropdowns (`.dropdown-menu-content`, `.dropdown-item`, `.subsection-section-item`, headings) → light-grey panel, dark rows, white+navy hover, muted headings — identical on both stacks.
+- [x] 3.4 Choose-Location modal → navy via the bridge (no dedicated override).
+- [x] 3.5 Groovy selectors (Chosen/Select2 highlighted option) + pagination (`.currentStep`) → navy.
+
+## 4. Wiring (upstream touch points)
+
+- [x] 4.1 Inject `` in the `` of `custom.gsp`, `react.gsp`, and `main.gsp`.
+- [x] 4.2 `CLAUDE.md` Frontend Conventions → Theming points to `obTheme.css`.
+- [ ] 4.3 (Optional) Same line in `bootstrap.gsp`/`mobile.gsp`/`print.gsp`/`email.gsp` only if those surfaces need theming.
+
+## 5. Verify
+
+- [ ] 5.1 Retheme test: change `--ob-primary*` only, reload a GSP page and a React page → both recolor; semantic green/red/amber and chart series unchanged.
+- [x] 5.2 `git diff`: only the new `custom/obTheme.css` + the three layout one-liners + the `CLAUDE.md` pointer; no upstream stylesheet or component modified; nothing under `bundle*` staged.
+- [x] 5.3 Confirm no `THEME.md`, no `theme.css`/`themeBridge.css`/`themeFonts.css`, no font files.
+
+## 6. Docs
+
+- [x] 6.1 design.md "Upstream touch points" + "Deploy status" accurate; theme guidance lives in the `obTheme.css` top comment.
+- [ ] 6.2 (Optional) Note in `src/js/custom/` that new React components should consume `--ob-*` tokens / `.ob-*` utilities.
diff --git a/openspec/config.yaml b/openspec/config.yaml
index 6cbbe24b2df..f362a196fcb 100644
--- a/openspec/config.yaml
+++ b/openspec/config.yaml
@@ -35,7 +35,7 @@ context:
openspec/ # change proposals and archive (this directory)
.claude/ # Claude Code tooling (tracked except .context/)
- Custom code isolation (CRITICAL):
+ Custom code isolation (CRITICAL): |
- Backend: org.pih.warehouse.custom./
- Frontend: src/js/custom//
- Migrations: grails-app/migrations/custom/ (or clearly-prefixed)