From 774059dfdda4953e52919ff4c25f08450d38c5ce Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 11:11:44 +0300 Subject: [PATCH 01/47] docs(specs): add dark theme completion specification Covers the seven follow-ups recorded on handover from the dark-theme series: theme preference, non-production dark default, GraphiQL and Mermaid theme binding, token migration for legacy pages, data viewer tone, and schema visualizer dark support. Co-Authored-By: Claude Opus 5 --- .../checklists/requirements.md | 50 +++ .../infp-46-dark-theme-completion/spec.md | 371 ++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 dev/specs/infp-46-dark-theme-completion/checklists/requirements.md create mode 100644 dev/specs/infp-46-dark-theme-completion/spec.md diff --git a/dev/specs/infp-46-dark-theme-completion/checklists/requirements.md b/dev/specs/infp-46-dark-theme-completion/checklists/requirements.md new file mode 100644 index 00000000000..082e97d9750 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/checklists/requirements.md @@ -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. diff --git a/dev/specs/infp-46-dark-theme-completion/spec.md b/dev/specs/infp-46-dark-theme-completion/spec.md new file mode 100644 index 00000000000..1cb34865009 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/spec.md @@ -0,0 +1,371 @@ +# Feature Specification: Dark Theme Completion + +**Feature Branch**: `dark-theme-completion-infp-46` + +**Ticket**: [INFP-46](https://opsmill.atlassian.net/browse/INFP-46) + +**Created**: 2026-08-17 + +**Status**: Draft + +**Input**: Follow-up work inherited from the dark-theme series (PRs #10247 → #10284). Seven known +limitations were recorded by the series author; this spec covers all seven. + +## Context + +A series of eleven merged pull requests tokenized the design system and swept most application +surfaces onto theme-aware CSS custom properties. A twelfth, [#10284](https://github.com/opsmill/infrahub/pull/10284) +("Adapt remaining app to dark theme", 151 files), is open and covers the remaining app surfaces. + +The result is a dark palette that exists but that **no user can reach**. Dark mode is activated only +by manually adding a `.dark` class at the top of the cascade, via the development-only +`@custom-variant dark` declaration in the shared theme stylesheet — which carries an explicit +`TODO: DELETE` marker. The series author drove it with a local, uncommitted debug button. + +This feature closes that gap and clears the seven limitations the author recorded on handover. + +### Relationship to PR #10284 + +Several items below (notably User Story 6) describe debt that #10284 *introduces* — hastily +dark-themed legacy pages carrying hardcoded variants rather than tokens. This spec assumes #10284 +lands. Its failing end-to-end checks are explicitly **out of scope** for this feature and are not +addressed here. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Choose a theme (Priority: P1) + +A signed-in user opens their preferences, sees a theme setting alongside the existing date-format and +timezone settings, and picks between light, dark, and matching their operating system. The choice +applies immediately, survives a reload, and follows them to another browser or machine because it is +stored with their account rather than in one browser. + +Because the dark palette is known to still contain visual defects, the dark choice is presented as +explicitly pre-release so a user opting in knows what they are accepting. + +**Why this priority**: This is the keystone. Every other story either binds a surface to "the +selected theme" or adjusts how that theme looks — none of them are meaningful until a selected theme +exists and is readable by the application. + +**Independent Test**: Sign in, change the theme setting, observe the application repaint without a +reload, reload the page and observe the choice persisted, then sign in on a second browser and +observe the same choice. + +**Acceptance Scenarios**: + +1. **Given** a signed-in user whose theme has never been set, **When** they open their preferences, + **Then** the theme setting shows the deployment's default as the effective value and indicates + that it comes from a default rather than from their own choice. +2. **Given** a user viewing the theme setting, **When** they look at the dark option, **Then** it + carries a visible pre-release marker distinguishing it from the light option. +3. **Given** a user on the light theme, **When** they select dark, **Then** the application switches + to the dark palette without a page reload. +4. **Given** a user who has selected dark, **When** they reload the page, **Then** the application + paints in dark from the first frame, with no visible flash of the light theme. +5. **Given** a user who has selected dark in one browser, **When** they sign in from a different + browser, **Then** the application is dark there too. +6. **Given** a user who has selected "match system", **When** their operating system switches from + light to dark while the page is open, **Then** the application follows without a reload. +7. **Given** an administrator setting an organisation-wide theme, **When** a user who has made no + personal choice loads the application, **Then** they see the organisation-wide theme; **and when** + a user who has made a personal choice loads it, **Then** their personal choice wins. + +--- + +### User Story 2 - Non-production deployments default to dark (Priority: P1) + +The team runs non-production builds of Infrahub day to day. Those deployments default to the dark +theme so that the team lives in it continuously and surfaces the remaining visual defects through +ordinary use, without every engineer having to opt in individually. Production builds continue to +default to light, because the dark theme is pre-release. + +**Why this priority**: This is the stated near-term goal of the whole effort — dogfooding dark for +the coming weeks. It is what converts the setting from a feature into a feedback loop, and it is +cheap once User Story 1 exists. + +**Independent Test**: Load a non-production deployment as a user with no theme preference set and +observe dark; load a production build the same way and observe light; in both, set a personal +preference and observe it override the default. + +**Acceptance Scenarios**: + +1. **Given** a deployment running a non-production build, **When** a user with no theme preference + loads the application, **Then** it paints in dark. +2. **Given** a deployment running a production build, **When** a user with no theme preference loads + the application, **Then** it paints in light. +3. **Given** a non-production deployment defaulting to dark, **When** a user explicitly selects + light, **Then** their choice is honoured and persists. +4. **Given** any deployment, **When** an operator explicitly configures the default theme, **Then** + that configuration overrides the build-derived default. + +--- + +### User Story 3 - The GraphQL sandbox follows the theme (Priority: P2) + +A user working in the GraphQL sandbox on a dark application sees the sandbox in dark too, rather than +a bright panel embedded in a dark page. + +**Why this priority**: The sandbox is a full-page surface that is currently pinned to light +regardless of the application theme, making it one of the two most jarring mismatches. It already +ships a dark theme of its own, so the work is binding rather than building. + +**Independent Test**: With the application in dark, navigate to the GraphQL sandbox and confirm it +renders dark; switch the theme and confirm the sandbox follows. + +**Acceptance Scenarios**: + +1. **Given** the application is in dark, **When** the user opens the GraphQL sandbox, **Then** the + sandbox renders using its dark theme. +2. **Given** the user is in the GraphQL sandbox, **When** they change the application theme, **Then** + the sandbox switches to match. +3. **Given** the application is in light, **When** the user opens the sandbox, **Then** it renders + exactly as it does today. + +--- + +### User Story 4 - Mermaid diagrams follow the theme (Priority: P2) + +A user reading a document containing a Mermaid diagram on a dark application sees the diagram +rendered for a dark background, with legible text and no bright panel behind it. + +**Why this priority**: Same class of mismatch as the sandbox, and diagrams appear inside ordinary +content where a bright block is especially disruptive. Currently only partially dark. + +**Independent Test**: With the application in dark, view content containing a Mermaid diagram and +confirm the diagram and its container are dark and legible; switch the theme and confirm the diagram +re-renders to match. + +**Acceptance Scenarios**: + +1. **Given** the application is in dark, **When** a Mermaid diagram renders, **Then** the diagram + uses a dark-appropriate palette and its container background matches the surrounding surface. +2. **Given** a rendered Mermaid diagram, **When** the user changes the application theme, **Then** + the diagram reflects the new theme. +3. **Given** a Mermaid diagram that fails to parse, **When** it renders its error state, **Then** + that error state is legible in both themes. + +--- + +### User Story 5 - Application surfaces use theme tokens, not hardcoded colors (Priority: P2) + +A user moving between pages on a dark application sees one coherent dark theme, rather than pockets +of near-black that were bolted on page by page. Pages carried over from the legacy structure — the +proposed-changes flow, diff and check views, path traversal — look like the rest of the application. + +**Why this priority**: This is the largest correctness debt and the most visible source of "almost +dark" defects. Hardcoded per-page variants also mean every future palette change has to be repeated +by hand in each of them, so leaving them in place taxes all later work. + +**Independent Test**: With the application in dark, walk the proposed-changes flow, a diff view, the +checks view and path traversal, and confirm each uses the same surfaces, borders and text colors as +the rest of the application. Separately, confirm no application source file specifies theme-specific +colors directly. + +**Acceptance Scenarios**: + +1. **Given** the application is in dark, **When** the user walks the legacy pages listed above, + **Then** every surface, border and text color matches the shared palette. +2. **Given** the application source, **When** it is inspected for per-theme color overrides or raw + color literals in application components, **Then** none remain. +3. **Given** the application is in light, **When** the same pages are compared against their previous + appearance, **Then** they are visually unchanged. + +--- + +### User Story 6 - The data viewer matches the theme's tone (Priority: P3) + +A user viewing file, artifact or object data sees a viewer whose greys belong to the same family as +the rest of the dark theme, rather than a colder panel that reads as a foreign element. + +**Why this priority**: A genuine inconsistency, but a tonal one — the viewer is already dark, just +the wrong dark. Lower user impact than surfaces that are still bright. + +**Independent Test**: With the application in dark, open the data viewer beside another dark surface +and confirm the greys belong to the same family. + +**Acceptance Scenarios**: + +1. **Given** the application is in dark, **When** the data viewer renders, **Then** its background, + border and text colors come from the shared palette. +2. **Given** the data viewer renders any of its content types, **When** each is displayed, **Then** + none of them shows a fixed light background in dark mode. + +--- + +### User Story 7 - The schema visualizer supports dark (Priority: P3) + +A user exploring the schema visualizer on a dark application sees a dark visualizer, consistent with +the application that embeds it. + +**Why this priority**: Real, but the longest lead time and the lowest coupling — the visualizer lives +in a separate repository and must be released there before this application can consume it. Deferring +it does not block any other story. + +**Independent Test**: With the application in dark, open the schema visualizer and confirm its canvas, +nodes, edges and controls are dark and legible. + +**Acceptance Scenarios**: + +1. **Given** the application is in dark, **When** the user opens the schema visualizer, **Then** its + canvas, nodes, edges, labels and controls render in dark and remain legible. +2. **Given** the application is in light, **When** the user opens the visualizer, **Then** it is + visually unchanged from today. +3. **Given** the visualizer's dark support is released upstream, **When** this application adopts the + release, **Then** the adoption is a version change here and carries no visualizer styling code in + this repository. + +--- + +### Edge Cases + +- **Before sign-in.** The login page has no user to read a preference from. It uses the deployment + default and must not flash. +- **Preference unavailable.** If the stored preference cannot be read (network failure, backend + error), the application still paints a coherent theme — the last known choice, or the deployment + default — and never lands half-styled. +- **First paint.** Theme selection is account-backed, so a naive implementation paints light and then + corrects itself. The visible flash this produces is a defect, not an acceptable cost. +- **Multiple tabs.** A user with the application open twice changes the theme in one tab. The other + tab must not end up in a broken mixed state; it may update live or on next navigation. +- **System theme changes.** A user on "match system" whose operating system switches appearance while + the page is open. +- **Content that carries its own colors.** Diagrams, syntax highlighting, status and severity colors, + and user-supplied content must stay meaningful in both themes — a severity color must remain + distinguishable, not merely dark. +- **Existing automated tests.** Tests that assert specific colors, or that screenshot the interface, + may be sensitive to the default theme changing on non-production builds. +- **Print and export.** Out of scope; printing behaviour is unchanged. + +## Requirements *(mandatory)* + +### Functional Requirements + +**Theme selection and persistence** + +- **FR-001**: The system MUST offer a theme preference with three choices: light, dark, and match the + operating system. +- **FR-002**: The system MUST persist a user's theme choice against their account, so it applies on + any browser or machine where they sign in. +- **FR-003**: The system MUST support an organisation-wide theme default that applies to users who + have made no personal choice, and MUST let a personal choice override it. +- **FR-004**: The system MUST report which layer an effective theme came from — the user's own + choice, the organisation default, or the built-in default — consistent with how existing + preferences report their source. +- **FR-005**: Users MUST be able to change the theme and see it applied without reloading the page. +- **FR-006**: The system MUST apply the correct theme on the first painted frame, with no visible + flash of the other theme. +- **FR-007**: When "match system" is selected, the system MUST follow changes to the operating + system's appearance while the page is open. +- **FR-008**: The system MUST mark the dark choice as pre-release in the interface, so users + understand they are opting into something that may still contain visual defects. Because "match + system" can resolve to dark, its description MUST make that consequence clear. +- **FR-009**: The system MUST render a coherent theme when no preference can be retrieved, falling + back to the last known choice and then to the deployment default. + +**Deployment defaults** + +- **FR-010**: Deployments running a non-production build MUST default to dark for users with no + personal choice. +- **FR-011**: Deployments running a production build MUST default to light. +- **FR-012**: Operators MUST be able to override the build-derived default with explicit + configuration. +- **FR-013**: A deployment default MUST NOT overwrite or reset any user's stored personal choice. + +**Embedded and third-party surfaces** + +- **FR-014**: The GraphQL sandbox MUST render in the application's active theme, and MUST follow + changes to it. It MUST NOT be pinned to a fixed theme. +- **FR-015**: Mermaid diagrams MUST render using a palette appropriate to the active theme, including + their container background and their parse-error state, and MUST reflect a theme change. +- **FR-016**: The schema visualizer MUST support both themes and follow the embedding application's + active theme. Its styling MUST be implemented in its own repository and consumed here as a released + version. + +**Token discipline** + +- **FR-017**: Application components MUST express color through shared theme tokens. Per-theme + overrides and raw color literals MUST NOT remain in application components. +- **FR-018**: The data viewer MUST draw its surfaces, borders and text from the shared palette, and + MUST NOT present a fixed light background in any of its content types. +- **FR-019**: The development-only mechanism that currently makes the dark palette reachable MUST be + removed once the theme preference supersedes it. + +**Preservation** + +- **FR-020**: The light theme MUST remain visually unchanged by this feature. +- **FR-021**: Colors that carry meaning — status, severity, diff conflict, danger — MUST remain + mutually distinguishable in both themes. + +### Key Entities + +- **Theme preference**: A user's chosen appearance. One of light, dark, or match-system. Stored per + account and, separately, once for the organisation. Absent by default; absence means "fall back". +- **Effective theme**: The appearance actually applied for a given user at a given moment. Resolved + from the user's choice, then the organisation default, then the deployment default; and if the + resolved choice is match-system, further resolved against the operating system's current + appearance. +- **Deployment default theme**: The appearance applied to users who have expressed no choice. + Derived from whether the running build is a production release, and overridable by operator + configuration. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A user can change the theme and see the whole application — including the GraphQL + sandbox, Mermaid diagrams and the schema visualizer — reflect the change, without reloading. +- **SC-002**: On reload, the correct theme is present in the first painted frame; no flash of the + opposite theme is observable. +- **SC-003**: A theme chosen on one machine is in effect when the same user signs in on another. +- **SC-004**: Zero application components specify per-theme color overrides or raw color literals; + this is verifiable by inspection of the source and holds as a standing property, not a one-time + cleanup. +- **SC-005**: The light theme is unchanged: a comparison of light-theme rendering before and after + this feature shows no visual differences. +- **SC-006**: Every page reachable from the main navigation renders with no bright-on-dark surface + when dark is active. +- **SC-007**: Non-production deployments present dark to a user with no stored preference, and + production deployments present light, without either altering stored preferences. +- **SC-008**: The team can run a non-production deployment in dark continuously for the dogfooding + period without needing per-engineer setup. + +## Assumptions + +These were decided during specification rather than left open. Each is a judgement call that a +reviewer may overturn. + +- **Production defaults to light.** Dark is pre-release. Defaulting production users into it on the + strength of their operating system setting would ship a known-defective appearance to people who + never opted in. "Match system" is therefore available but never the default. +- **Three choices, not two.** Match-system is included rather than deferred: it is the conventional + expectation for a theme setting, and adding it later would change the meaning of an already-stored + value. +- **The existing preference machinery is extended, not replaced.** Theme joins date-format and + timezone in the established two-layer user/organisation preference model, and inherits its + resolution and source-reporting semantics. +- **"Non-production build" is derived from the running version**, not from a separate deployment + flag, so that no additional configuration is required for the common case. Explicit configuration + remains available as an override. The precise derivation is a design decision for the plan. +- **PR #10284 lands before this work merges.** Its surfaces are the input to User Story 5. Its + failing end-to-end checks are out of scope. +- **The schema visualizer is a separate deliverable.** Upstream release precedes adoption here; the + adoption in this repository is a dependency version change with no styling code. +- **Cross-tab synchronisation is not required.** A second open tab may update live or on next + navigation, provided it never renders a mixed state. +- **Print, export and screenshot output are unchanged.** + +## Dependencies + +- PR [#10284](https://github.com/opsmill/infrahub/pull/10284) merged. +- The existing account-backed preference system (user and organisation layers, effective resolution, + source reporting). +- The existing build-version information already exposed by the backend. +- The `opsmill/infrahub-schema-visualizer` repository, for User Story 7 only. + +## Out of Scope + +- The failing end-to-end checks on PR #10284. +- Any change to the light theme's appearance. +- Additional themes beyond light and dark (high contrast, custom palettes, per-branch theming). +- Theming of printed or exported output. +- Restyling third-party surfaces beyond binding them to the active theme. From 14fbf998c62d2421c81a62661064030c4a51f83a Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 11:19:23 +0300 Subject: [PATCH 02/47] docs(specs): add dark theme completion implementation plan Resolves the two load-bearing design questions: the non-production signal is PEP 440 pre-release status on the running version, published as a resolved value on the unauthenticated config payload; and the first paint is owned by an inline classification script reading a localStorage mirror rather than by React. Co-Authored-By: Claude Opus 5 --- .../contracts/graphql-preferences.md | 109 +++++++ .../contracts/rest-config.md | 109 +++++++ .../data-model.md | 144 +++++++++ .../infp-46-dark-theme-completion/plan.md | 237 ++++++++++++++ .../quickstart.md | 169 ++++++++++ .../infp-46-dark-theme-completion/research.md | 288 ++++++++++++++++++ 6 files changed, 1056 insertions(+) create mode 100644 dev/specs/infp-46-dark-theme-completion/contracts/graphql-preferences.md create mode 100644 dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md create mode 100644 dev/specs/infp-46-dark-theme-completion/data-model.md create mode 100644 dev/specs/infp-46-dark-theme-completion/plan.md create mode 100644 dev/specs/infp-46-dark-theme-completion/quickstart.md create mode 100644 dev/specs/infp-46-dark-theme-completion/research.md diff --git a/dev/specs/infp-46-dark-theme-completion/contracts/graphql-preferences.md b/dev/specs/infp-46-dark-theme-completion/contracts/graphql-preferences.md new file mode 100644 index 00000000000..99a6a599656 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/contracts/graphql-preferences.md @@ -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` | +| 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. diff --git a/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md b/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md new file mode 100644 index 00000000000..e8fc2c42e17 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md @@ -0,0 +1,109 @@ +# Contract: deployment default theme on the config payload + +**Feature**: [spec.md](../spec.md) | **Covers**: FR-010, FR-011, FR-012 + +The deployment default is not a preference — it is a property of the running deployment, needed +before a user exists. It therefore travels on the unauthenticated config payload, not over GraphQL. + +⚠ `schema/openapi.json` and `frontend/app/src/shared/api/rest/types.generated.ts` are generated. +Regenerate with `uv run invoke schema.generate-jsonschema` and `cd frontend/app && pnpm codegen`, and +commit; CI validates them. + +## Why this endpoint + +`backend/infrahub/api/internal.py` exposes two endpoints with different auth postures: + +| Endpoint | Auth | Carries | +|---|---|---| +| `GET /api/config` | **none** | `main`, `logging`, `analytics`, `experimental_features`, `sso`, `ldap`, `installation_type`, `policy` | +| `GET /api/info` | `Depends(get_current_user)` | `deployment_id`, `version` | + +The login page must paint a theme before there is a session, so the value must come from the +unauthenticated endpoint. The version lives only on the authenticated one. + +⚠ **The frontend must not derive this itself.** It cannot read the version pre-login, and duplicating +PEP 440 parsing in TypeScript would drift from the backend's answer. The backend publishes the +resolved result. + +⚠ **Only the resolved value is published, never the version.** `light` or `dark` tells an anonymous +caller nothing about the build; putting the version string on an unauthenticated endpoint would newly +expose it and is not required. + +## Payload delta + +```diff + class ConfigAPI(BaseModel): + main: MainSettings + logging: LoggingSettings + analytics: AnalyticsSettings + experimental_features: ExperimentalFeaturesSettings + sso: config.SSOInfo + ldap: config.LDAPInfo + installation_type: str + policy: config.PolicySettings ++ default_theme: Literal["light", "dark"] +``` + +Always concrete — never `system`. A server cannot observe an operating system's appearance, and a +`system` default would leave the client with nothing to fall back to before its mirror exists. + +## Resolution + +```text +default_theme + = operator override, when explicitly configured + | "dark" when Version(infrahub.__version__).is_prerelease + | "light" otherwise +``` + +Verified shapes (see [research.md](../research.md) §R1): + +| Version | `is_prerelease` | `default_theme` | +|---|---|---| +| `1.11.0b2.dev134+geb5acb009` | `True` | `dark` | +| `1.12.0.dev5+g1a2b3c` | `True` | `dark` | +| `1.11.1rc1` | `True` | `dark` | +| `1.11.0` | `False` | `light` | + +⚠ `installation_type` is **not** the signal. It is `"community"` — community versus enterprise, not +production versus non-production. Its presence on this same payload makes it an easy false lead. + +## Operator override + +A new setting under the existing `INFRAHUB_EXPERIMENTAL_` family, tri-state so that "not configured" +stays distinguishable from "explicitly light": + +```python +class ExperimentalFeaturesSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="INFRAHUB_EXPERIMENTAL_") + graphql_enums: bool = False + value_db_index: bool = Field(default=False, deprecated="…") + default_theme: Literal["light", "dark"] | None = None # None → derive from version +``` + +⚠ A plain `bool` would be wrong: `False` could not be told apart from unset, so an operator could +never force light on a pre-release build, and FR-012 would be unmet in one direction. + +## Behavioural contract + +| Given | When | Then | +|---|---|---| +| Pre-release build, no override | `GET /api/config` | `default_theme = "dark"` | +| Release build, no override | `GET /api/config` | `default_theme = "light"` | +| Pre-release build, override `light` | `GET /api/config` | `default_theme = "light"` | +| Release build, override `dark` | `GET /api/config` | `default_theme = "dark"` | +| Any build | anonymous request | succeeds; no version information disclosed | +| User has a stored preference | any deployment default | the stored preference wins; the default is never written to storage (FR-013) | +| Deployment upgrades pre-release → release | users who chose a theme | unaffected — only un-chosen users' effective theme changes | + +## Consumer contract + +The client treats `default_theme` as the substitute for a `DEFAULT`-sourced effective preference: + +```text +choice = effective.theme.value ?? config.default_theme +resolved = choice == SYSTEM ? (prefers-color-scheme: dark ? dark : light) : choice +``` + +`resolved` is then mirrored to `localStorage` so the next load's inline script paints correctly from +the first frame. diff --git a/dev/specs/infp-46-dark-theme-completion/data-model.md b/dev/specs/infp-46-dark-theme-completion/data-model.md new file mode 100644 index 00000000000..c35b209c864 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/data-model.md @@ -0,0 +1,144 @@ +# Data Model: Dark Theme Completion + +**Feature**: [spec.md](./spec.md) | **Research**: [research.md](./research.md) | **Date**: 2026-08-17 + +Three entities from the spec, plus the resolution chain that connects them. Nothing here is a new +storage concept: the theme preference is a new field on an existing record, and the deployment +default is computed, never persisted. + +## Entities + +### Theme + +A closed set of appearance choices. Persisted as its member name, like the existing `DateFormat`. + +| Value | Meaning | +|---|---| +| `LIGHT` | Always the light palette | +| `DARK` | Always the dark palette. Pre-release (FR-008) | +| `SYSTEM` | Follow the operating system's current appearance | + +`SYSTEM` is stored as an explicit choice, not as absence. Absence means "inherit from the next layer +down" and is represented by `null`, exactly as `date_format` and `timezone` already do. Conflating +the two would make "follow my OS" indistinguishable from "I never chose", and an organisation +default could then never be overridden back to system-following. + +### Theme preference (a field, not a record) + +`theme` joins the existing `Preference` record rather than introducing storage of its own. + +```text +Preference (StandardNode) + owner_id : str # account id, or GLOBAL_OWNER_ID sentinel + date_format : Optional[DateFormat] # existing + timezone : Optional[str] # existing + theme : Optional[Theme] # NEW — null means "not set at this layer" +``` + +Constraints inherited from the existing record, both load-bearing: + +- **`Optional[Theme]`, never `Theme | None`.** `StandardNode.guess_field_type` requires the former; + this is documented in `models.py` and is not lifted by Python 3.14. +- **One row per owner**, with user rows keyed by account id and a single global row keyed by the + `GLOBAL_OWNER_ID` sentinel. Writes serialise per owner through `PREFERENCE_LOCK_NAMESPACE`. +- **Reads never create a row.** A missing row is "nothing set at this layer". + +Adding a nullable field to a `StandardNode` is additive: rows written before this change have no +`theme` property and read back as `None`, which is already a valid, meaningful state. No data +migration is expected — see the governance flag in [research.md](./research.md) §R5. + +### Deployment default theme + +Computed per deployment, never stored. Derived from the running build's PEP 440 pre-release status, +overridable by explicit operator configuration. + +```text +deployment_default_theme : LIGHT | DARK + = operator override, when configured + | DARK when Version(running_version).is_prerelease + | LIGHT otherwise +``` + +It is a *default*, not a value written anywhere: it never touches a stored preference (FR-013), so a +deployment that flips from pre-release to release changes what un-chosen users see and changes +nothing for users who chose. + +## Resolution chain + +Two distinct stages, deliberately separated. Conflating them is the mistake that makes GraphiQL and +the application disagree (see [research.md](./research.md) §R3). + +### Stage 1 — resolve the stored choice + +Server-side, identical in shape to the existing preferences: + +```text +effective.theme.value = user.theme ?? global.theme ?? null +effective.theme.source = USER | GLOBAL | DEFAULT +``` + +`source` reports which layer answered, so the interface can say "Your preference" versus "From the +organisation default" versus falling through — the convention `preference-fields.tsx` already +implements. + +When the chain yields `null` (source `DEFAULT`), the client substitutes the deployment default from +the config payload. + +### Stage 2 — resolve to a concrete palette + +Client-side, because only the client knows the operating system's appearance: + +```text +resolved : LIGHT | DARK + = LIGHT when choice is LIGHT + | DARK when choice is DARK + | (prefers-color-scheme: dark) ? DARK + : LIGHT when choice is SYSTEM +``` + +`resolved` is a strict two-value output. Every consumer — the document class, GraphiQL's +`forcedTheme`, Mermaid's `mermaidConfig.theme`, the schema visualizer — takes `resolved`, never the +raw choice. That is what guarantees they cannot drift from the application or from each other. + +Stage 2 re-runs when the operating system's appearance changes while the page is open (FR-007), which +is why it lives in the client and not in the resolution the server returns. + +## Client-side mirror + +A `localStorage` mirror of the resolution, existing solely to make the first paint correct (FR-006). + +| Key | Holds | Written when | +|---|---|---| +| choice | the stored choice, or the deployment default | the effective preference resolves | +| resolved | `light` or `dark` | stage 2 completes | + +Read synchronously by the inline classification script before first paint. It is a cache, never a +source of truth: the account-backed preference always wins on arrival, and a cleared mirror costs one +corrected repaint rather than a wrong theme. + +It doubles as the cross-tab channel — a second tab observing the `storage` event can reconcile, +satisfying the spec's "never render a mixed state" without committing to live cross-tab sync. + +## Relationships + +```text +Account ──owns──▶ Preference(owner_id = account id) ─┐ + ├─▶ effective choice ─▶ resolved ─▶ consumers +Organisation ───▶ Preference(owner_id = GLOBAL) ─┤ (document class, + │ GraphiQL, +Deployment ─────▶ default theme (computed) ─┘ Mermaid, + visualizer) +Operating system ────────────────────────────────────────▶ (consumed by stage 2 only) +``` + +## Validation rules + +- `theme` accepts only `Theme` members; unknown values are rejected at construction, including on + load from the database — the behaviour `date_format` already relies on by being enum-typed. +- Writing `null` clears the override at that layer and re-exposes the layer below; it is not an error + and is how a user returns to "Automatic (inherited)". +- Writing the global layer requires the same permission as the existing global preference writes; no + new permission is introduced. +- The deployment default is always concrete (`LIGHT` or `DARK`) and never `SYSTEM`: a server cannot + observe an operating system's appearance, and a `SYSTEM` default would leave the client with + nothing to fall back to before the mirror exists. diff --git a/dev/specs/infp-46-dark-theme-completion/plan.md b/dev/specs/infp-46-dark-theme-completion/plan.md new file mode 100644 index 00000000000..b8b670a79e8 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/plan.md @@ -0,0 +1,237 @@ +# Implementation Plan: Dark Theme Completion + +**Branch**: `dark-theme-completion-infp-46` | **Date**: 2026-08-17 | **Spec**: [spec.md](./spec.md) + +**Ticket**: [INFP-46](https://opsmill.atlassian.net/browse/INFP-46) + +**Input**: [spec.md](./spec.md), informed by [research.md](./research.md) + +## Summary + +The dark palette exists but is unreachable: it is activated only by a development-only +`@custom-variant` and a manually added class. This plan makes it reachable, binds every surface that +currently pins itself to light, retires the token debt, and turns non-production deployments dark by +default so the team dogfoods it. + +The technical spine is a **single resolution, computed once and handed down**. A stored choice +(`LIGHT`/`DARK`/`SYSTEM`) resolves server-side across the user and organisation layers; the client +resolves `SYSTEM` against the operating system to a strict `light`/`dark`; every consumer — the +document class, GraphiQL, Mermaid, the schema visualizer — takes that one resolved value. No +consumer runs its own `prefers-color-scheme` check, which is what makes them incapable of drifting +apart. + +Two decisions carry most of the risk and are settled in [research.md](./research.md): the +"non-production build" signal is PEP 440 pre-release status on the running version, published as a +resolved value on the unauthenticated config payload (§R1); and the first paint is owned by an inline +classification script reading a `localStorage` mirror, not by React (§R2). + +## Technical Context + +**Language/Version**: Python 3.14 (backend), TypeScript 5.9 (frontend) + +**Primary Dependencies**: FastAPI 0.131, Graphene, Pydantic 2.12; React 19.2, Vite 8.0, Tailwind CSS +4.2, `graphiql` 5.2.4, `rehype-mermaid`, `infrahub-schema-visualizer` (submodule) + +**Storage**: Neo4j — one additional nullable field on the existing `Preference` `StandardNode`. No +new records, no data migration expected. + +**Testing**: pytest 9.0 (backend unit), Vitest 4.1 (frontend unit), Playwright 1.60 and +pytest/testcontainers (end-to-end) + +**Target Platform**: Web application, evergreen browsers + +**Project Type**: Web — Python backend + React frontend, plus one external repository + +**Performance Goals**: Correct theme in the first painted frame on every load after a browser's first +visit. Theme switching repaints without a reload and without re-running the Mermaid pipeline on every +React render. + +**Constraints**: The light theme must be visually unchanged (FR-020). Semantic colors must stay +mutually distinguishable in both themes (FR-021). The login page must be themed before a session +exists. + +**Scale/Scope**: 3 backend layers (constants/model/GraphQL) + 1 config field; ~104 existing CSS +tokens reused, none added; ~20 application files carrying hardcoded variants; 1 external repository. + +## Constitution Check + +*GATE: evaluated before Phase 0 research and re-checked after the Phase 1 design below.* + +| Principle | Assessment | +|---|---| +| **I. Schema-Driven Integrity** | ⚠ **Gate — requires sign-off.** Generated artifacts change: `schema/schema.graphql`, `schema/openapi.json`, `frontend/app/src/shared/api/rest/types.generated.ts`. All are regenerated, never hand-edited, and committed. `AGENTS.md` lists GraphQL schema modifications and database schema changes as **Ask First**; see [Open governance points](#open-governance-points). | +| **II. Branch-Safe by Default** | ✅ Not applicable in substance. `Preference` is a `StandardNode` outside the branched graph, and a theme has no temporal or per-branch meaning. No branch-aware queries are introduced. | +| **III. Type Safety & Explicit Contracts** | ✅ `Theme` is a closed enum end to end — rejected at construction on read, typed through GraphQL, and a discriminated union on the client. ⚠ Two inherited constraints must be honoured: `Optional[Theme]` not `Theme \| None` (`StandardNode.guess_field_type`), and single-line enum descriptions (SDL printer stability). | +| **IV. Test Discipline** | ✅ Resolution logic (both stages), the pre-release derivation, and the three-state mutation argument are pure functions with table-driven unit tests. The version-derived default is tested by calling the resolver with version strings, never by faking a deployment. | +| **V. Query Performance & Efficiency** | ✅ One extra nullable property on a record already fetched. No new queries, no new round trips — `theme` rides the existing effective-preferences query and the existing config payload. | +| **VI. Security & Input Boundaries** | ✅ The config payload gains only a resolved `"light"`/`"dark"`; the version string is **not** newly exposed to anonymous callers. Global-scope writes reuse the existing permission check; no new permission is introduced. | +| **VII. Simplicity & Maintainability** | ✅ Extends the existing preference machinery rather than adding storage. Removes more than it adds: the `@custom-variant` escape hatch, ~20 files of hardcoded variants, and GraphiQL's now-redundant theme picker. | + +**Post-design re-check**: no violations introduced. [Complexity Tracking](#complexity-tracking) is +empty. + +## Project Structure + +### Documentation (this feature) + +```text +dev/specs/infp-46-dark-theme-completion/ +├── spec.md +├── research.md +├── data-model.md +├── quickstart.md +├── plan.md # this file +├── contracts/ +│ ├── graphql-preferences.md +│ └── rest-config.md +├── checklists/ +│ └── requirements.md +└── tasks.md # produced by the tasks phase +``` + +### Source code + +```text +backend/infrahub/ +├── core/preferences/ +│ ├── constants.py # + Theme enum +│ └── models.py # + Preference.theme: Optional[Theme] +├── graphql/ +│ ├── types/preferences.py # + Theme, EffectiveTheme; += EffectivePreferencesType, RawPreferencesType +│ ├── queries/preferences.py # resolve theme through the existing chain +│ └── mutations/preferences.py # + theme argument, _UNSET three-state handling +├── config.py # + ExperimentalFeaturesSettings.default_theme override +├── api/internal.py # + ConfigAPI.default_theme (resolved, unauthenticated) +└── core/preferences/theme.py # NEW — version → default theme, pure + +frontend/app/ +├── index.html # + inline pre-paint classification script +└── src/ + ├── entities/preferences/ + │ ├── domain/model/preference.ts # + theme + │ ├── domain/rules/theme.ts # NEW — stage-2 resolution, pure + │ ├── ui/theme-provider.tsx # NEW — applies class, mirrors, listens + │ ├── ui/preference-fields.tsx # + theme field with pre-release marker + │ └── ui/queries/*.ts # + theme in query and mutation documents + ├── pages/graphql/index.tsx # forcedTheme="light" → resolved theme + └── shared/components/ + ├── editor/markdown/ + │ ├── markdown-with-mermaid.tsx # memoised theme-dependent plugins + │ └── mermaid-diagram.tsx # bg-white → token + └── data-viewer/data-viewer.tsx # neutral/white → tokens + +frontend/packages/ui/src/styles/theme.css # − @custom-variant escape hatch +``` + +**Structure Decision**: Web application layout. The theme preference slots into the existing +`entities/preferences/` vertical on both sides, so no new architectural seam appears. The one new +backend module (`core/preferences/theme.py`) exists to keep the version→default derivation a pure, +directly testable function rather than logic embedded in the API layer. + +## Implementation phases + +Ordered by dependency, not by the numbering of the original handover list. Phase A is the keystone; +B–E are independent of each other once A exists and can proceed in parallel. + +### Phase A — Theme preference (US1) · P1 + +The foundation. Everything else consumes the resolved value it produces. + +1. **Backend store** — `Theme` enum in `constants.py`; `theme: Optional[Theme] = None` on + `Preference`; repository untouched (it persists whatever the model declares). +2. **Backend GraphQL** — per [contracts/graphql-preferences.md](./contracts/graphql-preferences.md). + ⚠ The mutation's three-state `_UNSET` handling is the single easiest thing to get wrong: collapsing + "omitted" and "null" makes an override impossible to clear. +3. **Regenerate** `schema/schema.graphql`, then frontend types. +4. **Frontend model and query** — extend `PreferenceValues` / `EffectivePreferences`; add `theme` to + the effective-preferences query and the upsert mutation. +5. **Stage-2 resolution** — `domain/rules/theme.ts`: pure `(choice, systemPrefersDark) → "light" | + "dark"`. +6. **Theme provider** — applies the class to the document element, writes the `localStorage` mirror, + subscribes to `prefers-color-scheme` changes (FR-007) and to `storage` events (multi-tab), and + exposes the resolved value to consumers. Sits alongside the existing + `date-preferences-provider.tsx`, which is the established pattern for this shape. +7. **Pre-paint script** — inline in `index.html` ``, before the module script, reading the + mirror. Per [research.md](./research.md) §R2, a browser's first-ever visit still corrects after + the config payload arrives; this is an accepted, documented boundary. +8. **Preference field** — a `Combobox` matching the existing fields, with the pre-release marker on + dark and a description on "match system" making clear it can resolve to the pre-release palette + (FR-008). "Automatic (inherited)" remains the empty-value label. +9. **Retire the escape hatch** — remove `@custom-variant dark` and its `TODO: DELETE` (FR-019). ⚠ Do + this **last** within Phase A: it is what the whole current dark rendering depends on, so removing + it before the provider works leaves the tree with no way to reach dark at all. + +### Phase B — Non-production default (US2) · P1 + +1. `core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark"`. +2. `ExperimentalFeaturesSettings.default_theme: Literal["light","dark"] | None`. ⚠ Tri-state, not + `bool` — see [contracts/rest-config.md](./contracts/rest-config.md). +3. `ConfigAPI.default_theme`; regenerate the OpenAPI schema and frontend REST types. +4. Client substitutes it when the effective preference resolves with source `DEFAULT`. +5. Pin the theme explicitly in both end-to-end suites so they stop depending on the build's version. + +### Phase C — Embedded surfaces (US3, US4) · P2 + +1. **GraphiQL** — replace `forcedTheme="light"` with the resolved value. ⚠ Pass `"light"`/`"dark"`, + never `"system"`: GraphiQL would then run its own detection and could disagree with the + application (see [research.md](./research.md) §R3). +2. **Mermaid** — derive `mermaidConfig.theme`; ⚠ memoise the plugin array on the resolved theme, or + the rehype pipeline re-runs every render; tokenise the `bg-white` container and the error banner. + +### Phase D — Token discipline (US5, US6) · P2/P3 + +1. Migrate the ~20 files carrying hardcoded `dark:` variants to tokens. +2. `shared/components/ui/badge.tsx` last and separately — twelve occurrences that likely encode + semantic colors, needing a palette decision under FR-021 rather than a mechanical swap. +3. Data viewer: `neutral`/`bg-white` → tokens. +4. Add the automated guard that makes SC-004 a standing property. `betterer` is already in CI and + is the lower-friction option; a lint rule is the stricter one. Decide when writing the tasks. +5. ⚠ Verify the light theme is unchanged after every batch, not once at the end — this is the + constraint a token swap breaks most easily, and a late discovery is expensive to bisect. + +### Phase E — Schema visualizer (US7) · P3 + +1. `git submodule update --init frontend/packages/schema-visualizer`. +2. Upstream pull request on `opsmill/infrahub-schema-visualizer`: dark support, theme accepted from + the embedding application. +3. Merge and release upstream. +4. Bump the pointer here. ⚠ Never point at an unpushed commit. + +## Open governance points + +Flagged rather than assumed, per `AGENTS.md` **Ask First**. Both want a decision before Phase A +starts. + +1. **GraphQL schema modification.** New `Theme` enum, new `EffectiveTheme` type, new field on two + types, new mutation argument. Additive and non-breaking, but it changes the public schema. +2. **Persisted model change.** A nullable field on the `Preference` `StandardNode`. Expected to be + additive with no data migration — pre-existing rows lack the property and read as `None`, which is + already the valid "nothing set" state. This expectation should be confirmed by someone who owns + the `StandardNode` persistence path rather than taken on the reasoning alone. + +## Risks + +| Risk | Impact | Mitigation | +|---|---|---| +| Removing `@custom-variant` before the provider works | Dark becomes unreachable mid-branch | Sequenced last within Phase A | +| Mermaid plugin array rebuilt per render | Continuous re-render, pinned CPU | Memoise on resolved theme; watch the profiler during US4 verification | +| Passing `"system"` to GraphiQL | Sandbox silently disagrees with the app | Pass only resolved `light`/`dark` | +| Token swap alters the light theme | Breaks FR-020, the one hard preservation constraint | Verify light after every batch | +| Semantic colors flattened during migration | Status/severity no longer distinguishable (FR-021) | Handle `badge.tsx` as a palette decision, not a swap | +| Default flip destabilises end-to-end suites | Failures misattributed | Pin the theme in both suites; baseline only from a green post-#10284 run | +| #10284 does not land | Phase D's target surfaces do not exist | Phases A–C and E are independent of it; D rebases onto it | +| Submodule pointer moved to an unpushed commit | Breaks every other checkout | Upstream merge strictly precedes the bump | + +## Dependencies + +- PR [#10284](https://github.com/opsmill/infrahub/pull/10284) merged — Phase D only. +- Existing account-backed preference machinery (user/global layers, effective resolution, source + reporting, permissions, locking). +- `hatch-vcs` version derivation (the INFP-566 work) — Phase B. +- `opsmill/infrahub-schema-visualizer` — Phase E only. + +## Complexity Tracking + +No constitutional violations require justification. This feature reuses an existing store, an +existing resolution chain, an existing permission model, and an existing token system; the only new +module is a pure function extracted for testability. diff --git a/dev/specs/infp-46-dark-theme-completion/quickstart.md b/dev/specs/infp-46-dark-theme-completion/quickstart.md new file mode 100644 index 00000000000..23fc96600b2 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/quickstart.md @@ -0,0 +1,169 @@ +# Quickstart: Dark Theme Completion + +**Feature**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md) + +How to set the work up and how to verify each user story. Written to be usable before any of the +implementation exists. + +## Prerequisites + +```bash +git submodule update --init frontend/packages/schema-visualizer +``` + +Required for User Story 7, and it also clears the two phantom `betterer` findings an uninitialised +submodule produces in a fresh worktree. + +```bash +uv pip install -e python_sdk +``` + +Fresh worktrees skip building the editable SDK, and `infrahub_sdk` imports fail without this. + +### Branch base + +This work assumes PR [#10284](https://github.com/opsmill/infrahub/pull/10284) has landed — it +supplies the surfaces User Story 5 migrates. Until it merges, rebase onto its branch to see the real +target state: + +```bash +git fetch origin bab-dark-theme-app && git rebase origin/bab-dark-theme-app +``` + +## Verification commands + +⚠ `pnpm test` and the other `pnpm` scripts abort before running in this environment. Call the +binaries directly. + +```bash +cd frontend/app && node_modules/.bin/vitest run +``` + +```bash +cd frontend/app && node_modules/.bin/biome ci . +``` + +```bash +cd frontend/app && node_modules/.bin/tsc --noEmit +``` + +```bash +cd frontend/app && node_modules/.bin/betterer ci +``` + +Backend: + +```bash +uv run invoke backend.test-unit +``` + +After changing the GraphQL schema or config model, regenerate and commit — CI fails on stale +generated files: + +```bash +uv run invoke schema.generate-graphqlschema && uv run invoke schema.generate-jsonschema +``` + +```bash +cd frontend/app && pnpm codegen +``` + +## Manual verification by user story + +Run the stack, then walk each story. The theme class lands on the document element, so the fastest +sanity check throughout is the browser console: + +```js +document.documentElement.classList.contains("dark") +``` + +### US1 — Choose a theme + +1. Sign in, open preferences. The theme field shows the deployment default with a source note + distinguishing it from a personal choice. +2. The dark option carries a visible pre-release marker. +3. Select dark — the interface repaints with no reload. +4. Reload. It is dark **in the first painted frame**. To check honestly, throttle the network hard + (DevTools → Network → Slow 3G) so the preference query is visibly slow: a correct implementation + still paints dark immediately, a broken one shows light and flips. +5. Sign in from a second browser: dark there too. +6. Select "match system", then switch the operating system's appearance with the page open — the + interface follows without a reload. +7. Set an organisation default, then check it applies to a user with no personal choice and loses to + one who has. + +### US2 — Non-production default + +```bash +uv run python -c "import importlib.metadata as m; from packaging.version import Version; v=m.version('infrahub-server'); print(v, Version(v).is_prerelease)" +``` + +A local checkout reports something like `1.11.0b2.dev134+geb5acb009 True`. + +1. As a user with **no** stored theme, load the application: dark. +2. Confirm `GET /api/config` returns `default_theme: "dark"` and does **not** include a version. +3. Set the operator override to `light`, restart, reload: light — the override beat the version. +4. Set a personal preference, then flip the override: the personal preference still wins and its + stored value is unchanged. + +To check the release path without cutting a release, exercise the resolution function directly with +`1.11.0` in a unit test rather than trying to fake the deployment's version. + +### US3 — GraphQL sandbox + +1. In dark, open the GraphQL sandbox: it renders dark. +2. Change the theme in another tab or via preferences: the sandbox follows. +3. Confirm GraphiQL's **own** theme picker is absent from its settings dialog — with `forcedTheme` + set, GraphiQL hides it, which is the intended single source of truth. +4. In light, confirm it is unchanged from today. + +### US4 — Mermaid diagrams + +1. In dark, open content containing a Mermaid diagram. Both the diagram palette and the container + behind it are dark — the `bg-white` wrapper is the usual culprit if the diagram looks correct but + sits on a bright panel. +2. Switch the theme with the diagram on screen: it re-renders to match. +3. ⚠ Watch the console and the React profiler while doing this. The plugin array must be memoised on + the resolved theme; if it is rebuilt every render the pipeline re-runs continuously and the + diagram flickers or the page pins a CPU core. +4. Render a deliberately invalid diagram and confirm the error banner is legible in both themes. + +### US5 — Token discipline + +1. In dark, walk the proposed-changes flow, a diff view, the checks view and path traversal. No + bright surface, and borders and text match the rest of the interface. +2. Confirm no application component carries per-theme overrides: + + ```bash + git grep -c "dark:" -- 'frontend/app/src/**/*.tsx' + ``` + + Expect no output. ⚠ `rtk` reformats grep output and an empty piped result is not proof — run this + through plain `git grep`. +3. In light, compare the same pages against the pre-change build: no visual difference. + +### US6 — Data viewer + +1. In dark, open the data viewer beside another dark surface: the greys belong to the same family. + The tell is `neutral` (cold) against the theme's `stone` (warm). +2. Exercise each content type the viewer handles. None shows a fixed light background — two `bg-white` + containers exist today. + +### US7 — Schema visualizer + +1. Upstream first: dark support merged and released in `opsmill/infrahub-schema-visualizer`. +2. Here: bump the pointer, then in dark open the visualizer and confirm canvas, nodes, edges, labels + and controls are dark and legible. +3. ⚠ Never move the submodule pointer to an unpushed commit — it breaks every other checkout. +4. Confirm no visualizer styling code landed in this repository. + +## Regression watch + +- **Light theme unchanged (FR-020, SC-005)** is the constraint most easily broken by a careless token + swap. Compare light-theme rendering before and after on every page touched. +- **Semantic colors (FR-021)** — status, severity, diff conflict, danger — must stay mutually + distinguishable, not merely dark. `shared/components/ui/badge.tsx` carries the most of these. +- **End-to-end suites** pin the theme explicitly rather than inheriting the build default, so they + stay deterministic. ⚠ #10284's end-to-end checks are already failing and are out of scope — do not + read those failures as fallout from this work; establish the baseline from a green run after it + lands. diff --git a/dev/specs/infp-46-dark-theme-completion/research.md b/dev/specs/infp-46-dark-theme-completion/research.md new file mode 100644 index 00000000000..09820ff7382 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/research.md @@ -0,0 +1,288 @@ +# Research: Dark Theme Completion + +**Feature**: [spec.md](./spec.md) | **Date**: 2026-08-17 + +Phase 0 output. Each section states an open question from the spec, the options considered, the +decision, and the evidence gathered from the codebase. Findings marked **⚠** are traps that would +cost a rewrite if discovered during implementation. + +## R1 — How is "non-production build" detected? (FR-010, FR-011, FR-012) + +**Question**: The handover note called this "make canary enabled by default". No `canary` concept +exists anywhere in the repository, so the mechanism had to be chosen rather than located. + +### Options considered + +| Option | Verdict | +|---|---| +| New `ExperimentalFeaturesSettings` flag | Rejected as the primary mechanism | +| `installation_type` | Rejected — wrong axis | +| Frontend build-time environment variable | Rejected | +| PEP 440 pre-release detection on the running version | **Chosen** | + +**`installation_type` is the wrong axis.** `backend/infrahub/constants/environment.py` defines +`INSTALLATION_TYPE = "community"`. It distinguishes community from enterprise, not production from +non-production. It is already on the config payload, which makes it a tempting false lead. ⚠ + +**A frontend build-time variable is wrong** because it is baked at asset-build time. A locally built +image deployed into a production-like environment would still claim non-production, and the same +published assets are served by every deployment. + +**An experimental flag alone is wrong** because it requires every non-production deployment to be +configured individually — which is exactly the per-engineer setup burden SC-008 exists to avoid. + +### Decision + +Derive the default from the running version's PEP 440 pre-release status, with an explicit operator +override retained on top. + +Versions come from `hatch-vcs` (`pyproject.toml`: `git_describe_command = ["git", "describe", +"--tags", "--long", "--match", "infrahub-v*"]`, generating `backend/infrahub/_version.py`) — the +INFP-566 dynamic-versions work. Verified against the running build: + +| Version | `is_prerelease` | Default theme | +|---|---|---| +| `1.11.0b2.dev134+geb5acb009` (this checkout) | `True` | dark | +| `1.12.0.dev5+g1a2b3c` (dev build) | `True` | dark | +| `1.11.0b2` (beta) | `True` | dark | +| `1.11.1rc1` (release candidate) | `True` | dark | +| `1.11.0` (release) | `False` | light | + +This needs no configuration for the common case: every build the team runs day to day carries a +`.devN`/`bN`/`rcN` segment, and every published release does not. The operator override (FR-012) +remains available for the deployment that wants to disagree. + +### ⚠ The frontend must not do this detection + +`backend/infrahub/api/internal.py` exposes two endpoints with different auth postures: + +- `GET /api/config` — **unauthenticated**. Carries `experimental_features`, `installation_type`, sso, + ldap, policy. +- `GET /api/info` — **authenticated** (`Depends(get_current_user)`). Carries `deployment_id` and + `version`. + +The version is only on the authenticated endpoint, but the login page needs a theme *before* there is +a session (spec edge case "Before sign-in"). Therefore the backend computes the default and publishes +the **result** on the unauthenticated config payload. The frontend never parses a version string, and +no version information is newly exposed to anonymous callers — only a resolved `light`/`dark` value. + +## R2 — How is the flash of the wrong theme prevented? (FR-006, SC-002) + +**Question**: The preference is account-backed and fetched asynchronously. A naive implementation +paints light, then repaints dark once the query resolves. + +### Evidence + +`frontend/app/index.html` is a plain SPA shell: ``, `
`, +and a module script. `frontend/app/src/main.tsx` is a bare `createRoot(...).render()`. There +is no server-side rendering and no template interpolation at serve time — the same static assets are +served to every deployment. + +### Decision + +A synchronous, render-blocking classification script inline in ``, before the module script, +which sets the theme class on the document element from a `localStorage` mirror. React never owns +the first paint decision. + +The mirror is written whenever the effective theme resolves (from the account preference, or from the +deployment default on the config payload). Precedence inside the inline script: + +1. Mirrored resolved theme, if present. +2. Mirrored raw choice of "system" → resolve against `prefers-color-scheme` at that instant. +3. Nothing mirrored → light. + +**Known and accepted limitation**: on a browser's *first ever* visit to a non-production deployment, +nothing is mirrored, so the first paint is light and corrects to dark once the config payload +arrives. Every subsequent load is correct from the first frame. Eliminating even the first-visit +flash would require the server to template the shell, which is disproportionate. This is recorded as +a deliberate scope boundary, not an oversight. + +**Reconciliation**: when the authoritative preference disagrees with the mirror, the class is updated +and the mirror rewritten. Because the mirror is also the cross-tab channel, the `storage` event +covers the multi-tab edge case for free — sufficient for the spec's "must never render a mixed +state", without committing to live cross-tab sync. + +## R3 — How is the GraphQL sandbox bound? (FR-014) + +**Question**: `frontend/app/src/pages/graphql/index.tsx:24` passes `forcedTheme="light"`. + +### Evidence + +From the installed `graphiql@5.2.4` sidebar implementation: + +```ts +const THEMES = ['light', 'dark', 'system'] as const; +forcedTheme?: (typeof THEMES)[number]; + +useEffect(() => { + if (forcedTheme === 'system') setTheme(null); + else if (forcedTheme === 'light' || forcedTheme === 'dark') setTheme(forcedTheme); +}, [forcedTheme, setTheme]); +``` + +Three facts follow. The prop is reactive, so a changing value propagates without remounting. When +`forcedTheme` is set, GraphiQL hides its own theme picker (`{!forcedTheme && …}`) — desirable, since +the application setting becomes the single source of truth. And GraphiQL persists its own theme in +its storage, which `setTheme` overwrites. + +### Decision + +Pass the application's **resolved** theme (`"light"` or `"dark"`), never `"system"`. + +⚠ Passing `"system"` would make GraphiQL run its own `prefers-color-scheme` resolution independently +of the application's. A user on "match system" whose application resolved to dark would be correct +only by coincidence, and would diverge from an organisation default or an explicit choice. Resolving +once, in the application, and handing down the answer is the only binding that cannot drift. + +## R4 — How are Mermaid diagrams bound? (FR-015) + +**Question**: `markdown-with-mermaid.tsx:11` pins `mermaidConfig: { theme: "default" }`. + +### Evidence + +The file uses `strategy: "inline-svg"`, so diagrams are rendered client-side in the browser and a +theme change can re-render them without a build step. Three obstacles are visible in the source: + +1. ⚠ **`rehypePlugins` is a module-level constant.** Making it theme-dependent means constructing it + per render. A new array identity on every render re-runs the rehype pipeline continuously — the + plugin array must be memoised on the resolved theme, and nothing else. +2. **`mermaid-diagram.tsx` hardcodes `className="relative bg-white"`** on the pan/zoom container. + This is the bright panel behind an otherwise dark diagram, independent of the diagram's own + palette. +3. **The parse-error fallback** builds a `mermaid-error` element. FR-015 requires that state legible + in both themes, so its styling must be tokenised alongside the container. + +### Decision + +Derive `mermaidConfig.theme` from the resolved application theme, mapping to Mermaid's built-in +`"dark"` and `"default"`. Memoise the plugin array on the resolved theme. Tokenise the container +background and the error banner. + +Mermaid's own `"neutral"` and `"forest"` themes are not used: the warm palette is not reproducible in +Mermaid's built-ins, and matching it precisely would mean a hand-authored theme-variables object — +disproportionate for the first pass, and a reasonable later refinement. + +## R5 — How does the theme preference join the existing store? (FR-001 → FR-004) + +**Question**: whether to extend the existing preference machinery or add separate storage. + +### Evidence + +The existing machinery is a complete two-layer implementation, and theme is structurally identical to +`date_format` — a small closed set of keys, nullable, resolved user → global → default. + +Backend: + +- `backend/infrahub/core/preferences/constants.py` — `DateFormat`, `PreferenceSource`, + `GLOBAL_OWNER_ID`, `PREFERENCE_LOCK_NAMESPACE`. +- `backend/infrahub/core/preferences/models.py` — `Preference(StandardNode)` with `owner_id`, + `date_format`, `timezone`; plus `ResolvedPreference[T]` and `EffectivePreferences`. +- `backend/infrahub/core/preferences/repository.py`, `permissions.py`. +- `backend/infrahub/graphql/types/preferences.py` — enums built with `Enum.from_enum`, and one + `Effective…` `ObjectType` per field carrying `value` + `source`. +- `backend/infrahub/graphql/queries/preferences.py`, `backend/infrahub/graphql/mutations/preferences.py`. + +Frontend: + +- `entities/preferences/domain/model/preference.ts` — `PreferenceValues`, `EffectivePreferences`. +- `entities/preferences/ui/preference-fields.tsx` — `Combobox` fields, source tooltips, and the + `EMPTY_VALUE_LABEL = "Automatic (inherited)"` convention for "no override". +- `entities/preferences/ui/{preferences-form,global-preferences-form,user-preferences-card}.tsx`. + +### Decision + +Extend. Adding a `Theme` enum and a nullable `theme` field mirrors `date_format` end to end, and +inherits resolution, source reporting, the global/user split, permissions and locking without new +concepts. + +⚠ Two constraints are documented in the existing source and must be carried: + +- `models.py` states persisted nullable fields must be written `Optional[X]`, **not** `X | None`, + because of how `StandardNode.guess_field_type` works. Python 3.14 has not lifted this. +- `types/preferences.py` states enum descriptions must stay on a single line, because + `graphql-core`'s SDL printer dedents multi-line descriptions inconsistently across versions and + makes the generated `schema/schema.graphql` environment-dependent. + +### ⚠ Open governance point + +`AGENTS.md` lists "Database schema or migration changes" and "GraphQL schema modifications" under +**Ask First**. `Preference` is a `StandardNode`, so adding a nullable field is additive — existing +rows simply lack the property and read as `None`, which is already the "nothing set" case. No data +migration is expected. The GraphQL schema does change (new enum, new field on the effective-preferences +type, new mutation input), and `schema/schema.graphql` is generated and CI-validated. Both points are +flagged for explicit human sign-off before implementation, not assumed. + +## R6 — Token migration surface (FR-017, FR-018, SC-004) + +### Evidence + +`frontend/packages/ui/src/styles/theme.css` defines 104 custom properties on `:root` with a `.dark` +block at line 60. The light palette is **warm** — `--background: var(--color-stone-100)`, +`--foreground: var(--color-stone-800)`, `--card`/`--panel` built from `stone`/`gray` stops. + +`shared/components/data-viewer/data-viewer.tsx` uses `bg-neutral-800 text-neutral-200` (line 29), +`border-neutral-700` (line 77) and two `bg-white` containers (lines 58, 89). `neutral` is Tailwind's +cold grey; `stone` is the warm one. This is precisely the reported tone mismatch, and the two +`bg-white` panels are a fixed light background in dark mode, which FR-018 forbids. + +Counted on the PR #10284 branch, roughly twenty application files still carry hardcoded `dark:` +variants, concentrated in `entities/diff/` (node-diff, checks, conflicts, badges), +`entities/path-traversal/`, `entities/proposed-changes/`, `entities/tasks/`, and +`shared/components/ui/badge.tsx` (twelve occurrences, the single largest). + +### Decision + +Migrate to tokens, and add an automated guard so SC-004 holds as a standing property rather than a +one-time cleanup. Without a guard the debt returns with the next feature branch, and SC-004's wording +("holds as a standing property") would be unenforceable. The concrete guard mechanism — a lint rule +versus a `betterer` counter — is left to the plan; `betterer` is already wired into CI here. + +`shared/components/ui/badge.tsx` is called out separately: at twelve occurrences it likely encodes +semantic colors (status, severity), which FR-021 requires to stay mutually distinguishable rather +than merely dark. It needs a palette decision, not a mechanical swap. + +## R7 — Schema visualizer (FR-016) + +### Evidence + +`git submodule status frontend/packages/schema-visualizer` reports +`-f7d3cc5af409e9db7916947e33b887737a626d4d` — the leading `-` means **uninitialised**, and the +directory is empty in this worktree. The package is consumed as `infrahub-schema-visualizer` from +`frontend/packages/schema-visualizer`. + +`AGENTS.md` is explicit: a submodule pointer must not move to an unpushed commit, because that +breaks every other checkout. The upstream change must be merged before the pointer bump lands here. + +### Decision + +Two deliverables in strict order: an upstream pull request against +`opsmill/infrahub-schema-visualizer` implementing dark support and accepting the embedding +application's theme; then a pointer bump here. No visualizer styling code lands in this repository. + +This is the reason User Story 7 is P3 and last: it is the only item whose completion is gated on a +merge in another repository, and nothing else depends on it. + +⚠ `git submodule update --init frontend/packages/schema-visualizer` is a prerequisite for any work on +this item, and also removes the two phantom `betterer` findings that an uninitialised submodule +produces in a fresh worktree. + +## R8 — Test and verification impact + +Changing the default theme on non-production builds (FR-010) changes what the end-to-end suites see, +since they run against locally built — therefore pre-release — versions. + +Both suites are affected: the legacy Playwright suite (`frontend/app`, `pnpm test:e2e`) and the +pytest/testcontainers suite (`tests/e2e`). Any assertion on a specific color, and any screenshot +comparison, may flip. + +**Decision**: end-to-end runs pin the theme explicitly rather than inheriting the build-derived +default, so the suites remain deterministic and independent of the version they happen to be built +at. This also keeps them from silently masking a regression in the default-resolution logic, which +gets its own targeted coverage instead. + +⚠ PR #10284's end-to-end checks are already failing and are out of scope. Their failures must not be +conflated with fallout from this change; the baseline needs to be established from a green run after +#10284 lands. + +Local verification runs the binaries directly rather than through `pnpm` scripts, which abort in this +environment: `node_modules/.bin/{vitest,biome,tsc,betterer}`. From 7ebfc5b4ef83a25829cc6f612f431a080a71b678 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 11:23:24 +0300 Subject: [PATCH 03/47] docs(specs): critique dark theme completion and apply must-address fixes Dual-lens review found three blocking items, all applied: - the planned file layout would have forced a shared/ -> entities/ import, which the frontend layer rules prohibit; the context now lives in shared/ and is filled from the entity, mirroring the existing DatePreferencesProvider - no contrast requirement existed for a feature entirely about color - the pre-paint script had no failure handling despite blocking render Left open for the requester: nothing defines when dark exits alpha, or where the defects the dogfooding surfaces are collected. Co-Authored-By: Claude Opus 5 --- .../critiques/critique-20260817-112103.md | 216 ++++++++++++++++++ .../infp-46-dark-theme-completion/plan.md | 35 ++- .../infp-46-dark-theme-completion/spec.md | 18 +- 3 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 dev/specs/infp-46-dark-theme-completion/critiques/critique-20260817-112103.md diff --git a/dev/specs/infp-46-dark-theme-completion/critiques/critique-20260817-112103.md b/dev/specs/infp-46-dark-theme-completion/critiques/critique-20260817-112103.md new file mode 100644 index 00000000000..5fa6d8d6953 --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/critiques/critique-20260817-112103.md @@ -0,0 +1,216 @@ +# Critique: Dark Theme Completion + +**Date**: 2026-08-17 11:21:03 | **Spec**: [spec.md](../spec.md) | **Plan**: [plan.md](../plan.md) + +## Executive Summary + +**Verdict: ⚠️ PROCEED WITH UPDATES** + +The problem is well-evidenced (a `TODO: DELETE` in the tree, a documented handover list), the scope +was explicitly confirmed by the requester, and the technical spine — resolve once, hand the result +down — is the right shape. Two of the three genuinely hard questions were settled with evidence +rather than assertion. + +Three must-address items. One is a real architecture violation in the plan's own file layout, caught +against a rule this repository documents and a precedent it already implements. One is a missing +requirement that matters more for a theme feature than for most (contrast). One is a robustness hole +in the single piece of code that runs before everything else. + +None are fundamental. All three are fixed below and applied. + +The most interesting weakness is not a defect in any artifact: **the feature has no defined exit from +"alpha"**. User Story 2 exists to surface visual defects through daily use, but nothing says where +those defects go or what condition retires the pre-release marker. That is a product decision, not an +engineering one, and it is raised as a question rather than resolved. + +## Product Lens Findings + +### 3a. Problem Validation + +Sound. The problem is concrete and self-evidencing: the dark palette is complete but unreachable, and +the code carries an explicit marker saying so. Scope was proposed at four items, challenged, and +confirmed at seven by the requester — recorded rather than assumed. + +**P4 (💡)** — User Story 7 is the only item whose completion depends on a merge in another +repository. As written, this feature cannot be declared done on its own timeline. Nothing else +depends on it, so the coupling buys nothing. + +### 3b. User Value Assessment + +Every story delivers user-visible value; US1 + US2 is a genuine MVP that would stand alone. + +**P5 (🤔)** — FR-003 introduces an organisation-wide default but never says who may set it. The plan +asserts the existing global-write permission is reused; the spec should state that so the requirement +is testable without reading the plan. + +### 3c. Alternative Approaches + +Considered and correctly rejected in research: a frontend build-time flag (baked at asset-build time, +wrong for a per-deployment property), `installation_type` (community-vs-enterprise, wrong axis), and +an experimental flag alone (needs per-deployment configuration, defeating SC-008). Extending the +existing preference store rather than adding storage is the right call and is well argued. + +### 3d. Edge Cases & User Experience + +Covered well — pre-login, preference-unavailable, first paint, multi-tab, system-appearance change, +semantically meaningful colors. + +**P1 (🎯) — Accessibility is absent.** For a feature whose entire subject is color, the spec never +mentions contrast. FR-021 requires semantic colors stay "mutually distinguishable", which is about +telling severities apart from each other — it says nothing about text remaining readable against its +background. A dark theme can satisfy every requirement in the current spec and still be unusable. +This is the one gap that could ship a defect the dogfooding period would rationalise as "looks fine +to me". + +**P3 (💡) — No feedback path.** US2 exists to surface visual defects through daily use. Nothing says +where a defect goes when someone finds one. Without a named destination, the dogfooding produces +observations that evaporate, and the story's stated purpose is unmet even when the story is +implemented perfectly. + +### 3e. Success Measurement + +Most criteria are measurable. SC-004 is verifiable by inspection, SC-005 by comparison. + +**P2 (💡)** — SC-008 ("continuously for the dogfooding period") is not measurable: the period has no +length and no exit condition. Related to P3, and together they are the same underlying gap — nothing +defines when dark stops being pre-release. + +## Engineering Lens Findings + +### 4a. Architecture Soundness + +The resolution model is sound: two stages, split at exactly the right seam (only the client can +observe the operating system), with one resolved value handed to every consumer. Refusing to pass +`"system"` to GraphiQL is the correct instinct. + +**E1 (🎯) — The plan's file layout violates this repository's layer rules.** `plan.md` places the +theme provider at `entities/preferences/ui/theme-provider.tsx` and simultaneously has +`shared/components/editor/markdown/*` and `shared/components/data-viewer/*` consume the resolved +theme. That is a `shared/` → `entities/` import, which +`dev/knowledge/frontend/entities-structure.md` prohibits: an entity's component "may be imported by +other entities and by higher layers — **never by `shared/`**". + +The repository already solves this exact problem. `DatePreferencesProvider` lives in +`entities/preferences/ui/` but fills a context declared in +`shared/context/date-preferences-context.tsx`, whose docstring states it holds only +`{ pattern, timezone }` and "never imports `entities`". + +Aggravating factor: these rules are "enforced by review only — there is no lint guard". Nothing would +catch this automatically, so a plan that names the wrong location is likely to become code that has +the wrong dependency direction. + +### 4b. Failure Mode Analysis + +Degradation is thought through — preference unavailable falls back to mirror, then to deployment +default. + +**E2 (🎯) — The pre-paint script is not failure-safe.** It is the first thing that runs, it blocks +rendering, and the plan gives it no error handling. Two concrete failures: + +- `localStorage` access **throws** when storage is disabled or unavailable (Safari private browsing + being the classic case). An uncaught throw in a blocking `` script degrades the load for a + cosmetic feature. +- The stored value is applied to the document element without validation. Same-origin, so this is + robustness rather than a live vulnerability — but reading a string from storage and using it to + drive a class is a shape that should validate against the known set on principle. + +### 4c. Security & Privacy Review + +Good, and one decision is better than it first appears: publishing the **resolved** default rather +than the version keeps the version string off an unauthenticated endpoint, where it would newly leak +build information. Worth keeping explicit so a later "simplification" doesn't undo it. + +No CSP is configured anywhere in the backend or the HTML shell, so the inline script raises no +policy problem today. ⚠ Recorded because it is exactly the kind of constraint added later that +silently breaks an inline script. + +### 4d. Performance & Scalability + +No new queries or round trips; `theme` rides payloads already fetched. The one real hazard — the +Mermaid plugin array being rebuilt every render and re-running the rehype pipeline — is identified in +research and carried into the plan's risk table. + +### 4e. Testing Strategy + +Resolution logic is pure and table-testable on both sides. + +**E3 (💡)** — The inline pre-paint script lives in `index.html`, outside the module graph, so Vitest +cannot reach it. It is the mechanism for FR-006 and SC-002 — the hardest requirement and the one most +likely to regress silently — and would ship with no automated coverage. It needs end-to-end coverage +specifically, not just the manual throttling check in the quickstart. + +### 4f. Operational Readiness + +Rollback is inherently clean: the default is derived, never written, so nothing to unwind. The +version-flip consequence (a deployment moving pre-release → release changes what un-chosen users see) +is identified and correct. + +### 4g. Dependencies & Integration Risks + +**E4 (💡)** — The GraphiQL binding relies on two behaviours verified by reading a bundled sourcemap, +not documented public API: that `forcedTheme` is reactive via an effect, and that setting it hides +GraphiQL's own picker. Both are load-bearing. A minor-version bump could change either without +notice. + +## Cross-Lens Insights + +**X1 (💡) — US7's cross-repo dependency is both a product-close risk (P4) and a sequencing risk.** +Both lenses reach the same conclusion: it should be tracked as its own deliverable so this feature +can close, with the pointer bump landing separately. The requester explicitly asked for all seven +items, so this is a tracking recommendation, **not** a scope reduction — the work stays in. + +**X2 — P1 (contrast) and E1 (layer violation) share a root cause**: both are places where the +artifacts were written from the shape of the problem rather than checked against an external standard +the repository already holds. The general lesson for the remaining phases is to verify against +`dev/knowledge/` rather than reason from the code alone — which `AGENTS.md` in fact instructs. + +## Findings Summary + +| ID | Lens | Severity | Category | Finding | Suggestion | +|----|------|----------|----------|---------|------------| +| E1 | Engineering | 🎯 | Architecture | Plan places the theme provider so that `shared/` must import `entities/`, which the layer rules prohibit | Declare the context in `shared/context/theme-context.tsx`; fill it from `entities/preferences/ui/theme-provider.tsx`, mirroring `DatePreferencesProvider` | +| P1 | Product | 🎯 | Accessibility | No contrast requirement; a compliant dark theme could still be unreadable | Add an explicit contrast requirement and a success criterion | +| E2 | Engineering | 🎯 | Failure Modes | Pre-paint script has no error handling and applies an unvalidated stored value | Wrap storage access in `try`/`catch`; validate against the known set before applying | +| P2 | Product | 💡 | Success Measurement | SC-008 has no duration and no exit condition | Make the dogfooding period time-bound with a stated exit criterion | +| P3 | Product | 💡 | User Value | No destination for defects the dogfooding is meant to surface | Name where reports go | +| E3 | Engineering | 💡 | Testing | The pre-paint script is untestable by Vitest, yet implements the hardest requirement | Require end-to-end coverage of first-paint correctness | +| E4 | Engineering | 💡 | Dependencies | GraphiQL binding relies on behaviour verified from a sourcemap, not public API | Record the version dependency; re-verify on upgrade | +| P4 | Product | 💡 | Scope | US7 gates this feature's completion on another repository | Track as a separate deliverable; keep the work in scope | +| X1 | Both | 💡 | Scope × Sequencing | Same as P4 from both lenses | As above | +| P5 | Product | 🤔 | Requirements | FR-003 does not say who may set the organisation default | State that it reuses the existing global-write permission | + +## Remediation Applied + +Per the autonomous execution mode this critique runs under, must-address items were applied rather +than offered. Low-risk recommendations were applied; the rest are recorded for the requester. + +**Applied to `spec.md`:** + +- **P1** — added **FR-022** (contrast) and **SC-009** (verifiable contrast outcome). +- **P5** — FR-003 now states the organisation default reuses the existing global-preference write + permission. +- **E3** — SC-002 now requires first-paint correctness to be covered by an automated end-to-end + check, not manual observation alone. +- **P4/X1** — the Dependencies section now records that US7 completes on the upstream repository's + timeline and is tracked as a separate deliverable, with the scope confirmation noted. + +**Applied to `plan.md`:** + +- **E1** — the project structure now declares `shared/context/theme-context.tsx` and states the + import direction explicitly, with the `DatePreferencesProvider` precedent named. Added to the risk + table, flagged that no lint guard exists. +- **E2** — Phase A step 7 now requires the pre-paint script to be exception-safe and to validate the + stored value; added to the risk table. +- **E4** — the GraphiQL risk row now records that the relied-upon behaviour is not documented public + API. + +**Not applied — for the requester:** + +- **P2** and **P3** are the same underlying gap: nothing defines when dark stops being pre-release, + or where the defects found in the meantime are collected. These are product decisions belonging to + whoever owns the dogfooding period, and inventing an answer would be worse than surfacing the + question. + +## Post-Critique + +Re-check after remediation: no must-address items remain. Proceed to task generation. diff --git a/dev/specs/infp-46-dark-theme-completion/plan.md b/dev/specs/infp-46-dark-theme-completion/plan.md index b8b670a79e8..9540ea80f31 100644 --- a/dev/specs/infp-46-dark-theme-completion/plan.md +++ b/dev/specs/infp-46-dark-theme-completion/plan.md @@ -107,10 +107,11 @@ backend/infrahub/ frontend/app/ ├── index.html # + inline pre-paint classification script └── src/ + ├── shared/context/theme-context.tsx # NEW — holds resolved "light"|"dark"; imports no entity ├── entities/preferences/ │ ├── domain/model/preference.ts # + theme - │ ├── domain/rules/theme.ts # NEW — stage-2 resolution, pure - │ ├── ui/theme-provider.tsx # NEW — applies class, mirrors, listens + │ ├── domain/rules/theme.ts # NEW — stage-2 resolution, pure (no storage access) + │ ├── ui/theme-provider.tsx # NEW — fills the shared context; applies class, mirrors, listens │ ├── ui/preference-fields.tsx # + theme field with pre-release marker │ └── ui/queries/*.ts # + theme in query and mutation documents ├── pages/graphql/index.tsx # forcedTheme="light" → resolved theme @@ -128,6 +129,26 @@ frontend/packages/ui/src/styles/theme.css # − @custom-variant escape hatch backend module (`core/preferences/theme.py`) exists to keep the version→default derivation a pure, directly testable function rather than logic embedded in the API layer. +⚠ **The context must live in `shared/`, not in the entity.** `shared/` components consume the +resolved theme (Mermaid, the data viewer), and `dev/knowledge/frontend/entities-structure.md` +prohibits `shared/` from importing an entity: an entity's component "may be imported by other +entities and by higher layers — never by `shared/`". So the dependency runs one way only: + +```text +shared/context/theme-context.tsx ← declares the context, imports no entity + ▲ ▲ + │ fills │ consumes +entities/preferences/ui/ shared/components/*, pages/* + theme-provider.tsx +``` + +This mirrors `DatePreferencesProvider` exactly — it lives in `entities/preferences/ui/` and fills +`shared/context/date-preferences-context.tsx`, whose docstring records that the shared context +"never imports `entities`". Copy that arrangement rather than inventing one. + +⚠ `domain/rules` may not touch browser storage, so `domain/rules/theme.ts` stays a pure function and +the `localStorage` mirror lives in the provider. + ## Implementation phases Ordered by dependency, not by the numbering of the original handover list. Phase A is the keystone; @@ -154,6 +175,13 @@ The foundation. Everything else consumes the resolved value it produces. 7. **Pre-paint script** — inline in `index.html` ``, before the module script, reading the mirror. Per [research.md](./research.md) §R2, a browser's first-ever visit still corrects after the config payload arrives; this is an accepted, documented boundary. + ⚠ It runs before everything and blocks rendering, so it must fail safe. `localStorage` access + **throws** when storage is disabled or unavailable (Safari private browsing), and an uncaught + throw here degrades the whole load for a cosmetic feature — wrap it in `try`/`catch` and fall + through to the light default. Validate the stored string against the known set before applying it + rather than using it directly as a class name. + ⚠ No Content-Security-Policy is configured today, so the inline script is fine. If one is ever + added it needs a nonce or hash, or the first paint silently reverts to light. 8. **Preference field** — a `Combobox` matching the existing fields, with the pre-release marker on dark and a description on "match system" making clear it can resolve to the pre-release palette (FR-008). "Automatic (inherited)" remains the empty-value label. @@ -216,6 +244,9 @@ starts. | Removing `@custom-variant` before the provider works | Dark becomes unreachable mid-branch | Sequenced last within Phase A | | Mermaid plugin array rebuilt per render | Continuous re-render, pinned CPU | Memoise on resolved theme; watch the profiler during US4 verification | | Passing `"system"` to GraphiQL | Sandbox silently disagrees with the app | Pass only resolved `light`/`dark` | +| GraphiQL's `forcedTheme` reactivity and picker-hiding are not documented public API — both were verified by reading the bundled source of 5.2.4 | A minor bump could break the binding without notice | Record the version dependency; re-verify on upgrade; cover the binding with a test | +| `shared/` importing `entities/` for the theme | Prohibited dependency direction, and **no lint guard exists** — layer rules are review-enforced only | Context in `shared/`, provider in the entity, per the `DatePreferencesProvider` precedent | +| Pre-paint script throws on unavailable `localStorage` | Blocking head script degrades every load | `try`/`catch` with a light fallback; validate the value before applying | | Token swap alters the light theme | Breaks FR-020, the one hard preservation constraint | Verify light after every batch | | Semantic colors flattened during migration | Status/severity no longer distinguishable (FR-021) | Handle `badge.tsx` as a palette decision, not a swap | | Default flip destabilises end-to-end suites | Failures misattributed | Pin the theme in both suites; baseline only from a green post-#10284 run | diff --git a/dev/specs/infp-46-dark-theme-completion/spec.md b/dev/specs/infp-46-dark-theme-completion/spec.md index 1cb34865009..44b727c48f5 100644 --- a/dev/specs/infp-46-dark-theme-completion/spec.md +++ b/dev/specs/infp-46-dark-theme-completion/spec.md @@ -247,7 +247,8 @@ nodes, edges and controls are dark and legible. - **FR-002**: The system MUST persist a user's theme choice against their account, so it applies on any browser or machine where they sign in. - **FR-003**: The system MUST support an organisation-wide theme default that applies to users who - have made no personal choice, and MUST let a personal choice override it. + have made no personal choice, and MUST let a personal choice override it. Setting it MUST require + the same permission as the existing organisation-wide preferences; no new permission is introduced. - **FR-004**: The system MUST report which layer an effective theme came from — the user's own choice, the organisation default, or the built-in default — consistent with how existing preferences report their source. @@ -295,6 +296,10 @@ nodes, edges and controls are dark and legible. - **FR-020**: The light theme MUST remain visually unchanged by this feature. - **FR-021**: Colors that carry meaning — status, severity, diff conflict, danger — MUST remain mutually distinguishable in both themes. +- **FR-022**: Text and essential interface elements MUST remain legible against their background in + both themes, meeting the contrast level the light theme already achieves. Distinguishability + between semantic colors (FR-021) is a separate property and does not imply legibility against a + surface: a palette can satisfy FR-021 in full and still be unreadable. ### Key Entities @@ -315,7 +320,9 @@ nodes, edges and controls are dark and legible. - **SC-001**: A user can change the theme and see the whole application — including the GraphQL sandbox, Mermaid diagrams and the schema visualizer — reflect the change, without reloading. - **SC-002**: On reload, the correct theme is present in the first painted frame; no flash of the - opposite theme is observable. + opposite theme is observable. This is verified by an automated end-to-end check, not by manual + observation alone — it is the requirement most likely to regress silently, and the mechanism that + delivers it runs outside the unit-test harness. - **SC-003**: A theme chosen on one machine is in effect when the same user signs in on another. - **SC-004**: Zero application components specify per-theme color overrides or raw color literals; this is verifiable by inspection of the source and holds as a standing property, not a one-time @@ -328,6 +335,8 @@ nodes, edges and controls are dark and legible. production deployments present light, without either altering stored preferences. - **SC-008**: The team can run a non-production deployment in dark continuously for the dogfooding period without needing per-engineer setup. +- **SC-009**: Text and essential interface elements meet the same contrast level in dark as the light + theme already achieves, verified across the pages walked for SC-006 rather than on a sample. ## Assumptions @@ -360,7 +369,10 @@ reviewer may overturn. - The existing account-backed preference system (user and organisation layers, effective resolution, source reporting). - The existing build-version information already exposed by the backend. -- The `opsmill/infrahub-schema-visualizer` repository, for User Story 7 only. +- The `opsmill/infrahub-schema-visualizer` repository, for User Story 7 only. That story completes on + the upstream repository's timeline, not this one, so it is tracked as its own deliverable and does + not gate the other six. It remains in scope — the seven-item scope was proposed narrower, queried, + and confirmed at all seven by the requester. ## Out of Scope From 0a0618907fe7c4691fee25629c89fe94b8d74d3c Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 11:25:17 +0300 Subject: [PATCH 04/47] docs(specs): add dark theme completion task breakdown 59 tasks across 10 phases, grouped by user story so each ships independently. Records the two Ask First approvals gating phase 3 and the sequencing constraints that would otherwise be discovered late: removing the custom-variant last, memoising the mermaid plugin array, and baselining the light theme before any token swap. Co-Authored-By: Claude Opus 5 --- .../infp-46-dark-theme-completion/tasks.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 dev/specs/infp-46-dark-theme-completion/tasks.md diff --git a/dev/specs/infp-46-dark-theme-completion/tasks.md b/dev/specs/infp-46-dark-theme-completion/tasks.md new file mode 100644 index 00000000000..d8a96164fde --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/tasks.md @@ -0,0 +1,209 @@ +# Tasks: Dark Theme Completion + +**Input**: Design documents in `dev/specs/infp-46-dark-theme-completion/` + +**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md), +[data-model.md](./data-model.md), [contracts/](./contracts/) + +**Tests**: Included. Constitution principle IV (Test Discipline) and `AGENTS.md` both require tests +for new functionality. Pure-function tasks are written test-first. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: can run in parallel — different files, no dependency on another incomplete task +- **[Story]**: the user story the task serves + +## ⚠ Before starting + +Two governance approvals are **required** before Phase 3 (see [plan.md](./plan.md#open-governance-points)). +`AGENTS.md` lists both as Ask First: + +1. **GraphQL schema modification** — new `Theme` enum, `EffectiveTheme` type, field on two types, new + mutation argument. Additive and non-breaking, but public schema. +2. **Persisted model change** — nullable `theme` on the `Preference` `StandardNode`. Expected additive + with no data migration; confirm with an owner of the `StandardNode` persistence path. + +Phases 1, 2 and 7–9 need neither approval and can proceed meanwhile. + +Verification uses the binaries directly — `pnpm` scripts abort in this environment: + +```bash +cd frontend/app && node_modules/.bin/vitest run && node_modules/.bin/biome ci . && node_modules/.bin/tsc --noEmit +``` + +--- + +## Phase 1: Setup + +**Purpose**: make the worktree able to build and give the light theme a reference to be compared against. + +- [ ] T001 Initialise the visualizer submodule: `git submodule update --init frontend/packages/schema-visualizer`. Required for US7, and it clears the two phantom `betterer` findings an uninitialised submodule produces. +- [ ] T002 [P] Install the editable SDK: `uv pip install -e python_sdk`. Fresh worktrees skip this and `infrahub_sdk` imports fail. +- [ ] T003 Rebase onto `origin/bab-dark-theme-app` until PR #10284 lands — it supplies the surfaces US5 migrates. Re-target to `develop` once merged. +- [ ] T004 Capture light-theme reference screenshots of every page US5/US6 touch (proposed changes, a diff view, checks, path traversal, data viewer). FR-020/SC-005 make "light is unchanged" a hard constraint, and it is unprovable later without a baseline taken now. + +**Checkpoint**: builds clean; light-theme baseline exists. + +--- + +## Phase 2: Foundational (blocking) + +**Purpose**: the theme value every other story consumes. Nothing here needs the backend, so it can +start immediately and in parallel with governance approval. + +⚠ The context lives in `shared/`, the provider in the entity. `shared/` may not import an entity +(`dev/knowledge/frontend/entities-structure.md`), and there is **no lint guard** — layer rules are +review-enforced only. + +- [ ] T005 Create `frontend/app/src/shared/context/theme-context.tsx` holding the resolved `"light" | "dark"`, defaulting to `"light"`. It must import nothing from `entities/`. Model it on the sibling `shared/context/date-preferences-context.tsx`. +- [ ] T006 [P] Write failing tests for stage-2 resolution in `frontend/app/src/entities/preferences/domain/rules/theme.test.ts`: table-driven over `(choice, systemPrefersDark)` → `"light" | "dark"`, covering all three choices and both system states. +- [ ] T007 Implement `frontend/app/src/entities/preferences/domain/rules/theme.ts` to pass T006. ⚠ Pure only — `domain/rules` may not touch browser storage or React. + +**Checkpoint**: a resolved theme can be held and read; consumers can be written against it. + +--- + +## Phase 3: User Story 1 — Choose a theme (P1) 🎯 MVP + +**Goal**: a user picks light / dark / match-system; it applies immediately, persists to their account, +and paints correctly on first frame. + +**Independent Test**: change the setting, watch it apply without reload; reload under heavy network +throttling and confirm no flash; sign in from a second browser and see the same choice. + +### Backend + +- [ ] T008 [US1] Add `Theme` StrEnum (`LIGHT`, `DARK`, `SYSTEM`) to `backend/infrahub/core/preferences/constants.py`, beside `DateFormat`. +- [ ] T009 [US1] Add `theme: Optional[Theme] = None` to `Preference` in `backend/infrahub/core/preferences/models.py`. ⚠ `Optional[Theme]`, never `Theme | None` — `StandardNode.guess_field_type` requires it, as the file's own comment records. +- [ ] T010 [US1] Add `Theme` and `EffectiveTheme` to `backend/infrahub/graphql/types/preferences.py`; add `theme` to `EffectivePreferencesType` and `RawPreferencesType` per [contracts/graphql-preferences.md](./contracts/graphql-preferences.md). ⚠ Enum descriptions stay on **one line** — the SDL printer dedents multi-line descriptions inconsistently and makes the generated schema environment-dependent. +- [ ] T011 [US1] Resolve `theme` through the existing user → global → default chain in `backend/infrahub/graphql/queries/preferences.py`. +- [ ] T012 [US1] Write failing tests for the mutation's three-state argument in `backend/tests/unit/graphql/test_preferences.py`: omitted leaves unchanged, explicit `null` clears, a value sets. ⚠ This is the single easiest thing to get wrong — collapsing "omitted" and "null" makes an override impossible to clear. +- [ ] T013 [US1] Add the `theme` argument and payload field to `backend/infrahub/graphql/mutations/preferences.py`, honouring `_UNSET` exactly as `date_format` does. Passes T012. +- [ ] T014 [P] [US1] Test the resolution chain: nothing set → `DEFAULT`/null; global only → `GLOBAL`; user overriding global → `USER`; clearing the user layer re-exposes global. +- [ ] T015 [P] [US1] Test that a non-`Theme` value is rejected on construction, including on load from the database. +- [ ] T016 [US1] Regenerate and commit: `uv run invoke schema.generate-graphqlschema`. CI fails on a stale `schema/schema.graphql`. + +### Frontend + +- [ ] T017 [US1] Add `theme` to `PreferenceValues` and `EffectivePreferences` in `frontend/app/src/entities/preferences/domain/model/preference.ts`. +- [ ] T018 [US1] Add `theme` to the effective-preferences query, the user upsert mutation and the global update mutation under `frontend/app/src/entities/preferences/ui/queries/`. +- [ ] T019 [US1] Regenerate frontend types: `cd frontend/app && pnpm codegen`. +- [ ] T020 [US1] Write failing tests for `frontend/app/src/entities/preferences/ui/theme-provider.test.tsx`: fills the shared context from the effective preference; falls back when the query fails; reacts to a `prefers-color-scheme` change while mounted; reconciles on a `storage` event. +- [ ] T021 [US1] Implement `frontend/app/src/entities/preferences/ui/theme-provider.tsx` — fills `shared/context/theme-context`, applies the class to `document.documentElement`, writes the `localStorage` mirror, subscribes to `prefers-color-scheme` and `storage`. Mirror `DatePreferencesProvider`'s shape. Passes T020. +- [ ] T022 [US1] Mount the provider in `frontend/app/src/app/app.tsx` alongside `DatePreferencesProvider`. +- [ ] T023 [US1] Add the inline pre-paint script to `frontend/app/index.html` ``, **before** the module script. ⚠ It blocks rendering and runs before everything: wrap storage access in `try`/`catch` and fall through to light (`localStorage` throws when storage is disabled, e.g. Safari private browsing), and validate the stored string against the known set before using it as a class name. +- [ ] T024 [US1] Add the theme field to `frontend/app/src/entities/preferences/ui/preference-fields.tsx` as a `Combobox` matching the existing fields, keeping `"Automatic (inherited)"` as the empty label. Dark carries a visible pre-release marker; "match system" says it can resolve to the pre-release palette (FR-008). +- [ ] T025 [P] [US1] Surface the field in `preferences-form.tsx`, `global-preferences-form.tsx` and `user-preferences-card.tsx`, updating their existing tests. +- [ ] T026 [US1] End-to-end test for first-paint correctness (FR-006 / SC-002): with a stored dark preference and the preference request delayed, assert the document element carries the dark class before the app has hydrated. ⚠ The pre-paint script sits outside the module graph so Vitest cannot reach it — this is its **only** automated coverage. +- [ ] T027 [US1] Remove `@custom-variant dark` and its `TODO: DELETE` from `frontend/packages/ui/src/styles/theme.css` (FR-019). ⚠ **Last task in this phase** — it is what all current dark rendering depends on; removing it earlier leaves the tree with no way to reach dark at all. + +**Checkpoint**: US1 ships standalone. Dark is reachable, persistent and flash-free. + +--- + +## Phase 4: User Story 2 — Non-production default (P1) + +**Goal**: non-production deployments default to dark so the team dogfoods it without per-engineer setup. + +**Independent Test**: load as a user with no stored preference on a pre-release build → dark; on a +release build → light; a personal choice beats both and is never overwritten. + +- [ ] T028 [P] [US2] Write failing tests in `backend/tests/unit/core/preferences/test_theme.py` over the table in [research.md](./research.md) §R1: `1.11.0` → light; `1.11.0b2`, `1.11.1rc1`, `1.12.0.dev5+g1a2b3c` → dark; override wins in both directions. +- [ ] T029 [US2] Implement `backend/infrahub/core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark"` using PEP 440 pre-release detection. Passes T028. ⚠ Not `installation_type`, which is community-vs-enterprise and a tempting false lead on the same payload. +- [ ] T030 [US2] Add `default_theme: Literal["light","dark"] | None = None` to `ExperimentalFeaturesSettings` in `backend/infrahub/config.py`. ⚠ Tri-state, not `bool` — `False` could not be distinguished from unset, so an operator could never force light on a pre-release build and FR-012 would be unmet in one direction. +- [ ] T031 [US2] Add the resolved `default_theme` to `ConfigAPI` in `backend/infrahub/api/internal.py`. ⚠ Publish only the resolved value — never the version, which would newly expose build information on an unauthenticated endpoint. +- [ ] T032 [US2] Regenerate and commit: `uv run invoke schema.generate-jsonschema`, then `cd frontend/app && pnpm codegen`. +- [ ] T033 [US2] Substitute `config.default_theme` in the provider when the effective preference resolves with source `DEFAULT`; extend T020's tests to cover it. +- [ ] T034 [P] [US2] Test that the deployment default never writes to stored preferences (FR-013). +- [ ] T035 [US2] Pin the theme explicitly in both end-to-end suites (`frontend/app` Playwright and `tests/e2e` pytest) so they stop inheriting the build-derived default. ⚠ #10284's e2e checks are already failing and are out of scope — baseline only from a green run after it lands, or its failures will be misread as fallout from this change. + +**Checkpoint**: the dogfooding loop is live. + +--- + +## Phase 5: User Story 3 — GraphQL sandbox (P2) + +- [ ] T036 [US3] Replace `forcedTheme="light"` in `frontend/app/src/pages/graphql/index.tsx` with the resolved theme from the shared context. ⚠ Pass `"light"`/`"dark"` only — never `"system"`, or GraphiQL runs its own `prefers-color-scheme` detection and can disagree with the application. +- [ ] T037 [US3] Test that the sandbox receives the resolved value and follows a theme change. ⚠ The relied-upon behaviour (reactive `forcedTheme`, and picker-hiding when set) is not documented public API — it was verified against `graphiql@5.2.4`'s bundled source, so a test is what protects the binding across upgrades. + +--- + +## Phase 6: User Story 4 — Mermaid diagrams (P2) + +- [ ] T038 [US4] Derive `mermaidConfig.theme` from the resolved theme in `frontend/app/src/shared/components/editor/markdown/markdown-with-mermaid.tsx`, mapping to Mermaid's `"dark"` / `"default"`. ⚠ `rehypePlugins` is currently a module-level constant; making it theme-dependent **must** memoise on the resolved theme alone. A new array identity per render re-runs the rehype pipeline continuously — the diagram flickers and a CPU core pins. +- [ ] T039 [US4] Replace the hardcoded `bg-white` on the pan/zoom container in `frontend/app/src/shared/components/editor/markdown/mermaid-diagram.tsx` with a surface token. This is the bright panel behind an otherwise-correct dark diagram. +- [ ] T040 [P] [US4] Tokenise the `mermaid-error` fallback styling so the parse-error state is legible in both themes (FR-015). +- [ ] T041 [US4] Test that a theme change re-renders the diagram, and that the plugin array is stable across renders at a fixed theme. + +--- + +## Phase 7: User Story 5 — Legacy pages onto tokens (P2) + +**Goal**: no application component carries per-theme overrides or raw color literals. + +⚠ Verify the light theme against the T004 baseline after **every batch**, not once at the end. This +is the constraint a token swap breaks most easily and the most expensive to bisect late. + +- [ ] T042 [P] [US5] Migrate `entities/diff/ui/` — `node-diff/utils.tsx`, `node-diff/node.tsx`, `checks/validator.tsx`, `checks/check.tsx`, `checks/data-conflict.tsx`, `diff-badge.tsx`. +- [ ] T043 [P] [US5] Migrate `entities/path-traversal/ui/` — `path-results-list.tsx`, `infra-node.tsx`, `path-traversal-page.tsx`. +- [ ] T044 [P] [US5] Migrate `entities/proposed-changes/ui/diff-summary/diff-summary-tag-group.tsx`, `entities/tasks/ui/task-display.tsx`, `entities/branches/ui/branch-working-notice.tsx`, `entities/schema/ui/styled.tsx`, `entities/user-profile/ui/account-token-create-action.tsx`. +- [ ] T045 [P] [US5] Migrate `shared/components/` — `modals/modal-confirm.tsx`, `table/style.tsx`, `table/sticky-cell-shadow.tsx`, `ui/infrahub-logo.tsx`, `ui/link-pill.tsx`. +- [ ] T046 [US5] Migrate `shared/components/ui/badge.tsx` **separately and last**. ⚠ Twelve occurrences, the most of any file, and they likely encode semantic colors — this needs a palette decision under FR-021 (severities must stay mutually distinguishable), not a mechanical swap. +- [ ] T047 [US5] Add the automated guard that makes SC-004 a standing property rather than a one-time cleanup. `betterer` is already wired into CI and is the lower-friction option; a lint rule is stricter. Without a guard the debt returns with the next feature branch. +- [ ] T048 [US5] Verify: `git grep -c "dark:" -- 'frontend/app/src/**/*.tsx'` returns nothing. ⚠ Use plain `git grep` — `rtk` reformats output and an empty piped result is not proof. + +--- + +## Phase 8: User Story 6 — Data viewer (P3) + +- [ ] T049 [US6] Replace `bg-neutral-800 text-neutral-200` (line 29) and `border-neutral-700` (line 77) in `frontend/app/src/shared/components/data-viewer/data-viewer.tsx` with palette tokens. `neutral` is Tailwind's cold grey; the theme is built on warm `stone` — that difference is the reported tone mismatch. +- [ ] T050 [US6] Replace the two `bg-white` containers (lines 58, 89) with tokens — a fixed light background in dark mode, which FR-018 forbids. +- [ ] T051 [P] [US6] Walk every content type the viewer handles and confirm none retains a fixed light background. + +--- + +## Phase 9: User Story 7 — Schema visualizer (P3) + +⚠ Completes on the upstream repository's timeline. Tracked separately so it does not gate the other +six; the work stays in scope. + +- [ ] T052 [US7] Open a pull request on `opsmill/infrahub-schema-visualizer` adding dark support: canvas, nodes, edges, labels and controls, with the theme accepted from the embedding application rather than detected independently. +- [ ] T053 [US7] Get it merged and released upstream. +- [ ] T054 [US7] Bump the submodule pointer here and pass the resolved theme into the visualizer. ⚠ Never point at an unpushed commit — it breaks every other checkout. +- [ ] T055 [US7] Confirm no visualizer styling code landed in this repository (FR-016). + +--- + +## Phase 10: Cross-cutting + +- [ ] T056 Contrast audit (FR-022 / SC-009) across the pages walked for SC-006, not a sample. ⚠ Distinguishability between semantic colors is a different property from legibility against a surface — a palette can satisfy FR-021 in full and still be unreadable. +- [ ] T057 [P] Add a changelog fragment under `changelog/`. This series used `ci/skip-changelog` for pure restyling, but a user-facing theme setting is a genuine feature and warrants an entry. +- [ ] T058 [P] Document the theme preference in the user-facing docs under `docs/`, including that dark is pre-release. +- [ ] T059 Run `/pre-ci` before pushing — it runs the locally-executable CI checks including generated-file and generated-doc validation, which this feature touches in three places. + +--- + +## Dependencies + +```text +Phase 1 (setup) + └─▶ Phase 2 (shared context + resolution) ← blocks every consumer + ├─▶ Phase 3 (US1) ← governance approval required + │ └─▶ Phase 4 (US2) + ├─▶ Phase 5 (US3) ┐ + ├─▶ Phase 6 (US4) ├─ independent of each other + └─▶ Phase 8 (US6) ┘ + +Phase 7 (US5) ── needs PR #10284 merged (or T003's rebase) +Phase 9 (US7) ── independent; gated on the upstream repository +Phase 10 ── after the phases it audits +``` + +**Critical path**: T001 → T005/T007 → T008–T027 (US1) → T028–T035 (US2). + +**Parallelisable once Phase 2 lands**: US3, US4, US6 and the US5 migration batches are all +independent of one another. US7 can start at any time. + +## Task count + +59 tasks: 4 setup, 3 foundational, 20 US1, 8 US2, 2 US3, 4 US4, 7 US5, 3 US6, 4 US7, 4 cross-cutting. From 30efa6d8cc638c570fb874de9c1299a354cc68ea Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 11:26:34 +0300 Subject: [PATCH 05/47] docs(specs): add dark theme completion alignment check All seven handover items traced to requirements; verdict minor drift. Corrected one fidelity loss: the dark option's label had been generalised to 'pre-release' when the handover named 'alpha' specifically. Co-Authored-By: Claude Opus 5 --- .../alignment-check.md | 74 +++++++++++++++++++ .../infp-46-dark-theme-completion/spec.md | 7 +- .../infp-46-dark-theme-completion/tasks.md | 2 +- 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 dev/specs/infp-46-dark-theme-completion/alignment-check.md diff --git a/dev/specs/infp-46-dark-theme-completion/alignment-check.md b/dev/specs/infp-46-dark-theme-completion/alignment-check.md new file mode 100644 index 00000000000..10bca787ebf --- /dev/null +++ b/dev/specs/infp-46-dark-theme-completion/alignment-check.md @@ -0,0 +1,74 @@ +# Alignment Check: Dark Theme Completion + +**Date**: 2026-08-17 | **Spec**: [spec.md](./spec.md) | **Remediation passes used**: 0 + +## 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-022, SC-009 | A contrast requirement, added by the engineering/product critique. Justified for a feature whose entire subject is color. | +| 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. | +| Open | unresolved | Item 7 — "for the coming weeks" | SC-008 | The dogfooding period has no stated length and no exit criterion, and nothing says where the defects it surfaces are collected. Raised in the critique as P2/P3 and deliberately **not** invented — it is a product decision for whoever owns the period. | + +### 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. + +## 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. diff --git a/dev/specs/infp-46-dark-theme-completion/spec.md b/dev/specs/infp-46-dark-theme-completion/spec.md index 44b727c48f5..d1a88f6bb8b 100644 --- a/dev/specs/infp-46-dark-theme-completion/spec.md +++ b/dev/specs/infp-46-dark-theme-completion/spec.md @@ -257,9 +257,10 @@ nodes, edges and controls are dark and legible. flash of the other theme. - **FR-007**: When "match system" is selected, the system MUST follow changes to the operating system's appearance while the page is open. -- **FR-008**: The system MUST mark the dark choice as pre-release in the interface, so users - understand they are opting into something that may still contain visual defects. Because "match - system" can resolve to dark, its description MUST make that consequence clear. +- **FR-008**: The system MUST mark the dark choice as **alpha** in the interface, so users understand + they are opting into something that may still contain visual defects. The handover named this + label specifically; "alpha" is the word to render, not a paraphrase of it. Because "match system" + can resolve to dark, its description MUST make that consequence clear. - **FR-009**: The system MUST render a coherent theme when no preference can be retrieved, falling back to the last known choice and then to the deployment default. diff --git a/dev/specs/infp-46-dark-theme-completion/tasks.md b/dev/specs/infp-46-dark-theme-completion/tasks.md index d8a96164fde..51a1abf8a84 100644 --- a/dev/specs/infp-46-dark-theme-completion/tasks.md +++ b/dev/specs/infp-46-dark-theme-completion/tasks.md @@ -92,7 +92,7 @@ throttling and confirm no flash; sign in from a second browser and see the same - [ ] T021 [US1] Implement `frontend/app/src/entities/preferences/ui/theme-provider.tsx` — fills `shared/context/theme-context`, applies the class to `document.documentElement`, writes the `localStorage` mirror, subscribes to `prefers-color-scheme` and `storage`. Mirror `DatePreferencesProvider`'s shape. Passes T020. - [ ] T022 [US1] Mount the provider in `frontend/app/src/app/app.tsx` alongside `DatePreferencesProvider`. - [ ] T023 [US1] Add the inline pre-paint script to `frontend/app/index.html` ``, **before** the module script. ⚠ It blocks rendering and runs before everything: wrap storage access in `try`/`catch` and fall through to light (`localStorage` throws when storage is disabled, e.g. Safari private browsing), and validate the stored string against the known set before using it as a class name. -- [ ] T024 [US1] Add the theme field to `frontend/app/src/entities/preferences/ui/preference-fields.tsx` as a `Combobox` matching the existing fields, keeping `"Automatic (inherited)"` as the empty label. Dark carries a visible pre-release marker; "match system" says it can resolve to the pre-release palette (FR-008). +- [ ] T024 [US1] Add the theme field to `frontend/app/src/entities/preferences/ui/preference-fields.tsx` as a `Combobox` matching the existing fields, keeping `"Automatic (inherited)"` as the empty label. Dark carries a visible **"alpha"** tag — the handover named that word specifically, so render it rather than a synonym; "match system" says it can resolve to the alpha palette (FR-008). - [ ] T025 [P] [US1] Surface the field in `preferences-form.tsx`, `global-preferences-form.tsx` and `user-preferences-card.tsx`, updating their existing tests. - [ ] T026 [US1] End-to-end test for first-paint correctness (FR-006 / SC-002): with a stored dark preference and the preference request delayed, assert the document element carries the dark class before the app has hydrated. ⚠ The pre-paint script sits outside the module graph so Vitest cannot reach it — this is its **only** automated coverage. - [ ] T027 [US1] Remove `@custom-variant dark` and its `TODO: DELETE` from `frontend/packages/ui/src/styles/theme.css` (FR-019). ⚠ **Last task in this phase** — it is what all current dark rendering depends on; removing it earlier leaves the tree with no way to reach dark at all. From 642bb5fdff81194a457417abe85e2284beeaf0d2 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 14:30:36 +0300 Subject: [PATCH 06/47] docs(specs): apply edge-case review to dark theme completion Production now defaults to following the user's browser appearance rather than to light, so the deployment default is three-valued. That removed a limitation instead of adding one: with system as both the cold-start fallback and the production default, a first-ever visit is correct from the first frame rather than flashing. Also: cross-tab sync and semantic-colour palettes move out of scope, system-appearance reaction stays in, the e2e suites stay in, and the branch stacks on bab-dark-theme-app with the PR targeting it. Co-Authored-By: Claude Opus 5 --- .../alignment-check.md | 28 ++++- .../contracts/rest-config.md | 27 ++-- .../data-model.md | 27 ++-- .../infp-46-dark-theme-completion/plan.md | 41 ++++--- .../quickstart.md | 27 ++-- .../infp-46-dark-theme-completion/research.md | 39 ++++-- .../infp-46-dark-theme-completion/spec.md | 115 +++++++++++------- .../infp-46-dark-theme-completion/tasks.md | 22 ++-- 8 files changed, 219 insertions(+), 107 deletions(-) diff --git a/dev/specs/infp-46-dark-theme-completion/alignment-check.md b/dev/specs/infp-46-dark-theme-completion/alignment-check.md index 10bca787ebf..6f537f689c4 100644 --- a/dev/specs/infp-46-dark-theme-completion/alignment-check.md +++ b/dev/specs/infp-46-dark-theme-completion/alignment-check.md @@ -2,6 +2,32 @@ **Date**: 2026-08-17 | **Spec**: [spec.md](./spec.md) | **Remediation passes used**: 0 +## 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" | **Production default changed from `light` to `system`** (FR-011). The deployment default is now three-valued; non-production still forces `dark`. | +| "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. | + +**Consequence worth noting**: making the production default `system` removed a limitation rather than +adding one. The earlier design accepted a first-visit flash because an empty cache fell back to +light while the deployment default might be dark. With `system` as both the cold-start fallback and +the production default, those two agree, so a first-ever visit to production is now correct from the +first frame. The only residual flash is a non-production deployment on a light system. + +**Trade recorded**: on production, a user whose system is dark now reaches the alpha palette without +explicitly choosing it. This was raised before the decision and confirmed; it is documented in the +spec's Assumptions so a later reviewer sees the trade rather than rediscovering it. + ## Source The source of truth is the **inline handover list** supplied by the requester: seven numbered @@ -50,7 +76,7 @@ finding was a genuine fidelity loss and has been corrected. | 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-022, SC-009 | A contrast requirement, added by the engineering/product critique. Justified for a feature whose entire subject is color. | +| 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. | | Open | unresolved | Item 7 — "for the coming weeks" | SC-008 | The dogfooding period has no stated length and no exit criterion, and nothing says where the defects it surfaces are collected. Raised in the critique as P2/P3 and deliberately **not** invented — it is a product decision for whoever owns the period. | diff --git a/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md b/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md index e8fc2c42e17..1a9f8038288 100644 --- a/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md +++ b/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md @@ -41,19 +41,20 @@ expose it and is not required. ldap: config.LDAPInfo installation_type: str policy: config.PolicySettings -+ default_theme: Literal["light", "dark"] ++ default_theme: Literal["light", "dark", "system"] ``` -Always concrete — never `system`. A server cannot observe an operating system's appearance, and a -`system` default would leave the client with nothing to fall back to before its mirror exists. +`system` is a **deferral**, not an unresolved value: the client resolves it against the browser's +appearance, including synchronously inside the pre-paint script. Publishing it costs the client +nothing and is what lets a cold start be correct. ## Resolution ```text default_theme = operator override, when explicitly configured - | "dark" when Version(infrahub.__version__).is_prerelease - | "light" otherwise + | "dark" when Version(infrahub.__version__).is_prerelease + | "system" otherwise ``` Verified shapes (see [research.md](../research.md) §R1): @@ -63,7 +64,11 @@ Verified shapes (see [research.md](../research.md) §R1): | `1.11.0b2.dev134+geb5acb009` | `True` | `dark` | | `1.12.0.dev5+g1a2b3c` | `True` | `dark` | | `1.11.1rc1` | `True` | `dark` | -| `1.11.0` | `False` | `light` | +| `1.11.0` | `False` | `system` | + +⚠ A production deployment therefore never forces a palette on a user who has not chosen one — it +defers to their browser. A non-production deployment deliberately does force one, because the point +of the dogfooding period is that the team sees dark whatever their operating system says. ⚠ `installation_type` is **not** the signal. It is `"community"` — community versus enterprise, not production versus non-production. Its presence on this same payload makes it an easy false lead. @@ -78,20 +83,24 @@ class ExperimentalFeaturesSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="INFRAHUB_EXPERIMENTAL_") graphql_enums: bool = False value_db_index: bool = Field(default=False, deprecated="…") - default_theme: Literal["light", "dark"] | None = None # None → derive from version + default_theme: Literal["light", "dark", "system"] | None = None # None → derive from version ``` ⚠ A plain `bool` would be wrong: `False` could not be told apart from unset, so an operator could -never force light on a pre-release build, and FR-012 would be unmet in one direction. +never force light on a pre-release build, and FR-012 would be unmet in one direction. The `| None` is +load-bearing for the same reason — "not configured" must stay distinguishable from every configured +value, including `"system"`. ## Behavioural contract | Given | When | Then | |---|---|---| | Pre-release build, no override | `GET /api/config` | `default_theme = "dark"` | -| Release build, no override | `GET /api/config` | `default_theme = "light"` | +| Release build, no override | `GET /api/config` | `default_theme = "system"` | | Pre-release build, override `light` | `GET /api/config` | `default_theme = "light"` | | Release build, override `dark` | `GET /api/config` | `default_theme = "dark"` | +| Release build, no override, user's system is dark | client resolves | dark, without anything stored | +| Release build, no override, user's system flips to light | client resolves | follows to light | | Any build | anonymous request | succeeds; no version information disclosed | | User has a stored preference | any deployment default | the stored preference wins; the default is never written to storage (FR-013) | | Deployment upgrades pre-release → release | users who chose a theme | unaffected — only un-chosen users' effective theme changes | diff --git a/dev/specs/infp-46-dark-theme-completion/data-model.md b/dev/specs/infp-46-dark-theme-completion/data-model.md index c35b209c864..ed1b451c587 100644 --- a/dev/specs/infp-46-dark-theme-completion/data-model.md +++ b/dev/specs/infp-46-dark-theme-completion/data-model.md @@ -53,12 +53,16 @@ Computed per deployment, never stored. Derived from the running build's PEP 440 overridable by explicit operator configuration. ```text -deployment_default_theme : LIGHT | DARK +deployment_default_theme : LIGHT | DARK | SYSTEM = operator override, when configured - | DARK when Version(running_version).is_prerelease - | LIGHT otherwise + | DARK when Version(running_version).is_prerelease + | SYSTEM otherwise ``` +The production default is `SYSTEM`, not a fixed palette: a deployment with no opinion about a +particular user defers to that user's own browser setting. Non-production overrides that to `DARK` +deliberately, because the point is that the team sees dark whatever their operating system says. + It is a *default*, not a value written anywhere: it never touches a stored preference (FR-013), so a deployment that flips from pre-release to release changes what un-chosen users see and changes nothing for users who chose. @@ -116,8 +120,13 @@ Read synchronously by the inline classification script before first paint. It is source of truth: the account-backed preference always wins on arrival, and a cleared mirror costs one corrected repaint rather than a wrong theme. -It doubles as the cross-tab channel — a second tab observing the `storage` event can reconcile, -satisfying the spec's "never render a mixed state" without committing to live cross-tab sync. +⚠ On a cold start the mirror is empty, and the fallback is the browser's own appearance — the same +answer the production deployment default gives. Cache-hit and cache-miss therefore agree, so a +first-ever visit is correct rather than merely tolerable. The one case that still corrects after the +config payload arrives is a **non-production** deployment on a light system: the script paints light +from the system, then flips to dark. Accepted — it affects the team's own builds only. + +Cross-tab synchronisation is out of scope; a second tab picks up a change on its next load. ## Relationships @@ -139,6 +148,8 @@ Operating system ───────────────────── and is how a user returns to "Automatic (inherited)". - Writing the global layer requires the same permission as the existing global preference writes; no new permission is introduced. -- The deployment default is always concrete (`LIGHT` or `DARK`) and never `SYSTEM`: a server cannot - observe an operating system's appearance, and a `SYSTEM` default would leave the client with - nothing to fall back to before the mirror exists. +- The deployment default may be `SYSTEM`, and on production it is. The server cannot observe an + operating system's appearance, but it does not need to: `SYSTEM` is a *deferral*, and stage 2 + resolves it on the client — including inside the pre-paint script, which can read the browser's + appearance synchronously. A `SYSTEM` default therefore leaves the client with a complete answer on + a cold start rather than a gap. diff --git a/dev/specs/infp-46-dark-theme-completion/plan.md b/dev/specs/infp-46-dark-theme-completion/plan.md index 9540ea80f31..d1ec5a7a4fa 100644 --- a/dev/specs/infp-46-dark-theme-completion/plan.md +++ b/dev/specs/infp-46-dark-theme-completion/plan.md @@ -1,6 +1,9 @@ # Implementation Plan: Dark Theme Completion -**Branch**: `dark-theme-completion-infp-46` | **Date**: 2026-08-17 | **Spec**: [spec.md](./spec.md) +**Branch**: `dark-theme-completion-infp-46`, **stacked on `bab-dark-theme-app`** (PR #10284) — the +pull request targets that branch, not `develop`, and re-targets `develop` once #10284 merges. + +**Date**: 2026-08-17 | **Spec**: [spec.md](./spec.md) **Ticket**: [INFP-46](https://opsmill.atlassian.net/browse/INFP-46) @@ -168,18 +171,21 @@ The foundation. Everything else consumes the resolved value it produces. the effective-preferences query and the upsert mutation. 5. **Stage-2 resolution** — `domain/rules/theme.ts`: pure `(choice, systemPrefersDark) → "light" | "dark"`. -6. **Theme provider** — applies the class to the document element, writes the `localStorage` mirror, - subscribes to `prefers-color-scheme` changes (FR-007) and to `storage` events (multi-tab), and - exposes the resolved value to consumers. Sits alongside the existing - `date-preferences-provider.tsx`, which is the established pattern for this shape. +6. **Theme provider** — fills the shared context, applies the class to the document element, writes + the `localStorage` mirror, and subscribes to `prefers-color-scheme` changes (FR-007). Sits + alongside the existing `date-preferences-provider.tsx`, which is the established pattern for this + shape. No `storage` listener: cross-tab synchronisation is out of scope. 7. **Pre-paint script** — inline in `index.html` ``, before the module script, reading the mirror. Per [research.md](./research.md) §R2, a browser's first-ever visit still corrects after the config payload arrives; this is an accepted, documented boundary. ⚠ It runs before everything and blocks rendering, so it must fail safe. `localStorage` access **throws** when storage is disabled or unavailable (Safari private browsing), and an uncaught throw here degrades the whole load for a cosmetic feature — wrap it in `try`/`catch` and fall - through to the light default. Validate the stored string against the known set before applying it - rather than using it directly as a class name. + through to `prefers-color-scheme`. Validate the stored string against the known set before applying + it rather than using it directly as a class name. + ⚠ The empty-cache fallback is `prefers-color-scheme`, **not** light. Because the production + deployment default is itself `system`, that makes a cold start correct rather than merely + tolerable — the cache-miss path and the authoritative value agree. ⚠ No Content-Security-Policy is configured today, so the inline script is fine. If one is ever added it needs a nonce or hash, or the first paint silently reverts to light. 8. **Preference field** — a `Combobox` matching the existing fields, with the pre-release marker on @@ -191,9 +197,11 @@ The foundation. Everything else consumes the resolved value it produces. ### Phase B — Non-production default (US2) · P1 -1. `core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark"`. -2. `ExperimentalFeaturesSettings.default_theme: Literal["light","dark"] | None`. ⚠ Tri-state, not - `bool` — see [contracts/rest-config.md](./contracts/rest-config.md). +1. `core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark" | "system"`. + Pre-release → `dark`; otherwise → `system`. +2. `ExperimentalFeaturesSettings.default_theme: Literal["light","dark","system"] | None`. ⚠ The + `| None` is load-bearing: "not configured" must stay distinguishable from every configured value + — see [contracts/rest-config.md](./contracts/rest-config.md). 3. `ConfigAPI.default_theme`; regenerate the OpenAPI schema and frontend REST types. 4. Client substitutes it when the effective preference resolves with source `DEFAULT`. 5. Pin the theme explicitly in both end-to-end suites so they stop depending on the build's version. @@ -210,7 +218,9 @@ The foundation. Everything else consumes the resolved value it produces. 1. Migrate the ~20 files carrying hardcoded `dark:` variants to tokens. 2. `shared/components/ui/badge.tsx` last and separately — twelve occurrences that likely encode - semantic colors, needing a palette decision under FR-021 rather than a mechanical swap. + semantic colors. ⚠ Redesigning semantic palettes is **out of scope** (tracked separately); the + rule here is do not *degrade* them. Where a mechanical swap would flatten two distinct severities + into one, keep the distinction and note it for that separate effort. 3. Data viewer: `neutral`/`bg-white` → tokens. 4. Add the automated guard that makes SC-004 a standing property. `betterer` is already in CI and is the lower-friction option; a lint rule is the stricter one. Decide when writing the tasks. @@ -248,14 +258,17 @@ starts. | `shared/` importing `entities/` for the theme | Prohibited dependency direction, and **no lint guard exists** — layer rules are review-enforced only | Context in `shared/`, provider in the entity, per the `DatePreferencesProvider` precedent | | Pre-paint script throws on unavailable `localStorage` | Blocking head script degrades every load | `try`/`catch` with a light fallback; validate the value before applying | | Token swap alters the light theme | Breaks FR-020, the one hard preservation constraint | Verify light after every batch | -| Semantic colors flattened during migration | Status/severity no longer distinguishable (FR-021) | Handle `badge.tsx` as a palette decision, not a swap | +| Semantic colors flattened during migration | Status/severity no longer distinguishable, in work explicitly scoped out of redesigning them | Migrate `badge.tsx` without degrading existing distinctions; hand anomalies to the separate effort | | Default flip destabilises end-to-end suites | Failures misattributed | Pin the theme in both suites; baseline only from a green post-#10284 run | -| #10284 does not land | Phase D's target surfaces do not exist | Phases A–C and E are independent of it; D rebases onto it | +| #10284 is revised or does not land | This branch is stacked on it, so its history moves under us | Rebase onto `bab-dark-theme-app`; Phases A–C and E do not depend on its content, only D does | +| #10284's failing e2e checks are inherited by this stacked PR | Reviewers misread them as caused by this work | State it in the PR description; baseline only from a green post-merge run | | Submodule pointer moved to an unpushed commit | Breaks every other checkout | Upstream merge strictly precedes the bump | ## Dependencies -- PR [#10284](https://github.com/opsmill/infrahub/pull/10284) merged — Phase D only. +- PR [#10284](https://github.com/opsmill/infrahub/pull/10284) — this branch is **stacked** on + `bab-dark-theme-app` rather than waiting for it to merge. Phase D consumes its surfaces; the other + phases only inherit its base. - Existing account-backed preference machinery (user/global layers, effective resolution, source reporting, permissions, locking). - `hatch-vcs` version derivation (the INFP-566 work) — Phase B. diff --git a/dev/specs/infp-46-dark-theme-completion/quickstart.md b/dev/specs/infp-46-dark-theme-completion/quickstart.md index 23fc96600b2..37a575c34b8 100644 --- a/dev/specs/infp-46-dark-theme-completion/quickstart.md +++ b/dev/specs/infp-46-dark-theme-completion/quickstart.md @@ -20,16 +20,21 @@ uv pip install -e python_sdk Fresh worktrees skip building the editable SDK, and `infrahub_sdk` imports fail without this. -### Branch base +### Branch base — this is a stacked PR -This work assumes PR [#10284](https://github.com/opsmill/infrahub/pull/10284) has landed — it -supplies the surfaces User Story 5 migrates. Until it merges, rebase onto its branch to see the real -target state: +The branch is based on `bab-dark-theme-app` (PR +[#10284](https://github.com/opsmill/infrahub/pull/10284)) and the pull request **targets that +branch**, not `develop`. That puts the surfaces User Story 5 migrates actually in the tree and keeps +this review free of #10284's 151 files. ```bash git fetch origin bab-dark-theme-app && git rebase origin/bab-dark-theme-app ``` +Re-target `develop` once #10284 merges; rebase again if it is revised. ⚠ #10284's failing end-to-end +checks are inherited by this pull request — say so in the description so reviewers do not read them +as caused by this work. + ## Verification commands ⚠ `pnpm test` and the other `pnpm` scripts abort before running in this environment. Call the @@ -100,14 +105,17 @@ uv run python -c "import importlib.metadata as m; from packaging.version import A local checkout reports something like `1.11.0b2.dev134+geb5acb009 True`. -1. As a user with **no** stored theme, load the application: dark. +1. As a user with **no** stored theme, load the application: dark, regardless of your system setting. 2. Confirm `GET /api/config` returns `default_theme: "dark"` and does **not** include a version. 3. Set the operator override to `light`, restart, reload: light — the override beat the version. 4. Set a personal preference, then flip the override: the personal preference still wins and its stored value is unchanged. +5. Clear your browser storage and reload with the operating system in dark: the first paint is dark + with nothing cached — the cold-start path resolving from `prefers-color-scheme`. To check the release path without cutting a release, exercise the resolution function directly with -`1.11.0` in a unit test rather than trying to fake the deployment's version. +`1.11.0` in a unit test — it must return `system`, not `light` — rather than trying to fake the +deployment's version. ### US3 — GraphQL sandbox @@ -161,8 +169,11 @@ To check the release path without cutting a release, exercise the resolution fun - **Light theme unchanged (FR-020, SC-005)** is the constraint most easily broken by a careless token swap. Compare light-theme rendering before and after on every page touched. -- **Semantic colors (FR-021)** — status, severity, diff conflict, danger — must stay mutually - distinguishable, not merely dark. `shared/components/ui/badge.tsx` carries the most of these. +- **Contrast (FR-021)** — text and essential interface elements must stay legible against their + surfaces in dark, at the level light already achieves. +- **Semantic colors are out of scope** — status, severity, diff conflict, danger palettes are tracked + separately. Do not redesign them here; do not let a mechanical token swap flatten two distinct + severities into one either. `shared/components/ui/badge.tsx` carries the most of these. - **End-to-end suites** pin the theme explicitly rather than inheriting the build default, so they stay deterministic. ⚠ #10284's end-to-end checks are already failing and are out of scope — do not read those failures as fallout from this work; establish the baseline from a green run after it diff --git a/dev/specs/infp-46-dark-theme-completion/research.md b/dev/specs/infp-46-dark-theme-completion/research.md index 09820ff7382..8ea618e1b72 100644 --- a/dev/specs/infp-46-dark-theme-completion/research.md +++ b/dev/specs/infp-46-dark-theme-completion/research.md @@ -46,7 +46,13 @@ INFP-566 dynamic-versions work. Verified against the running build: | `1.12.0.dev5+g1a2b3c` (dev build) | `True` | dark | | `1.11.0b2` (beta) | `True` | dark | | `1.11.1rc1` (release candidate) | `True` | dark | -| `1.11.0` (release) | `False` | light | +| `1.11.0` (release) | `False` | **system** | + +The production default is `system` rather than a fixed palette — specified directly by the requester. +A deployment with no opinion about a particular user defers to that user's browser setting; only +non-production overrides that, deliberately, so the team sees dark whatever their operating system +says. The trade this accepts is recorded in the spec's Assumptions: on production, a user whose +system is dark reaches the alpha palette without explicitly choosing it. This needs no configuration for the common case: every build the team runs day to day carries a `.devN`/`bN`/`rcN` segment, and every published release does not. The operator override (FR-012) @@ -89,18 +95,24 @@ deployment default on the config payload). Precedence inside the inline script: 1. Mirrored resolved theme, if present. 2. Mirrored raw choice of "system" → resolve against `prefers-color-scheme` at that instant. -3. Nothing mirrored → light. +3. Nothing mirrored → resolve against `prefers-color-scheme`. + +**Step 3 is why the cold start is correct, not merely tolerable.** Because the production deployment +default is itself `system`, an empty cache and a populated one give the same answer for a +never-visited production deployment — the fallback and the authoritative value agree instead of +fighting. An earlier draft defaulted step 3 to light and accepted a first-visit flash as a scope +boundary; making the production default `system` removes that case rather than tolerating it. -**Known and accepted limitation**: on a browser's *first ever* visit to a non-production deployment, -nothing is mirrored, so the first paint is light and corrects to dark once the config payload -arrives. Every subsequent load is correct from the first frame. Eliminating even the first-visit -flash would require the server to template the shell, which is disproportionate. This is recorded as -a deliberate scope boundary, not an oversight. +**Residual case**: a *non-production* deployment on a light system. The script paints light from the +system setting, then flips to dark when the config payload arrives. This affects the team's own +builds only, and eliminating it would require the server to template the shell — disproportionate. **Reconciliation**: when the authoritative preference disagrees with the mirror, the class is updated -and the mirror rewritten. Because the mirror is also the cross-tab channel, the `storage` event -covers the multi-tab edge case for free — sufficient for the spec's "must never render a mixed -state", without committing to live cross-tab sync. +and the mirror rewritten. + +Cross-tab synchronisation is **out of scope** — a second tab picks up a change on its next load. The +mirror would make a `storage`-event implementation nearly free, so this is a deliberate deferral +rather than a limitation of the design. ## R3 — How is the GraphQL sandbox bound? (FR-014) @@ -238,8 +250,11 @@ one-time cleanup. Without a guard the debt returns with the next feature branch, versus a `betterer` counter — is left to the plan; `betterer` is already wired into CI here. `shared/components/ui/badge.tsx` is called out separately: at twelve occurrences it likely encodes -semantic colors (status, severity), which FR-021 requires to stay mutually distinguishable rather -than merely dark. It needs a palette decision, not a mechanical swap. +semantic colors (status, severity). **Redesigning semantic palettes is out of scope** — that is the +separately-tracked "content that carries its own colors" work. The instruction here is narrower and +easier to get wrong in the opposite direction: migrate it without *degrading* what exists. Where a +mechanical token swap would flatten two currently-distinct severities into one, leave the distinction +in place and note it for the separate effort rather than collapsing it. ## R7 — Schema visualizer (FR-016) diff --git a/dev/specs/infp-46-dark-theme-completion/spec.md b/dev/specs/infp-46-dark-theme-completion/spec.md index d1a88f6bb8b..3f65cfdcbca 100644 --- a/dev/specs/infp-46-dark-theme-completion/spec.md +++ b/dev/specs/infp-46-dark-theme-completion/spec.md @@ -26,10 +26,17 @@ This feature closes that gap and clears the seven limitations the author recorde ### Relationship to PR #10284 -Several items below (notably User Story 6) describe debt that #10284 *introduces* — hastily -dark-themed legacy pages carrying hardcoded variants rather than tokens. This spec assumes #10284 -lands. Its failing end-to-end checks are explicitly **out of scope** for this feature and are not -addressed here. +Several items below (notably User Story 5) describe debt that #10284 *introduces* — hastily +dark-themed legacy pages carrying hardcoded variants rather than tokens. + +**This work stacks on #10284**: the branch is based on `bab-dark-theme-app` and the pull request +targets it, not `develop`. That makes the debt US5 migrates actually present in the tree, and keeps +this review free of #10284's 151 files. When #10284 merges, this branch re-targets `develop`; if +#10284 is revised, this branch rebases onto it. + +Its failing end-to-end checks are explicitly **out of scope** and are not addressed here. ⚠ Stacking +means those failures are inherited and will appear on this pull request too — they are pre-existing, +not caused by this work. ## User Scenarios & Testing *(mandatory)* @@ -76,23 +83,24 @@ observe the same choice. The team runs non-production builds of Infrahub day to day. Those deployments default to the dark theme so that the team lives in it continuously and surfaces the remaining visual defects through -ordinary use, without every engineer having to opt in individually. Production builds continue to -default to light, because the dark theme is pre-release. +ordinary use, without every engineer having to opt in individually. Production builds instead follow +each user's own browser or system appearance. **Why this priority**: This is the stated near-term goal of the whole effort — dogfooding dark for the coming weeks. It is what converts the setting from a feature into a feedback loop, and it is cheap once User Story 1 exists. **Independent Test**: Load a non-production deployment as a user with no theme preference set and -observe dark; load a production build the same way and observe light; in both, set a personal -preference and observe it override the default. +observe dark; load a production build the same way and observe it match the operating system's +appearance; in both, set a personal preference and observe it override the default. **Acceptance Scenarios**: 1. **Given** a deployment running a non-production build, **When** a user with no theme preference - loads the application, **Then** it paints in dark. + loads the application, **Then** it paints in dark regardless of their system appearance. 2. **Given** a deployment running a production build, **When** a user with no theme preference loads - the application, **Then** it paints in light. + the application, **Then** it paints to match their system appearance; **and when** they change + that system appearance, **Then** the application follows. 3. **Given** a non-production deployment defaulting to dark, **When** a user explicitly selects light, **Then** their choice is honoured and persists. 4. **Given** any deployment, **When** an operator explicitly configures the default theme, **Then** @@ -218,23 +226,29 @@ nodes, edges and controls are dark and legible. ### Edge Cases -- **Before sign-in.** The login page has no user to read a preference from. It uses the deployment - default and must not flash. -- **Preference unavailable.** If the stored preference cannot be read (network failure, backend - error), the application still paints a coherent theme — the last known choice, or the deployment - default — and never lands half-styled. -- **First paint.** Theme selection is account-backed, so a naive implementation paints light and then - corrects itself. The visible flash this produces is a defect, not an acceptable cost. -- **Multiple tabs.** A user with the application open twice changes the theme in one tab. The other - tab must not end up in a broken mixed state; it may update live or on next navigation. -- **System theme changes.** A user on "match system" whose operating system switches appearance while - the page is open. -- **Content that carries its own colors.** Diagrams, syntax highlighting, status and severity colors, - and user-supplied content must stay meaningful in both themes — a severity color must remain - distinguishable, not merely dark. +The first three are one problem with one answer, so they are grouped rather than listed apart. + +- **No account-backed answer yet — before sign-in, on first paint, or when the preference cannot be + read.** In all three the application must still paint a coherent theme immediately and never land + half-styled or flash. + + A single mechanism covers all three: a locally cached copy of the last resolved theme, read + synchronously before the first frame. When nothing is cached, the browser's own appearance setting + answers instead — which is also the deployment default, so the cached path and the cold path agree + rather than fighting. The account-backed preference reconciles on arrival and refreshes the cache. + + This means the cold-start case is *correct*, not merely tolerable: a first-ever visit resolves from + the browser's setting rather than guessing. + +- **System appearance changes while the page is open.** A user following their system switches their + operating system's appearance. The application follows without a reload. Cheap to support — + the browser exposes this as a subscribable change — so it is in scope rather than deferred. + - **Existing automated tests.** Tests that assert specific colors, or that screenshot the interface, - may be sensitive to the default theme changing on non-production builds. -- **Print and export.** Out of scope; printing behaviour is unchanged. + are sensitive to the default changing on non-production builds. In scope: the suites must be made + deterministic rather than left to inherit whatever the build implies. + +- **Print and export.** Unchanged; out of scope. ## Requirements *(mandatory)* @@ -262,13 +276,16 @@ nodes, edges and controls are dark and legible. label specifically; "alpha" is the word to render, not a paraphrase of it. Because "match system" can resolve to dark, its description MUST make that consequence clear. - **FR-009**: The system MUST render a coherent theme when no preference can be retrieved, falling - back to the last known choice and then to the deployment default. + back to the last locally cached resolution and then to the deployment default. The cache MUST be + read synchronously before the first frame, and its absence or unavailability MUST NOT prevent the + application from loading. **Deployment defaults** - **FR-010**: Deployments running a non-production build MUST default to dark for users with no personal choice. -- **FR-011**: Deployments running a production build MUST default to light. +- **FR-011**: Deployments running a production build MUST default to following the user's browser or + operating system appearance, rather than to a fixed palette. - **FR-012**: Operators MUST be able to override the build-derived default with explicit configuration. - **FR-013**: A deployment default MUST NOT overwrite or reset any user's stored personal choice. @@ -295,12 +312,13 @@ nodes, edges and controls are dark and legible. **Preservation** - **FR-020**: The light theme MUST remain visually unchanged by this feature. -- **FR-021**: Colors that carry meaning — status, severity, diff conflict, danger — MUST remain - mutually distinguishable in both themes. -- **FR-022**: Text and essential interface elements MUST remain legible against their background in - both themes, meeting the contrast level the light theme already achieves. Distinguishability - between semantic colors (FR-021) is a separate property and does not imply legibility against a - surface: a palette can satisfy FR-021 in full and still be unreadable. +- **FR-021**: Text and essential interface elements MUST remain legible against their background in + both themes, meeting the contrast level the light theme already achieves. + + This is about text against a surface, not about telling semantic colors apart from each other. + Content that carries its own colors — diagrams, syntax highlighting, status and severity palettes, + user-supplied content — is **out of scope** and tracked separately; a migration here must not make + those worse, but redesigning them is not this feature's job. ### Key Entities @@ -333,7 +351,8 @@ nodes, edges and controls are dark and legible. - **SC-006**: Every page reachable from the main navigation renders with no bright-on-dark surface when dark is active. - **SC-007**: Non-production deployments present dark to a user with no stored preference, and - production deployments present light, without either altering stored preferences. + production deployments present whatever that user's system appearance calls for, without either + altering stored preferences. - **SC-008**: The team can run a non-production deployment in dark continuously for the dogfooding period without needing per-engineer setup. - **SC-009**: Text and essential interface elements meet the same contrast level in dark as the light @@ -344,24 +363,27 @@ nodes, edges and controls are dark and legible. These were decided during specification rather than left open. Each is a judgement call that a reviewer may overturn. -- **Production defaults to light.** Dark is pre-release. Defaulting production users into it on the - strength of their operating system setting would ship a known-defective appearance to people who - never opted in. "Match system" is therefore available but never the default. +- **Production follows the user's system appearance by default.** This was specified directly by the + requester. It was initially assumed to be light, on the reasoning that dark is alpha and + defaulting production users into it ships a known-defective appearance to people who never opted + in — that consequence was raised explicitly and the decision was confirmed. It is recorded here so + a later reviewer sees the trade rather than rediscovering it: **on production, a user whose system + is dark gets the alpha theme without choosing it**, and the alpha tag then labels only an explicit + choice they never had to make. - **Three choices, not two.** Match-system is included rather than deferred: it is the conventional - expectation for a theme setting, and adding it later would change the meaning of an already-stored - value. + expectation for a theme setting, it is what the production default resolves to, and adding it later + would change the meaning of an already-stored value. - **The existing preference machinery is extended, not replaced.** Theme joins date-format and timezone in the established two-layer user/organisation preference model, and inherits its resolution and source-reporting semantics. - **"Non-production build" is derived from the running version**, not from a separate deployment flag, so that no additional configuration is required for the common case. Explicit configuration remains available as an override. The precise derivation is a design decision for the plan. -- **PR #10284 lands before this work merges.** Its surfaces are the input to User Story 5. Its - failing end-to-end checks are out of scope. +- **This work stacks on PR #10284.** The branch is based on `bab-dark-theme-app` and the pull request + targets it, rather than `develop`. #10284's surfaces are the input to User Story 5, and its failing + end-to-end checks are out of scope. When #10284 merges, this branch re-targets `develop`. - **The schema visualizer is a separate deliverable.** Upstream release precedes adoption here; the adoption in this repository is a dependency version change with no styling code. -- **Cross-tab synchronisation is not required.** A second open tab may update live or on next - navigation, provided it never renders a mixed state. - **Print, export and screenshot output are unchanged.** ## Dependencies @@ -379,6 +401,11 @@ reviewer may overturn. - The failing end-to-end checks on PR #10284. - Any change to the light theme's appearance. +- **Content that carries its own colors** — diagrams, syntax highlighting, status and severity + palettes, user-supplied content. Making these meaningful in both themes is a separate piece of + work. This feature must not degrade them, but does not redesign them. +- **Cross-tab synchronisation.** A second open tab is not required to react to a theme change made in + the first; it picks the change up on its next load. - Additional themes beyond light and dark (high contrast, custom palettes, per-branch theming). - Theming of printed or exported output. - Restyling third-party surfaces beyond binding them to the active theme. diff --git a/dev/specs/infp-46-dark-theme-completion/tasks.md b/dev/specs/infp-46-dark-theme-completion/tasks.md index 51a1abf8a84..c91c3d9cf78 100644 --- a/dev/specs/infp-46-dark-theme-completion/tasks.md +++ b/dev/specs/infp-46-dark-theme-completion/tasks.md @@ -39,7 +39,7 @@ cd frontend/app && node_modules/.bin/vitest run && node_modules/.bin/biome ci . - [ ] T001 Initialise the visualizer submodule: `git submodule update --init frontend/packages/schema-visualizer`. Required for US7, and it clears the two phantom `betterer` findings an uninitialised submodule produces. - [ ] T002 [P] Install the editable SDK: `uv pip install -e python_sdk`. Fresh worktrees skip this and `infrahub_sdk` imports fail. -- [ ] T003 Rebase onto `origin/bab-dark-theme-app` until PR #10284 lands — it supplies the surfaces US5 migrates. Re-target to `develop` once merged. +- [ ] T003 Base the branch on `origin/bab-dark-theme-app` and open the pull request **against that branch**, not `develop` — this is a stacked PR on #10284. It supplies the surfaces US5 migrates and keeps this review free of #10284's 151 files. Re-target `develop` once #10284 merges; rebase if it is revised. ⚠ #10284's failing e2e checks are inherited and will show on this PR — say so in the description so they are not read as caused by this work. - [ ] T004 Capture light-theme reference screenshots of every page US5/US6 touch (proposed changes, a diff view, checks, path traversal, data viewer). FR-020/SC-005 make "light is unchanged" a hard constraint, and it is unprovable later without a baseline taken now. **Checkpoint**: builds clean; light-theme baseline exists. @@ -88,13 +88,13 @@ throttling and confirm no flash; sign in from a second browser and see the same - [ ] T017 [US1] Add `theme` to `PreferenceValues` and `EffectivePreferences` in `frontend/app/src/entities/preferences/domain/model/preference.ts`. - [ ] T018 [US1] Add `theme` to the effective-preferences query, the user upsert mutation and the global update mutation under `frontend/app/src/entities/preferences/ui/queries/`. - [ ] T019 [US1] Regenerate frontend types: `cd frontend/app && pnpm codegen`. -- [ ] T020 [US1] Write failing tests for `frontend/app/src/entities/preferences/ui/theme-provider.test.tsx`: fills the shared context from the effective preference; falls back when the query fails; reacts to a `prefers-color-scheme` change while mounted; reconciles on a `storage` event. -- [ ] T021 [US1] Implement `frontend/app/src/entities/preferences/ui/theme-provider.tsx` — fills `shared/context/theme-context`, applies the class to `document.documentElement`, writes the `localStorage` mirror, subscribes to `prefers-color-scheme` and `storage`. Mirror `DatePreferencesProvider`'s shape. Passes T020. +- [ ] T020 [US1] Write failing tests for `frontend/app/src/entities/preferences/ui/theme-provider.test.tsx`: fills the shared context from the effective preference; falls back to the deployment default when the query fails; reacts to a `prefers-color-scheme` change while mounted. +- [ ] T021 [US1] Implement `frontend/app/src/entities/preferences/ui/theme-provider.tsx` — fills `shared/context/theme-context`, applies the class to `document.documentElement`, writes the `localStorage` mirror, subscribes to `prefers-color-scheme`. Mirror `DatePreferencesProvider`'s shape. Passes T020. No `storage` listener — cross-tab sync is out of scope. - [ ] T022 [US1] Mount the provider in `frontend/app/src/app/app.tsx` alongside `DatePreferencesProvider`. -- [ ] T023 [US1] Add the inline pre-paint script to `frontend/app/index.html` ``, **before** the module script. ⚠ It blocks rendering and runs before everything: wrap storage access in `try`/`catch` and fall through to light (`localStorage` throws when storage is disabled, e.g. Safari private browsing), and validate the stored string against the known set before using it as a class name. +- [ ] T023 [US1] Add the inline pre-paint script to `frontend/app/index.html` ``, **before** the module script. Precedence: mirrored resolved theme → mirrored `system` choice resolved against `prefers-color-scheme` → `prefers-color-scheme`. ⚠ The empty-cache fallback is `prefers-color-scheme`, **not** light: because the production deployment default is itself `system`, this makes a cold start correct rather than merely tolerable. ⚠ It blocks rendering and runs before everything: wrap storage access in `try`/`catch` (`localStorage` throws when storage is disabled, e.g. Safari private browsing) and validate the stored string against the known set before using it as a class name. - [ ] T024 [US1] Add the theme field to `frontend/app/src/entities/preferences/ui/preference-fields.tsx` as a `Combobox` matching the existing fields, keeping `"Automatic (inherited)"` as the empty label. Dark carries a visible **"alpha"** tag — the handover named that word specifically, so render it rather than a synonym; "match system" says it can resolve to the alpha palette (FR-008). - [ ] T025 [P] [US1] Surface the field in `preferences-form.tsx`, `global-preferences-form.tsx` and `user-preferences-card.tsx`, updating their existing tests. -- [ ] T026 [US1] End-to-end test for first-paint correctness (FR-006 / SC-002): with a stored dark preference and the preference request delayed, assert the document element carries the dark class before the app has hydrated. ⚠ The pre-paint script sits outside the module graph so Vitest cannot reach it — this is its **only** automated coverage. +- [ ] T026 [US1] End-to-end test for first-paint correctness (FR-006 / SC-002): with a stored dark preference and the preference request delayed, assert the document element carries the dark class before the app has hydrated. Add a cold-cache case too — no mirror, emulated dark browser preference — asserting dark from the first frame. ⚠ The pre-paint script sits outside the module graph so Vitest cannot reach it; this is its **only** automated coverage. - [ ] T027 [US1] Remove `@custom-variant dark` and its `TODO: DELETE` from `frontend/packages/ui/src/styles/theme.css` (FR-019). ⚠ **Last task in this phase** — it is what all current dark rendering depends on; removing it earlier leaves the tree with no way to reach dark at all. **Checkpoint**: US1 ships standalone. Dark is reachable, persistent and flash-free. @@ -108,12 +108,12 @@ throttling and confirm no flash; sign in from a second browser and see the same **Independent Test**: load as a user with no stored preference on a pre-release build → dark; on a release build → light; a personal choice beats both and is never overwritten. -- [ ] T028 [P] [US2] Write failing tests in `backend/tests/unit/core/preferences/test_theme.py` over the table in [research.md](./research.md) §R1: `1.11.0` → light; `1.11.0b2`, `1.11.1rc1`, `1.12.0.dev5+g1a2b3c` → dark; override wins in both directions. -- [ ] T029 [US2] Implement `backend/infrahub/core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark"` using PEP 440 pre-release detection. Passes T028. ⚠ Not `installation_type`, which is community-vs-enterprise and a tempting false lead on the same payload. -- [ ] T030 [US2] Add `default_theme: Literal["light","dark"] | None = None` to `ExperimentalFeaturesSettings` in `backend/infrahub/config.py`. ⚠ Tri-state, not `bool` — `False` could not be distinguished from unset, so an operator could never force light on a pre-release build and FR-012 would be unmet in one direction. +- [ ] T028 [P] [US2] Write failing tests in `backend/tests/unit/core/preferences/test_theme.py` over the table in [research.md](./research.md) §R1: `1.11.0` → **`system`**; `1.11.0b2`, `1.11.1rc1`, `1.12.0.dev5+g1a2b3c` → `dark`; override wins in every direction including forcing `light` on a pre-release build. +- [ ] T029 [US2] Implement `backend/infrahub/core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark" | "system"` using PEP 440 pre-release detection: pre-release → `dark`, otherwise → `system`. Passes T028. ⚠ Not `installation_type`, which is community-vs-enterprise and a tempting false lead on the same payload. +- [ ] T030 [US2] Add `default_theme: Literal["light","dark","system"] | None = None` to `ExperimentalFeaturesSettings` in `backend/infrahub/config.py`. ⚠ The `| None` is load-bearing: "not configured" must stay distinguishable from every configured value, or an operator could never force a palette on a build whose derived default already matches. - [ ] T031 [US2] Add the resolved `default_theme` to `ConfigAPI` in `backend/infrahub/api/internal.py`. ⚠ Publish only the resolved value — never the version, which would newly expose build information on an unauthenticated endpoint. - [ ] T032 [US2] Regenerate and commit: `uv run invoke schema.generate-jsonschema`, then `cd frontend/app && pnpm codegen`. -- [ ] T033 [US2] Substitute `config.default_theme` in the provider when the effective preference resolves with source `DEFAULT`; extend T020's tests to cover it. +- [ ] T033 [US2] Substitute `config.default_theme` in the provider when the effective preference resolves with source `DEFAULT`, then run it through stage-2 resolution — a `system` default must resolve against the browser, not be applied literally. Extend T020's tests to cover a `system` default on both system appearances. - [ ] T034 [P] [US2] Test that the deployment default never writes to stored preferences (FR-013). - [ ] T035 [US2] Pin the theme explicitly in both end-to-end suites (`frontend/app` Playwright and `tests/e2e` pytest) so they stop inheriting the build-derived default. ⚠ #10284's e2e checks are already failing and are out of scope — baseline only from a green run after it lands, or its failures will be misread as fallout from this change. @@ -148,7 +148,7 @@ is the constraint a token swap breaks most easily and the most expensive to bise - [ ] T043 [P] [US5] Migrate `entities/path-traversal/ui/` — `path-results-list.tsx`, `infra-node.tsx`, `path-traversal-page.tsx`. - [ ] T044 [P] [US5] Migrate `entities/proposed-changes/ui/diff-summary/diff-summary-tag-group.tsx`, `entities/tasks/ui/task-display.tsx`, `entities/branches/ui/branch-working-notice.tsx`, `entities/schema/ui/styled.tsx`, `entities/user-profile/ui/account-token-create-action.tsx`. - [ ] T045 [P] [US5] Migrate `shared/components/` — `modals/modal-confirm.tsx`, `table/style.tsx`, `table/sticky-cell-shadow.tsx`, `ui/infrahub-logo.tsx`, `ui/link-pill.tsx`. -- [ ] T046 [US5] Migrate `shared/components/ui/badge.tsx` **separately and last**. ⚠ Twelve occurrences, the most of any file, and they likely encode semantic colors — this needs a palette decision under FR-021 (severities must stay mutually distinguishable), not a mechanical swap. +- [ ] T046 [US5] Migrate `shared/components/ui/badge.tsx` **separately and last**. ⚠ Twelve occurrences, the most of any file, and they likely encode semantic colors. Redesigning semantic palettes is **out of scope** (tracked separately); the rule is do not *degrade* them. Where a mechanical swap would flatten two currently-distinct severities into one, keep the distinction and note it for that separate effort. - [ ] T047 [US5] Add the automated guard that makes SC-004 a standing property rather than a one-time cleanup. `betterer` is already wired into CI and is the lower-friction option; a lint rule is stricter. Without a guard the debt returns with the next feature branch. - [ ] T048 [US5] Verify: `git grep -c "dark:" -- 'frontend/app/src/**/*.tsx'` returns nothing. ⚠ Use plain `git grep` — `rtk` reformats output and an empty piped result is not proof. @@ -176,7 +176,7 @@ six; the work stays in scope. ## Phase 10: Cross-cutting -- [ ] T056 Contrast audit (FR-022 / SC-009) across the pages walked for SC-006, not a sample. ⚠ Distinguishability between semantic colors is a different property from legibility against a surface — a palette can satisfy FR-021 in full and still be unreadable. +- [ ] T056 Contrast audit (FR-021 / SC-009) across the pages walked for SC-006, not a sample — text and essential interface elements against their surfaces. ⚠ Scope boundary: this is legibility against a background, **not** semantic palettes (diagram, syntax-highlighting, status and severity colors), which are tracked separately. Record anything noticed there for that effort rather than fixing it here. - [ ] T057 [P] Add a changelog fragment under `changelog/`. This series used `ci/skip-changelog` for pure restyling, but a user-facing theme setting is a genuine feature and warrants an entry. - [ ] T058 [P] Document the theme preference in the user-facing docs under `docs/`, including that dark is pre-release. - [ ] T059 Run `/pre-ci` before pushing — it runs the locally-executable CI checks including generated-file and generated-doc validation, which this feature touches in three places. From 15c195ea0d02b45148183666b07814b1265f9ac8 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 14:42:44 +0300 Subject: [PATCH 07/47] docs(specs): keep dark opt-in; production defaults to light Withdraws the intermediate revision that defaulted production to the system appearance. It would have put dark-OS production users into the alpha palette without choosing it, which is what the alpha label exists to prevent. The spec now states one governing rule: dark is never reached by inference. That decides both defaults and the pre-paint fallback. Non-production still forces dark ignoring the OS, so an engineer on a light machine still dogfoods it. Co-Authored-By: Claude Opus 5 --- .../alignment-check.md | 20 +++--- .../contracts/rest-config.md | 32 ++++----- .../data-model.md | 38 ++++++----- .../infp-46-dark-theme-completion/plan.md | 22 +++--- .../quickstart.md | 13 ++-- .../infp-46-dark-theme-completion/research.md | 33 ++++----- .../infp-46-dark-theme-completion/spec.md | 67 ++++++++++++------- .../infp-46-dark-theme-completion/tasks.md | 12 ++-- 8 files changed, 136 insertions(+), 101 deletions(-) diff --git a/dev/specs/infp-46-dark-theme-completion/alignment-check.md b/dev/specs/infp-46-dark-theme-completion/alignment-check.md index 6f537f689c4..8ac821de1c3 100644 --- a/dev/specs/infp-46-dark-theme-completion/alignment-check.md +++ b/dev/specs/infp-46-dark-theme-completion/alignment-check.md @@ -10,7 +10,7 @@ The requester reviewed the Edge Cases section and directed six changes. All are | Direction | Effect | |---|---| -| "By default we should respect the user's browser/system config" | **Production default changed from `light` to `system`** (FR-011). The deployment default is now three-valued; non-production still forces `dark`. | +| "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. | @@ -18,15 +18,17 @@ The requester reviewed the Edge Cases section and directed six changes. All are | "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. | -**Consequence worth noting**: making the production default `system` removed a limitation rather than -adding one. The earlier design accepted a first-visit flash because an empty cache fell back to -light while the deployment default might be dark. With `system` as both the cold-start fallback and -the production default, those two agree, so a first-ever visit to production is now correct from the -first frame. The only residual flash is a non-production deployment on a light system. +**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 — production is light rather than system-following, and the pre-paint +script's empty-cache fallback is light rather than `prefers-color-scheme`. -**Trade recorded**: on production, a user whose system is dark now reaches the alpha palette without -explicitly choosing it. This was raised before the decision and confirmed; it is documented in the -spec's Assumptions so a later reviewer sees the trade rather than rediscovering it. +The mirror-image rule governs the other default: non-production forces dark *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 non-production +deployment paints light for one frame before correcting to dark. Production is unaffected, since +light is already its default. ## Source diff --git a/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md b/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md index 1a9f8038288..2d8386c6d14 100644 --- a/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md +++ b/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md @@ -41,20 +41,21 @@ expose it and is not required. ldap: config.LDAPInfo installation_type: str policy: config.PolicySettings -+ default_theme: Literal["light", "dark", "system"] ++ default_theme: Literal["light", "dark"] ``` -`system` is a **deferral**, not an unresolved value: the client resolves it against the browser's -appearance, including synchronously inside the pre-paint script. Publishing it costs the client -nothing and is what lets a cold start be correct. +Always a concrete palette, never `"system"`. Not a technical limit — the client could resolve a +`"system"` default fine — but a policy one: a defaulted user must never reach the alpha palette by +inference, and a system-following non-production default would defeat the dogfooding. `"system"` +stays available to users as an explicit choice; it is simply never a default. ## Resolution ```text default_theme = operator override, when explicitly configured - | "dark" when Version(infrahub.__version__).is_prerelease - | "system" otherwise + | "dark" when Version(infrahub.__version__).is_prerelease + | "light" otherwise ``` Verified shapes (see [research.md](../research.md) §R1): @@ -64,11 +65,11 @@ Verified shapes (see [research.md](../research.md) §R1): | `1.11.0b2.dev134+geb5acb009` | `True` | `dark` | | `1.12.0.dev5+g1a2b3c` | `True` | `dark` | | `1.11.1rc1` | `True` | `dark` | -| `1.11.0` | `False` | `system` | +| `1.11.0` | `False` | `light` | -⚠ A production deployment therefore never forces a palette on a user who has not chosen one — it -defers to their browser. A non-production deployment deliberately does force one, because the point -of the dogfooding period is that the team sees dark whatever their operating system says. +⚠ Neither value consults the operating system, and both directions are deliberate: production stays +light so a dark-OS user never reaches the alpha palette without choosing it, and non-production +forces dark so an engineer on a light system still dogfoods it. ⚠ `installation_type` is **not** the signal. It is `"community"` — community versus enterprise, not production versus non-production. Its presence on this same payload makes it an easy false lead. @@ -83,24 +84,25 @@ class ExperimentalFeaturesSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="INFRAHUB_EXPERIMENTAL_") graphql_enums: bool = False value_db_index: bool = Field(default=False, deprecated="…") - default_theme: Literal["light", "dark", "system"] | None = None # None → derive from version + default_theme: Literal["light", "dark"] | None = None # None → derive from version ``` ⚠ A plain `bool` would be wrong: `False` could not be told apart from unset, so an operator could never force light on a pre-release build, and FR-012 would be unmet in one direction. The `| None` is load-bearing for the same reason — "not configured" must stay distinguishable from every configured -value, including `"system"`. +value. ## Behavioural contract | Given | When | Then | |---|---|---| | Pre-release build, no override | `GET /api/config` | `default_theme = "dark"` | -| Release build, no override | `GET /api/config` | `default_theme = "system"` | +| Release build, no override | `GET /api/config` | `default_theme = "light"` | | Pre-release build, override `light` | `GET /api/config` | `default_theme = "light"` | | Release build, override `dark` | `GET /api/config` | `default_theme = "dark"` | -| Release build, no override, user's system is dark | client resolves | dark, without anything stored | -| Release build, no override, user's system flips to light | client resolves | follows to light | +| Release build, no override, user's system is dark | client resolves | **light** — the default never consults the system | +| Pre-release build, no override, user's system is light | client resolves | **dark** — the default never consults the system | +| Any build, user explicitly chose match-system | client resolves | follows the system, because they asked for it | | Any build | anonymous request | succeeds; no version information disclosed | | User has a stored preference | any deployment default | the stored preference wins; the default is never written to storage (FR-013) | | Deployment upgrades pre-release → release | users who chose a theme | unaffected — only un-chosen users' effective theme changes | diff --git a/dev/specs/infp-46-dark-theme-completion/data-model.md b/dev/specs/infp-46-dark-theme-completion/data-model.md index ed1b451c587..415d0b99c4f 100644 --- a/dev/specs/infp-46-dark-theme-completion/data-model.md +++ b/dev/specs/infp-46-dark-theme-completion/data-model.md @@ -53,15 +53,21 @@ Computed per deployment, never stored. Derived from the running build's PEP 440 overridable by explicit operator configuration. ```text -deployment_default_theme : LIGHT | DARK | SYSTEM +deployment_default_theme : LIGHT | DARK = operator override, when configured - | DARK when Version(running_version).is_prerelease - | SYSTEM otherwise + | DARK when Version(running_version).is_prerelease + | LIGHT otherwise ``` -The production default is `SYSTEM`, not a fixed palette: a deployment with no opinion about a -particular user defers to that user's own browser setting. Non-production overrides that to `DARK` -deliberately, because the point is that the team sees dark whatever their operating system says. +Always a concrete palette, never `SYSTEM`. Both ends of that are deliberate: + +- **Production is `LIGHT`, not `SYSTEM`.** Dark is alpha, so it is reached only by a user's own + choice. Deferring to the operating system would put dark-OS users into it by inference. +- **Non-production is `DARK`, not `SYSTEM`.** Following the system would leave every engineer on a + light system out of the dogfooding, which is the whole point of that default. + +`SYSTEM` remains available to users as an explicit choice on any deployment — it is simply never a +default. It is a *default*, not a value written anywhere: it never touches a stored preference (FR-013), so a deployment that flips from pre-release to release changes what un-chosen users see and changes @@ -120,11 +126,12 @@ Read synchronously by the inline classification script before first paint. It is source of truth: the account-backed preference always wins on arrival, and a cleared mirror costs one corrected repaint rather than a wrong theme. -⚠ On a cold start the mirror is empty, and the fallback is the browser's own appearance — the same -answer the production deployment default gives. Cache-hit and cache-miss therefore agree, so a -first-ever visit is correct rather than merely tolerable. The one case that still corrects after the -config payload arrives is a **non-production** deployment on a light system: the script paints light -from the system, then flips to dark. Accepted — it affects the team's own builds only. +⚠ On a cold start the mirror is empty and the fallback is **light** — not the browser's appearance. +Consulting the system here would put a dark-OS user into the alpha palette before any preference has +been read, which is the inference the whole design forbids. On production this fallback matches the +deployment default, so a first-ever visit is correct; on a non-production deployment it paints light +and corrects to dark once the config payload lands. That single first-visit frame is accepted — it +affects the team's own builds only. Cross-tab synchronisation is out of scope; a second tab picks up a change on its next load. @@ -148,8 +155,7 @@ Operating system ───────────────────── and is how a user returns to "Automatic (inherited)". - Writing the global layer requires the same permission as the existing global preference writes; no new permission is introduced. -- The deployment default may be `SYSTEM`, and on production it is. The server cannot observe an - operating system's appearance, but it does not need to: `SYSTEM` is a *deferral*, and stage 2 - resolves it on the client — including inside the pre-paint script, which can read the browser's - appearance synchronously. A `SYSTEM` default therefore leaves the client with a complete answer on - a cold start rather than a gap. +- The deployment default is always concrete (`LIGHT` or `DARK`) and never `SYSTEM`. This is a policy + constraint, not a technical one — stage 2 could resolve a `SYSTEM` default perfectly well. It is + excluded because a defaulted user must never reach the alpha palette by inference, and because a + system-following non-production default would defeat the dogfooding. diff --git a/dev/specs/infp-46-dark-theme-completion/plan.md b/dev/specs/infp-46-dark-theme-completion/plan.md index d1ec5a7a4fa..56c7d6b1c0c 100644 --- a/dev/specs/infp-46-dark-theme-completion/plan.md +++ b/dev/specs/infp-46-dark-theme-completion/plan.md @@ -181,11 +181,12 @@ The foundation. Everything else consumes the resolved value it produces. ⚠ It runs before everything and blocks rendering, so it must fail safe. `localStorage` access **throws** when storage is disabled or unavailable (Safari private browsing), and an uncaught throw here degrades the whole load for a cosmetic feature — wrap it in `try`/`catch` and fall - through to `prefers-color-scheme`. Validate the stored string against the known set before applying - it rather than using it directly as a class name. - ⚠ The empty-cache fallback is `prefers-color-scheme`, **not** light. Because the production - deployment default is itself `system`, that makes a cold start correct rather than merely - tolerable — the cache-miss path and the authoritative value agree. + through to light. Validate the stored string against the known set before applying it rather than + using it directly as a class name. + ⚠ The empty-cache fallback is **light**, not `prefers-color-scheme`. Consulting the system there + would put a dark-OS user into the alpha palette before any preference has been read. The cost is + one corrected frame on a first-ever visit to a non-production deployment; production is correct + because light is already its default. ⚠ No Content-Security-Policy is configured today, so the inline script is fine. If one is ever added it needs a nonce or hash, or the first paint silently reverts to light. 8. **Preference field** — a `Combobox` matching the existing fields, with the pre-release marker on @@ -197,11 +198,12 @@ The foundation. Everything else consumes the resolved value it produces. ### Phase B — Non-production default (US2) · P1 -1. `core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark" | "system"`. - Pre-release → `dark`; otherwise → `system`. -2. `ExperimentalFeaturesSettings.default_theme: Literal["light","dark","system"] | None`. ⚠ The - `| None` is load-bearing: "not configured" must stay distinguishable from every configured value - — see [contracts/rest-config.md](./contracts/rest-config.md). +1. `core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark"`. Pre-release → + `dark`; otherwise → `light`. ⚠ Never returns `"system"`: a defaulted user must not reach the alpha + palette by inference, and a system-following non-production default would defeat the dogfooding. +2. `ExperimentalFeaturesSettings.default_theme: Literal["light","dark"] | None`. ⚠ The `| None` is + load-bearing: "not configured" must stay distinguishable from every configured value — see + [contracts/rest-config.md](./contracts/rest-config.md). 3. `ConfigAPI.default_theme`; regenerate the OpenAPI schema and frontend REST types. 4. Client substitutes it when the effective preference resolves with source `DEFAULT`. 5. Pin the theme explicitly in both end-to-end suites so they stop depending on the build's version. diff --git a/dev/specs/infp-46-dark-theme-completion/quickstart.md b/dev/specs/infp-46-dark-theme-completion/quickstart.md index 37a575c34b8..8c53db058f8 100644 --- a/dev/specs/infp-46-dark-theme-completion/quickstart.md +++ b/dev/specs/infp-46-dark-theme-completion/quickstart.md @@ -105,17 +105,20 @@ uv run python -c "import importlib.metadata as m; from packaging.version import A local checkout reports something like `1.11.0b2.dev134+geb5acb009 True`. -1. As a user with **no** stored theme, load the application: dark, regardless of your system setting. +1. As a user with **no** stored theme, load the application: dark. Then set your operating system to + light and reload — still dark. The non-production default ignores the system deliberately, or an + engineer on a light machine would never dogfood it. 2. Confirm `GET /api/config` returns `default_theme: "dark"` and does **not** include a version. 3. Set the operator override to `light`, restart, reload: light — the override beat the version. 4. Set a personal preference, then flip the override: the personal preference still wins and its stored value is unchanged. -5. Clear your browser storage and reload with the operating system in dark: the first paint is dark - with nothing cached — the cold-start path resolving from `prefers-color-scheme`. +5. Clear browser storage, set the operating system to **dark**, and reload: the first paint is + **light**, then corrects to dark. Both halves matter — light because a defaulted user must not + reach the alpha palette by inference, and the correction because the deployment default is dark. To check the release path without cutting a release, exercise the resolution function directly with -`1.11.0` in a unit test — it must return `system`, not `light` — rather than trying to fake the -deployment's version. +`1.11.0` in a unit test rather than trying to fake the deployment's version. On production the +equivalent check is: system set to dark, no stored preference → the application stays **light**. ### US3 — GraphQL sandbox diff --git a/dev/specs/infp-46-dark-theme-completion/research.md b/dev/specs/infp-46-dark-theme-completion/research.md index 8ea618e1b72..e9867a80740 100644 --- a/dev/specs/infp-46-dark-theme-completion/research.md +++ b/dev/specs/infp-46-dark-theme-completion/research.md @@ -46,13 +46,16 @@ INFP-566 dynamic-versions work. Verified against the running build: | `1.12.0.dev5+g1a2b3c` (dev build) | `True` | dark | | `1.11.0b2` (beta) | `True` | dark | | `1.11.1rc1` (release candidate) | `True` | dark | -| `1.11.0` (release) | `False` | **system** | +| `1.11.0` (release) | `False` | light | -The production default is `system` rather than a fixed palette — specified directly by the requester. -A deployment with no opinion about a particular user defers to that user's browser setting; only -non-production overrides that, deliberately, so the team sees dark whatever their operating system -says. The trade this accepts is recorded in the spec's Assumptions: on production, a user whose -system is dark reaches the alpha palette without explicitly choosing it. +⚠ Neither default consults the operating system, and both directions are deliberate. Production stays +light because dark is alpha and must be reached only by an explicit choice — deferring to the system +would put dark-OS users into it by inference. Non-production forces dark because following the system +would leave every engineer on a light machine out of the dogfooding, which is that default's whole +purpose. + +An intermediate revision defaulted production to `system` and was withdrawn once that first +consequence was made explicit. This needs no configuration for the common case: every build the team runs day to day carries a `.devN`/`bN`/`rcN` segment, and every published release does not. The operator override (FR-012) @@ -95,17 +98,17 @@ deployment default on the config payload). Precedence inside the inline script: 1. Mirrored resolved theme, if present. 2. Mirrored raw choice of "system" → resolve against `prefers-color-scheme` at that instant. -3. Nothing mirrored → resolve against `prefers-color-scheme`. +3. Nothing mirrored → light. -**Step 3 is why the cold start is correct, not merely tolerable.** Because the production deployment -default is itself `system`, an empty cache and a populated one give the same answer for a -never-visited production deployment — the fallback and the authoritative value agree instead of -fighting. An earlier draft defaulted step 3 to light and accepted a first-visit flash as a scope -boundary; making the production default `system` removes that case rather than tolerating it. +⚠ **Step 3 is light, not `prefers-color-scheme`.** Consulting the system here would put a dark-OS +user into the alpha palette before any preference has been read — the inference the design forbids. +On production this fallback matches the deployment default, so a first-ever visit is correct. -**Residual case**: a *non-production* deployment on a light system. The script paints light from the -system setting, then flips to dark when the config payload arrives. This affects the team's own -builds only, and eliminating it would require the server to template the shell — disproportionate. +**Known and accepted limitation**: on a browser's *first ever* visit to a **non-production** +deployment, nothing is mirrored, so the first paint is light and corrects to dark once the config +payload arrives. Every subsequent load is correct from the first frame. Eliminating even that one +frame would require the server to template the HTML shell, which is disproportionate for a case +affecting the team's own builds only. Recorded as a deliberate boundary, not an oversight. **Reconciliation**: when the authoritative preference disagrees with the mirror, the class is updated and the mirror rewritten. diff --git a/dev/specs/infp-46-dark-theme-completion/spec.md b/dev/specs/infp-46-dark-theme-completion/spec.md index 3f65cfdcbca..81944dcac8b 100644 --- a/dev/specs/infp-46-dark-theme-completion/spec.md +++ b/dev/specs/infp-46-dark-theme-completion/spec.md @@ -83,24 +83,29 @@ observe the same choice. The team runs non-production builds of Infrahub day to day. Those deployments default to the dark theme so that the team lives in it continuously and surfaces the remaining visual defects through -ordinary use, without every engineer having to opt in individually. Production builds instead follow -each user's own browser or system appearance. +ordinary use, without every engineer having to opt in individually. This default ignores the +operating system's appearance deliberately: an engineer on a light system must still see dark, or +they are not dogfooding it. + +Production builds default to light. Dark is alpha, so it is reached only by an explicit choice — +never by inference from a user's system setting. **Why this priority**: This is the stated near-term goal of the whole effort — dogfooding dark for the coming weeks. It is what converts the setting from a feature into a feedback loop, and it is cheap once User Story 1 exists. **Independent Test**: Load a non-production deployment as a user with no theme preference set and -observe dark; load a production build the same way and observe it match the operating system's -appearance; in both, set a personal preference and observe it override the default. +observe dark; load a production build the same way and observe light; in both, set a personal +preference and observe it override the default. **Acceptance Scenarios**: 1. **Given** a deployment running a non-production build, **When** a user with no theme preference - loads the application, **Then** it paints in dark regardless of their system appearance. + loads the application, **Then** it paints in dark **regardless of their system appearance** — + including for an engineer whose operating system is light. 2. **Given** a deployment running a production build, **When** a user with no theme preference loads - the application, **Then** it paints to match their system appearance; **and when** they change - that system appearance, **Then** the application follows. + the application, **Then** it paints in light, **even if their operating system is dark**. Dark is + alpha and is never reached by inference. 3. **Given** a non-production deployment defaulting to dark, **When** a user explicitly selects light, **Then** their choice is honoured and persists. 4. **Given** any deployment, **When** an operator explicitly configures the default theme, **Then** @@ -233,12 +238,19 @@ The first three are one problem with one answer, so they are grouped rather than half-styled or flash. A single mechanism covers all three: a locally cached copy of the last resolved theme, read - synchronously before the first frame. When nothing is cached, the browser's own appearance setting - answers instead — which is also the deployment default, so the cached path and the cold path agree - rather than fighting. The account-backed preference reconciles on arrival and refreshes the cache. + synchronously before the first frame. The account-backed preference reconciles on arrival and + refreshes the cache. Because the cache holds the *resolved* theme, a returning user — signed in or + not — paints correctly from the first frame. + + With nothing cached, the fallback is light. On production that is already the deployment default, + so a first-ever visit is correct. On a non-production deployment it is not: that first visit paints + light and corrects to dark once the deployment default arrives. Accepted — it is one frame, on the + team's own builds, on a browser that has never loaded the application before. Removing it would + mean the server templating the HTML shell, which is disproportionate. - This means the cold-start case is *correct*, not merely tolerable: a first-ever visit resolves from - the browser's setting rather than guessing. + ⚠ The fallback is light rather than the operating system's appearance. Consulting the system here + would put a dark-OS user into the alpha palette before any preference has been read — the exact + inference FR-011 forbids. - **System appearance changes while the page is open.** A user following their system switches their operating system's appearance. The application follows without a reload. Cheap to support — @@ -284,8 +296,8 @@ The first three are one problem with one answer, so they are grouped rather than - **FR-010**: Deployments running a non-production build MUST default to dark for users with no personal choice. -- **FR-011**: Deployments running a production build MUST default to following the user's browser or - operating system appearance, rather than to a fixed palette. +- **FR-011**: Deployments running a production build MUST default to light. Dark MUST NOT be reached + without an explicit user choice, because it is alpha. - **FR-012**: Operators MUST be able to override the build-derived default with explicit configuration. - **FR-013**: A deployment default MUST NOT overwrite or reset any user's stored personal choice. @@ -351,8 +363,8 @@ The first three are one problem with one answer, so they are grouped rather than - **SC-006**: Every page reachable from the main navigation renders with no bright-on-dark surface when dark is active. - **SC-007**: Non-production deployments present dark to a user with no stored preference, and - production deployments present whatever that user's system appearance calls for, without either - altering stored preferences. + production deployments present light, without either altering stored preferences and without + either consulting the operating system. - **SC-008**: The team can run a non-production deployment in dark continuously for the dogfooding period without needing per-engineer setup. - **SC-009**: Text and essential interface elements meet the same contrast level in dark as the light @@ -363,16 +375,21 @@ The first three are one problem with one answer, so they are grouped rather than These were decided during specification rather than left open. Each is a judgement call that a reviewer may overturn. -- **Production follows the user's system appearance by default.** This was specified directly by the - requester. It was initially assumed to be light, on the reasoning that dark is alpha and - defaulting production users into it ships a known-defective appearance to people who never opted - in — that consequence was raised explicitly and the decision was confirmed. It is recorded here so - a later reviewer sees the trade rather than rediscovering it: **on production, a user whose system - is dark gets the alpha theme without choosing it**, and the alpha tag then labels only an explicit - choice they never had to make. +- **Dark is never reached by inference.** Because it is alpha, a user arrives at it only by choosing + it — either by selecting dark, or by selecting match-system on a dark operating system. Production + therefore defaults to light rather than to the system appearance, and the alpha tag always labels + something the user actually chose. + + An intermediate revision of this spec defaulted production to the system appearance. That was + withdrawn once the consequence was made explicit: it would have put dark-OS production users into + the alpha palette without any choice on their part, which is precisely what the alpha label exists + to prevent. +- **The non-production default ignores the operating system deliberately.** Following the system on + non-production builds would leave every engineer on a light system out of the dogfooding, which is + the entire purpose of that default. - **Three choices, not two.** Match-system is included rather than deferred: it is the conventional - expectation for a theme setting, it is what the production default resolves to, and adding it later - would change the meaning of an already-stored value. + expectation for a theme setting, and adding it later would change the meaning of an already-stored + value. It is available on every deployment, but only ever as an explicit choice — never a default. - **The existing preference machinery is extended, not replaced.** Theme joins date-format and timezone in the established two-layer user/organisation preference model, and inherits its resolution and source-reporting semantics. diff --git a/dev/specs/infp-46-dark-theme-completion/tasks.md b/dev/specs/infp-46-dark-theme-completion/tasks.md index c91c3d9cf78..a54814843f0 100644 --- a/dev/specs/infp-46-dark-theme-completion/tasks.md +++ b/dev/specs/infp-46-dark-theme-completion/tasks.md @@ -91,10 +91,10 @@ throttling and confirm no flash; sign in from a second browser and see the same - [ ] T020 [US1] Write failing tests for `frontend/app/src/entities/preferences/ui/theme-provider.test.tsx`: fills the shared context from the effective preference; falls back to the deployment default when the query fails; reacts to a `prefers-color-scheme` change while mounted. - [ ] T021 [US1] Implement `frontend/app/src/entities/preferences/ui/theme-provider.tsx` — fills `shared/context/theme-context`, applies the class to `document.documentElement`, writes the `localStorage` mirror, subscribes to `prefers-color-scheme`. Mirror `DatePreferencesProvider`'s shape. Passes T020. No `storage` listener — cross-tab sync is out of scope. - [ ] T022 [US1] Mount the provider in `frontend/app/src/app/app.tsx` alongside `DatePreferencesProvider`. -- [ ] T023 [US1] Add the inline pre-paint script to `frontend/app/index.html` ``, **before** the module script. Precedence: mirrored resolved theme → mirrored `system` choice resolved against `prefers-color-scheme` → `prefers-color-scheme`. ⚠ The empty-cache fallback is `prefers-color-scheme`, **not** light: because the production deployment default is itself `system`, this makes a cold start correct rather than merely tolerable. ⚠ It blocks rendering and runs before everything: wrap storage access in `try`/`catch` (`localStorage` throws when storage is disabled, e.g. Safari private browsing) and validate the stored string against the known set before using it as a class name. +- [ ] T023 [US1] Add the inline pre-paint script to `frontend/app/index.html` ``, **before** the module script. Precedence: mirrored resolved theme → mirrored `system` choice resolved against `prefers-color-scheme` → light. ⚠ The empty-cache fallback is **light**, not `prefers-color-scheme` — consulting the system there would put a dark-OS user into the alpha palette before any preference has been read. `prefers-color-scheme` is read only when the *user* has chosen match-system. ⚠ It blocks rendering and runs before everything: wrap storage access in `try`/`catch` (`localStorage` throws when storage is disabled, e.g. Safari private browsing) and validate the stored string against the known set before using it as a class name. - [ ] T024 [US1] Add the theme field to `frontend/app/src/entities/preferences/ui/preference-fields.tsx` as a `Combobox` matching the existing fields, keeping `"Automatic (inherited)"` as the empty label. Dark carries a visible **"alpha"** tag — the handover named that word specifically, so render it rather than a synonym; "match system" says it can resolve to the alpha palette (FR-008). - [ ] T025 [P] [US1] Surface the field in `preferences-form.tsx`, `global-preferences-form.tsx` and `user-preferences-card.tsx`, updating their existing tests. -- [ ] T026 [US1] End-to-end test for first-paint correctness (FR-006 / SC-002): with a stored dark preference and the preference request delayed, assert the document element carries the dark class before the app has hydrated. Add a cold-cache case too — no mirror, emulated dark browser preference — asserting dark from the first frame. ⚠ The pre-paint script sits outside the module graph so Vitest cannot reach it; this is its **only** automated coverage. +- [ ] T026 [US1] End-to-end test for first-paint correctness (FR-006 / SC-002): with a stored dark preference and the preference request delayed, assert the document element carries the dark class before the app has hydrated. Add a cold-cache case — no mirror, emulated **dark** browser preference — asserting the first paint is **light**, since a defaulted user must not reach the alpha palette by inference. ⚠ The pre-paint script sits outside the module graph so Vitest cannot reach it; this is its **only** automated coverage. - [ ] T027 [US1] Remove `@custom-variant dark` and its `TODO: DELETE` from `frontend/packages/ui/src/styles/theme.css` (FR-019). ⚠ **Last task in this phase** — it is what all current dark rendering depends on; removing it earlier leaves the tree with no way to reach dark at all. **Checkpoint**: US1 ships standalone. Dark is reachable, persistent and flash-free. @@ -108,12 +108,12 @@ throttling and confirm no flash; sign in from a second browser and see the same **Independent Test**: load as a user with no stored preference on a pre-release build → dark; on a release build → light; a personal choice beats both and is never overwritten. -- [ ] T028 [P] [US2] Write failing tests in `backend/tests/unit/core/preferences/test_theme.py` over the table in [research.md](./research.md) §R1: `1.11.0` → **`system`**; `1.11.0b2`, `1.11.1rc1`, `1.12.0.dev5+g1a2b3c` → `dark`; override wins in every direction including forcing `light` on a pre-release build. -- [ ] T029 [US2] Implement `backend/infrahub/core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark" | "system"` using PEP 440 pre-release detection: pre-release → `dark`, otherwise → `system`. Passes T028. ⚠ Not `installation_type`, which is community-vs-enterprise and a tempting false lead on the same payload. -- [ ] T030 [US2] Add `default_theme: Literal["light","dark","system"] | None = None` to `ExperimentalFeaturesSettings` in `backend/infrahub/config.py`. ⚠ The `| None` is load-bearing: "not configured" must stay distinguishable from every configured value, or an operator could never force a palette on a build whose derived default already matches. +- [ ] T028 [P] [US2] Write failing tests in `backend/tests/unit/core/preferences/test_theme.py` over the table in [research.md](./research.md) §R1: `1.11.0` → `light`; `1.11.0b2`, `1.11.1rc1`, `1.12.0.dev5+g1a2b3c` → `dark`; override wins in every direction including forcing `light` on a pre-release build. Assert the result is **never** `"system"`. +- [ ] T029 [US2] Implement `backend/infrahub/core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark"` using PEP 440 pre-release detection: pre-release → `dark`, otherwise → `light`. Passes T028. ⚠ Not `installation_type`, which is community-vs-enterprise and a tempting false lead on the same payload. +- [ ] T030 [US2] Add `default_theme: Literal["light","dark"] | None = None` to `ExperimentalFeaturesSettings` in `backend/infrahub/config.py`. ⚠ The `| None` is load-bearing: "not configured" must stay distinguishable from every configured value, or an operator could never force a palette on a build whose derived default already matches. - [ ] T031 [US2] Add the resolved `default_theme` to `ConfigAPI` in `backend/infrahub/api/internal.py`. ⚠ Publish only the resolved value — never the version, which would newly expose build information on an unauthenticated endpoint. - [ ] T032 [US2] Regenerate and commit: `uv run invoke schema.generate-jsonschema`, then `cd frontend/app && pnpm codegen`. -- [ ] T033 [US2] Substitute `config.default_theme` in the provider when the effective preference resolves with source `DEFAULT`, then run it through stage-2 resolution — a `system` default must resolve against the browser, not be applied literally. Extend T020's tests to cover a `system` default on both system appearances. +- [ ] T033 [US2] Substitute `config.default_theme` in the provider when the effective preference resolves with source `DEFAULT`. It is already a concrete palette, so it is applied directly — stage-2 resolution runs only for a *user's* explicit match-system choice. Extend T020's tests to assert a defaulted user's theme does not change when the emulated system appearance flips. - [ ] T034 [P] [US2] Test that the deployment default never writes to stored preferences (FR-013). - [ ] T035 [US2] Pin the theme explicitly in both end-to-end suites (`frontend/app` Playwright and `tests/e2e` pytest) so they stop inheriting the build-derived default. ⚠ #10284's e2e checks are already failing and are out of scope — baseline only from a green run after it lands, or its failures will be misread as fallout from this change. From 763dec38a37813ba071f3c1c6576f317a063dc7b Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 15:46:05 +0300 Subject: [PATCH 08/47] docs(specs): gate dark theme behind an experimental flag Grilling the product core replaced the version-derived deployment default with a plain experimental flag, following the convention both existing experimental settings already use: off by default, enabled in development/docker-compose.yml. Rejected the PEP 440 derivation because pre-release is a property of a version, not a deployment, so it also caught customer betas. Following the existing convention targets the intended deployments and removes a resolver module, the version parsing, a config field, and a dependency. Also: flag off hides the theme field entirely (a light-only picker would leak dark through match-system), stored preferences survive a flag flip, and the organisation-wide default is deferred to GA. Co-Authored-By: Claude Opus 5 --- .../alignment-check.md | 20 ++ .../contracts/rest-config.md | 145 ++++++------ .../data-model.md | 102 +++++---- .../infp-46-dark-theme-completion/plan.md | 56 +++-- .../quickstart.md | 52 ++--- .../infp-46-dark-theme-completion/research.md | 109 +++++---- .../infp-46-dark-theme-completion/spec.md | 215 ++++++++++-------- .../infp-46-dark-theme-completion/tasks.md | 27 ++- 8 files changed, 401 insertions(+), 325 deletions(-) diff --git a/dev/specs/infp-46-dark-theme-completion/alignment-check.md b/dev/specs/infp-46-dark-theme-completion/alignment-check.md index 8ac821de1c3..6ca5d2bfcf4 100644 --- a/dev/specs/infp-46-dark-theme-completion/alignment-check.md +++ b/dev/specs/infp-46-dark-theme-completion/alignment-check.md @@ -2,6 +2,26 @@ **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 +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. | + +**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 diff --git a/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md b/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md index 2d8386c6d14..d3e7f6b3fa2 100644 --- a/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md +++ b/dev/specs/infp-46-dark-theme-completion/contracts/rest-config.md @@ -1,9 +1,9 @@ -# Contract: deployment default theme on the config payload +# Contract: the dark-theme feature flag on the config payload -**Feature**: [spec.md](../spec.md) | **Covers**: FR-010, FR-011, FR-012 +**Feature**: [spec.md](../spec.md) | **Covers**: FR-010, FR-011, FR-012, FR-013 -The deployment default is not a preference — it is a property of the running deployment, needed -before a user exists. It therefore travels on the unauthenticated config payload, not over GraphQL. +The theme feature is gated per deployment. The gate is not a preference — it is a property of the +deployment, needed before a user exists — so it travels on the unauthenticated config payload. ⚠ `schema/openapi.json` and `frontend/app/src/shared/api/rest/types.generated.ts` are generated. Regenerate with `uv run invoke schema.generate-jsonschema` and `cd frontend/app && pnpm codegen`, and @@ -18,103 +18,90 @@ commit; CI validates them. | `GET /api/config` | **none** | `main`, `logging`, `analytics`, `experimental_features`, `sso`, `ldap`, `installation_type`, `policy` | | `GET /api/info` | `Depends(get_current_user)` | `deployment_id`, `version` | -The login page must paint a theme before there is a session, so the value must come from the -unauthenticated endpoint. The version lives only on the authenticated one. +The login page must know whether the feature exists before there is a session, and `/api/config` +already carries `experimental_features`. So the flag needs **no new field and no new endpoint** — it +joins a payload the frontend already consumes. -⚠ **The frontend must not derive this itself.** It cannot read the version pre-login, and duplicating -PEP 440 parsing in TypeScript would drift from the backend's answer. The backend publishes the -resolved result. - -⚠ **Only the resolved value is published, never the version.** `light` or `dark` tells an anonymous -caller nothing about the build; putting the version string on an unauthenticated endpoint would newly -expose it and is not required. - -## Payload delta +## Settings delta ```diff - class ConfigAPI(BaseModel): - main: MainSettings - logging: LoggingSettings - analytics: AnalyticsSettings - experimental_features: ExperimentalFeaturesSettings - sso: config.SSOInfo - ldap: config.LDAPInfo - installation_type: str - policy: config.PolicySettings -+ default_theme: Literal["light", "dark"] + class ExperimentalFeaturesSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="INFRAHUB_EXPERIMENTAL_") + graphql_enums: bool = False + value_db_index: bool = Field(default=False, deprecated="…") ++ dark_theme: bool = False ``` -Always a concrete palette, never `"system"`. Not a technical limit — the client could resolve a -`"system"` default fine — but a policy one: a defaulted user must never reach the alpha palette by -inference, and a system-following non-production default would defeat the dogfooding. `"system"` -stays available to users as an explicit choice; it is simply never a default. +A plain `bool` defaulting to `False`, matching `graphql_enums` exactly. No tri-state is needed: unlike +the earlier design there is nothing to distinguish "unset" from "off", because the flag no longer +carries a derived value. -## Resolution +## Deployment configuration -```text -default_theme - = operator override, when explicitly configured - | "dark" when Version(infrahub.__version__).is_prerelease - | "light" otherwise -``` +Following the convention both existing flags already use in `development/docker-compose.yml` and the +root `docker-compose.yml`: -Verified shapes (see [research.md](../research.md) §R1): - -| Version | `is_prerelease` | `default_theme` | -|---|---|---| -| `1.11.0b2.dev134+geb5acb009` | `True` | `dark` | -| `1.12.0.dev5+g1a2b3c` | `True` | `dark` | -| `1.11.1rc1` | `True` | `dark` | -| `1.11.0` | `False` | `light` | +```diff + INFRAHUB_EXPERIMENTAL_GRAPHQL_ENUMS: ${INFRAHUB_EXPERIMENTAL_GRAPHQL_ENUMS:-false} + INFRAHUB_EXPERIMENTAL_VALUE_DB_INDEX: ${INFRAHUB_EXPERIMENTAL_VALUE_DB_INDEX:-false} ++ INFRAHUB_EXPERIMENTAL_DARK_THEME: ${INFRAHUB_EXPERIMENTAL_DARK_THEME:-true} +``` -⚠ Neither value consults the operating system, and both directions are deliberate: production stays -light so a dark-OS user never reaches the alpha palette without choosing it, and non-production -forces dark so an engineer on a light system still dogfoods it. +⚠ The development stack defaults this one to **`true`**, unlike its two neighbours — that single +character is what delivers SC-008 (an engineer gets dark with zero further steps). The env var still +overrides, so an engineer who wants light can set it without editing the file. -⚠ `installation_type` is **not** the signal. It is `"community"` — community versus enterprise, not -production versus non-production. Its presence on this same payload makes it an easy false lead. +⚠ Decide deliberately whether the **root** `docker-compose.yml` also defaults to `true`. It is used +for deployments beyond the dev stack; defaulting it on there widens the blast radius past "the +deployments we run". -## Operator override +## What the flag governs -A new setting under the existing `INFRAHUB_EXPERIMENTAL_` family, tri-state so that "not configured" -stays distinguishable from "explicitly light": +While dark is alpha the flag does two jobs at once. This is deliberate compression, not conflation — +they separate when the flag is removed. -```python -class ExperimentalFeaturesSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="INFRAHUB_EXPERIMENTAL_") - graphql_enums: bool = False - value_db_index: bool = Field(default=False, deprecated="…") - default_theme: Literal["light", "dark"] | None = None # None → derive from version -``` +| Flag | Theme setting offered | Default for a user who has not chosen | +|---|---|---| +| `false` | **No** — the field is absent entirely | light | +| `true` | Yes — light / dark (alpha) / match-system | **dark** | -⚠ A plain `bool` would be wrong: `False` could not be told apart from unset, so an operator could -never force light on a pre-release build, and FR-012 would be unmet in one direction. The `| None` is -load-bearing for the same reason — "not configured" must stay distinguishable from every configured -value. +⚠ With the flag off the field is hidden **entirely**, not reduced to light-only. Offering "light" and +"match system" would leave a hole: a user on a dark operating system selects match-system and reaches +the alpha palette, defeating the flag. A one-option picker is also not a setting. ## Behavioural contract | Given | When | Then | |---|---|---| -| Pre-release build, no override | `GET /api/config` | `default_theme = "dark"` | -| Release build, no override | `GET /api/config` | `default_theme = "light"` | -| Pre-release build, override `light` | `GET /api/config` | `default_theme = "light"` | -| Release build, override `dark` | `GET /api/config` | `default_theme = "dark"` | -| Release build, no override, user's system is dark | client resolves | **light** — the default never consults the system | -| Pre-release build, no override, user's system is light | client resolves | **dark** — the default never consults the system | -| Any build, user explicitly chose match-system | client resolves | follows the system, because they asked for it | -| Any build | anonymous request | succeeds; no version information disclosed | -| User has a stored preference | any deployment default | the stored preference wins; the default is never written to storage (FR-013) | -| Deployment upgrades pre-release → release | users who chose a theme | unaffected — only un-chosen users' effective theme changes | +| Flag `true`, no stored preference | app loads | dark, whatever the operating system says | +| Flag `true`, no stored preference, light OS | app loads | **dark** — the default never consults the system | +| Flag `false`, no stored preference, dark OS | app loads | **light**, and no theme setting is rendered | +| Flag `true`, user chose light | app loads | light — the user's choice beats the default | +| Flag `true`, user chose match-system | OS appearance changes | follows live, because they asked for it | +| Flag flipped `true` → `false`, user had dark stored | app loads | light; **the stored preference is retained** | +| Flag flipped back `false` → `true` | app loads | that user's dark choice is honoured again | +| Any state | anonymous request to `/api/config` | succeeds; no version information disclosed | ## Consumer contract -The client treats `default_theme` as the substitute for a `DEFAULT`-sourced effective preference: - ```text -choice = effective.theme.value ?? config.default_theme -resolved = choice == SYSTEM ? (prefers-color-scheme: dark ? dark : light) : choice +if (!config.experimental_features.dark_theme) → light; render no theme field +else choice = effective.theme.value ?? DARK + resolved = choice == SYSTEM ? (prefers-color-scheme: dark ? dark : light) : choice ``` -`resolved` is then mirrored to `localStorage` so the next load's inline script paints correctly from -the first frame. +`resolved` is mirrored to local storage so the next load's pre-paint script paints correctly from the +first frame. + +⚠ The pre-paint script's empty-cache fallback is **light**, never `prefers-color-scheme`. It runs +before the config payload has arrived, so it cannot know whether the flag is on — and guessing from +the operating system would put a dark-OS user into the alpha palette on a deployment where the +feature is switched off entirely. + +## What this contract replaced + +An earlier revision published a computed `default_theme` derived from the running version's PEP 440 +pre-release status. Withdrawn: "pre-release" catches any beta or release candidate, including one a +customer runs in their own environment, which is broader than the intended "the deployments we run". +Following the existing experimental-settings convention targets exactly the intended deployments and +removes the resolver, the version parsing, and the config field. See [research.md](../research.md) §R1. diff --git a/dev/specs/infp-46-dark-theme-completion/data-model.md b/dev/specs/infp-46-dark-theme-completion/data-model.md index 415d0b99c4f..296d2516051 100644 --- a/dev/specs/infp-46-dark-theme-completion/data-model.md +++ b/dev/specs/infp-46-dark-theme-completion/data-model.md @@ -3,8 +3,8 @@ **Feature**: [spec.md](./spec.md) | **Research**: [research.md](./research.md) | **Date**: 2026-08-17 Three entities from the spec, plus the resolution chain that connects them. Nothing here is a new -storage concept: the theme preference is a new field on an existing record, and the deployment -default is computed, never persisted. +storage concept: the theme preference is a new field on an existing record, and the feature flag is +read from configuration, never persisted. ## Entities @@ -18,10 +18,10 @@ A closed set of appearance choices. Persisted as its member name, like the exist | `DARK` | Always the dark palette. Pre-release (FR-008) | | `SYSTEM` | Follow the operating system's current appearance | -`SYSTEM` is stored as an explicit choice, not as absence. Absence means "inherit from the next layer -down" and is represented by `null`, exactly as `date_format` and `timezone` already do. Conflating -the two would make "follow my OS" indistinguishable from "I never chose", and an organisation -default could then never be overridden back to system-following. +`SYSTEM` is stored as an explicit choice, not as absence. Absence means "inherit" and is represented +by `null`, exactly as `date_format` and `timezone` already do. Conflating the two would make "follow +my OS" indistinguishable from "I never chose", so a user could never return to system-following after +setting anything else. ### Theme preference (a field, not a record) @@ -47,31 +47,38 @@ Adding a nullable field to a `StandardNode` is additive: rows written before thi `theme` property and read back as `None`, which is already a valid, meaningful state. No data migration is expected — see the governance flag in [research.md](./research.md) §R5. -### Deployment default theme +### Theme feature flag -Computed per deployment, never stored. Derived from the running build's PEP 440 pre-release status, -overridable by explicit operator configuration. +A per-deployment boolean, read from configuration, never stored against a user. It is not derived +from anything — the deployment states it. ```text -deployment_default_theme : LIGHT | DARK - = operator override, when configured - | DARK when Version(running_version).is_prerelease - | LIGHT otherwise +dark_theme : bool = INFRAHUB_EXPERIMENTAL_DARK_THEME, default false ``` -Always a concrete palette, never `SYSTEM`. Both ends of that are deliberate: +While dark is alpha it governs two things at once: -- **Production is `LIGHT`, not `SYSTEM`.** Dark is alpha, so it is reached only by a user's own - choice. Deferring to the operating system would put dark-OS users into it by inference. -- **Non-production is `DARK`, not `SYSTEM`.** Following the system would leave every engineer on a - light system out of the dogfooding, which is the whole point of that default. +| `dark_theme` | Theme setting offered | Default for a user who has not chosen | +|---|---|---| +| `false` | none — the field is absent | `LIGHT` | +| `true` | `LIGHT` / `DARK` (alpha) / `SYSTEM` | `DARK` | + +Both defaults are concrete palettes, never `SYSTEM`, and both directions are deliberate: + +- **Flag off gives `LIGHT`, not `SYSTEM`.** Dark is alpha, so it is reached only by a user's own + choice. Deferring to the operating system would put dark-OS users into it by inference — and on a + deployment where the feature is switched off entirely, there is no choice to infer from. +- **Flag on gives `DARK`, not `SYSTEM`.** Following the system would leave every engineer on a light + machine out of the dogfooding, which is the flag's whole point. -`SYSTEM` remains available to users as an explicit choice on any deployment — it is simply never a -default. +`SYSTEM` remains available to users as an explicit choice wherever the flag is on — it is simply +never a default. -It is a *default*, not a value written anywhere: it never touches a stored preference (FR-013), so a -deployment that flips from pre-release to release changes what un-chosen users see and changes -nothing for users who chose. +The two jobs separate when the flag is removed: the production default then becomes its own decision +rather than a consequence of the gate. + +It is a *default*, not a value written anywhere: it never touches a stored preference (FR-013), so +flipping the flag changes what un-chosen users see and changes nothing for users who chose. ## Resolution chain @@ -87,12 +94,17 @@ effective.theme.value = user.theme ?? global.theme ?? null effective.theme.source = USER | GLOBAL | DEFAULT ``` -`source` reports which layer answered, so the interface can say "Your preference" versus "From the -organisation default" versus falling through — the convention `preference-fields.tsx` already -implements. +The chain is the existing one, unchanged — but **theme is exposed at the user scope only**, so no +interface writes the global layer and `global.theme` is always `null` in practice. The chain +therefore reduces to `user.theme ?? null`. Nothing needs removing from the backend to achieve this: +the mutation's `scope` argument is shared across fields, so the global layer simply has no writer. +When an organisation-wide default is added later, the chain already supports it. + +`source` reports which layer answered, so the interface can say "Your preference" versus falling +through to a default — the convention `preference-fields.tsx` already implements. -When the chain yields `null` (source `DEFAULT`), the client substitutes the deployment default from -the config payload. +When the chain yields `null` (source `DEFAULT`), the client substitutes the flag's default: `DARK` +where the flag is on, `LIGHT` where it is off. ### Stage 2 — resolve to a concrete palette @@ -119,7 +131,7 @@ A `localStorage` mirror of the resolution, existing solely to make the first pai | Key | Holds | Written when | |---|---|---| -| choice | the stored choice, or the deployment default | the effective preference resolves | +| choice | the stored choice, or the flag's default | the effective preference resolves | | resolved | `light` or `dark` | stage 2 completes | Read synchronously by the inline classification script before first paint. It is a cache, never a @@ -127,26 +139,28 @@ source of truth: the account-backed preference always wins on arrival, and a cle corrected repaint rather than a wrong theme. ⚠ On a cold start the mirror is empty and the fallback is **light** — not the browser's appearance. -Consulting the system here would put a dark-OS user into the alpha palette before any preference has -been read, which is the inference the whole design forbids. On production this fallback matches the -deployment default, so a first-ever visit is correct; on a non-production deployment it paints light -and corrects to dark once the config payload lands. That single first-visit frame is accepted — it -affects the team's own builds only. +The script runs before the config payload arrives, so it cannot know whether the flag is even on; +guessing from the operating system would put a dark-OS user into the alpha palette on a deployment +where the feature is switched off entirely. Where the flag is off, light is also the final answer, so +a first-ever visit is correct; where it is on, that visit paints light and corrects to dark once the +config payload lands. That single frame is accepted — it affects flag-enabled deployments only. Cross-tab synchronisation is out of scope; a second tab picks up a change on its next load. ## Relationships ```text -Account ──owns──▶ Preference(owner_id = account id) ─┐ - ├─▶ effective choice ─▶ resolved ─▶ consumers -Organisation ───▶ Preference(owner_id = GLOBAL) ─┤ (document class, - │ GraphiQL, -Deployment ─────▶ default theme (computed) ─┘ Mermaid, - visualizer) -Operating system ────────────────────────────────────────▶ (consumed by stage 2 only) +Account ──owns──▶ Preference(owner_id = account id).theme ─┐ + ├─▶ effective choice ─▶ resolved ─▶ consumers +Deployment ─────▶ dark_theme flag ─▶ default (DARK|LIGHT) ─┘ (document class, + GraphiQL, +Operating system ─────────────────▶ (stage 2 only, and only for an explicit SYSTEM choice) Mermaid, + visualizer) ``` +No organisation edge: the global `Preference` row exists for `date_format` and `timezone`, but +nothing writes `theme` into it in this version. + ## Validation rules - `theme` accepts only `Theme` members; unknown values are rejected at construction, including on @@ -155,7 +169,9 @@ Operating system ───────────────────── and is how a user returns to "Automatic (inherited)". - Writing the global layer requires the same permission as the existing global preference writes; no new permission is introduced. -- The deployment default is always concrete (`LIGHT` or `DARK`) and never `SYSTEM`. This is a policy +- The flag's default is always concrete (`LIGHT` or `DARK`) and never `SYSTEM`. This is a policy constraint, not a technical one — stage 2 could resolve a `SYSTEM` default perfectly well. It is excluded because a defaulted user must never reach the alpha palette by inference, and because a - system-following non-production default would defeat the dogfooding. + system-following default would defeat the dogfooding. +- Turning the flag off MUST NOT delete a stored `theme`. The value is ignored while unreachable and + honoured again if the flag returns; a configuration change must never destroy user data. diff --git a/dev/specs/infp-46-dark-theme-completion/plan.md b/dev/specs/infp-46-dark-theme-completion/plan.md index 56c7d6b1c0c..680d703bf99 100644 --- a/dev/specs/infp-46-dark-theme-completion/plan.md +++ b/dev/specs/infp-46-dark-theme-completion/plan.md @@ -13,20 +13,20 @@ pull request targets that branch, not `develop`, and re-targets `develop` once # The dark palette exists but is unreachable: it is activated only by a development-only `@custom-variant` and a manually added class. This plan makes it reachable, binds every surface that -currently pins itself to light, retires the token debt, and turns non-production deployments dark by +currently pins itself to light, retires the token debt, and turns the development stack dark by default so the team dogfoods it. The technical spine is a **single resolution, computed once and handed down**. A stored choice -(`LIGHT`/`DARK`/`SYSTEM`) resolves server-side across the user and organisation layers; the client +(`LIGHT`/`DARK`/`SYSTEM`) resolves server-side at the user layer; the client resolves `SYSTEM` against the operating system to a strict `light`/`dark`; every consumer — the document class, GraphiQL, Mermaid, the schema visualizer — takes that one resolved value. No consumer runs its own `prefers-color-scheme` check, which is what makes them incapable of drifting apart. Two decisions carry most of the risk and are settled in [research.md](./research.md): the -"non-production build" signal is PEP 440 pre-release status on the running version, published as a -resolved value on the unauthenticated config payload (§R1); and the first paint is owned by an inline -classification script reading a `localStorage` mirror, not by React (§R2). +dogfooding gate is an experimental flag following the convention the two existing experimental +settings already use — off by default, enabled in the development stack (§R1); and the first paint is +owned by an inline classification script reading a `localStorage` mirror, not by React (§R2). ## Technical Context @@ -49,11 +49,11 @@ pytest/testcontainers (end-to-end) visit. Theme switching repaints without a reload and without re-running the Mermaid pipeline on every React render. -**Constraints**: The light theme must be visually unchanged (FR-020). Semantic colors must stay -mutually distinguishable in both themes (FR-021). The login page must be themed before a session -exists. +**Constraints**: The light theme must be visually unchanged (FR-020). Text must stay legible against +its surface in both themes (FR-021); semantic palettes are out of scope. The login page must be +themed before a session exists. -**Scale/Scope**: 3 backend layers (constants/model/GraphQL) + 1 config field; ~104 existing CSS +**Scale/Scope**: 3 backend layers (constants/model/GraphQL) + 1 boolean setting; ~104 existing CSS tokens reused, none added; ~20 application files carrying hardcoded variants; 1 external repository. ## Constitution Check @@ -65,9 +65,9 @@ tokens reused, none added; ~20 application files carrying hardcoded variants; 1 | **I. Schema-Driven Integrity** | ⚠ **Gate — requires sign-off.** Generated artifacts change: `schema/schema.graphql`, `schema/openapi.json`, `frontend/app/src/shared/api/rest/types.generated.ts`. All are regenerated, never hand-edited, and committed. `AGENTS.md` lists GraphQL schema modifications and database schema changes as **Ask First**; see [Open governance points](#open-governance-points). | | **II. Branch-Safe by Default** | ✅ Not applicable in substance. `Preference` is a `StandardNode` outside the branched graph, and a theme has no temporal or per-branch meaning. No branch-aware queries are introduced. | | **III. Type Safety & Explicit Contracts** | ✅ `Theme` is a closed enum end to end — rejected at construction on read, typed through GraphQL, and a discriminated union on the client. ⚠ Two inherited constraints must be honoured: `Optional[Theme]` not `Theme \| None` (`StandardNode.guess_field_type`), and single-line enum descriptions (SDL printer stability). | -| **IV. Test Discipline** | ✅ Resolution logic (both stages), the pre-release derivation, and the three-state mutation argument are pure functions with table-driven unit tests. The version-derived default is tested by calling the resolver with version strings, never by faking a deployment. | +| **IV. Test Discipline** | ✅ Resolution logic (both stages) and the three-state mutation argument are pure functions with table-driven unit tests. The flag's two states are tested by setting the setting directly, never by faking a deployment. | | **V. Query Performance & Efficiency** | ✅ One extra nullable property on a record already fetched. No new queries, no new round trips — `theme` rides the existing effective-preferences query and the existing config payload. | -| **VI. Security & Input Boundaries** | ✅ The config payload gains only a resolved `"light"`/`"dark"`; the version string is **not** newly exposed to anonymous callers. Global-scope writes reuse the existing permission check; no new permission is introduced. | +| **VI. Security & Input Boundaries** | ✅ The config payload gains one boolean on a field it already carries; no version information is exposed to anonymous callers. Theme is user-scoped only, so no permission surface is touched at all. | | **VII. Simplicity & Maintainability** | ✅ Extends the existing preference machinery rather than adding storage. Removes more than it adds: the `@custom-variant` escape hatch, ~20 files of hardcoded variants, and GraphiQL's now-redundant theme picker. | **Post-design re-check**: no violations introduced. [Complexity Tracking](#complexity-tracking) is @@ -103,9 +103,9 @@ backend/infrahub/ │ ├── types/preferences.py # + Theme, EffectiveTheme; += EffectivePreferencesType, RawPreferencesType │ ├── queries/preferences.py # resolve theme through the existing chain │ └── mutations/preferences.py # + theme argument, _UNSET three-state handling -├── config.py # + ExperimentalFeaturesSettings.default_theme override -├── api/internal.py # + ConfigAPI.default_theme (resolved, unauthenticated) -└── core/preferences/theme.py # NEW — version → default theme, pure +└── config.py # + ExperimentalFeaturesSettings.dark_theme: bool = False + +development/docker-compose.yml # + INFRAHUB_EXPERIMENTAL_DARK_THEME, defaulted true frontend/app/ ├── index.html # + inline pre-paint classification script @@ -115,7 +115,7 @@ frontend/app/ │ ├── domain/model/preference.ts # + theme │ ├── domain/rules/theme.ts # NEW — stage-2 resolution, pure (no storage access) │ ├── ui/theme-provider.tsx # NEW — fills the shared context; applies class, mirrors, listens - │ ├── ui/preference-fields.tsx # + theme field with pre-release marker + │ ├── ui/preference-fields.tsx # + theme field with "alpha" tag; hidden when flag off │ └── ui/queries/*.ts # + theme in query and mutation documents ├── pages/graphql/index.tsx # forcedTheme="light" → resolved theme └── shared/components/ @@ -128,9 +128,10 @@ frontend/packages/ui/src/styles/theme.css # − @custom-variant escape hatch ``` **Structure Decision**: Web application layout. The theme preference slots into the existing -`entities/preferences/` vertical on both sides, so no new architectural seam appears. The one new -backend module (`core/preferences/theme.py`) exists to keep the version→default derivation a pure, -directly testable function rather than logic embedded in the API layer. +`entities/preferences/` vertical on both sides, and the gate slots into the existing experimental +settings, so no new architectural seam appears and **no new backend module is needed**. An earlier +revision added `core/preferences/theme.py` to hold a version→default derivation; adopting the +existing flag convention removed it. ⚠ **The context must live in `shared/`, not in the entity.** `shared/` components consume the resolved theme (Mermaid, the data viewer), and `dev/knowledge/frontend/entities-structure.md` @@ -198,13 +199,15 @@ The foundation. Everything else consumes the resolved value it produces. ### Phase B — Non-production default (US2) · P1 -1. `core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark"`. Pre-release → - `dark`; otherwise → `light`. ⚠ Never returns `"system"`: a defaulted user must not reach the alpha - palette by inference, and a system-following non-production default would defeat the dogfooding. -2. `ExperimentalFeaturesSettings.default_theme: Literal["light","dark"] | None`. ⚠ The `| None` is - load-bearing: "not configured" must stay distinguishable from every configured value — see - [contracts/rest-config.md](./contracts/rest-config.md). -3. `ConfigAPI.default_theme`; regenerate the OpenAPI schema and frontend REST types. +1. `ExperimentalFeaturesSettings.dark_theme: bool = False` — a plain bool, matching `graphql_enums`. + No new endpoint or payload field: `experimental_features` is already on the unauthenticated + `/api/config`. Regenerate the OpenAPI schema and frontend REST types. +2. Enable it in `development/docker-compose.yml`: + `INFRAHUB_EXPERIMENTAL_DARK_THEME: ${INFRAHUB_EXPERIMENTAL_DARK_THEME:-true}`. ⚠ That default of + `true` — unlike its two neighbours — is what delivers SC-008. Decide deliberately whether the root + `docker-compose.yml` follows suit; it reaches beyond the deployments the team runs. +3. With the flag off, render light and **omit the theme field entirely**. ⚠ Not a light-only picker: + offering match-system would let a dark-OS user reach the alpha palette straight through the gate. 4. Client substitutes it when the effective preference resolves with source `DEFAULT`. 5. Pin the theme explicitly in both end-to-end suites so they stop depending on the build's version. @@ -263,6 +266,9 @@ starts. | Semantic colors flattened during migration | Status/severity no longer distinguishable, in work explicitly scoped out of redesigning them | Migrate `badge.tsx` without degrading existing distinctions; hand anomalies to the separate effort | | Default flip destabilises end-to-end suites | Failures misattributed | Pin the theme in both suites; baseline only from a green post-#10284 run | | #10284 is revised or does not land | This branch is stacked on it, so its history moves under us | Rebase onto `bab-dark-theme-app`; Phases A–C and E do not depend on its content, only D does | +| Flag off still leaves a route to dark via match-system | The gate leaks for any dark-OS user | Hide the theme field entirely when the flag is off — not a light-only picker | +| Turning the flag off deletes stored preferences | A config change destroys user data | Ignore the stored value while unreachable; never delete it | +| The flag outlives the alpha and becomes permanent | `value_db_index` in the same settings class is already a dead flag with a deprecation notice | Recorded as knowingly open-ended; revisit when the release cycle for the target version is known | | #10284's failing e2e checks are inherited by this stacked PR | Reviewers misread them as caused by this work | State it in the PR description; baseline only from a green post-merge run | | Submodule pointer moved to an unpushed commit | Breaks every other checkout | Upstream merge strictly precedes the bump | diff --git a/dev/specs/infp-46-dark-theme-completion/quickstart.md b/dev/specs/infp-46-dark-theme-completion/quickstart.md index 8c53db058f8..3b70f6be13d 100644 --- a/dev/specs/infp-46-dark-theme-completion/quickstart.md +++ b/dev/specs/infp-46-dark-theme-completion/quickstart.md @@ -84,9 +84,9 @@ document.documentElement.classList.contains("dark") ### US1 — Choose a theme -1. Sign in, open preferences. The theme field shows the deployment default with a source note +1. Sign in, open preferences. The theme field shows the flag's default with a source note distinguishing it from a personal choice. -2. The dark option carries a visible pre-release marker. +2. The dark option carries a visible **"alpha"** tag. 3. Select dark — the interface repaints with no reload. 4. Reload. It is dark **in the first painted frame**. To check honestly, throttle the network hard (DevTools → Network → Slow 3G) so the preference query is visibly slow: a correct implementation @@ -94,31 +94,27 @@ document.documentElement.classList.contains("dark") 5. Sign in from a second browser: dark there too. 6. Select "match system", then switch the operating system's appearance with the page open — the interface follows without a reload. -7. Set an organisation default, then check it applies to a user with no personal choice and loses to - one who has. - -### US2 — Non-production default - -```bash -uv run python -c "import importlib.metadata as m; from packaging.version import Version; v=m.version('infrahub-server'); print(v, Version(v).is_prerelease)" -``` - -A local checkout reports something like `1.11.0b2.dev134+geb5acb009 True`. - -1. As a user with **no** stored theme, load the application: dark. Then set your operating system to - light and reload — still dark. The non-production default ignores the system deliberately, or an - engineer on a light machine would never dogfood it. -2. Confirm `GET /api/config` returns `default_theme: "dark"` and does **not** include a version. -3. Set the operator override to `light`, restart, reload: light — the override beat the version. -4. Set a personal preference, then flip the override: the personal preference still wins and its - stored value is unchanged. -5. Clear browser storage, set the operating system to **dark**, and reload: the first paint is - **light**, then corrects to dark. Both halves matter — light because a defaulted user must not - reach the alpha palette by inference, and the correction because the deployment default is dark. - -To check the release path without cutting a release, exercise the resolution function directly with -`1.11.0` in a unit test rather than trying to fake the deployment's version. On production the -equivalent check is: system set to dark, no stored preference → the application stays **light**. +7. Clear your choice back to the inherited default and confirm the source note reverts to reporting a + default rather than your own preference. +8. ⚠ Confirm there is **no** theme field on the organisation-wide preferences form — theme is + user-scoped in this version. + +### US2 — The feature flag + +1. With `INFRAHUB_EXPERIMENTAL_DARK_THEME` on (the dev stack default) and **no** stored theme, load + the app: dark. Set your operating system to light and reload — still dark. The default ignores the + system deliberately, or an engineer on a light machine would never dogfood it. +2. Confirm `GET /api/config` reports `experimental_features.dark_theme: true`, unauthenticated, with + no version disclosed. +3. Turn the flag off, restart, reload: light, **and the theme field is gone from preferences** — not + merely reduced to light. Check "match system" is absent too; leaving it would be a hole straight + through the flag for anyone on a dark operating system. +4. With the flag off, confirm a previously stored `DARK` preference is **still in the database** — + ignored, not deleted. Turn the flag back on and confirm that user is dark again. +5. Clear browser storage, set the operating system to **dark**, and reload with the flag on: the + first paint is **light**, then corrects to dark. Both halves matter — light because the pre-paint + script runs before it knows whether the flag is even on, and the correction because the flag's + default is dark. ### US3 — GraphQL sandbox @@ -177,7 +173,7 @@ equivalent check is: system set to dark, no stored preference → the applicatio - **Semantic colors are out of scope** — status, severity, diff conflict, danger palettes are tracked separately. Do not redesign them here; do not let a mechanical token swap flatten two distinct severities into one either. `shared/components/ui/badge.tsx` carries the most of these. -- **End-to-end suites** pin the theme explicitly rather than inheriting the build default, so they +- **End-to-end suites** pin the theme explicitly rather than inheriting the flag's value, so they stay deterministic. ⚠ #10284's end-to-end checks are already failing and are out of scope — do not read those failures as fallout from this work; establish the baseline from a green run after it lands. diff --git a/dev/specs/infp-46-dark-theme-completion/research.md b/dev/specs/infp-46-dark-theme-completion/research.md index e9867a80740..8f9166ebf8f 100644 --- a/dev/specs/infp-46-dark-theme-completion/research.md +++ b/dev/specs/infp-46-dark-theme-completion/research.md @@ -6,7 +6,7 @@ Phase 0 output. Each section states an open question from the spec, the options decision, and the evidence gathered from the codebase. Findings marked **⚠** are traps that would cost a rewrite if discovered during implementation. -## R1 — How is "non-production build" detected? (FR-010, FR-011, FR-012) +## R1 — How is the dogfooding deployment gated? (FR-010, FR-011, FR-012, FR-013) **Question**: The handover note called this "make canary enabled by default". No `canary` concept exists anywhere in the repository, so the mechanism had to be chosen rather than located. @@ -15,10 +15,10 @@ exists anywhere in the repository, so the mechanism had to be chosen rather than | Option | Verdict | |---|---| -| New `ExperimentalFeaturesSettings` flag | Rejected as the primary mechanism | +| An `ExperimentalFeaturesSettings` flag, following the existing convention | **Chosen** | +| PEP 440 pre-release detection on the running version | Considered at length, then rejected | | `installation_type` | Rejected — wrong axis | | Frontend build-time environment variable | Rejected | -| PEP 440 pre-release detection on the running version | **Chosen** | **`installation_type` is the wrong axis.** `backend/infrahub/constants/environment.py` defines `INSTALLATION_TYPE = "community"`. It distinguishes community from enterprise, not production from @@ -28,52 +28,68 @@ non-production. It is already on the config payload, which makes it a tempting f image deployed into a production-like environment would still claim non-production, and the same published assets are served by every deployment. -**An experimental flag alone is wrong** because it requires every non-production deployment to be -configured individually — which is exactly the per-engineer setup burden SC-008 exists to avoid. - ### Decision -Derive the default from the running version's PEP 440 pre-release status, with an explicit operator -override retained on top. +Add `dark_theme: bool = False` to `ExperimentalFeaturesSettings` and enable it in the development +stack's compose configuration. + +The codebase already has this pattern, twice, and both instances default to off with a per-deployment +env override: + +```yaml +INFRAHUB_EXPERIMENTAL_GRAPHQL_ENUMS: ${INFRAHUB_EXPERIMENTAL_GRAPHQL_ENUMS:-false} +INFRAHUB_EXPERIMENTAL_VALUE_DB_INDEX: ${INFRAHUB_EXPERIMENTAL_VALUE_DB_INDEX:-false} +``` + +`experimental_features` is already on the unauthenticated `/api/config` payload, so the flag is +readable before sign-in with no new field and no new endpoint. + +### Why the version-derived design was withdrawn + +It was specified first, and in detail. Versions come from `hatch-vcs` (`pyproject.toml`: +`git_describe_command = [..., "--match", "infrahub-v*"]`, generating `backend/infrahub/_version.py`) +— the INFP-566 dynamic-versions work. The derivation was verified against real builds: + +| Version | `is_prerelease` | +|---|---| +| `1.11.0b2.dev134+geb5acb009` (this checkout) | `True` | +| `1.12.0.dev5+g1a2b3c` (dev build) | `True` | +| `1.11.1rc1` (release candidate) | `True` | +| `1.11.0` (release) | `False` | + +It worked, and it needed no configuration. It was rejected anyway, for two reasons: -Versions come from `hatch-vcs` (`pyproject.toml`: `git_describe_command = ["git", "describe", -"--tags", "--long", "--match", "infrahub-v*"]`, generating `backend/infrahub/_version.py`) — the -INFP-566 dynamic-versions work. Verified against the running build: +1. **It gates on the wrong thing.** "Pre-release" is a property of a *version*, not of a deployment. + A customer running `1.11.0rc1` in their own staging environment matches it exactly — and they are + not "the deployments we run". The flag targets deployments directly, which is what was meant. +2. **It invents a mechanism where one exists.** The repository already has a convention for exactly + this, used by both existing experimental settings. Deriving a bespoke default from version strings + is more machinery doing the same job less precisely. -| Version | `is_prerelease` | Default theme | -|---|---|---| -| `1.11.0b2.dev134+geb5acb009` (this checkout) | `True` | dark | -| `1.12.0.dev5+g1a2b3c` (dev build) | `True` | dark | -| `1.11.0b2` (beta) | `True` | dark | -| `1.11.1rc1` (release candidate) | `True` | dark | -| `1.11.0` (release) | `False` | light | +⚠ The rejected design also required parsing PEP 440 versions in the backend. The chosen design needs +none, so this feature introduces **no new dependency** — one fewer governance gate crossed. -⚠ Neither default consults the operating system, and both directions are deliberate. Production stays -light because dark is alpha and must be reached only by an explicit choice — deferring to the system -would put dark-OS users into it by inference. Non-production forces dark because following the system -would leave every engineer on a light machine out of the dogfooding, which is that default's whole -purpose. +Withdrawing it removed a resolver module, the version parsing, a config field, and five tasks. -An intermediate revision defaulted production to `system` and was withdrawn once that first -consequence was made explicit. +### The accepted trade -This needs no configuration for the common case: every build the team runs day to day carries a -`.devN`/`bN`/`rcN` segment, and every published release does not. The operator override (FR-012) -remains available for the deployment that wants to disagree. +The flag only reaches deployments whose configuration this repository controls. A deployment started +some other way — a shared staging box, a cloud instance — stays light until someone sets the env var +there. The version-derived design would have covered those automatically. This was raised explicitly +and the trade accepted: the deployments the team actually runs come from these compose files. -### ⚠ The frontend must not do this detection +### What the flag governs -`backend/infrahub/api/internal.py` exposes two endpoints with different auth postures: +While dark is alpha the flag decides both *whether the feature exists* and, where it does, *that dark +is the default*. Compressing two jobs into one switch is deliberate: they separate at the moment the +flag is removed, when the production default becomes its own decision. -- `GET /api/config` — **unauthenticated**. Carries `experimental_features`, `installation_type`, sso, - ldap, policy. -- `GET /api/info` — **authenticated** (`Depends(get_current_user)`). Carries `deployment_id` and - `version`. +⚠ With the flag off the theme field is hidden **entirely**, not reduced to a light-only picker. +Offering "light" and "match system" would leave a hole straight through the gate — a user on a dark +operating system selects match-system and reaches the alpha palette anyway. -The version is only on the authenticated endpoint, but the login page needs a theme *before* there is -a session (spec edge case "Before sign-in"). Therefore the backend computes the default and publishes -the **result** on the unauthenticated config payload. The frontend never parses a version string, and -no version information is newly exposed to anonymous callers — only a resolved `light`/`dark` value. +⚠ Flipping the flag off must **ignore** a stored `DARK` preference, never delete it. A configuration +change that destroys user data is a much worse failure than a theme that reverts. ## R2 — How is the flash of the wrong theme prevented? (FR-006, SC-002) @@ -94,21 +110,22 @@ which sets the theme class on the document element from a `localStorage` mirror. the first paint decision. The mirror is written whenever the effective theme resolves (from the account preference, or from the -deployment default on the config payload). Precedence inside the inline script: +flag's default). Precedence inside the inline script: 1. Mirrored resolved theme, if present. 2. Mirrored raw choice of "system" → resolve against `prefers-color-scheme` at that instant. 3. Nothing mirrored → light. -⚠ **Step 3 is light, not `prefers-color-scheme`.** Consulting the system here would put a dark-OS -user into the alpha palette before any preference has been read — the inference the design forbids. -On production this fallback matches the deployment default, so a first-ever visit is correct. +⚠ **Step 3 is light, not `prefers-color-scheme`.** The script runs before the config payload arrives, +so it cannot know whether the flag is even on. Guessing from the operating system would put a dark-OS +user into the alpha palette on a deployment where the feature is switched off entirely. Where the +flag is off, light is also the final answer, so this fallback is correct rather than merely safe. -**Known and accepted limitation**: on a browser's *first ever* visit to a **non-production** +**Known and accepted limitation**: on a browser's *first ever* visit to a **flag-enabled** deployment, nothing is mirrored, so the first paint is light and corrects to dark once the config payload arrives. Every subsequent load is correct from the first frame. Eliminating even that one frame would require the server to template the HTML shell, which is disproportionate for a case -affecting the team's own builds only. Recorded as a deliberate boundary, not an oversight. +affecting flag-enabled deployments only. Recorded as a deliberate boundary, not an oversight. **Reconciliation**: when the authoritative preference disagrees with the mirror, the class is updated and the mirror rewritten. @@ -286,8 +303,8 @@ produces in a fresh worktree. ## R8 — Test and verification impact -Changing the default theme on non-production builds (FR-010) changes what the end-to-end suites see, -since they run against locally built — therefore pre-release — versions. +Enabling the flag (FR-010) changes what the end-to-end suites see, since they run against the +repository's own compose configuration — the same place the flag is switched on. Both suites are affected: the legacy Playwright suite (`frontend/app`, `pnpm test:e2e`) and the pytest/testcontainers suite (`tests/e2e`). Any assertion on a specific color, and any screenshot diff --git a/dev/specs/infp-46-dark-theme-completion/spec.md b/dev/specs/infp-46-dark-theme-completion/spec.md index 81944dcac8b..ef106e31791 100644 --- a/dev/specs/infp-46-dark-theme-completion/spec.md +++ b/dev/specs/infp-46-dark-theme-completion/spec.md @@ -61,10 +61,10 @@ observe the same choice. **Acceptance Scenarios**: 1. **Given** a signed-in user whose theme has never been set, **When** they open their preferences, - **Then** the theme setting shows the deployment's default as the effective value and indicates - that it comes from a default rather than from their own choice. + **Then** the theme setting shows the effective value and indicates that it comes from a default + rather than from their own choice. 2. **Given** a user viewing the theme setting, **When** they look at the dark option, **Then** it - carries a visible pre-release marker distinguishing it from the light option. + carries a visible **alpha** marker distinguishing it from the light option. 3. **Given** a user on the light theme, **When** they select dark, **Then** the application switches to the dark palette without a page reload. 4. **Given** a user who has selected dark, **When** they reload the page, **Then** the application @@ -73,43 +73,46 @@ observe the same choice. browser, **Then** the application is dark there too. 6. **Given** a user who has selected "match system", **When** their operating system switches from light to dark while the page is open, **Then** the application follows without a reload. -7. **Given** an administrator setting an organisation-wide theme, **When** a user who has made no - personal choice loads the application, **Then** they see the organisation-wide theme; **and when** - a user who has made a personal choice loads it, **Then** their personal choice wins. +7. **Given** a user who has selected a theme, **When** they clear it back to the inherited default, + **Then** the setting reports the value as coming from a default again rather than from their own + choice. --- -### User Story 2 - Non-production deployments default to dark (Priority: P1) +### User Story 2 - The whole feature sits behind a flag, on for the dev stack (Priority: P1) -The team runs non-production builds of Infrahub day to day. Those deployments default to the dark -theme so that the team lives in it continuously and surfaces the remaining visual defects through -ordinary use, without every engineer having to opt in individually. This default ignores the -operating system's appearance deliberately: an engineer on a light system must still see dark, or -they are not dogfooding it. +The theme feature is gated by an experimental flag. It is off by default everywhere, and turned on in +the development stack so the team lives in dark continuously and surfaces its remaining visual +defects through ordinary use, without any engineer configuring anything themselves. -Production builds default to light. Dark is alpha, so it is reached only by an explicit choice — -never by inference from a user's system setting. +The flag does two jobs while dark is alpha: it decides whether the feature exists at all, and — where +it exists — it makes dark the default for anyone who has not chosen. With the flag off there is no +theme setting and the application is light. Both are deliberate: an engineer on a light system must +still see dark or they are not dogfooding it, and a user on a deployment where the flag is off must +have no route into the alpha palette at all. -**Why this priority**: This is the stated near-term goal of the whole effort — dogfooding dark for -the coming weeks. It is what converts the setting from a feature into a feedback loop, and it is -cheap once User Story 1 exists. +**Why this priority**: This is the stated near-term goal — dogfooding dark for the coming weeks — and +it is what keeps an unfinished theme away from anyone who has not opted into running it. -**Independent Test**: Load a non-production deployment as a user with no theme preference set and -observe dark; load a production build the same way and observe light; in both, set a personal -preference and observe it override the default. +**Independent Test**: Start the dev stack with no per-engineer setup and observe dark; start with the +flag off and observe light with no theme setting present; in both, confirm a stored preference is +never destroyed. **Acceptance Scenarios**: -1. **Given** a deployment running a non-production build, **When** a user with no theme preference - loads the application, **Then** it paints in dark **regardless of their system appearance** — - including for an engineer whose operating system is light. -2. **Given** a deployment running a production build, **When** a user with no theme preference loads - the application, **Then** it paints in light, **even if their operating system is dark**. Dark is - alpha and is never reached by inference. -3. **Given** a non-production deployment defaulting to dark, **When** a user explicitly selects +1. **Given** a deployment with the flag on and a user with no theme preference, **When** they load the + application, **Then** it paints dark **regardless of their system appearance** — including for an + engineer whose operating system is light. +2. **Given** a deployment with the flag off, **When** any user loads the application, **Then** it + paints light and **no theme setting is offered**. In particular "match system" is absent, so a + user on a dark operating system has no route to the alpha palette. +3. **Given** the flag is on and a user has selected dark, **When** an operator turns the flag off, + **Then** the application renders light **and the stored preference is retained, not deleted** — + turning the flag back on restores their choice. +4. **Given** a deployment with the flag on defaulting to dark, **When** a user explicitly selects light, **Then** their choice is honoured and persists. -4. **Given** any deployment, **When** an operator explicitly configures the default theme, **Then** - that configuration overrides the build-derived default. +5. **Given** an engineer starting the development stack, **When** they do nothing else, **Then** the + application is dark — no per-engineer configuration step exists. --- @@ -242,23 +245,27 @@ The first three are one problem with one answer, so they are grouped rather than refreshes the cache. Because the cache holds the *resolved* theme, a returning user — signed in or not — paints correctly from the first frame. - With nothing cached, the fallback is light. On production that is already the deployment default, - so a first-ever visit is correct. On a non-production deployment it is not: that first visit paints - light and corrects to dark once the deployment default arrives. Accepted — it is one frame, on the - team's own builds, on a browser that has never loaded the application before. Removing it would - mean the server templating the HTML shell, which is disproportionate. + With nothing cached, the fallback is light. With the flag off that is already the answer, so the + first-ever visit is correct. With the flag on it is not: that first visit paints light and corrects + to dark once the flag's value arrives. Accepted — it is one frame, on a flag-enabled deployment, on + a browser that has never loaded the application before. Removing it would mean the server + templating the HTML shell, which is disproportionate. ⚠ The fallback is light rather than the operating system's appearance. Consulting the system here - would put a dark-OS user into the alpha palette before any preference has been read — the exact - inference FR-011 forbids. + would put a dark-OS user into the alpha palette before either the preference or the flag has been + read — exactly what FR-011 exists to prevent. -- **System appearance changes while the page is open.** A user following their system switches their - operating system's appearance. The application follows without a reload. Cheap to support — - the browser exposes this as a subscribable change — so it is in scope rather than deferred. +- **The flag is turned off while a user has dark stored.** The application renders light; the stored + preference is retained untouched and honoured again if the flag returns. A config change must + never destroy user data. + +- **System appearance changes while the page is open.** A user who chose match-system switches their + operating system's appearance. The application follows without a reload. Cheap to support — the + browser exposes this as a subscribable change — so it is in scope rather than deferred. - **Existing automated tests.** Tests that assert specific colors, or that screenshot the interface, - are sensitive to the default changing on non-production builds. In scope: the suites must be made - deterministic rather than left to inherit whatever the build implies. + are sensitive to the flag's value. In scope: the suites must pin the theme explicitly rather than + inherit whatever the deployment implies. - **Print and export.** Unchanged; out of scope. @@ -272,12 +279,13 @@ The first three are one problem with one answer, so they are grouped rather than operating system. - **FR-002**: The system MUST persist a user's theme choice against their account, so it applies on any browser or machine where they sign in. -- **FR-003**: The system MUST support an organisation-wide theme default that applies to users who - have made no personal choice, and MUST let a personal choice override it. Setting it MUST require - the same permission as the existing organisation-wide preferences; no new permission is introduced. +- **FR-003**: The theme preference MUST be user-scoped only. No organisation-wide theme default is + offered in this version — while the feature is flag-gated to the development stack there is no + administrator setting a house theme for anyone. This is deferred to the moment the flag is removed, + when a real user for it exists. - **FR-004**: The system MUST report which layer an effective theme came from — the user's own - choice, the organisation default, or the built-in default — consistent with how existing - preferences report their source. + choice or the built-in default — consistent with how existing preferences report their source. No + new permission is introduced. - **FR-005**: Users MUST be able to change the theme and see it applied without reloading the page. - **FR-006**: The system MUST apply the correct theme on the first painted frame, with no visible flash of the other theme. @@ -288,19 +296,25 @@ The first three are one problem with one answer, so they are grouped rather than label specifically; "alpha" is the word to render, not a paraphrase of it. Because "match system" can resolve to dark, its description MUST make that consequence clear. - **FR-009**: The system MUST render a coherent theme when no preference can be retrieved, falling - back to the last locally cached resolution and then to the deployment default. The cache MUST be - read synchronously before the first frame, and its absence or unavailability MUST NOT prevent the - application from loading. - -**Deployment defaults** - -- **FR-010**: Deployments running a non-production build MUST default to dark for users with no - personal choice. -- **FR-011**: Deployments running a production build MUST default to light. Dark MUST NOT be reached - without an explicit user choice, because it is alpha. -- **FR-012**: Operators MUST be able to override the build-derived default with explicit - configuration. -- **FR-013**: A deployment default MUST NOT overwrite or reset any user's stored personal choice. + back to the last locally cached resolution and then to light. The cache MUST be read synchronously + before the first frame, and its absence or unavailability MUST NOT prevent the application from + loading. + +**Feature flag** + +- **FR-010**: The theme feature MUST be gated by an experimental flag, following the convention the + existing experimental settings already use: off by default, enabled per deployment through + configuration. The development stack MUST enable it, so an engineer gets dark by starting the + stack and performing no other step. +- **FR-011**: With the flag off, the system MUST render light and MUST NOT offer a theme setting at + all. Offering only "light" and "match system" is not sufficient: a user on a dark operating system + would reach the alpha palette through match-system, defeating the flag. +- **FR-012**: With the flag on, the system MUST default to dark for users with no personal choice, + **regardless of their operating system's appearance**. Following the system here would leave every + engineer on a light machine out of the dogfooding, which is the flag's whole purpose. +- **FR-013**: Changing the flag MUST NOT overwrite, reset or delete any user's stored preference. A + stored choice that the flag makes unreachable MUST be ignored while the flag is off and honoured + again when it returns. **Embedded and third-party surfaces** @@ -335,14 +349,14 @@ The first three are one problem with one answer, so they are grouped rather than ### Key Entities - **Theme preference**: A user's chosen appearance. One of light, dark, or match-system. Stored per - account and, separately, once for the organisation. Absent by default; absence means "fall back". + account only. Absent by default; absence means "fall back". - **Effective theme**: The appearance actually applied for a given user at a given moment. Resolved - from the user's choice, then the organisation default, then the deployment default; and if the - resolved choice is match-system, further resolved against the operating system's current - appearance. -- **Deployment default theme**: The appearance applied to users who have expressed no choice. - Derived from whether the running build is a production release, and overridable by operator - configuration. + from the user's choice, then the flag's default; and if the resolved choice is match-system, + further resolved against the operating system's current appearance. +- **Theme feature flag**: A per-deployment switch, off by default and enabled by configuration. While + dark is alpha it decides both whether the feature exists and, where it does, that dark is the + default for users who have not chosen. It is never stored against a user and never modifies what + is stored against one. ## Success Criteria *(mandatory)* @@ -362,11 +376,12 @@ The first three are one problem with one answer, so they are grouped rather than this feature shows no visual differences. - **SC-006**: Every page reachable from the main navigation renders with no bright-on-dark surface when dark is active. -- **SC-007**: Non-production deployments present dark to a user with no stored preference, and - production deployments present light, without either altering stored preferences and without - either consulting the operating system. -- **SC-008**: The team can run a non-production deployment in dark continuously for the dogfooding - period without needing per-engineer setup. +- **SC-007**: With the flag on, a user with no stored preference sees dark whatever their operating + system says; with the flag off, every user sees light and no theme setting exists. Neither state + alters a stored preference, and neither consults the operating system. +- **SC-008**: An engineer gets dark by starting the development stack and taking **zero** further + configuration steps. Counted literally: the number of actions between "stack is up" and "interface + is dark" is nought. - **SC-009**: Text and essential interface elements meet the same contrast level in dark as the light theme already achieves, verified across the pages walked for SC-006 rather than on a sample. @@ -376,26 +391,37 @@ These were decided during specification rather than left open. Each is a judgeme reviewer may overturn. - **Dark is never reached by inference.** Because it is alpha, a user arrives at it only by choosing - it — either by selecting dark, or by selecting match-system on a dark operating system. Production - therefore defaults to light rather than to the system appearance, and the alpha tag always labels - something the user actually chose. - - An intermediate revision of this spec defaulted production to the system appearance. That was - withdrawn once the consequence was made explicit: it would have put dark-OS production users into - the alpha palette without any choice on their part, which is precisely what the alpha label exists - to prevent. -- **The non-production default ignores the operating system deliberately.** Following the system on - non-production builds would leave every engineer on a light system out of the dogfooding, which is - the entire purpose of that default. + it — either by selecting dark, or by selecting match-system on a dark operating system. Where the + flag is off there is no route in at all; where it is on, the deployment has opted in on the user's + behalf. The alpha tag therefore always labels something someone actually chose. + + An intermediate revision defaulted production to the system appearance. It was withdrawn once the + consequence was explicit: it would have put dark-OS users into the alpha palette with no choice on + their part, which is precisely what the alpha label exists to prevent. +- **The flag's default ignores the operating system deliberately.** Following the system would leave + every engineer on a light machine out of the dogfooding, which is the flag's entire purpose. - **Three choices, not two.** Match-system is included rather than deferred: it is the conventional expectation for a theme setting, and adding it later would change the meaning of an already-stored - value. It is available on every deployment, but only ever as an explicit choice — never a default. + value. It is offered only where the flag is on, and only ever as an explicit choice — never a + default. - **The existing preference machinery is extended, not replaced.** Theme joins date-format and - timezone in the established two-layer user/organisation preference model, and inherits its - resolution and source-reporting semantics. -- **"Non-production build" is derived from the running version**, not from a separate deployment - flag, so that no additional configuration is required for the common case. Explicit configuration - remains available as an override. The precise derivation is a design decision for the plan. + timezone in the established preference model and inherits its resolution and source-reporting + semantics — but is exposed at the user scope only. +- **The flag follows the existing experimental-settings convention** rather than deriving from the + running version. The two experimental settings already in the codebase default to `false` and are + enabled per deployment through configuration; this one does the same, and the development stack + enables it. + + An earlier revision derived the default from the version's pre-release status. It was withdrawn + because "pre-release" catches any beta or release candidate — including one a customer runs in + their own environment — which is broader than "the deployments we run". Following the existing + convention targets exactly the intended deployments, matches how the codebase already works, and + removes a subsystem. The accepted trade: deployments not started from this repository's + configuration files are not covered and stay light unless configured. +- **The flag has no removal date, knowingly.** It is recorded as open-ended rather than tied to a + release, because the release cycle for the version this would land in is not yet known. ⚠ The same + settings class already contains a dead experimental flag carrying a deprecation notice, so + flag-rot here is a realised failure mode rather than a hypothetical one. - **This work stacks on PR #10284.** The branch is based on `bab-dark-theme-app` and the pull request targets it, rather than `develop`. #10284's surfaces are the input to User Story 5, and its failing end-to-end checks are out of scope. When #10284 merges, this branch re-targets `develop`. @@ -405,10 +431,10 @@ reviewer may overturn. ## Dependencies -- PR [#10284](https://github.com/opsmill/infrahub/pull/10284) merged. -- The existing account-backed preference system (user and organisation layers, effective resolution, - source reporting). -- The existing build-version information already exposed by the backend. +- PR [#10284](https://github.com/opsmill/infrahub/pull/10284) — stacked on, not waited for. +- The existing account-backed preference system (effective resolution, source reporting), used at the + user scope only. +- The existing experimental-settings mechanism, already surfaced to the frontend before sign-in. - The `opsmill/infrahub-schema-visualizer` repository, for User Story 7 only. That story completes on the upstream repository's timeline, not this one, so it is tracked as its own deliverable and does not gate the other six. It remains in scope — the seven-item scope was proposed narrower, queried, @@ -423,6 +449,11 @@ reviewer may overturn. work. This feature must not degrade them, but does not redesign them. - **Cross-tab synchronisation.** A second open tab is not required to react to a theme change made in the first; it picks the change up on its next load. +- **An organisation-wide theme default.** While the feature is flag-gated there is no administrator + setting a house theme for anyone. Deferred to the moment the flag is removed, when a real user for + it exists. The backend gains it for free either way — the preference mutation's scope argument is + shared — so this defers only the interface for it. +- **A removal date for the flag**, recorded as knowingly open-ended rather than left unstated. - Additional themes beyond light and dark (high contrast, custom palettes, per-branch theming). - Theming of printed or exported output. - Restyling third-party surfaces beyond binding them to the active theme. diff --git a/dev/specs/infp-46-dark-theme-completion/tasks.md b/dev/specs/infp-46-dark-theme-completion/tasks.md index a54814843f0..8c1978980cb 100644 --- a/dev/specs/infp-46-dark-theme-completion/tasks.md +++ b/dev/specs/infp-46-dark-theme-completion/tasks.md @@ -79,21 +79,21 @@ throttling and confirm no flash; sign in from a second browser and see the same - [ ] T011 [US1] Resolve `theme` through the existing user → global → default chain in `backend/infrahub/graphql/queries/preferences.py`. - [ ] T012 [US1] Write failing tests for the mutation's three-state argument in `backend/tests/unit/graphql/test_preferences.py`: omitted leaves unchanged, explicit `null` clears, a value sets. ⚠ This is the single easiest thing to get wrong — collapsing "omitted" and "null" makes an override impossible to clear. - [ ] T013 [US1] Add the `theme` argument and payload field to `backend/infrahub/graphql/mutations/preferences.py`, honouring `_UNSET` exactly as `date_format` does. Passes T012. -- [ ] T014 [P] [US1] Test the resolution chain: nothing set → `DEFAULT`/null; global only → `GLOBAL`; user overriding global → `USER`; clearing the user layer re-exposes global. +- [ ] T014 [P] [US1] Test the resolution chain: nothing set → `DEFAULT`/null; user set → `USER`; clearing the user layer returns to `DEFAULT`. The global layer is exercised too — the mutation's `scope` argument reaches it and the chain must keep working — even though no interface writes it in this version. - [ ] T015 [P] [US1] Test that a non-`Theme` value is rejected on construction, including on load from the database. - [ ] T016 [US1] Regenerate and commit: `uv run invoke schema.generate-graphqlschema`. CI fails on a stale `schema/schema.graphql`. ### Frontend - [ ] T017 [US1] Add `theme` to `PreferenceValues` and `EffectivePreferences` in `frontend/app/src/entities/preferences/domain/model/preference.ts`. -- [ ] T018 [US1] Add `theme` to the effective-preferences query, the user upsert mutation and the global update mutation under `frontend/app/src/entities/preferences/ui/queries/`. +- [ ] T018 [US1] Add `theme` to the effective-preferences query and the **user** upsert mutation under `frontend/app/src/entities/preferences/ui/queries/`. ⚠ Not `update-global-preferences.mutation.ts` — leaving `theme` out of that document is what keeps the organisation scope unreachable from the interface without needing backend changes. - [ ] T019 [US1] Regenerate frontend types: `cd frontend/app && pnpm codegen`. -- [ ] T020 [US1] Write failing tests for `frontend/app/src/entities/preferences/ui/theme-provider.test.tsx`: fills the shared context from the effective preference; falls back to the deployment default when the query fails; reacts to a `prefers-color-scheme` change while mounted. +- [ ] T020 [US1] Write failing tests for `frontend/app/src/entities/preferences/ui/theme-provider.test.tsx`: fills the shared context from the effective preference; falls back to the flag's default when the query fails; reacts to a `prefers-color-scheme` change while mounted. - [ ] T021 [US1] Implement `frontend/app/src/entities/preferences/ui/theme-provider.tsx` — fills `shared/context/theme-context`, applies the class to `document.documentElement`, writes the `localStorage` mirror, subscribes to `prefers-color-scheme`. Mirror `DatePreferencesProvider`'s shape. Passes T020. No `storage` listener — cross-tab sync is out of scope. - [ ] T022 [US1] Mount the provider in `frontend/app/src/app/app.tsx` alongside `DatePreferencesProvider`. - [ ] T023 [US1] Add the inline pre-paint script to `frontend/app/index.html` ``, **before** the module script. Precedence: mirrored resolved theme → mirrored `system` choice resolved against `prefers-color-scheme` → light. ⚠ The empty-cache fallback is **light**, not `prefers-color-scheme` — consulting the system there would put a dark-OS user into the alpha palette before any preference has been read. `prefers-color-scheme` is read only when the *user* has chosen match-system. ⚠ It blocks rendering and runs before everything: wrap storage access in `try`/`catch` (`localStorage` throws when storage is disabled, e.g. Safari private browsing) and validate the stored string against the known set before using it as a class name. - [ ] T024 [US1] Add the theme field to `frontend/app/src/entities/preferences/ui/preference-fields.tsx` as a `Combobox` matching the existing fields, keeping `"Automatic (inherited)"` as the empty label. Dark carries a visible **"alpha"** tag — the handover named that word specifically, so render it rather than a synonym; "match system" says it can resolve to the alpha palette (FR-008). -- [ ] T025 [P] [US1] Surface the field in `preferences-form.tsx`, `global-preferences-form.tsx` and `user-preferences-card.tsx`, updating their existing tests. +- [ ] T025 [P] [US1] Surface the field in `preferences-form.tsx` and `user-preferences-card.tsx`, updating their existing tests. ⚠ **Not** `global-preferences-form.tsx` — theme is user-scoped only in this version. The backend gains the global scope for free (the mutation's `scope` argument is shared), so this defers only the interface. - [ ] T026 [US1] End-to-end test for first-paint correctness (FR-006 / SC-002): with a stored dark preference and the preference request delayed, assert the document element carries the dark class before the app has hydrated. Add a cold-cache case — no mirror, emulated **dark** browser preference — asserting the first paint is **light**, since a defaulted user must not reach the alpha palette by inference. ⚠ The pre-paint script sits outside the module graph so Vitest cannot reach it; this is its **only** automated coverage. - [ ] T027 [US1] Remove `@custom-variant dark` and its `TODO: DELETE` from `frontend/packages/ui/src/styles/theme.css` (FR-019). ⚠ **Last task in this phase** — it is what all current dark rendering depends on; removing it earlier leaves the tree with no way to reach dark at all. @@ -108,14 +108,14 @@ throttling and confirm no flash; sign in from a second browser and see the same **Independent Test**: load as a user with no stored preference on a pre-release build → dark; on a release build → light; a personal choice beats both and is never overwritten. -- [ ] T028 [P] [US2] Write failing tests in `backend/tests/unit/core/preferences/test_theme.py` over the table in [research.md](./research.md) §R1: `1.11.0` → `light`; `1.11.0b2`, `1.11.1rc1`, `1.12.0.dev5+g1a2b3c` → `dark`; override wins in every direction including forcing `light` on a pre-release build. Assert the result is **never** `"system"`. -- [ ] T029 [US2] Implement `backend/infrahub/core/preferences/theme.py` — pure `(version: str, override) → "light" | "dark"` using PEP 440 pre-release detection: pre-release → `dark`, otherwise → `light`. Passes T028. ⚠ Not `installation_type`, which is community-vs-enterprise and a tempting false lead on the same payload. -- [ ] T030 [US2] Add `default_theme: Literal["light","dark"] | None = None` to `ExperimentalFeaturesSettings` in `backend/infrahub/config.py`. ⚠ The `| None` is load-bearing: "not configured" must stay distinguishable from every configured value, or an operator could never force a palette on a build whose derived default already matches. -- [ ] T031 [US2] Add the resolved `default_theme` to `ConfigAPI` in `backend/infrahub/api/internal.py`. ⚠ Publish only the resolved value — never the version, which would newly expose build information on an unauthenticated endpoint. -- [ ] T032 [US2] Regenerate and commit: `uv run invoke schema.generate-jsonschema`, then `cd frontend/app && pnpm codegen`. -- [ ] T033 [US2] Substitute `config.default_theme` in the provider when the effective preference resolves with source `DEFAULT`. It is already a concrete palette, so it is applied directly — stage-2 resolution runs only for a *user's* explicit match-system choice. Extend T020's tests to assert a defaulted user's theme does not change when the emulated system appearance flips. -- [ ] T034 [P] [US2] Test that the deployment default never writes to stored preferences (FR-013). -- [ ] T035 [US2] Pin the theme explicitly in both end-to-end suites (`frontend/app` Playwright and `tests/e2e` pytest) so they stop inheriting the build-derived default. ⚠ #10284's e2e checks are already failing and are out of scope — baseline only from a green run after it lands, or its failures will be misread as fallout from this change. +- [ ] T028 [US2] Add `dark_theme: bool = False` to `ExperimentalFeaturesSettings` in `backend/infrahub/config.py`, beside `graphql_enums`. A plain bool — no tri-state is needed, since the flag carries no derived value. ⚠ Not `installation_type`, which is community-vs-enterprise and a tempting false lead on the same payload. +- [ ] T029 [US2] Regenerate and commit: `uv run invoke schema.generate-jsonschema`, then `cd frontend/app && pnpm codegen`. No new endpoint or payload field — `experimental_features` is already on the unauthenticated `/api/config`. +- [ ] T030 [US2] Enable it in `development/docker-compose.yml`: `INFRAHUB_EXPERIMENTAL_DARK_THEME: ${INFRAHUB_EXPERIMENTAL_DARK_THEME:-true}`. ⚠ Defaulting to `true` — unlike its two neighbours — is precisely what delivers SC-008. The env var still overrides for an engineer who wants light. +- [ ] T031 [US2] Decide deliberately whether the **root** `docker-compose.yml` also defaults it on. It reaches deployments beyond the ones the team runs; leaving it `:-false` there is the conservative choice. +- [ ] T032 [US2] Gate the theme field on the flag: with it off, render light and **omit the field entirely**. ⚠ Not a light-only picker — offering match-system would let a dark-OS user reach the alpha palette straight through the gate. +- [ ] T033 [US2] Default a user with no stored preference to dark when the flag is on, **ignoring the operating system**. Extend T020's tests to assert a defaulted user's theme does not change when the emulated system appearance flips. +- [ ] T034 [P] [US2] Test that turning the flag off **retains** a stored `DARK` preference — renders light, leaves the stored value intact, and honours it again when the flag returns. ⚠ A config change must never destroy user data (FR-013). +- [ ] T035 [US2] Pin the theme explicitly in both end-to-end suites (`frontend/app` Playwright and `tests/e2e` pytest) so they stop inheriting the flag's value. ⚠ #10284's e2e checks are already failing and are out of scope — baseline only from a green run after it lands, or its failures will be misread as fallout from this change. **Checkpoint**: the dogfooding loop is live. @@ -207,3 +207,6 @@ independent of one another. US7 can start at any time. ## Task count 59 tasks: 4 setup, 3 foundational, 20 US1, 8 US2, 2 US3, 4 US4, 7 US5, 3 US6, 4 US7, 4 cross-cutting. + +The US2 count is unchanged but its content is not: the version-derived resolver, its tests and the +config field were replaced by the flag, its compose wiring, and the flag-off behaviour. From e8da3a364859a071c16b5a1b12176beb20cb716f Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 15:51:59 +0300 Subject: [PATCH 09/47] docs(specs): scope the flag to the dev compose file and name the defect path T031 decided rather than left open: the flag goes in development/docker-compose.yml only, not the root compose file, so a deployment brought up from the root cannot enable dark via the host env var while it is still alpha. Dogfooding defects are reported over Slack, which closes the last open question and makes 'no new defects were found' checkable rather than an absence of evidence. Co-Authored-By: Claude Opus 5 --- .../alignment-check.md | 16 +++++++++------- dev/specs/infp-46-dark-theme-completion/spec.md | 7 +++++-- dev/specs/infp-46-dark-theme-completion/tasks.md | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/dev/specs/infp-46-dark-theme-completion/alignment-check.md b/dev/specs/infp-46-dark-theme-completion/alignment-check.md index 6ca5d2bfcf4..6ea7a0da564 100644 --- a/dev/specs/infp-46-dark-theme-completion/alignment-check.md +++ b/dev/specs/infp-46-dark-theme-completion/alignment-check.md @@ -14,6 +14,8 @@ decisions. **The feature got smaller.** Nothing was added. | 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 @@ -40,15 +42,15 @@ The requester reviewed the Edge Cases section and directed six changes. All are **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 — production is light rather than system-following, and the pre-paint +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: non-production forces dark *ignoring* the system, -because following it would leave every engineer on a light machine out of the dogfooding. +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 non-production -deployment paints light for one frame before correcting to dark. Production is unaffected, since -light is already its default. +**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 @@ -101,7 +103,7 @@ finding was a genuine fidelity loss and has been corrected. | 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. | -| Open | unresolved | Item 7 — "for the coming weeks" | SC-008 | The dogfooding period has no stated length and no exit criterion, and nothing says where the defects it surfaces are collected. Raised in the critique as P2/P3 and deliberately **not** invented — it is a product decision for whoever owns the period. | +| 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 diff --git a/dev/specs/infp-46-dark-theme-completion/spec.md b/dev/specs/infp-46-dark-theme-completion/spec.md index ef106e31791..daf996c4e3d 100644 --- a/dev/specs/infp-46-dark-theme-completion/spec.md +++ b/dev/specs/infp-46-dark-theme-completion/spec.md @@ -47,8 +47,8 @@ timezone settings, and picks between light, dark, and matching their operating s applies immediately, survives a reload, and follows them to another browser or machine because it is stored with their account rather than in one browser. -Because the dark palette is known to still contain visual defects, the dark choice is presented as -explicitly pre-release so a user opting in knows what they are accepting. +Because the dark palette is known to still contain visual defects, the dark choice is tagged **alpha** +so a user opting in knows what they are accepting. **Why this priority**: This is the keystone. Every other story either binds a surface to "the selected theme" or adjusts how that theme looks — none of them are meaningful until a selected theme @@ -94,6 +94,9 @@ have no route into the alpha palette at all. **Why this priority**: This is the stated near-term goal — dogfooding dark for the coming weeks — and it is what keeps an unfinished theme away from anyone who has not opted into running it. +Defects found this way are reported over Slack. Naming the destination is what makes "no new defects +were found" a claim someone can check rather than an absence of evidence. + **Independent Test**: Start the dev stack with no per-engineer setup and observe dark; start with the flag off and observe light with no theme setting present; in both, confirm a stored preference is never destroyed. diff --git a/dev/specs/infp-46-dark-theme-completion/tasks.md b/dev/specs/infp-46-dark-theme-completion/tasks.md index 8c1978980cb..9414997b01e 100644 --- a/dev/specs/infp-46-dark-theme-completion/tasks.md +++ b/dev/specs/infp-46-dark-theme-completion/tasks.md @@ -111,7 +111,7 @@ release build → light; a personal choice beats both and is never overwritten. - [ ] T028 [US2] Add `dark_theme: bool = False` to `ExperimentalFeaturesSettings` in `backend/infrahub/config.py`, beside `graphql_enums`. A plain bool — no tri-state is needed, since the flag carries no derived value. ⚠ Not `installation_type`, which is community-vs-enterprise and a tempting false lead on the same payload. - [ ] T029 [US2] Regenerate and commit: `uv run invoke schema.generate-jsonschema`, then `cd frontend/app && pnpm codegen`. No new endpoint or payload field — `experimental_features` is already on the unauthenticated `/api/config`. - [ ] T030 [US2] Enable it in `development/docker-compose.yml`: `INFRAHUB_EXPERIMENTAL_DARK_THEME: ${INFRAHUB_EXPERIMENTAL_DARK_THEME:-true}`. ⚠ Defaulting to `true` — unlike its two neighbours — is precisely what delivers SC-008. The env var still overrides for an engineer who wants light. -- [ ] T031 [US2] Decide deliberately whether the **root** `docker-compose.yml` also defaults it on. It reaches deployments beyond the ones the team runs; leaving it `:-false` there is the conservative choice. +- [ ] T031 [US2] ⚠ Add the flag to `development/docker-compose.yml` **only**. The root `docker-compose.yml` does **not** get it, unlike its two experimental siblings there — decided, not an oversight. Consequence to accept: a deployment brought up from the root compose file cannot enable dark by setting the env var on the host, because there is no passthrough line for it. That is the intent while dark is alpha. - [ ] T032 [US2] Gate the theme field on the flag: with it off, render light and **omit the field entirely**. ⚠ Not a light-only picker — offering match-system would let a dark-OS user reach the alpha palette straight through the gate. - [ ] T033 [US2] Default a user with no stored preference to dark when the flag is on, **ignoring the operating system**. Extend T020's tests to assert a defaulted user's theme does not change when the emulated system appearance flips. - [ ] T034 [P] [US2] Test that turning the flag off **retains** a stored `DARK` preference — renders light, leaves the stored value intact, and honours it again when the flag returns. ⚠ A config change must never destroy user data (FR-013). From 6055b319a72c8bb62017a58938fae71bbff0f157 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 16:04:25 +0300 Subject: [PATCH 10/47] feat(frontend): add theme resolution rule and resolved-theme type Stage-2 theme resolution as a pure function: a stored choice plus the browser's appearance resolves to a concrete palette. The browser is consulted only for an explicit SYSTEM choice, so an absent, unreadable or unrecognised choice falls back to light rather than inferring dark from a setting the user never pointed at this application. The React context const was planned for this phase but moved to land with its provider: knip fails on any export without a consumer, so a context with neither a producer nor a reader cannot ship green alone. Co-Authored-By: Claude Opus 5 --- .../infp-46-dark-theme-completion/tasks.md | 15 ++++---- .../preferences/domain/model/theme.ts | 5 +++ .../domain/rules/resolve-theme.test.ts | 38 +++++++++++++++++++ .../preferences/domain/rules/resolve-theme.ts | 24 ++++++++++++ .../app/src/shared/context/theme-context.ts | 7 ++++ 5 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 frontend/app/src/entities/preferences/domain/model/theme.ts create mode 100644 frontend/app/src/entities/preferences/domain/rules/resolve-theme.test.ts create mode 100644 frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts create mode 100644 frontend/app/src/shared/context/theme-context.ts diff --git a/dev/specs/infp-46-dark-theme-completion/tasks.md b/dev/specs/infp-46-dark-theme-completion/tasks.md index 9414997b01e..042eb36f937 100644 --- a/dev/specs/infp-46-dark-theme-completion/tasks.md +++ b/dev/specs/infp-46-dark-theme-completion/tasks.md @@ -37,10 +37,10 @@ cd frontend/app && node_modules/.bin/vitest run && node_modules/.bin/biome ci . **Purpose**: make the worktree able to build and give the light theme a reference to be compared against. -- [ ] T001 Initialise the visualizer submodule: `git submodule update --init frontend/packages/schema-visualizer`. Required for US7, and it clears the two phantom `betterer` findings an uninitialised submodule produces. -- [ ] T002 [P] Install the editable SDK: `uv pip install -e python_sdk`. Fresh worktrees skip this and `infrahub_sdk` imports fail. -- [ ] T003 Base the branch on `origin/bab-dark-theme-app` and open the pull request **against that branch**, not `develop` — this is a stacked PR on #10284. It supplies the surfaces US5 migrates and keeps this review free of #10284's 151 files. Re-target `develop` once #10284 merges; rebase if it is revised. ⚠ #10284's failing e2e checks are inherited and will show on this PR — say so in the description so they are not read as caused by this work. -- [ ] T004 Capture light-theme reference screenshots of every page US5/US6 touch (proposed changes, a diff view, checks, path traversal, data viewer). FR-020/SC-005 make "light is unchanged" a hard constraint, and it is unprovable later without a baseline taken now. +- [x] T001 Initialise the visualizer submodule: `git submodule update --init frontend/packages/schema-visualizer`. Required for US7, and it clears the two phantom `betterer` findings an uninitialised submodule produces. +- [x] T002 [P] Install the editable SDK: `uv pip install -e python_sdk`. Fresh worktrees skip this and `infrahub_sdk` imports fail. +- [x] T003 Base the branch on `origin/bab-dark-theme-app` and open the pull request **against that branch**, not `develop` — this is a stacked PR on #10284. It supplies the surfaces US5 migrates and keeps this review free of #10284's 151 files. Re-target `develop` once #10284 merges; rebase if it is revised. ⚠ #10284's failing e2e checks are inherited and will show on this PR — say so in the description so they are not read as caused by this work. +- [ ] T004 ⏸ **Deferred to just before Phase 7** (needs a running stack; only US5/US6 depend on it). Capture light-theme reference screenshots of every page US5/US6 touch (proposed changes, a diff view, checks, path traversal, data viewer). FR-020/SC-005 make "light is unchanged" a hard constraint, and it is unprovable later without a baseline taken now. **Checkpoint**: builds clean; light-theme baseline exists. @@ -55,9 +55,9 @@ start immediately and in parallel with governance approval. (`dev/knowledge/frontend/entities-structure.md`), and there is **no lint guard** — layer rules are review-enforced only. -- [ ] T005 Create `frontend/app/src/shared/context/theme-context.tsx` holding the resolved `"light" | "dark"`, defaulting to `"light"`. It must import nothing from `entities/`. Model it on the sibling `shared/context/date-preferences-context.tsx`. -- [ ] T006 [P] Write failing tests for stage-2 resolution in `frontend/app/src/entities/preferences/domain/rules/theme.test.ts`: table-driven over `(choice, systemPrefersDark)` → `"light" | "dark"`, covering all three choices and both system states. -- [ ] T007 Implement `frontend/app/src/entities/preferences/domain/rules/theme.ts` to pass T006. ⚠ Pure only — `domain/rules` may not touch browser storage or React. +- [x] T005 Create `frontend/app/src/shared/context/theme-context.ts` exporting the `ResolvedTheme` type (`"light" | "dark"`). It must import nothing from `entities/`. ⚠ **Scope corrected during implementation**: the React context const itself moved to Phase 3 (T021a). `knip` runs in CI and fails on any export without a consumer, so a context with neither a producer nor a reader cannot land green on its own — it must arrive with its provider. The *type* lands here because the pure resolver consumes it immediately. +- [x] T006 [P] Write failing tests for stage-2 resolution in `frontend/app/src/entities/preferences/domain/rules/theme.test.ts`: table-driven over `(choice, systemPrefersDark)` → `"light" | "dark"`, covering all three choices and both system states. +- [x] T007 Implement `frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts` to pass T006 (named for the house `resolve-*` convention). ⚠ Pure only — `domain/rules` may not touch browser storage or React. **Checkpoint**: a resolved theme can be held and read; consumers can be written against it. @@ -89,6 +89,7 @@ throttling and confirm no flash; sign in from a second browser and see the same - [ ] T018 [US1] Add `theme` to the effective-preferences query and the **user** upsert mutation under `frontend/app/src/entities/preferences/ui/queries/`. ⚠ Not `update-global-preferences.mutation.ts` — leaving `theme` out of that document is what keeps the organisation scope unreachable from the interface without needing backend changes. - [ ] T019 [US1] Regenerate frontend types: `cd frontend/app && pnpm codegen`. - [ ] T020 [US1] Write failing tests for `frontend/app/src/entities/preferences/ui/theme-provider.test.tsx`: fills the shared context from the effective preference; falls back to the flag's default when the query fails; reacts to a `prefers-color-scheme` change while mounted. +- [ ] T021a [US1] Add the `ThemeContext` const and its reader hook to `frontend/app/src/shared/context/theme-context.ts` (moved from T005 — see the note there). Model it on the sibling `shared/context/date-preferences-context.tsx`. Land it in the same commit as T021 so no export exists without a consumer. - [ ] T021 [US1] Implement `frontend/app/src/entities/preferences/ui/theme-provider.tsx` — fills `shared/context/theme-context`, applies the class to `document.documentElement`, writes the `localStorage` mirror, subscribes to `prefers-color-scheme`. Mirror `DatePreferencesProvider`'s shape. Passes T020. No `storage` listener — cross-tab sync is out of scope. - [ ] T022 [US1] Mount the provider in `frontend/app/src/app/app.tsx` alongside `DatePreferencesProvider`. - [ ] T023 [US1] Add the inline pre-paint script to `frontend/app/index.html` ``, **before** the module script. Precedence: mirrored resolved theme → mirrored `system` choice resolved against `prefers-color-scheme` → light. ⚠ The empty-cache fallback is **light**, not `prefers-color-scheme` — consulting the system there would put a dark-OS user into the alpha palette before any preference has been read. `prefers-color-scheme` is read only when the *user* has chosen match-system. ⚠ It blocks rendering and runs before everything: wrap storage access in `try`/`catch` (`localStorage` throws when storage is disabled, e.g. Safari private browsing) and validate the stored string against the known set before using it as a class name. diff --git a/frontend/app/src/entities/preferences/domain/model/theme.ts b/frontend/app/src/entities/preferences/domain/model/theme.ts new file mode 100644 index 00000000000..e51a777e201 --- /dev/null +++ b/frontend/app/src/entities/preferences/domain/model/theme.ts @@ -0,0 +1,5 @@ +// The key set mirrors the backend Theme enum in backend/infrahub/core/preferences/constants.py. +// SYSTEM is a stored choice, not an absence: null means "nothing set at this layer", so conflating +// the two would leave a user unable to return to system-following once they had picked anything. + +export type ThemeChoice = "LIGHT" | "DARK" | "SYSTEM"; diff --git a/frontend/app/src/entities/preferences/domain/rules/resolve-theme.test.ts b/frontend/app/src/entities/preferences/domain/rules/resolve-theme.test.ts new file mode 100644 index 00000000000..16214713afa --- /dev/null +++ b/frontend/app/src/entities/preferences/domain/rules/resolve-theme.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; + +import type { ThemeChoice } from "@/entities/preferences/domain/model/theme"; +import { resolveTheme } from "@/entities/preferences/domain/rules/resolve-theme"; + +describe("resolveTheme", () => { + test.each([ + ["LIGHT", false, "light"], + ["LIGHT", true, "light"], + ["DARK", false, "dark"], + ["DARK", true, "dark"], + ] as const)("%s ignores the system appearance (systemPrefersDark=%s)", (choice, systemPrefersDark, expected) => { + expect(resolveTheme(choice, systemPrefersDark)).toBe(expected); + }); + + test.each([ + [true, "dark"], + [false, "light"], + ] as const)("SYSTEM follows the system appearance (systemPrefersDark=%s)", (systemPrefersDark, expected) => { + expect(resolveTheme("SYSTEM", systemPrefersDark)).toBe(expected); + }); + + test("falls back to light when no choice is set", () => { + // GIVEN nothing stored at any layer and no deployment default supplied + // WHEN resolved + // THEN light, never the system appearance — dark is pre-release and must not be reached by + // inference from a browser setting the user never pointed at this application. + expect(resolveTheme(null, true)).toBe("light"); + expect(resolveTheme(undefined, true)).toBe("light"); + }); + + test("rejects an unknown stored value by falling back to light", () => { + // GIVEN a value that is not a Theme member, e.g. written by a newer version and read by an older + // WHEN resolved + // THEN light rather than a crash or an unstyled page + expect(resolveTheme("SOLARIZED" as ThemeChoice, true)).toBe("light"); + }); +}); diff --git a/frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts b/frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts new file mode 100644 index 00000000000..5231ef576a0 --- /dev/null +++ b/frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts @@ -0,0 +1,24 @@ +import type { ResolvedTheme } from "@/shared/context/theme-context"; + +import type { ThemeChoice } from "@/entities/preferences/domain/model/theme"; + +/** + * Resolves a stored choice to the palette to paint. The browser's appearance is consulted only for + * an explicit SYSTEM choice: dark is pre-release, so an absent, unreadable or unrecognised choice + * falls back to light rather than inferring dark from a setting the user never pointed here. + * + * Pure by contract — no storage, no DOM. The caller supplies the browser's current appearance. + */ +export function resolveTheme( + choice: ThemeChoice | null | undefined, + systemPrefersDark: boolean +): ResolvedTheme { + switch (choice) { + case "DARK": + return "dark"; + case "SYSTEM": + return systemPrefersDark ? "dark" : "light"; + default: + return "light"; + } +} diff --git a/frontend/app/src/shared/context/theme-context.ts b/frontend/app/src/shared/context/theme-context.ts new file mode 100644 index 00000000000..9524f7fad6c --- /dev/null +++ b/frontend/app/src/shared/context/theme-context.ts @@ -0,0 +1,7 @@ +/** The palette actually in effect. Always concrete — a "follow the system" choice is resolved away + * before it reaches here, so every consumer gets an answer it can render without asking the browser + * anything. + * + * Lives in `shared` because generic components follow the theme too, and `shared` must not depend on + * where preferences come from. The React context that carries this value lands alongside it. */ +export type ResolvedTheme = "light" | "dark"; From 3566d0a039faa1ad35fc483d256e00b7161f26e7 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 16:35:12 +0300 Subject: [PATCH 11/47] feat(frontend): bind GraphiQL and Mermaid to the active theme The GraphQL sandbox was pinned with forcedTheme="light" and rendered as a bright panel inside the dark shell; Mermaid was pinned to its default palette. Both now follow the active theme and react to a change without a reload. Adds a useResolvedTheme hook that reads the document class rather than a preference. That class is what the CSS already keys off, so a consumer cannot disagree with what the page has painted, and whatever ends up deciding the theme only has to set the class. Adds --content/--content-muted for raised content surfaces and migrates 18 files off hardcoded per-theme variants. Each mapping is byte-identical in both themes: the token resolves to rgb(255,255,255) in light, exactly the bg-white it replaced, and stone-900 in dark. Passes GraphiQL the resolved palette rather than "system" so the sandbox cannot run its own prefers-color-scheme check and drift from the app. Memoises the Mermaid plugin array on the theme, since a fresh array each render would re-run the rehype pipeline continuously. Co-Authored-By: Claude Opus 5 --- frontend/app/.betterer.results | 4 +- .../ui/branches-table/branches-table.tsx | 2 +- .../branches/ui/modal-delete-branch.tsx | 2 +- .../app/src/entities/diff/ui/checks/check.tsx | 4 +- .../ui/checks/data-integrity-conflicts.tsx | 2 +- .../ui/checks/schema-integrity-conflicts.tsx | 2 +- .../src/entities/diff/ui/checks/validator.tsx | 6 +- .../src/entities/diff/ui/node-diff/node.tsx | 2 +- .../src/entities/diff/ui/node-diff/utils.tsx | 9 +-- .../object/ui/object-table/object-table.tsx | 2 +- .../relationship-table/relationship-table.tsx | 2 +- .../preferences/domain/rules/resolve-theme.ts | 2 +- .../role-manager/ui/account-table.tsx | 2 +- .../ui/global-permissions-table.tsx | 2 +- .../entities/role-manager/ui/group-table.tsx | 2 +- .../ui/object-permission-table.tsx | 2 +- .../entities/role-manager/ui/role-table.tsx | 2 +- .../ui/account-token-create-action.tsx | 2 +- frontend/app/src/pages/graphql/index.tsx | 7 ++- .../editor/markdown/markdown-with-mermaid.tsx | 21 +++++-- .../components/modals/modal-confirm.tsx | 2 +- .../shared/components/modals/modal-danger.tsx | 2 +- .../app/src/shared/context/theme-context.ts | 7 --- .../src/shared/hooks/use-resolved-theme.ts | 30 ++++++++++ frontend/packages/ui/src/styles/theme.css | 59 +++++++++++++++---- 25 files changed, 125 insertions(+), 54 deletions(-) delete mode 100644 frontend/app/src/shared/context/theme-context.ts create mode 100644 frontend/app/src/shared/hooks/use-resolved-theme.ts diff --git a/frontend/app/.betterer.results b/frontend/app/.betterer.results index 3edc1726e48..98618a2961c 100644 --- a/frontend/app/.betterer.results +++ b/frontend/app/.betterer.results @@ -24,7 +24,7 @@ exports[`fix ts error`] = { [331, 20, 11, "tsc: \'fileContent\' is possibly \'undefined\'.", "1561581386"], [333, 30, 11, "tsc: \'fileContent\' is possibly \'undefined\'.", "1561581386"] ], - "src/entities/diff/ui/checks/check.tsx:3290350559": [ + "src/entities/diff/ui/checks/check.tsx:950742999": [ [108, 4, 9, "tsc: Property \'conflicts\' does not exist on type \'null; artifact_id: { value: string | null; commit: { value: string | null; conclusion: { value: string | null; conclusion: { value: string | null; conclusion: { value: string | null; conclusion: { value: string | null; conclusion: { value: string | null; conclusion: { value: string | null; conflicts: { value: unknown; } | null; conflicts: { value: unknown; } | null; created_at: { value: string | null; created_at: { value: string | null; created_at: { value: string | null; created_at: { value: string | null; created_at: { value: string | null; created_at: { value: string | null; display_label: string | null; display_label: string | null; display_label: string | null; display_label: string | null; display_label: string | null; display_label: string | null; files: { value: unknown; } | null; keep_branch: { value: string | null; kind: { value: string | null; kind: { value: string | null; kind: { value: string | null; kind: { value: string | null; kind: { value: string | null; kind: { value: string | null; message: { value: string | null; message: { value: string | null; message: { value: string | null; message: { value: string | null; message: { value: string | null; message: { value: string | null; name: { value: string | null; name: { value: string | null; name: { value: string | null; name: { value: string | null; name: { value: string | null; name: { value: string | null; origin: { value: string | null; origin: { value: string | null; origin: { value: string | null; origin: { value: string | null; origin: { value: string | null; origin: { value: string | null; severity: { value: string | null; severity: { value: string | null; severity: { value: string | null; severity: { value: string | null; severity: { value: string | null; severity: { value: string | null; storage_id: { value: string | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | null; } | { __typename: \\"CoreArtifactCheck\\"; id: string | { __typename: \\"CoreDataCheck\\"; id: string | { __typename: \\"CoreFileCheck\\"; id: string | { __typename: \\"CoreGeneratorCheck\\"; id: string | { __typename: \\"CoreSchemaCheck\\"; id: string | { __typename: \\"CoreStandardCheck\\"; id: string\'.", "1029368512"], [171, 61, 6, "tsc: Property \'length\' does not exist on type \'{}\'.", "1433765721"], [172, 32, 9, "tsc: Type \'{ value: unknown; }\' is not assignable to type \'Maybe\'.\\n Type \'{ value: unknown; }\' is missing 3 properties from type \'AttributeInterface\'", "1029368512"], @@ -47,7 +47,7 @@ exports[`fix ts error`] = { [49, 22, 17, "tsc: Property \'diff_branch_label\' does not exist on type \'DiffConflict\'. Did you mean \'diff_branch_value\'?", "1869793242"], [83, 29, 12, "tsc: Cannot find name \'DataConflict\'.", "3642611779"] ], - "src/entities/diff/ui/node-diff/node.tsx:2254113127": [ + "src/entities/diff/ui/node-diff/node.tsx:3761240176": [ [94, 21, 13, "tsc: Binding element \'property_type\' implicitly has an \'any\' type.", "324947141"], [102, 20, 13, "tsc: Type \'Element | null | string\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "1656119487"], [106, 20, 8, "tsc: Type \'Element | null | string\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "288015442"], diff --git a/frontend/app/src/entities/branches/ui/branches-table/branches-table.tsx b/frontend/app/src/entities/branches/ui/branches-table/branches-table.tsx index 8bee2df933d..1f69bdbefa7 100644 --- a/frontend/app/src/entities/branches/ui/branches-table/branches-table.tsx +++ b/frontend/app/src/entities/branches/ui/branches-table/branches-table.tsx @@ -32,7 +32,7 @@ export function BranchesTable() { return ( diff --git a/frontend/app/src/entities/branches/ui/modal-delete-branch.tsx b/frontend/app/src/entities/branches/ui/modal-delete-branch.tsx index d3f2ce802cf..ae3bf0abe5a 100644 --- a/frontend/app/src/entities/branches/ui/modal-delete-branch.tsx +++ b/frontend/app/src/entities/branches/ui/modal-delete-branch.tsx @@ -143,7 +143,7 @@ export function ModalDeleteBranch({ - + diff --git a/frontend/app/src/entities/diff/ui/checks/check.tsx b/frontend/app/src/entities/diff/ui/checks/check.tsx index 484b96cba18..4a900589114 100644 --- a/frontend/app/src/entities/diff/ui/checks/check.tsx +++ b/frontend/app/src/entities/diff/ui/checks/check.tsx @@ -82,7 +82,7 @@ export const Check = ({ id }: tCheckProps) => { if (error) { return ( -
+
); @@ -135,7 +135,7 @@ export const Check = ({ id }: tCheckProps) => { return (
diff --git a/frontend/app/src/entities/diff/ui/checks/data-integrity-conflicts.tsx b/frontend/app/src/entities/diff/ui/checks/data-integrity-conflicts.tsx index 29674ba71f0..0cbf2cef808 100644 --- a/frontend/app/src/entities/diff/ui/checks/data-integrity-conflicts.tsx +++ b/frontend/app/src/entities/diff/ui/checks/data-integrity-conflicts.tsx @@ -10,7 +10,7 @@ export const DataIntegrityConflicts = ({ conflicts }: Pick +
{" "} diff --git a/frontend/app/src/entities/diff/ui/checks/schema-integrity-conflicts.tsx b/frontend/app/src/entities/diff/ui/checks/schema-integrity-conflicts.tsx index fb3873d8fdf..2a255a62da2 100644 --- a/frontend/app/src/entities/diff/ui/checks/schema-integrity-conflicts.tsx +++ b/frontend/app/src/entities/diff/ui/checks/schema-integrity-conflicts.tsx @@ -4,7 +4,7 @@ import { SchemaConflict } from "./schema-conflict"; export const SchemaIntegrityConflicts = ({ conflicts }: Pick) => { return ( -
+
{conflicts?.value?.map((conflict: any) => { return ; })} diff --git a/frontend/app/src/entities/diff/ui/checks/validator.tsx b/frontend/app/src/entities/diff/ui/checks/validator.tsx index abebf0b6b10..93a6c2e90eb 100644 --- a/frontend/app/src/entities/diff/ui/checks/validator.tsx +++ b/frontend/app/src/entities/diff/ui/checks/validator.tsx @@ -157,11 +157,7 @@ export const Validator = ({ validator }: tValidatorProps) => { ); return ( - + ); diff --git a/frontend/app/src/entities/diff/ui/node-diff/node.tsx b/frontend/app/src/entities/diff/ui/node-diff/node.tsx index e6aa4890dd0..f48b17d3e80 100644 --- a/frontend/app/src/entities/diff/ui/node-diff/node.tsx +++ b/frontend/app/src/entities/diff/ui/node-diff/node.tsx @@ -79,7 +79,7 @@ export const DiffNode = ({ sourceBranch, destinationBranch, node }: DiffNodeProp } className="rounded-md border bg-gray-100 dark:bg-white/5" > -
+
{sourceBranch} diff --git a/frontend/app/src/entities/diff/ui/node-diff/utils.tsx b/frontend/app/src/entities/diff/ui/node-diff/utils.tsx index ae58dfe8281..39859c02078 100644 --- a/frontend/app/src/entities/diff/ui/node-diff/utils.tsx +++ b/frontend/app/src/entities/diff/ui/node-diff/utils.tsx @@ -82,18 +82,15 @@ const DiffDisplay = ({
{title} -
+
{left}
-
+
{ scrollX hasNextPage={hasNextPage} onLoadMore={fetchNextPage} - className="dark:bg-table-frame" + className="bg-table-frame" > diff --git a/frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts b/frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts index 5231ef576a0..781f293f2ec 100644 --- a/frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts +++ b/frontend/app/src/entities/preferences/domain/rules/resolve-theme.ts @@ -1,4 +1,4 @@ -import type { ResolvedTheme } from "@/shared/context/theme-context"; +import type { ResolvedTheme } from "@/shared/hooks/use-resolved-theme"; import type { ThemeChoice } from "@/entities/preferences/domain/model/theme"; diff --git a/frontend/app/src/entities/role-manager/ui/account-table.tsx b/frontend/app/src/entities/role-manager/ui/account-table.tsx index c9e4716030c..51715921b68 100644 --- a/frontend/app/src/entities/role-manager/ui/account-table.tsx +++ b/frontend/app/src/entities/role-manager/ui/account-table.tsx @@ -43,7 +43,7 @@ export function AccountTable() { return ( diff --git a/frontend/app/src/entities/role-manager/ui/global-permissions-table.tsx b/frontend/app/src/entities/role-manager/ui/global-permissions-table.tsx index 505d8a90722..019c5fad737 100644 --- a/frontend/app/src/entities/role-manager/ui/global-permissions-table.tsx +++ b/frontend/app/src/entities/role-manager/ui/global-permissions-table.tsx @@ -56,7 +56,7 @@ export function GlobalPermissionsTable() { return ( diff --git a/frontend/app/src/entities/role-manager/ui/group-table.tsx b/frontend/app/src/entities/role-manager/ui/group-table.tsx index d9756c72aac..a6630324739 100644 --- a/frontend/app/src/entities/role-manager/ui/group-table.tsx +++ b/frontend/app/src/entities/role-manager/ui/group-table.tsx @@ -40,7 +40,7 @@ export function GroupTable() { return ( diff --git a/frontend/app/src/entities/role-manager/ui/object-permission-table.tsx b/frontend/app/src/entities/role-manager/ui/object-permission-table.tsx index 76de22885a6..d36e00b6858 100644 --- a/frontend/app/src/entities/role-manager/ui/object-permission-table.tsx +++ b/frontend/app/src/entities/role-manager/ui/object-permission-table.tsx @@ -56,7 +56,7 @@ export function ObjectPermissionTable() { return ( diff --git a/frontend/app/src/entities/role-manager/ui/role-table.tsx b/frontend/app/src/entities/role-manager/ui/role-table.tsx index 9cdf210eb35..e5f31735b4c 100644 --- a/frontend/app/src/entities/role-manager/ui/role-table.tsx +++ b/frontend/app/src/entities/role-manager/ui/role-table.tsx @@ -40,7 +40,7 @@ export function RoleTable() { return ( diff --git a/frontend/app/src/entities/user-profile/ui/account-token-create-action.tsx b/frontend/app/src/entities/user-profile/ui/account-token-create-action.tsx index 84ed51e1c85..fa3c4e07b30 100644 --- a/frontend/app/src/entities/user-profile/ui/account-token-create-action.tsx +++ b/frontend/app/src/entities/user-profile/ui/account-token-create-action.tsx @@ -70,7 +70,7 @@ export function AccountTokenCreateAction() { - + diff --git a/frontend/app/src/pages/graphql/index.tsx b/frontend/app/src/pages/graphql/index.tsx index fdb4a30c849..2afd9361f38 100644 --- a/frontend/app/src/pages/graphql/index.tsx +++ b/frontend/app/src/pages/graphql/index.tsx @@ -3,6 +3,7 @@ import { GraphiQL, HISTORY_PLUGIN } from "graphiql"; import { useQueryState } from "nuqs"; import { QSP } from "@/shared/config/qsp"; +import { useResolvedTheme } from "@/shared/hooks/use-resolved-theme"; import { parallelModePlugin } from "@/shared/libs/graphiql/parallel-mode-plugin"; import { useGraphiqlFetcher } from "@/shared/libs/graphiql/use-graphiql-fetcher"; @@ -14,6 +15,10 @@ const plugins = [HISTORY_PLUGIN, explorerPlugin(), parallelModePlugin]; export function Component() { const [query] = useQueryState(QSP.QUERY); const fetcher = useGraphiqlFetcher(); + // Forcing the resolved palette rather than "system" keeps the sandbox from running its own + // prefers-color-scheme check, which could disagree with the app around it. It also hides + // GraphiQL's own theme picker, leaving one place to change the theme. + const theme = useResolvedTheme(); return ( ); diff --git a/frontend/app/src/shared/components/editor/markdown/markdown-with-mermaid.tsx b/frontend/app/src/shared/components/editor/markdown/markdown-with-mermaid.tsx index b546fad09bc..31642d5a727 100644 --- a/frontend/app/src/shared/components/editor/markdown/markdown-with-mermaid.tsx +++ b/frontend/app/src/shared/components/editor/markdown/markdown-with-mermaid.tsx @@ -1,14 +1,18 @@ import type React from "react"; +import { useMemo } from "react"; import type { Components, Options } from "react-markdown"; import { MarkdownHooks } from "react-markdown"; import rehypeMermaid, { type RehypeMermaidOptions } from "rehype-mermaid"; import { remarkPlugins } from "@/shared/components/editor/markdown/markdown-render"; import { MermaidDiagram } from "@/shared/components/editor/markdown/mermaid-diagram"; +import { type ResolvedTheme, useResolvedTheme } from "@/shared/hooks/use-resolved-theme"; -const rehypeMermaidOptions: RehypeMermaidOptions = { +const mermaidThemeFor = (theme: ResolvedTheme) => (theme === "dark" ? "dark" : "default"); + +const rehypeMermaidOptions = (theme: ResolvedTheme): RehypeMermaidOptions => ({ strategy: "inline-svg", - mermaidConfig: { securityLevel: "strict", theme: "default" }, + mermaidConfig: { securityLevel: "strict", theme: mermaidThemeFor(theme) }, // On failure, show a red error banner with the message above the raw diagram errorFallback: (_element, diagram, error) => { const message = error instanceof Error ? error.message : String(error); @@ -39,9 +43,7 @@ const rehypeMermaidOptions: RehypeMermaidOptions = { ], }; }, -}; - -const rehypePlugins: Options["rehypePlugins"] = [[rehypeMermaid, rehypeMermaidOptions]]; +}); // Wrap the rendered mermaid in a pan/zoom container with controls. const components: Components = { svg: MermaidDiagram }; @@ -52,6 +54,15 @@ type MarkdownWithMermaidProps = { }; export default function MarkdownWithMermaid({ markdownText, fallback }: MarkdownWithMermaidProps) { + const theme = useResolvedTheme(); + // Mermaid bakes colors into the SVG it emits, so a theme change has to re-run the pipeline. Keying + // the plugin array on the resolved theme alone is what bounds that: a fresh array on every render + // would re-render every diagram continuously. + const rehypePlugins = useMemo( + () => [[rehypeMermaid, rehypeMermaidOptions(theme)]], + [theme] + ); + return ( {description}

} - + diff --git a/frontend/app/src/shared/components/modals/modal-danger.tsx b/frontend/app/src/shared/components/modals/modal-danger.tsx index 646e3fe0022..334be558747 100644 --- a/frontend/app/src/shared/components/modals/modal-danger.tsx +++ b/frontend/app/src/shared/components/modals/modal-danger.tsx @@ -44,7 +44,7 @@ export function ModalDanger({ {description &&
{description}
} - + diff --git a/frontend/app/src/shared/context/theme-context.ts b/frontend/app/src/shared/context/theme-context.ts deleted file mode 100644 index 9524f7fad6c..00000000000 --- a/frontend/app/src/shared/context/theme-context.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** The palette actually in effect. Always concrete — a "follow the system" choice is resolved away - * before it reaches here, so every consumer gets an answer it can render without asking the browser - * anything. - * - * Lives in `shared` because generic components follow the theme too, and `shared` must not depend on - * where preferences come from. The React context that carries this value lands alongside it. */ -export type ResolvedTheme = "light" | "dark"; diff --git a/frontend/app/src/shared/hooks/use-resolved-theme.ts b/frontend/app/src/shared/hooks/use-resolved-theme.ts new file mode 100644 index 00000000000..988c6465209 --- /dev/null +++ b/frontend/app/src/shared/hooks/use-resolved-theme.ts @@ -0,0 +1,30 @@ +import { useSyncExternalStore } from "react"; + +/** The palette actually in effect. Always concrete — a "follow the system" choice is resolved away + * before it reaches here, so every consumer gets an answer it can render without asking the browser + * anything. */ +export type ResolvedTheme = "light" | "dark"; + +const DARK_CLASS = "dark"; + +function subscribe(onStoreChange: () => void): () => void { + const observer = new MutationObserver(onStoreChange); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); +} + +function getSnapshot(): ResolvedTheme { + return document.documentElement.classList.contains(DARK_CLASS) ? "dark" : "light"; +} + +/** + * Reads the active palette, for the surfaces that cannot express themselves in CSS alone — a + * third-party component taking a theme prop, or a renderer that bakes colors into its output. + * + * Reads the document class rather than a preference, deliberately: that class is what the CSS + * actually keys off, so a consumer of this hook cannot disagree with what the rest of the page has + * already painted. Whatever decides the theme only has to set the class. + */ +export function useResolvedTheme(): ResolvedTheme { + return useSyncExternalStore(subscribe, getSnapshot, () => "light"); +} diff --git a/frontend/packages/ui/src/styles/theme.css b/frontend/packages/ui/src/styles/theme.css index ce800fe945a..310c700a9b6 100644 --- a/frontend/packages/ui/src/styles/theme.css +++ b/frontend/packages/ui/src/styles/theme.css @@ -20,6 +20,11 @@ --danger: var(--color-rose-700); --danger-surface: --alpha(var(--color-rose-500) / 15%); + /* A raised content surface: diff rows, check panels, rendered diagrams. Distinct from --surface, + which sits lower, and from --input, which happens to share these values but tracks form fields. */ + --content: var(--color-white); + --content-muted: var(--color-gray-50); + --input: var(--color-white); --input-border: var(--border); --input-shadow: 0 2px 4px rgb(0 0 0 / 0.04); @@ -37,12 +42,20 @@ --selected-shadow: 0 0 #0000; --selected-highlight: var(--highlight), var(--selected); - --card: linear-gradient(to bottom, var(--color-stone-50) 0%, var(--color-white) 10%); + --card: linear-gradient( + to bottom, + var(--color-stone-50) 0%, + var(--color-white) 10% + ); --card-shadow: inset 0 2px 0 rgb(255 255 255), inset 0 -1px 2px 1px rgb(0 0 0 / 0.03), 0 1px 1px rgb(0 0 0 / 0.02); - --card-header: linear-gradient(to bottom, var(--color-white) 0%, var(--color-neutral-50) 100%); + --card-header: linear-gradient( + to bottom, + var(--color-white) 0%, + var(--color-neutral-50) 100% + ); --card-header-foreground: var(--color-neutral-700); --card-header-shadow: inset 0 1px 0 rgb(255 255 255 / 0.85); @@ -54,7 +67,8 @@ --modal-frame: --alpha(var(--color-white) / 25%); --modal-frame-border: var(--border); - --modal-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --modal-shadow: + 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); --table-cell: var(--color-stone-50); --table-cell-pinned: var(--color-white); @@ -80,6 +94,9 @@ --danger: var(--color-rose-400); --danger-surface: --alpha(var(--color-rose-400) / 20%); + --content: var(--color-stone-900); + --content-muted: rgb(255 255 255 / 0.05); + --input: var(--color-stone-900); --input-border: var(--border-strong); --input-shadow: 0 2px 4px rgb(0 0 0 / 0.35); @@ -87,25 +104,44 @@ --popover: --alpha(var(--color-stone-900) / 70%); --highlight: - radial-gradient(72% 100% at 50% 100%, rgb(255 255 255 / 0.09), transparent 72%), + radial-gradient( + 72% 100% at 50% 100%, + rgb(255 255 255 / 0.09), + transparent 72% + ), linear-gradient(rgb(255 255 255 / 0.02), rgb(255 255 255 / 0.02)); --highlight-foreground: var(--foreground); --selected: - radial-gradient(72% 100% at 50% 100%, rgb(255 255 255 / 0.16), transparent 72%), + radial-gradient( + 72% 100% at 50% 100%, + rgb(255 255 255 / 0.16), + transparent 72% + ), linear-gradient(rgb(255 255 255 / 0.05), rgb(255 255 255 / 0.05)); --selected-foreground: var(--foreground); - --selected-shadow: inset 0 1px 0 rgb(255 255 255 / 0.08), 0 1px 2px rgb(0 0 0 / 0.6); + --selected-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.08), 0 1px 2px rgb(0 0 0 / 0.6); --card: linear-gradient(180deg, #201d1c 0%, #191716 55%, #141312 100%); - --card-shadow: inset 0 2px 2px 1px rgb(32 28 26 / 0.2), 0 1px 2px rgb(0 0 0 / 0.5); + --card-shadow: + inset 0 2px 2px 1px rgb(32 28 26 / 0.2), 0 1px 2px rgb(0 0 0 / 0.5); - --card-header: linear-gradient(180deg, rgb(255 251 246 / 0.045) 0%, rgb(255 251 246 / 0.03) 100%); + --card-header: linear-gradient( + 180deg, + rgb(255 251 246 / 0.045) 0%, + rgb(255 251 246 / 0.03) 100% + ); --card-header-foreground: var(--color-stone-300); - --card-header-shadow: inset 0 1px 0 lch(24.13 2.26 37.96), 0 0 2px oklch(0.16 0.01 0); + --card-header-shadow: + inset 0 1px 0 lch(24.13 2.26 37.96), 0 0 2px oklch(0.16 0.01 0); --secondary: - radial-gradient(120% 55% at 0% 0%, rgb(255 255 255 / 0.005), transparent 62%), + radial-gradient( + 120% 55% at 0% 0%, + rgb(255 255 255 / 0.005), + transparent 62% + ), linear-gradient(180deg, #1a1817 0%, #0d0c0b 18%, #000000 100%); --secondary-shadow: 0 0 #0000; @@ -151,6 +187,9 @@ --color-danger: var(--danger); --color-danger-surface: var(--danger-surface); + --color-content: var(--content); + --color-content-muted: var(--content-muted); + --color-input: var(--input); --color-input-border: var(--input-border); --shadow-input: var(--input-shadow); From 4aab39cdc80e3df5ffd6630b9a9115c001e068ec Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 17 Aug 2026 16:45:29 +0300 Subject: [PATCH 12/47] fix(frontend): tokenize always-light surfaces broken in dark mode The remaining dark-mode debt was not the files carrying hardcoded dark: variants -- those render correctly, they are only unmaintainable. It was the files carrying none: 38 unconditional bg-white and 21 bg-gray-* literals that paint bright regardless of theme. The artifacts tab was the clearest case, rendering every row as a white bar on black. Counting dark: occurrences would never have found these, since the defect is the absence of the variant rather than its presence. Adds --content-strong to the content family and migrates all three levels. Every mapping is byte-identical in light -- the token resolves to rgb(255,255,255) where it replaced bg-white -- so light cannot shift. Drops dark: overrides that now duplicate the token's own dark value. Warms the data viewer from neutral to stone, matching the theme's warm greys instead of a colder family. Co-Authored-By: Claude Opus 5 --- frontend/app/.betterer.results | 14 +++++++------- .../ui/artifact-diff/artifact-content-diff.tsx | 4 ++-- .../diff/ui/artifact-diff/artifact-repo-diff.tsx | 2 +- .../src/entities/diff/ui/checks/data-conflict.tsx | 8 ++------ .../src/entities/diff/ui/diff-branch-not-found.tsx | 2 +- frontend/app/src/entities/diff/ui/diff-empty.tsx | 2 +- .../app/src/entities/diff/ui/diff-no-found.tsx | 2 +- .../diff/ui/file-diff/file-content-diff.tsx | 6 +++--- .../entities/diff/ui/file-diff/file-repo-diff.tsx | 2 +- .../app/src/entities/diff/ui/node-diff/node.tsx | 2 +- .../ui/sidebar/sidebar-menu-item-avatar.tsx | 4 +++- .../graphql-query-details-page-skeleton.tsx | 2 +- .../object/ui/metadata/field-metadata-form.tsx | 2 +- .../groups/toolbar-add-to-groups-action.tsx | 2 +- .../groups/toolbar-remove-from-groups-action.tsx | 2 +- .../ui/object-template/object-template-form.tsx | 2 +- .../ui/diff-summary/diff-summary-tag-group.tsx | 2 +- .../resource-manager/ui/number-pool-form.tsx | 2 +- .../app/src/entities/tasks/ui/task-display.tsx | 6 +++--- .../ui/tasks-homepage/tasks-homepage-skeleton.tsx | 2 +- .../ui/account-token-create-action.tsx | 2 +- .../pages/objects/object-details/task-details.tsx | 2 +- .../proposed-change-details/task-details.tsx | 2 +- .../components/data-viewer/data-viewer.styles.ts | 2 +- .../shared/components/data-viewer/data-viewer.tsx | 8 ++++---- .../src/shared/components/inputs/file-dropzone.tsx | 2 +- frontend/app/src/shared/components/inputs/list.tsx | 2 +- .../app/src/shared/components/layout/content.tsx | 2 +- frontend/app/src/shared/components/ui/alert.tsx | 4 ++-- frontend/app/src/shared/components/ui/badge.tsx | 12 ++++++------ frontend/packages/ui/src/styles/theme.css | 3 +++ 31 files changed, 56 insertions(+), 55 deletions(-) diff --git a/frontend/app/.betterer.results b/frontend/app/.betterer.results index 98618a2961c..b26f55b2745 100644 --- a/frontend/app/.betterer.results +++ b/frontend/app/.betterer.results @@ -17,7 +17,7 @@ exports[`fix ts error`] = { "src/entities/branches/ui/branch-create-form.tsx:4075623856": [ [34, 33, 13, "tsc: Argument of type \'BranchListItem\' is not assignable to parameter of type \'Branch\'.\\n Type \'BranchListItem\' is missing 4 properties from type \'Branch\'", "2717214769"] ], - "src/entities/diff/ui/artifact-diff/artifact-content-diff.tsx:863579160": [ + "src/entities/diff/ui/artifact-diff/artifact-content-diff.tsx:3984866554": [ [7, 39, 9, "tsc: Could not find a declaration file for module \'unidiff\'. \'../../../../../../node_modules/.pnpm/unidiff@1.0.4/node_modules/unidiff/index.js\' implicitly has an \'any\' type.\\n Try \`npm i --save-dev @types/unidiff\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'unidiff\';\`", "1870574010"], [29, 13, 23, "tsc: No overload matches this call.\\n Overload 1 of 3, \'(message: Buffer | string, options?: Sha1AsStringOptions | undefined): string\', gave the following error.\\n Argument of type \'number\' is not assignable to parameter of type \'Buffer | string\'.\\n Overload 2 of 3, \'(message: Buffer | string, options?: Sha1AsBytesOptions | undefined): Uint8Array\', gave the following error.\\n Argument of type \'number\' is not assignable to parameter of type \'string | Buffer\'.\\n Overload 3 of 3, \'(message: string | Buffer, options?: Sha1Options | Uint8Array | undefined): string\', gave the following error.\\n Argument of type \'number\' is not assignable to parameter of type \'string | Buffer\'.", "1877915893"], [329, 17, 11, "tsc: \'fileContent\' is possibly \'undefined\'.", "1561581386"], @@ -31,7 +31,7 @@ exports[`fix ts error`] = { [175, 63, 6, "tsc: Property \'length\' does not exist on type \'{}\'.", "1433765721"], [176, 34, 9, "tsc: Type \'{ value: unknown; }\' is not assignable to type \'Maybe\'.\\n Type \'{ value: unknown; }\' is missing 3 properties from type \'AttributeInterface\'", "1029368512"] ], - "src/entities/diff/ui/file-diff/file-content-diff.tsx:4266470414": [ + "src/entities/diff/ui/file-diff/file-content-diff.tsx:1428611458": [ [23, 39, 9, "tsc: Could not find a declaration file for module \'unidiff\'. \'../../../../../../node_modules/.pnpm/unidiff@1.0.4/node_modules/unidiff/index.js\' implicitly has an \'any\' type.\\n Try \`npm i --save-dev @types/unidiff\` if it exists or add a new declaration (.d.ts) file containing \`declare module \'unidiff\';\`", "1870574010"], [42, 13, 23, "tsc: No overload matches this call.\\n Overload 1 of 3, \'(message: Buffer | string, options?: Sha1AsStringOptions | undefined): string\', gave the following error.\\n Argument of type \'number\' is not assignable to parameter of type \'Buffer | string\'.\\n Overload 2 of 3, \'(message: Buffer | string, options?: Sha1AsBytesOptions | undefined): Uint8Array\', gave the following error.\\n Argument of type \'number\' is not assignable to parameter of type \'string | Buffer\'.\\n Overload 3 of 3, \'(message: string | Buffer, options?: Sha1Options | Uint8Array | undefined): string\', gave the following error.\\n Argument of type \'number\' is not assignable to parameter of type \'string | Buffer\'.", "1877915893"], [352, 19, 11, "tsc: \'fileContent\' is possibly \'undefined\'.", "1561581386"], @@ -47,7 +47,7 @@ exports[`fix ts error`] = { [49, 22, 17, "tsc: Property \'diff_branch_label\' does not exist on type \'DiffConflict\'. Did you mean \'diff_branch_value\'?", "1869793242"], [83, 29, 12, "tsc: Cannot find name \'DataConflict\'.", "3642611779"] ], - "src/entities/diff/ui/node-diff/node.tsx:3761240176": [ + "src/entities/diff/ui/node-diff/node.tsx:3075633910": [ [94, 21, 13, "tsc: Binding element \'property_type\' implicitly has an \'any\' type.", "324947141"], [102, 20, 13, "tsc: Type \'Element | null | string\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "1656119487"], [106, 20, 8, "tsc: Type \'Element | null | string\' is not assignable to type \'string | undefined\'.\\n Type \'null\' is not assignable to type \'string | undefined\'.", "288015442"], @@ -98,7 +98,7 @@ exports[`fix ts error`] = { "src/entities/nodes/object/ui/object-relationship-list.tsx:629247395": [ [38, 54, 5, "tsc: Property \'items\' does not exist on type \'NodeObject[]\'.", "179721187"] ], - "src/entities/nodes/object/ui/object-template/object-template-form.tsx:3248229051": [ + "src/entities/nodes/object/ui/object-template/object-template-form.tsx:1732330133": [ [86, 44, 5, "tsc: Property \'edges\' does not exist on type \'NodeAttribute | number | string | string[] | string[]\'.\\n Property \'edges\' does not exist on type \'string\'.", "165200885"] ], "src/entities/nodes/relationships/ui/queries/get-default-parent.query.ts:1805247483": [ @@ -126,7 +126,7 @@ exports[`fix ts error`] = { "src/entities/repository/ui/repository-objects-manager.tsx:3064139168": [ [40, 6, 18, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hierarchical: boolean; generate_profile: boolean; used_by?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; restricted_namespaces?: string[] | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'ModelSchema\'.\\n Type \'null\' is not assignable to type \'ModelSchema\'.", "3283107600"] ], - "src/entities/resource-manager/ui/number-pool-form.tsx:3337327002": [ + "src/entities/resource-manager/ui/number-pool-form.tsx:3955593524": [ [50, 39, 13, "tsc: Argument of type \'NodeFieldsWithMetadata | undefined\' is not assignable to parameter of type \'Record | undefined\'.\\n Type \'NodeFieldsWithMetadata\' is not assignable to type \'Record\'.\\n \'string\' index signatures are incompatible.\\n Type \'NodeAttributeWithMetadata | NodeCorePropertyValue | NodeRelationshipWithMetadata\' is not assignable to type \'NodeAttributeWithMetadata\'.\\n Type \'undefined\' is not assignable to type \'NodeAttributeWithMetadata\'.", "3940586649"], [51, 53, 13, "tsc: Argument of type \'NodeFieldsWithMetadata | undefined\' is not assignable to parameter of type \'Record | undefined\'.\\n Type \'NodeFieldsWithMetadata\' is not assignable to type \'Record\'.\\n \'string\' index signatures are incompatible.\\n Type \'NodeAttributeWithMetadata | NodeCorePropertyValue | NodeRelationshipWithMetadata\' is not assignable to type \'NodeAttributeWithMetadata\'.\\n Type \'undefined\' is not assignable to type \'NodeAttributeWithMetadata\'.", "3940586649"], [52, 39, 13, "tsc: Argument of type \'NodeFieldsWithMetadata | undefined\' is not assignable to parameter of type \'Record | undefined\'.\\n Type \'NodeFieldsWithMetadata\' is not assignable to type \'Record\'.\\n \'string\' index signatures are incompatible.\\n Type \'NodeAttributeWithMetadata | NodeCorePropertyValue | NodeRelationshipWithMetadata\' is not assignable to type \'NodeAttributeWithMetadata\'.\\n Type \'undefined\' is not assignable to type \'NodeAttributeWithMetadata\'.", "3940586649"], @@ -185,7 +185,7 @@ exports[`fix ts error`] = { "src/entities/schema/ui/computed-attribute-display.tsx:4204959992": [ [38, 14, 8, "tsc: Type \'{ title: string; fileName: string; data: string; }\' is not assignable to type \'IntrinsicAttributes & DataViewerProps\'.\\n Property \'fileName\' does not exist on type \'IntrinsicAttributes & DataViewerProps\'.", "3193632964"] ], - "src/entities/tasks/ui/task-display.tsx:3856668743": [ + "src/entities/tasks/ui/task-display.tsx:4156427209": [ [46, 16, 4, "tsc: Binding element \'task\' implicitly has an \'any\' type.", "2087951912"], [53, 10, 22, "tsc: Element implicitly has an \'any\' type because expression of type \'any\' can\'t be used to index type \'{ SCHEDULED: string; PENDING: string; RUNNING: string; PAUSED: string; CANCELLING: string; COMPLETED: string; CANCELLED: string; FAILED: string; CRASHED: string; }\'.", "1950518905"], [68, 39, 4, "tsc: Parameter \'edge\' implicitly has an \'any\' type.", "2087414470"], @@ -326,7 +326,7 @@ exports[`fix ts error`] = { [67, 40, 23, "tsc: Argument of type \'number | string | tRowValue | undefined\' is not assignable to parameter of type \'number | string | tRowValue\'.\\n Type \'undefined\' is not assignable to type \'number | string | tRowValue\'.", "1426455104"], [73, 40, 23, "tsc: Argument of type \'number | string | tRowValue | undefined\' is not assignable to parameter of type \'number | string | tRowValue\'.\\n Type \'undefined\' is not assignable to type \'number | string | tRowValue\'.", "1426455104"] ], - "src/shared/components/ui/alert.tsx:85410580": [ + "src/shared/components/ui/alert.tsx:594415186": [ [15, 12, 11, "tsc: This syntax is not allowed when \'erasableSyntaxOnly\' is enabled.", "2602525471"] ] }` diff --git a/frontend/app/src/entities/diff/ui/artifact-diff/artifact-content-diff.tsx b/frontend/app/src/entities/diff/ui/artifact-diff/artifact-content-diff.tsx index 34972b3c0c5..ed3f97f00ed 100644 --- a/frontend/app/src/entities/diff/ui/artifact-diff/artifact-content-diff.tsx +++ b/frontend/app/src/entities/diff/ui/artifact-diff/artifact-content-diff.tsx @@ -255,7 +255,7 @@ export const ArtifactContentDiff = ({ itemPrevious, itemNew, id }: ArtifactConte return { ...widgets, [changeKey]: change?.comments?.map((comment: any, index: number) => ( -
+
{comment.message}
)), @@ -324,7 +324,7 @@ export const ArtifactContentDiff = ({ itemPrevious, itemNew, id }: ArtifactConte
-
+
+
diff --git a/frontend/app/src/entities/diff/ui/checks/data-conflict.tsx b/frontend/app/src/entities/diff/ui/checks/data-conflict.tsx index a21fa6e0549..6433ae0c02f 100644 --- a/frontend/app/src/entities/diff/ui/checks/data-conflict.tsx +++ b/frontend/app/src/entities/diff/ui/checks/data-conflict.tsx @@ -75,9 +75,7 @@ export const DataConflict = ({ id, changes, kind, name }: DataConflictProps) =>
} - leftClassName={classNames( - "font-normal group-hover:bg-gray-100 dark:group-hover:bg-white/10 transition-all" - )} + leftClassName={classNames("font-normal group-hover:bg-content-strong transition-all")} right={
{branchChange?.previous} @@ -87,9 +85,7 @@ export const DataConflict = ({ id, changes, kind, name }: DataConflictProps) =>
} - rightClassName={classNames( - "font-normal group-hover:bg-gray-50 dark:group-hover:bg-white/5 transition-all" - )} + rightClassName={classNames("font-normal group-hover:bg-content-muted transition-all")} />
diff --git a/frontend/app/src/entities/diff/ui/diff-branch-not-found.tsx b/frontend/app/src/entities/diff/ui/diff-branch-not-found.tsx index 5560d113661..749bf9f9ff3 100644 --- a/frontend/app/src/entities/diff/ui/diff-branch-not-found.tsx +++ b/frontend/app/src/entities/diff/ui/diff-branch-not-found.tsx @@ -13,7 +13,7 @@ interface DiffBranchNotFoundProps { export function DiffBranchNotFound({ branchName }: DiffBranchNotFoundProps) { return (
-
+
diff --git a/frontend/app/src/entities/diff/ui/diff-empty.tsx b/frontend/app/src/entities/diff/ui/diff-empty.tsx index 307075fc75f..8961ccd288a 100644 --- a/frontend/app/src/entities/diff/ui/diff-empty.tsx +++ b/frontend/app/src/entities/diff/ui/diff-empty.tsx @@ -22,7 +22,7 @@ export function DiffEmpty({ return (
-
+
diff --git a/frontend/app/src/entities/diff/ui/diff-no-found.tsx b/frontend/app/src/entities/diff/ui/diff-no-found.tsx index 0e771bb4a5d..c284cd93c25 100644 --- a/frontend/app/src/entities/diff/ui/diff-no-found.tsx +++ b/frontend/app/src/entities/diff/ui/diff-no-found.tsx @@ -9,7 +9,7 @@ export interface DiffNoFoundProps { export function DiffNoFound({ diffStatus }: DiffNoFoundProps) { return (
-
+
diff --git a/frontend/app/src/entities/diff/ui/file-diff/file-content-diff.tsx b/frontend/app/src/entities/diff/ui/file-diff/file-content-diff.tsx index 0398f448c7e..8059c37c2c5 100644 --- a/frontend/app/src/entities/diff/ui/file-diff/file-content-diff.tsx +++ b/frontend/app/src/entities/diff/ui/file-diff/file-content-diff.tsx @@ -267,7 +267,7 @@ export function FileContentDiff({ return { ...widgets, [changeKey]: change?.comments?.map((comment: any, index: number) => ( -
+
{comment.message}
)), @@ -328,7 +328,7 @@ export function FileContentDiff({ }); return ( -
+
@@ -347,7 +347,7 @@ export function FileContentDiff({
-
+
+
{files.map((file) => ( }
} - className="rounded-md border bg-gray-100 dark:bg-white/5" + className="rounded-md border bg-content-strong dark:bg-white/5" >
diff --git a/frontend/app/src/entities/navigation/ui/sidebar/sidebar-menu-item-avatar.tsx b/frontend/app/src/entities/navigation/ui/sidebar/sidebar-menu-item-avatar.tsx index 4f98ae24018..35b528ad77e 100644 --- a/frontend/app/src/entities/navigation/ui/sidebar/sidebar-menu-item-avatar.tsx +++ b/frontend/app/src/entities/navigation/ui/sidebar/sidebar-menu-item-avatar.tsx @@ -13,7 +13,9 @@ const STYLES = [ export function SidebarMenuItemAvatar({ name }: { name: string }) { const firstLetter = name[0]; if (!firstLetter) { - return
; + return ( +
+ ); } const styleIndex = firstLetter.charCodeAt(0) % STYLES.length; diff --git a/frontend/app/src/entities/nodes/object/ui/CoreGraphQLQuery/graphql-query-details-page-skeleton.tsx b/frontend/app/src/entities/nodes/object/ui/CoreGraphQLQuery/graphql-query-details-page-skeleton.tsx index 1150e543cde..7a646a1aeb3 100644 --- a/frontend/app/src/entities/nodes/object/ui/CoreGraphQLQuery/graphql-query-details-page-skeleton.tsx +++ b/frontend/app/src/entities/nodes/object/ui/CoreGraphQLQuery/graphql-query-details-page-skeleton.tsx @@ -3,7 +3,7 @@ import { Skeleton } from "@/shared/components/loading/skeleton"; export function GraphQLQueryDetailsPageSkeleton() { return (
-
+
diff --git a/frontend/app/src/entities/nodes/object/ui/metadata/field-metadata-form.tsx b/frontend/app/src/entities/nodes/object/ui/metadata/field-metadata-form.tsx index 8aef4b5a4f9..edb03c8d1c4 100644 --- a/frontend/app/src/entities/nodes/object/ui/metadata/field-metadata-form.tsx +++ b/frontend/app/src/entities/nodes/object/ui/metadata/field-metadata-form.tsx @@ -58,7 +58,7 @@ export default function FieldMetadataForm({ } return ( -
+
- + {({ close }) => { return ( diff --git a/frontend/app/src/entities/nodes/object/ui/object-table/toolbar/actions/groups/toolbar-remove-from-groups-action.tsx b/frontend/app/src/entities/nodes/object/ui/object-table/toolbar/actions/groups/toolbar-remove-from-groups-action.tsx index a24a965ee20..008c2584c53 100644 --- a/frontend/app/src/entities/nodes/object/ui/object-table/toolbar/actions/groups/toolbar-remove-from-groups-action.tsx +++ b/frontend/app/src/entities/nodes/object/ui/object-table/toolbar/actions/groups/toolbar-remove-from-groups-action.tsx @@ -35,7 +35,7 @@ export function ToolbarRemoveFromGroupsAction({ selectedRows }: ToolbarRemoveFro Remove from groups - + {({ close }) => ( - + ({ }: DiffSummaryProps) { return ( - + {children} diff --git a/frontend/app/src/entities/resource-manager/ui/number-pool-form.tsx b/frontend/app/src/entities/resource-manager/ui/number-pool-form.tsx index bff1249c67e..63a7428e396 100644 --- a/frontend/app/src/entities/resource-manager/ui/number-pool-form.tsx +++ b/frontend/app/src/entities/resource-manager/ui/number-pool-form.tsx @@ -112,7 +112,7 @@ export const NumberPoolForm = ({ currentObject, onSuccess, onCancel }: NumberPoo } return ( -
+
diff --git a/frontend/app/src/entities/tasks/ui/task-display.tsx b/frontend/app/src/entities/tasks/ui/task-display.tsx index 3c72bf55ee6..0095dc3c998 100644 --- a/frontend/app/src/entities/tasks/ui/task-display.tsx +++ b/frontend/app/src/entities/tasks/ui/task-display.tsx @@ -50,7 +50,7 @@ function Task({ task }) {
@@ -105,7 +105,7 @@ export function TaskDisplay({ branch, workflow, relatedNode }: TaskDisplayProps) if (tasks.length === 0) { return ( - + No task ); @@ -122,7 +122,7 @@ export function TaskDisplay({ branch, workflow, relatedNode }: TaskDisplayProps) function Log({ message, severity, timestamp }: tLog) { return ( -
+
{getSeverityBadge[severity]} diff --git a/frontend/app/src/entities/tasks/ui/tasks-homepage/tasks-homepage-skeleton.tsx b/frontend/app/src/entities/tasks/ui/tasks-homepage/tasks-homepage-skeleton.tsx index 45449003a6c..de96e564f0b 100644 --- a/frontend/app/src/entities/tasks/ui/tasks-homepage/tasks-homepage-skeleton.tsx +++ b/frontend/app/src/entities/tasks/ui/tasks-homepage/tasks-homepage-skeleton.tsx @@ -2,7 +2,7 @@ import { Skeleton } from "@/shared/components/loading/skeleton"; export const TaskHomepageCardSkeleton = () => { return ( -
+
diff --git a/frontend/app/src/entities/user-profile/ui/account-token-create-action.tsx b/frontend/app/src/entities/user-profile/ui/account-token-create-action.tsx index fa3c4e07b30..b3b63e3aee4 100644 --- a/frontend/app/src/entities/user-profile/ui/account-token-create-action.tsx +++ b/frontend/app/src/entities/user-profile/ui/account-token-create-action.tsx @@ -65,7 +65,7 @@ export function AccountTokenCreateAction() {
-
{newToken}
+
{newToken}
diff --git a/frontend/app/src/pages/objects/object-details/task-details.tsx b/frontend/app/src/pages/objects/object-details/task-details.tsx index 1154666dbcf..a331fcb3df9 100644 --- a/frontend/app/src/pages/objects/object-details/task-details.tsx +++ b/frontend/app/src/pages/objects/object-details/task-details.tsx @@ -16,7 +16,7 @@ export function Component() { return ( <> -
+
-
+
+ {title} {actions} @@ -55,7 +55,7 @@ function DataViewerContent({ case "image/svg+xml": { return ( - + ); @@ -74,7 +74,7 @@ function DataViewerContent({