Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
774059d
docs(specs): add dark theme completion specification
saltas888 Aug 17, 2026
14fbf99
docs(specs): add dark theme completion implementation plan
saltas888 Aug 17, 2026
7ebfc5b
docs(specs): critique dark theme completion and apply must-address fixes
saltas888 Aug 17, 2026
0a06189
docs(specs): add dark theme completion task breakdown
saltas888 Aug 17, 2026
30efa6d
docs(specs): add dark theme completion alignment check
saltas888 Aug 17, 2026
642bb5f
docs(specs): apply edge-case review to dark theme completion
saltas888 Aug 17, 2026
15c195e
docs(specs): keep dark opt-in; production defaults to light
saltas888 Aug 17, 2026
763dec3
docs(specs): gate dark theme behind an experimental flag
saltas888 Aug 17, 2026
e8da3a3
docs(specs): scope the flag to the dev compose file and name the defe…
saltas888 Aug 17, 2026
6055b31
feat(frontend): add theme resolution rule and resolved-theme type
saltas888 Aug 17, 2026
3566d0a
feat(frontend): bind GraphiQL and Mermaid to the active theme
saltas888 Aug 17, 2026
4aab39c
fix(frontend): tokenize always-light surfaces broken in dark mode
saltas888 Aug 17, 2026
4ef12a7
docs(specs): correct SC-004 after implementation found the real defects
saltas888 Aug 17, 2026
38747ed
fix(frontend): actually theme mermaid diagrams
saltas888 Aug 17, 2026
3122a61
docs(frontend): forbid fixed-palette surface classes
saltas888 Aug 17, 2026
946eb0c
fix(frontend): paint native browser UI in the active theme
saltas888 Aug 17, 2026
abf1996
refactor(frontend): drop a redundant dark variant from the gray badge
saltas888 Aug 17, 2026
4739b1a
fix(frontend): tokenize the active-state indigo
saltas888 Aug 17, 2026
e217f33
fix(frontend): give the avatar ramp and the template tile a dark palette
saltas888 Aug 17, 2026
b777823
feat(frontend): default non-production deployments to the dark theme
saltas888 Aug 17, 2026
198e6b2
feat(frontend): offer the dark theme when the backend predates the flag
saltas888 Aug 18, 2026
b46cffd
fix(frontend): paint reloads from the resolved mirror only
saltas888 Aug 18, 2026
d48d55f
fix(frontend): keep the mermaid directive out of quoted fences
saltas888 Aug 18, 2026
c5cb03e
fix(frontend): theme the alert close-button focus ring
saltas888 Aug 18, 2026
207326e
test(frontend): cover the theme's reading hook and its user journeys
saltas888 Aug 18, 2026
c3cca10
docs(dev): explain how theming works
saltas888 Aug 18, 2026
be0e7ee
docs(specs): reconcile spec artifacts with the implemented design
saltas888 Aug 18, 2026
ae77ce4
refactor(frontend): move theming into the design system, let mermaid …
saltas888 Aug 18, 2026
92258d2
fix(frontend): clear the contrast audit in both themes
saltas888 Aug 18, 2026
c3b3e9b
docs: document the dark theme for users
saltas888 Aug 18, 2026
d21affa
chore(frontend): refresh the betterer baseline after the theming move
saltas888 Aug 18, 2026
8eed77b
fix(docs): commit the configuration reference byte-exact with the gen…
saltas888 Aug 18, 2026
f8ac285
fix(ci): let the generator own the dark-theme flag in the root compos…
saltas888 Aug 18, 2026
9f2afe8
refactor(frontend): move the theme provider into the design system
saltas888 Aug 18, 2026
313b133
chore: TEMPORARY — default the dark theme on so preview envs serve it
saltas888 Aug 18, 2026
79d891c
chore(spec): tick T059 now that the local CI gate has been run
saltas888 Aug 18, 2026
31c8014
Merge branch 'bab-dark-theme-app' into dark-theme-completion-infp-46
saltas888 Aug 18, 2026
de36a8a
fix(frontend): regenerate the REST types after the OpenAPI default ch…
saltas888 Aug 18, 2026
30a8241
Merge branch 'bab-dark-theme-app' into dark-theme-completion-infp-46
saltas888 Aug 18, 2026
441db4a
fix(frontend): theme the date-time picker
saltas888 Aug 18, 2026
7a1d36a
fix(frontend): theme the toasts
saltas888 Aug 18, 2026
4091840
fix(frontend): fade the IPAM header into its real surface, not into w…
saltas888 Aug 18, 2026
a5edf71
fix(frontend): sweep the remaining fixed-light surfaces
saltas888 Aug 18, 2026
48906c0
fix(frontend): retune GraphiQL's dark palette to the app's stone family
saltas888 Aug 18, 2026
489d809
fix: unbreak develop's tree E2E jobs and stop a class-name leak (#10287)
saltas888 Aug 17, 2026
96ea50b
fix(frontend): paint the theme before the first frame, not after it
saltas888 Aug 19, 2026
f454ee7
fix(frontend): tag only the option that switches into the alpha theme
saltas888 Aug 19, 2026
d17a7b1
revert: restore the dark theme's default to off
saltas888 Aug 19, 2026
5f755ab
feat(frontend): theme the schema visualizer with the application's theme
saltas888 Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dev/guidelines/frontend/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,15 @@ export const Button = ({ variant, size, className, ref, ...props }: ButtonProps)
| Inline `style={{}}` | Tailwind classes |
| CSS modules | Tailwind utilities |
| `bg-[#1e40af]` | `bg-custom-blue-700` (use theme) |
| `bg-white`, `bg-gray-50`, `bg-gray-100` | `bg-content`, `bg-content-muted`, `bg-content-strong` |
| `bg-white dark:bg-stone-900` | `bg-content` — one token already carries both themes |
| `<div className="flex items-center gap-2">` | `<Row>` from `@/shared/components/container` |
| `<div className="flex flex-col gap-2">` | `<Col>` from `@/shared/components/container` |

### Why a fixed palette is forbidden, not just discouraged

A class like `bg-white` is not theme-neutral — it paints light in *both* themes, so the surface stays bright when the rest of the page goes dark. This is easy to miss in review because the defect is the **absence** of a variant rather than the presence of a wrong one: searching for `dark:` finds the files that already work and none of the files that are broken.

Pairing a literal with a `dark:` override (`bg-white dark:bg-stone-900`) renders correctly but duplicates in every call site what a token defines once, so the next palette change has to be repeated by hand in each of them.

A `dark:` variant is legitimate only where no token can express the difference — swapping between two different assets, for example, or a dark-only effect such as a backdrop blur.
124 changes: 124 additions & 0 deletions dev/specs/infp-46-dark-theme-completion/alignment-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Alignment Check: Dark Theme Completion

**Date**: 2026-08-17 | **Spec**: [spec.md](./spec.md) | **Remediation passes used**: 0

## Revision 2 — 2026-08-17, after idea grilling

A structured grilling pass over the product core (the part that had wobbled twice) produced five

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Custom agent: Flag AI Slop and Fabricated Changes

The Revision 2 section says the grilling pass "produced five decisions," but the Decision/Effect table directly below lists 7 decisions (gate flag, flag-off light-only, dev-config defaults, no removal date, no org-wide default, dev-compose-only, Slack reporting). The edge-case revision likewise says the requester "directed six changes," yet its Direction/Effect table lists 7 rows. Correct both counts to match the tables (or trim the tables) so the narrative counts agree with the content in this fidelity-focused alignment check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At dev/specs/infp-46-dark-theme-completion/alignment-check.md, line 7:

<comment>The Revision 2 section says the grilling pass "produced five decisions," but the Decision/Effect table directly below lists 7 decisions (gate flag, flag-off light-only, dev-config defaults, no removal date, no org-wide default, dev-compose-only, Slack reporting). The edge-case revision likewise says the requester "directed six changes," yet its Direction/Effect table lists 7 rows. Correct both counts to match the tables (or trim the tables) so the narrative counts agree with the content in this fidelity-focused alignment check.</comment>

<file context>
@@ -0,0 +1,124 @@
+
+## Revision 2 — 2026-08-17, after idea grilling
+
+A structured grilling pass over the product core (the part that had wobbled twice) produced five
+decisions. **The feature got smaller.** Nothing was added.
+
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed the count, be0e7ee

decisions. **The feature got smaller.** Nothing was added.

| Decision | Effect |
|---|---|
| Gate the whole feature behind a flag rather than defining an alpha exit date | `INFRAHUB_EXPERIMENTAL_DARK_THEME`, off by default, on in `development/docker-compose.yml`. Motivated by the release cycle for the target version being unknown — `1.11.0` has not shipped, the newest tag is `1.11.0b2`. |
| Flag off → light only | The theme field is hidden entirely. ⚠ Not a light-only picker: offering match-system would let a dark-OS user reach the alpha palette straight through the gate. Stored `DARK` is ignored, never deleted. |
| Flag defaults from dev config, **not** from the running version | Replaced the PEP 440 pre-release derivation. "Pre-release" is a property of a version, so it caught customer betas too — broader than "the deployments we run". Follows the convention both existing experimental settings already use. |
| No removal date for the flag | Recorded as knowingly open-ended rather than left unstated. ⚠ `value_db_index` sits in the same settings class today as a dead flag with a deprecation notice — a realised failure mode. |
| No organisation-wide theme default | FR-003 was an addition made during specification, never requested. With the feature flag-gated to the dev stack it has no user. Backend gains the scope for free; only the interface is deferred. |
| The flag goes in `development/docker-compose.yml` **only** | The root `docker-compose.yml` does not get it, unlike its two experimental siblings there. Decided, not an oversight — a deployment brought up from the root file therefore cannot enable dark via the host env var, which is the intent while dark is alpha. |
| Dogfooding defects are reported over Slack | Closes the critique's P3. Naming the destination is what makes "no new defects were found" checkable rather than an absence of evidence. |

**Removed by this revision**: `core/preferences/theme.py`, PEP 440 version parsing, the
`default_theme` config field and its payload entry, the organisation-scope interface, and the
`Deployment default theme` entity. **One governance gate dropped** — no new dependency, since nothing
parses versions any more.

**Net against the original handover**: still all seven items, still fully covered.

## Revision — 2026-08-17, after edge-case review

The requester reviewed the Edge Cases section and directed six changes. All are applied across
`spec.md`, `research.md`, `data-model.md`, `contracts/rest-config.md`, `plan.md`, `quickstart.md` and
`tasks.md`. They do not change the seven-item coverage below.

| Direction | Effect |
|---|---|
| "By default we should respect the user's browser/system config" — then, on seeing the consequence: "dark should remain alpha; respect system preferences only if you're in alpha" | **Net effect: no change.** An intermediate revision moved the production default to `system`; it was withdrawn once the requester saw that it would put dark-OS production users into the alpha palette without choosing it. Final state matches the original spec: production → `light`, non-production → `dark` (forced, ignoring the OS). Match-system stays available everywhere as an explicit user choice, never a default. |
| "Couldn't we store something in localStorage?" | Confirmed — already the design. The three cache-related edge cases (pre-sign-in, preference-unavailable, first paint) are now stated as one problem with one mechanism rather than three bullets. |
| "Multiple tabs — ignore this" | Moved to Out of Scope; the `storage` listener is dropped from the provider. |
| "System theme changes — react, only if easy" | Kept (FR-007). It is a subscribable browser event, so the cost is small. |
| "Content that carries its own colors — tackle separately" | Moved to Out of Scope. Former FR-021 (semantic distinguishability) removed; contrast promoted to FR-021 with an explicit boundary. `badge.tsx` becomes migrate-without-degrading rather than a palette redesign. |
| "Existing automated tests — let's tackle this" | Confirmed in scope; T035 unchanged. |
| "Build this as a stacked PR on the existing one" | Branch bases on `bab-dark-theme-app` and the PR targets it, not `develop`. Recorded that #10284's failing checks are inherited. |

**Governing principle, now stated explicitly in the spec**: dark is never reached by inference. A
user arrives at it only by choosing dark, or by choosing match-system on a dark machine. That single
rule decides both defaults — flag-off is light rather than system-following, and the pre-paint
script's empty-cache fallback is light rather than `prefers-color-scheme`.

The mirror-image rule governs the other default: with the flag on, dark is forced *ignoring* the
system, because following it would leave every engineer on a light machine out of the dogfooding.

**Residual limitation, unchanged from the original design**: a first-ever visit to a flag-enabled
deployment paints light for one frame before correcting to dark. Flag-off deployments are unaffected,
since light is already the final answer there.

## Source

The source of truth is the **inline handover list** supplied by the requester: seven numbered
"Known limitations / follow-ups" recorded by the author of the dark-theme series, together with the
framing statement about taking over PR
[#10284](https://github.com/opsmill/infrahub/pull/10284).

It qualifies as a substantive PRD: structured, requirement-bearing, and well over the length
threshold. No external PRD document was linked, so nothing needed fetching — the only URL in the ask
is the pull request itself, which was read for context rather than as a requirements source.

Two clarifications were obtained directly from the requester during specification and count as part
of the source:

- Scope confirmed at **all seven items**, including the separate schema-visualizer repository, after
being challenged as four.
- PR #10284's failing end-to-end checks: **explicitly deferred**, out of scope.

## Verdict

**⚠️ MINOR DRIFT (proceeding)**

All seven items are present and traceable. No requirement was dropped, softened, or reversed. The
drift is entirely in one direction — the spec adds material the handover did not ask for — and every
addition is either a necessary consequence of the chosen approach or a recorded judgement call. One
finding was a genuine fidelity loss and has been corrected.

## Coverage of the source ask

| # | Handover item | Spec location | Status |
|---|---|---|---|
| 1 | No user preference to switch themes; `@custom-variant` is a dev-only crutch; "alpha" tag next to dark | US1, FR-001–FR-009, FR-019 | ✅ |
| 2 | GraphiQL has its own dark theme, bind it to the app theme | US3, FR-014 | ✅ |
| 3 | Mermaid only partially dark, bind to selected theme | US4, FR-015 | ✅ |
| 4 | Schema visualizer is in another repo, not dark-compatible | US7, FR-016 | ✅ |
| 5 | DataViewer uses a colder (neutral) tone than the warmer theme | US6, FR-018 | ✅ |
| 6 | Legacy pages (e.g. Proposed Changes) have hardcoded `dark:` variants and raw colors | US5, FR-017 | ✅ |
| 7 | "Make canary enabled by default" so non-production versions default to dark | US2, FR-010–FR-013 | ✅ |
| — | Take over #10284; ignore its failing E2E | Context, Out of Scope | ✅ |

## Findings

| Severity | Category | Source reference | Spec reference | Description |
|---|---|---|---|---|
| Corrected | changed | Item 1 — "add an *alpha* tag" | FR-008, T024 | The spec had generalised the label to "pre-release". The requester named "alpha" specifically; a synonym is a small but real loss of fidelity in the one string users read. **Fixed** — FR-008 and T024 now require the literal word. |
| Minor | added | not in source | FR-001, `Theme.SYSTEM` | A match-system option was added. The handover implies a light/dark toggle. Recorded in Assumptions: it is the conventional expectation, and adding it later would change the meaning of an already-stored value. Reviewer-overturnable. |
| Minor | added | not in source | FR-003 | An organisation-wide default. Not requested, but it falls out of reusing the existing preference store, which is already two-layer — excluding it would have meant *removing* behaviour the machinery provides. |
| Minor | added | not in source | FR-006, SC-002 | First-paint correctness. Not requested, but shipping an account-backed theme setting without it produces a visible flash on every load; treated as inherent to item 1 rather than new scope. |
| Minor | added | critique | FR-021, SC-009 | A contrast requirement, added by the engineering/product critique. Justified for a feature whose entire subject is color. (Numbered FR-022 when added; renumbered to FR-021 in the revision above, when semantic-color distinguishability moved out of scope.) |
| Minor | added | not in source | T047 | An automated guard so the token cleanup does not regress. Follows from SC-004's "standing property" wording rather than from the ask. |
| Minor | added | house rules | T057, T058 | Changelog fragment and user-facing documentation. Required by `AGENTS.md` for a user-facing feature, not by the handover. |
| Resolved | — | Item 7 — "for the coming weeks" | SC-008, US2 | Raised in the critique as P2/P3 and left open at the time. Both halves now answered by the requester: the period is bounded by a feature flag rather than a date or an exit criterion, and defects found during it are reported over Slack. SC-008 was rewritten to something countable in the meantime. |

### On item 7's mechanism

The handover asked to "make canary enabled by default". No `canary` concept exists anywhere in the
repository, so the term had no referent to implement. Rather than guess silently, the mechanism was
chosen with evidence and documented in [research.md](./research.md) §R1: PEP 440 pre-release status
on the running version, verified against the actual build (`1.11.0b2.dev134+geb5acb009` →
pre-release; `1.11.0` → not).

This is recorded as a **resolution of an underspecified item**, not as drift — the intent ("the
non-production versions we usually run default to dark") is met exactly. But it is the single
decision in this spec most worth a reviewer's attention, because the requester may have had a
specific existing concept in mind that this analysis did not find.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
## Action

Proceed. The one fidelity loss is corrected; the remaining drift is additive, documented, and
individually reversible by a reviewer. No remediation pass was required.
50 changes: 50 additions & 0 deletions dev/specs/infp-46-dark-theme-completion/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Specification Quality Checklist: Dark Theme Completion

**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-08-17
**Feature**: [spec.md](../spec.md)

## Content Quality

- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed

## Requirement Completeness

- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified

## Feature Readiness

- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification

## Notes

Validation observations, recorded rather than silently passed:

- **Implementation detail in Context, deliberately.** The Context section names the `.dark` class and
the `@custom-variant dark` declaration. These describe the *status quo* being replaced, not the
design of the solution, and the corresponding requirement (FR-019) is stated abstractly. Kept.
- **SC-004 is close to the line.** "Zero application components specify per-theme color overrides or
raw color literals" describes a source property rather than a user-observable one. It is retained
because it is precisely the outcome requested on handover, and because the user-visible
consequence (SC-005, SC-006) alone would not catch debt that merely *happens* to look right today.
- **Named surfaces are product scope, not implementation.** GraphQL sandbox, Mermaid diagrams, data
viewer and schema visualizer are named throughout. They are the user-facing surfaces the feature
is defined by; naming them is not a leak.
- **Three decisions were resolved by judgement rather than marked for clarification**, per the
autonomous-execution mode this spec was generated under. All three are recorded in Assumptions:
production defaulting to light, the inclusion of match-system, and deriving "non-production build"
from the running version. Each is a reviewer-overturnable call, and the third is deliberately left
to the plan to make concrete.
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Contract: GraphQL preferences — theme field

**Feature**: [spec.md](../spec.md) | **Covers**: FR-001, FR-002, FR-003, FR-004

Additive delta to the existing preferences surface. Every change mirrors how `date_format` is already
modelled, so nothing below introduces a pattern the schema does not already use.

⚠ `schema/schema.graphql` is generated and CI-validated (`uv run invoke docs.validate`). Regenerate
with `uv run invoke schema.generate-graphqlschema` and commit, or CI fails on a stale file.

⚠ GraphQL schema modifications are **Ask First** per `AGENTS.md`. This contract is a proposal
requiring sign-off, not an approved change.

## New enum

```graphql
"""
Appearance choices. SYSTEM follows the operating system; the dark palette is pre-release.
"""
enum Theme {
LIGHT
DARK
SYSTEM
}
```

⚠ The description **must stay on one line** in the Python source. `graphql-core`'s SDL printer
dedents multi-line descriptions differently across versions, which makes the generated
`schema.graphql` environment-dependent — a constraint already documented in
`backend/infrahub/graphql/types/preferences.py`.

## New effective-value type

```graphql
"""An effective `theme` value and the source it was resolved from."""
type EffectiveTheme {
source: PreferenceSource!
value: Theme
}
```

`value` is null when nothing is stored at any layer; `source` is then `DEFAULT` and the client
substitutes the deployment default from the config payload.

## Changed types

```diff
type EffectivePreferencesType {
date_format: EffectiveDateFormat!
timezone: EffectiveTimezone!
+ theme: EffectiveTheme!
}

type RawPreferencesType {
date_format: DateFormat
timezone: String
+ theme: Theme
}
```

`EffectivePreferencesType.theme` is non-null (the wrapper always exists); the `value` inside it is
nullable. That is the existing convention — the wrapper reports a source even when there is no value.

## Changed mutation

```diff
-InfrahubSetPreferences(date_format: DateFormat, scope: PreferenceWriteScope!, timezone: String): InfrahubSetPreferences
+InfrahubSetPreferences(date_format: DateFormat, scope: PreferenceWriteScope!, theme: Theme, timezone: String): InfrahubSetPreferences
```

The payload gains a matching `theme: Theme` output field.

### ⚠ Three-state argument semantics

`InfrahubSetPreferences` distinguishes three cases via the `_UNSET` sentinel in
`backend/infrahub/graphql/mutations/preferences.py`. `theme` must honour all three, and a naive
`theme: Theme | None = None` parameter collapses the first two and makes clearing impossible:

| Client sends | Meaning | Stored |
|---|---|---|
| argument omitted | leave untouched | unchanged |
| `theme: null` | clear the override at this scope | `None` |
| `theme: DARK` | set the override | `Theme.DARK` |

Mirror the existing handling exactly:

```python
if theme is not _UNSET:
preference.theme = None if theme is None else ThemeEnum(theme)
```

## Behavioural contract

| Given | When | Then |
|---|---|---|
| No preference at any layer | `InfrahubEffectivePreferences` queried | `theme.value = null`, `theme.source = DEFAULT` |
| Global set to `DARK`, no user value | queried | `theme.value = DARK`, `theme.source = GLOBAL` |
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
| Global `DARK`, user `LIGHT` | queried | `theme.value = LIGHT`, `theme.source = USER` |
| User `LIGHT` | mutation with `theme: null`, scope `USER` | user override cleared; next query resolves to global or default |
| Any state | mutation omitting `theme` | `theme` unchanged; other supplied fields still written |
| Caller lacks global-write permission | mutation with scope `GLOBAL` | rejected by the existing permission check; no new permission introduced |
| Stored value not a `Theme` member | read from database | rejected at construction, as `date_format` already behaves |

## Out of scope for this contract

- The deployment default — it is not a preference and is not served over GraphQL. See
[rest-config.md](./rest-config.md).
- Stage-2 resolution of `SYSTEM` to a concrete palette. The server returns the stored choice; only
the client can observe the operating system's appearance.
Loading
Loading