Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 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
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ repos:
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
exclude: ^(schema/schema\.graphql|schema/openapi\.json)$
# Generator output, committed byte-exact. These generators emit no trailing newline, so
# appending one here would guarantee a mismatch against what CI regenerates.
exclude: ^(schema/schema\.graphql|schema/openapi\.json|docs/docs/reference/configuration\.mdx)$

- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
Expand Down
12 changes: 12 additions & 0 deletions backend/infrahub/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,18 @@ class AnalyticsSettings(BaseSettings):
class ExperimentalFeaturesSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="INFRAHUB_EXPERIMENTAL_")
graphql_enums: bool = False
# TEMPORARY — REVERT TO default=False BEFORE THIS LEAVES DRAFT (INFP-46).
# Defaulted on so the `cd/preview` ephemeral environment serves dark without a deployment-side
# env var, which is the only other lever and lives outside this repository. Every other guard
# still holds: the flag exists, an operator setting it to false wins, and the frontend renders
# light the moment it does. Reverting is one line here plus `invoke release.gen-config-env`,
# `invoke docs.generate`, `invoke schema.generate-jsonschema` and, in `frontend/app`,
# `pnpm codegen:openapi` — four generated artifacts record this default, and the frontend one
# is downstream of the OpenAPI schema rather than of this file, so it is the easy one to miss.
dark_theme: bool = Field(

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

This PR adds an externally visible config and /api/config schema field despite claiming no API/schema or config/env changes. Update the PR scope/impact documentation to mention dark_theme and INFRAHUB_EXPERIMENTAL_DARK_THEME.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/config.py, line 993:

<comment>This PR adds an externally visible config and `/api/config` schema field despite claiming no API/schema or config/env changes. Update the PR scope/impact documentation to mention `dark_theme` and `INFRAHUB_EXPERIMENTAL_DARK_THEME`.</comment>

<file context>
@@ -990,6 +990,10 @@ class AnalyticsSettings(BaseSettings):
 class ExperimentalFeaturesSettings(BaseSettings):
     model_config = SettingsConfigDict(env_prefix="INFRAHUB_EXPERIMENTAL_")
     graphql_enums: bool = False
+    dark_theme: bool = Field(
+        default=False,
+        description="Offer the dark theme in the web interface. Alpha: some surfaces still render incorrectly.",
</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.

description updated, it now lists the config field and the compose passthrough under impact

default=True,
description="Offer the dark theme in the web interface. Alpha: some surfaces still render incorrectly.",
)
value_db_index: bool = Field(
default=False,
deprecated="This setting has no effect and will be removed in a future version.",
Expand Down
1 change: 1 addition & 0 deletions changelog/+dark-theme.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added an experimental dark theme: development deployments default to it, and a switch in the account menu (marked alpha) toggles between light and dark, gated by the INFRAHUB_EXPERIMENTAL_DARK_THEME setting

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 flagged.

This release-note fragment omits the linked issue ID; rename it to infp-46-dark-theme.added.md to follow the required changelog naming convention.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At changelog/+dark-theme.added.md, line 1:

<comment>This release-note fragment omits the linked issue ID; rename it to `infp-46-dark-theme.added.md` to follow the required changelog naming convention.</comment>

<file context>
@@ -0,0 +1 @@
+Added an experimental dark theme: development deployments default to it, and a switch in the account menu (marked alpha) toggles between light and dark, gated by the INFRAHUB_EXPERIMENTAL_DARK_THEME setting
</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.

the leading + is towncrier's orphan_prefix, set in pyproject.toml, and it is the supported way to mark a fragment with no linked issue. renaming to infp-46-dark-theme.added.md would drop that marker, and towncrier would then read infp-46 as an issue number and render a dead link through issue_format, which builds /issues/infp-46. most fragments in changelog/ use the + form.

15 changes: 14 additions & 1 deletion dev/guidelines/frontend/styling.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Styling Guidelines

> Part of: `dev/guidelines/frontend/`
> Part of: `dev/guidelines/frontend/` | Related: [Theming](../../knowledge/frontend/theming.md)

## Layout Components

Expand Down Expand Up @@ -79,5 +79,18 @@ 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 |
| `text-indigo-500`, `text-indigo-700` for an open or active state | `text-active`, `bg-active/10` |
| `<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.

Contrast is the other reason. A mid-ramp shade that reads well on one background rarely clears WCAG AA on its opposite: `text-indigo-500` measured 3.7:1 on the light sidebar and 4.3:1 on the dark one, failing the 4.5:1 threshold in both. Each theme needs its own end of the ramp, which is exactly what a token holds and a literal cannot.
125 changes: 125 additions & 0 deletions dev/knowledge/frontend/theming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Theming

> Part of: `dev/knowledge/frontend/` | Related: [Styling Guidelines](../../guidelines/frontend/styling.md)

How the light and dark themes work, and how to change them safely. The short version: every colour
the app paints should resolve through a semantic token defined once per theme in a single file, and
dark mode is nothing more than a `dark` class on the document element swapping those definitions.

## Where colours live

`frontend/packages/ui/src/styles/theme.css` is the single source of truth. It has three parts, and
a colour change touches one, two, or three of them depending on the change:

| Block | What it holds |
|---|---|
| `:root { … }` | The light palette: one custom property per semantic token, plus `color-scheme: light` |
| `.dark { … }` | The dark palette: the **same property names** with dark values, plus `color-scheme: dark` |
| `@theme inline { … }` | The Tailwind bridge: `--color-<name>: var(--<name>)` lines that turn each token into utilities (`bg-<name>`, `text-<name>`, `ring-<name>`, …) |

### Change a colour in dark only

Edit its value inside the `.dark` block. Nothing else — no component changes, no `dark:` variants,
no light-theme risk, because the light value in `:root` is untouched.

### Change a colour in both themes

Edit the token's value in `:root` and in `.dark`. Every call site follows.

### Add a new token

Three edits in `theme.css`: a light value in `:root`, a dark value in `.dark`, and a
`--color-<name>: var(--<name>);` line in `@theme inline`. Then use `bg-<name>` / `text-<name>`
etc. in components. Name the token for its **role** (`--active`, `--danger-surface`, `--content`),
never its colour — a token called `--indigo` cannot honestly hold anything else.

Paired tokens follow the `X` / `X-surface` convention (`--danger` / `--danger-surface`,
`--active` / `--active-surface`): the bare name is the foreground/stroke, `-surface` is the tinted
background behind it.

## How dark mode switches on

The `dark` class on `document.documentElement` is the only switch. The primitives live in the
design system (`frontend/packages/ui/src/theme/`), so anything built on `@infrahub/ui` can read and
offer the theme; the application owns only the *policy* that decides it. Three things manage the
class:

1. **The pre-paint script** in `frontend/app/index.html` — a blocking inline script in `<head>`
that applies the class before the first frame, from the `infrahub.theme.resolved` localStorage
mirror. It is deliberately outside the module graph (it must run before any bundle loads), so
the storage key is duplicated there verbatim — renaming the key means changing both files in
the same commit.
2. **`ThemeProvider`** (`frontend/app/src/entities/config/ui/theme-provider.tsx`) — the policy.
Decides the real theme once config arrives: the `dark_theme` experimental flag gates whether
dark is offered at all, `infrahub.theme.choice` holds this browser's explicit choice, and the
resolved outcome is applied to the class and mirrored back to storage (`applyTheme` and the
storage helpers come from `@infrahub/ui`). It fills the design system's `ThemeContext`, which is
what makes `ThemeSwitchMenuItem` — the ready-made switch a menu can drop in — render and work.
An absent flag (backend predates it) counts as enabled under a Vite dev server only — see
`entities/config/domain/rules/can-offer-dark-theme.ts`.
3. **`useResolvedTheme`** (from `@infrahub/ui`) — how components *read* the current theme: a
`useSyncExternalStore` subscription to the class via MutationObserver. Components never read
storage or config for this; the document element is the single source of truth.

The deployment gate is `INFRAHUB_EXPERIMENTAL_DARK_THEME`, passed through in
`development/docker-compose.yml` only (default `true` there). The root compose file deliberately
has no passthrough while the theme is alpha.

## Content that carries its own colours

Three renderers bake colours into their output and cannot be themed by CSS tokens:

- **Mermaid diagrams** — themed through `mermaid.initialize({ theme })`, called from a small rehype
plugin sequenced before the rendering plugin
(`shared/components/editor/markdown/markdown-with-mermaid.tsx`). The rendering plugin's own
`mermaidConfig` option is silently ignored by its browser build; its documentation says to call
`initialize` manually, and the browser build renders against that same global config. Two traps
worth knowing: the `mermaid` version range must stay compatible with the one `mermaid-isomorphic`
declares (two instances in the tree would mean configuring the wrong one), and the call must live
*inside* the pipeline — a render-phase call is dropped by the React Compiler, and an effect races
the child's async processing. A diagram's own `%%{init}%%` directive still wins, by mermaid's own
precedence.
- **GraphiQL** — has its own theme; the sandbox page passes the app's resolved theme through
`forcedTheme` so it can never disagree with the app around it.
- **Schema-defined colours** (role badges, kind palettes, user-picked hex values) — data, not
style. Rendered as-is in both themes; out of scope for tokens.

## When a `dark:` variant is acceptable

Almost never — a fixed palette class (`bg-white`, `bg-gray-50`) is a bug even when it *looks* fine
in light, and pairing it with a `dark:` override duplicates per call site what a token defines
once. The two legitimate exceptions, both from
[Styling Guidelines](../../guidelines/frontend/styling.md):

- No token can express the difference — swapping assets, dark-only effects (backdrop blur).
- Categorical ramps where the hue carries no meaning (the sidebar avatar colours): there is no
semantic name to give a token, and the ramp has a single definition site, so the duplication a
token prevents cannot arise.

## Verifying a colour change

- **Contrast**: WCAG AA needs 4.5:1 for normal text, 3:1 for large text and UI parts. Measure
against the surface the element *actually sits on*, compositing translucent layers — a mid-ramp
shade that passes on one theme's background usually fails on the other's (that is why `--active`
holds `indigo-700` in light but `indigo-400` in dark).
- **Probing gotcha**: Tailwind only generates classes that appear in source. A class assembled
dynamically in a devtools probe (`bg-${hue}-400/15`) silently resolves to nothing and reads as
transparent — probe with the exact class strings the component ships.
- **Both themes, always**: toggle via the account-menu switch, or
`document.documentElement.classList.toggle("dark")` in the console. The light theme is the
shipped default; a dark fix must not move light pixels unless that is the intent.

## Test coverage

| Concern | Test |
|---|---|
| Flag/choice resolution, retention across flag flips | `entities/config/ui/theme-provider.test.tsx`, `entities/config/domain/rules/can-offer-dark-theme.test.ts` |
| Reading the theme from the class | `shared/hooks/use-resolved-theme.test.tsx` |
| The switch in the account menu, alpha tag, gating | `entities/user-profile/ui/account-menu.test.tsx` |
| Mermaid renders in the active theme, reacts to a flip, author directive wins | `shared/components/editor/markdown/markdown-with-mermaid.test.tsx` (asserts the colours baked into the real SVG) |
| First-paint, persistence, flag-off journeys | `tests/e2e/theme.spec.ts` (Playwright, needs a stack) |
| Docs screenshots stay light | pinned in `tests/utils.ts` |

The design-system package has no test runner, so tests for its theme primitives are hosted in the
application suite. The pre-paint script itself is reachable only by the e2e suite — it sits outside
the module graph, so no vitest test can import it.
Loading
Loading