diff --git a/assets/archetype-form.tsx b/assets/archetype-form.tsx
index 2844bb0..9e43d32 100644
--- a/assets/archetype-form.tsx
+++ b/assets/archetype-form.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import PlaystyleTagSelect from './components/PlaystyleTagSelect';
import PokemonSpriteSelect from './components/PokemonSpriteSelect';
import MarkdownEditor from './components/MarkdownEditor';
@@ -39,12 +39,12 @@ if (spriteRoot) {
const hiddenInputName = spriteRoot.dataset.hiddenInputName ?? 'archetype_form[pokemonSlugs]';
createRoot(spriteRoot).render(
-
+
- ,
+ ,
);
}
@@ -74,14 +74,14 @@ if (playstyleRoot) {
const placeholder = playstyleRoot.dataset.placeholder ?? undefined;
createRoot(playstyleRoot).render(
-
+
- ,
+ ,
);
}
@@ -98,12 +98,12 @@ editorRoots.forEach((root) => {
}
createRoot(root).render(
-
+
- ,
+ ,
);
});
diff --git a/assets/archetype-variants.tsx b/assets/archetype-variants.tsx
index 1d37287..c8e7dfd 100644
--- a/assets/archetype-variants.tsx
+++ b/assets/archetype-variants.tsx
@@ -13,7 +13,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import '@mantine/core/styles.css';
import ArchetypeVariantSelector from './components/ArchetypeVariantSelector';
@@ -47,8 +47,8 @@ if (root) {
};
createRoot(root).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/banned-card-form.tsx b/assets/banned-card-form.tsx
index de152b5..fefb499 100644
--- a/assets/banned-card-form.tsx
+++ b/assets/banned-card-form.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import MarkdownEditor from './components/MarkdownEditor';
import '@mantine/core/styles.css';
@@ -33,12 +33,12 @@ editorRoots.forEach((root) => {
}
createRoot(root).render(
-
+
- ,
+ ,
);
});
diff --git a/assets/catalog-filters.tsx b/assets/catalog-filters.tsx
index d71efed..5543aee 100644
--- a/assets/catalog-filters.tsx
+++ b/assets/catalog-filters.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import ArchetypeFilterSelect from './components/ArchetypeFilterSelect';
import AsyncAutocomplete from './components/AsyncAutocomplete';
@@ -34,7 +34,7 @@ document.querySelectorAll('[data-catalog-archetype]').forEach((root
const initialLabel = root.dataset.initialLabel ?? '';
createRoot(root).render(
-
+
('[data-catalog-archetype]').forEach((root
initialValue={initialValue}
initialLabel={initialLabel}
/>
- ,
+ ,
);
});
@@ -54,7 +54,7 @@ document.querySelectorAll('[data-catalog-event]').forEach((root) =>
const initialId = root.dataset.initialId ?? '';
createRoot(root).render(
-
+
('[data-catalog-event]').forEach((root) =>
secondary: `${item.date as string} · ${item.location as string}`,
})}
/>
- ,
+ ,
);
});
@@ -79,7 +79,7 @@ document.querySelectorAll('[data-catalog-owner]').forEach((root) =>
const initialId = root.dataset.initialId ?? '';
createRoot(root).render(
-
+
('[data-catalog-owner]').forEach((root) =>
label: item.screenName as string,
})}
/>
- ,
+ ,
);
});
diff --git a/assets/components/AppMantineProvider.tsx b/assets/components/AppMantineProvider.tsx
new file mode 100644
index 0000000..117159d
--- /dev/null
+++ b/assets/components/AppMantineProvider.tsx
@@ -0,0 +1,27 @@
+/*
+ * This file is part of the Expanded Decks project.
+ *
+ * (c) Expanded Decks contributors
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import React from 'react';
+import { MantineProvider } from '@mantine/core';
+
+/**
+ * Shared Mantine provider for all React islands in the app. Configures
+ * Mantine to follow the operating-system color scheme (`auto`) so it stays
+ * in sync with the `data-bs-theme` attribute set on `` by the inline
+ * bridge script in base.html.twig.
+ *
+ * @see docs/features.md F20.1 — Dark theme following OS preference
+ */
+export default function AppMantineProvider({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/assets/deck-card-list.tsx b/assets/deck-card-list.tsx
index 3132be7..814d929 100644
--- a/assets/deck-card-list.tsx
+++ b/assets/deck-card-list.tsx
@@ -16,7 +16,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import { DeckCardList, type DeckCardListProps } from './components/DeckCardList';
import '@mantine/core/styles.css';
@@ -55,7 +55,7 @@ if (root) {
};
createRoot(root).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/deck-form.tsx b/assets/deck-form.tsx
index 7d901c2..d32f0c8 100644
--- a/assets/deck-form.tsx
+++ b/assets/deck-form.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import ArchetypeSelect from './components/ArchetypeSelect';
import LanguageSelect from './components/LanguageSelect';
import PokemonSpriteSelect from './components/PokemonSpriteSelect';
@@ -32,7 +32,7 @@ if (archetypeRoot) {
const initialName = archetypeRoot.dataset.archetypeName ?? undefined;
createRoot(archetypeRoot).render(
-
+
- ,
+ ,
);
}
@@ -57,12 +57,12 @@ if (languageRoot) {
}
createRoot(languageRoot).render(
-
+
- ,
+ ,
);
}
@@ -79,11 +79,11 @@ if (spriteRoot) {
}
createRoot(spriteRoot).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/deck-found.tsx b/assets/deck-found.tsx
index 681c2ad..7bb36c7 100644
--- a/assets/deck-found.tsx
+++ b/assets/deck-found.tsx
@@ -12,7 +12,7 @@
*/
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import '@mantine/core/styles.css';
import DeckFoundModal from './components/DeckFoundModal';
@@ -26,7 +26,7 @@ if (root) {
const labels = JSON.parse(root.dataset.labels ?? '{}');
createRoot(root).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/deck-version-compare.tsx b/assets/deck-version-compare.tsx
index f65346d..1539961 100644
--- a/assets/deck-version-compare.tsx
+++ b/assets/deck-version-compare.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import DeckVersionCompare from './components/DeckVersionCompare';
import '@mantine/core/styles.css';
@@ -45,8 +45,8 @@ document.querySelectorAll('[data-version-compare]').forEach((root)
};
createRoot(root).render(
-
+
- ,
+ ,
);
});
diff --git a/assets/event-form.tsx b/assets/event-form.tsx
index 3b87427..2f2e372 100644
--- a/assets/event-form.tsx
+++ b/assets/event-form.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import EventTagSelect from './components/EventTagSelect';
import '@mantine/core/styles.css';
@@ -46,13 +46,13 @@ if (tagRoot) {
const placeholder = tagRoot.dataset.placeholder ?? '';
createRoot(tagRoot).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/homepage-editor.tsx b/assets/homepage-editor.tsx
index 2abe34c..9c75486 100644
--- a/assets/homepage-editor.tsx
+++ b/assets/homepage-editor.tsx
@@ -12,7 +12,7 @@
*/
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import HomepageEditor from './components/HomepageEditor';
import '@mantine/core/styles.css';
@@ -61,7 +61,7 @@ if (root) {
} catch { /* use default */ }
createRoot(root).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/navbar-search.tsx b/assets/navbar-search.tsx
index fd63338..026bcaa 100644
--- a/assets/navbar-search.tsx
+++ b/assets/navbar-search.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import NavbarSearch from './components/NavbarSearch';
import '@mantine/core/styles.css';
@@ -27,7 +27,7 @@ if (root) {
const labelNoResults = root.dataset.labelNoResults ?? 'No results';
createRoot(root).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/notification-bell.tsx b/assets/notification-bell.tsx
index e9e1be5..6cdd0f7 100644
--- a/assets/notification-bell.tsx
+++ b/assets/notification-bell.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import NotificationBell from './components/NotificationBell';
import '@mantine/core/styles.css';
@@ -30,7 +30,7 @@ if (root) {
const labelEmpty = root.dataset.labelEmpty ?? 'No notifications yet';
createRoot(root).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/page-form.tsx b/assets/page-form.tsx
index ab98209..5ea8f9a 100644
--- a/assets/page-form.tsx
+++ b/assets/page-form.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import MarkdownEditor from './components/MarkdownEditor';
import ImageUrlField from './components/ImageUrlField';
@@ -33,13 +33,13 @@ editorRoots.forEach((root) => {
}
createRoot(root).render(
-
+
- ,
+ ,
);
});
@@ -83,8 +83,8 @@ imageUrlRoots.forEach((root) => {
};
createRoot(root).render(
-
+
- ,
+ ,
);
});
diff --git a/assets/staff-autocomplete.tsx b/assets/staff-autocomplete.tsx
index 7552b25..fa0d9b9 100644
--- a/assets/staff-autocomplete.tsx
+++ b/assets/staff-autocomplete.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import AsyncAutocomplete from './components/AsyncAutocomplete';
import '@mantine/core/styles.css';
@@ -29,7 +29,7 @@ document.querySelectorAll('[data-staff-autocomplete]').forEach((roo
const placeholder = root.dataset.placeholder ?? 'Screen name, email, or Pokemon ID';
createRoot(root).render(
-
+
('[data-staff-autocomplete]').forEach((roo
secondary: [item.email, item.playerId].filter(Boolean).join(' · '),
})}
/>
- ,
+ ,
);
});
diff --git a/assets/styles/app.scss b/assets/styles/app.scss
index 93db184..0a95551 100644
--- a/assets/styles/app.scss
+++ b/assets/styles/app.scss
@@ -60,6 +60,28 @@ $grid-row-columns: 9;
@import "~bootstrap/scss/bootstrap";
@import "~bootstrap-icons/font/bootstrap-icons.css";
+// --- Runtime theme tokens ---
+// SCSS variables above feed Bootstrap's compile-time generation for the LIGHT
+// theme. These CSS custom properties mirror them so selectors that should
+// react to the OS color scheme can use var(--ed-*) instead.
+// @see docs/features.md F20.1 — Dark theme following OS preference
+
+:root {
+ --ed-navy: #{$ed-navy};
+ --ed-blue: #{$ed-blue};
+ --ed-gold: #{$ed-gold};
+ --ed-red: #{$ed-red};
+ --ed-bg: #{$ed-bg};
+}
+
+[data-bs-theme="dark"] {
+ --ed-navy: #6478b8;
+ --ed-blue: #6a9bff;
+ --ed-gold: #ffd95e;
+ --ed-red: #ff7080;
+ --ed-bg: #121826;
+}
+
// --- Layout ---
html, body {
@@ -721,3 +743,120 @@ footer {
letter-spacing: .05em;
}
}
+
+// --- Dark theme overrides ---
+// Selectors below paint navy-tinted surfaces over a white body in light mode.
+// On a dark body that recipe inverts (the surface disappears or sits darker
+// than the body), so re-tint them against the dark background.
+// @see docs/features.md F20.1 — Dark theme following OS preference
+
+[data-bs-theme="dark"] {
+ body {
+ background-color: var(--ed-bg);
+ background-image: none;
+ }
+
+ // The SCSS overrides at the top of this file ($card-bg, $card-border-color,
+ // $card-cap-bg, $table-striped-bg, $secondary) hardcode light values into
+ // Bootstrap's component selectors (e.g. `.card { --bs-card-bg: #fff }`),
+ // which has higher specificity than `:root[data-bs-theme="dark"]`. To
+ // restore Bootstrap's native dark mode for those surfaces we re-declare
+ // the CSS custom properties on the component selectors under dark mode.
+ .card {
+ --bs-card-bg: #1a2238;
+ --bs-card-border-color: rgb(255 255 255 / 12%);
+ --bs-card-cap-bg: rgb(255 255 255 / 4%);
+ }
+
+ .dropdown-menu {
+ --bs-dropdown-bg: #1a2238;
+ --bs-dropdown-border-color: rgb(255 255 255 / 12%);
+ --bs-dropdown-link-hover-bg: rgb(255 255 255 / 8%);
+ --bs-dropdown-divider-bg: rgb(255 255 255 / 10%);
+ }
+
+ .modal-content {
+ --bs-modal-bg: #1a2238;
+ --bs-modal-border-color: rgb(255 255 255 / 12%);
+ }
+
+ .popover {
+ --bs-popover-bg: #1a2238;
+ --bs-popover-border-color: rgb(255 255 255 / 12%);
+ }
+
+ .list-group {
+ --bs-list-group-bg: #1a2238;
+ --bs-list-group-border-color: rgb(255 255 255 / 12%);
+ --bs-list-group-action-hover-bg: rgb(255 255 255 / 6%);
+ }
+
+ .table {
+ --bs-table-striped-bg: rgb(255 255 255 / 4%);
+ --bs-table-border-color: rgb(255 255 255 / 12%);
+ }
+
+ .alert {
+ --bs-alert-border-color: rgb(255 255 255 / 12%);
+ }
+
+ .form-control,
+ .form-select {
+ background-color: rgb(255 255 255 / 4%);
+ border-color: rgb(255 255 255 / 15%);
+ color: var(--bs-body-color);
+
+ &:focus {
+ background-color: rgb(255 255 255 / 6%);
+ }
+ }
+
+ .hero-pokemon {
+ background: linear-gradient(135deg, #1c2538 0%, #2a3552 100%);
+
+ .hero-text {
+ color: rgb(255 255 255 / 80%);
+ }
+ }
+
+ .card-header-themed {
+ background-color: rgb(255 255 255 / 6%);
+ border-bottom-color: var(--ed-gold);
+
+ .nav-tabs .nav-link.active {
+ color: var(--bs-body-color);
+ background-color: var(--bs-card-bg);
+ border-color: var(--bs-border-color) var(--bs-border-color) transparent;
+ }
+ }
+
+ .table-themed thead {
+ background-color: rgb(255 255 255 / 6%);
+ }
+
+ .badge-lent {
+ color: #1a1a1a;
+ }
+
+ .badge-retired {
+ background-color: #555;
+ }
+
+ .cms-content blockquote {
+ border-left-color: rgb(255 255 255 / 30%);
+ background-color: rgb(255 255 255 / 5%);
+ }
+
+ /* stylelint-disable-next-line selector-class-pattern */
+ .mantine-RichTextEditor-content,
+ .cms-content {
+ th {
+ background-color: rgb(255 255 255 / 8%);
+ }
+ }
+
+ /* stylelint-disable-next-line selector-class-pattern */
+ .mantine-RichTextEditor-content .selectedCell {
+ background-color: rgb(255 255 255 / 12%);
+ }
+}
diff --git a/assets/styles/themes/expandedtalks/_overrides.scss b/assets/styles/themes/expandedtalks/_overrides.scss
index ee24daf..6d64f00 100644
--- a/assets/styles/themes/expandedtalks/_overrides.scss
+++ b/assets/styles/themes/expandedtalks/_overrides.scss
@@ -132,3 +132,36 @@ a {
color: rgba(#fff, .85);
}
}
+
+// --- Dark theme overrides ---
+// Channel-scoped tweaks for the OS-preference dark mode. The slate and sand
+// hues already work on dark; only the body background and a few accent
+// surfaces need adjustment.
+// @see docs/features.md F20.1 — Dark theme following OS preference
+
+[data-bs-theme="dark"] {
+ body {
+ background-color: #14171c;
+ background-image: none;
+ }
+
+ .hero-pokemon {
+ background: linear-gradient(135deg, #2a2e36 0%, #3d4350 100%);
+ }
+
+ .card-header-themed {
+ background: rgba(#fff, .06);
+ border-bottom: 2px solid $cs-sand;
+ }
+
+ .btn-outline-primary {
+ color: #a8c98a;
+ border-color: #a8c98a;
+
+ &:hover, &:focus {
+ background-color: #a8c98a;
+ border-color: #a8c98a;
+ color: #14171c;
+ }
+ }
+}
diff --git a/assets/variant-compare-modal.tsx b/assets/variant-compare-modal.tsx
index 369e009..df3c189 100644
--- a/assets/variant-compare-modal.tsx
+++ b/assets/variant-compare-modal.tsx
@@ -14,7 +14,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import '@mantine/core/styles.css';
import CardImageModal, { type FlatCard } from './components/CardImageModal';
@@ -53,9 +53,9 @@ if (root) {
const cards = JSON.parse(root.dataset.cards ?? '[]') as FlatCard[];
createRoot(root).render(
-
+
- ,
+ ,
);
// Wire up click handlers on card names to dispatch custom event
diff --git a/assets/variant-compare.tsx b/assets/variant-compare.tsx
index 3f3daa0..4e73e98 100644
--- a/assets/variant-compare.tsx
+++ b/assets/variant-compare.tsx
@@ -13,7 +13,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import '@mantine/core/styles.css';
import VariantComparePicker from './components/VariantComparePicker';
@@ -22,7 +22,7 @@ if (root) {
const variants = JSON.parse(root.dataset.variants ?? '[]');
createRoot(root).render(
-
+
- ,
+ ,
);
}
diff --git a/assets/walk-up-autocomplete.tsx b/assets/walk-up-autocomplete.tsx
index d7f9968..af2db82 100644
--- a/assets/walk-up-autocomplete.tsx
+++ b/assets/walk-up-autocomplete.tsx
@@ -9,7 +9,7 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
-import { MantineProvider } from '@mantine/core';
+import AppMantineProvider from './components/AppMantineProvider';
import AsyncAutocomplete from './components/AsyncAutocomplete';
import '@mantine/core/styles.css';
@@ -30,7 +30,7 @@ document.querySelectorAll('[data-walk-up-deck-autocomplete]').forEa
const placeholder = root.dataset.placeholder ?? 'Search by deck name or tag...';
createRoot(root).render(
-
+
('[data-walk-up-deck-autocomplete]').forEa
secondary: `${item.ownerName as string} · ${item.shortTag as string}`,
})}
/>
- ,
+ ,
);
});
@@ -52,7 +52,7 @@ document.querySelectorAll('[data-walk-up-user-autocomplete]').forEa
const placeholder = root.dataset.placeholder ?? 'Search by screen name, email, or Pokemon ID...';
createRoot(root).render(
-
+
('[data-walk-up-user-autocomplete]').forEa
secondary: [item.email, item.playerId].filter(Boolean).join(' · '),
})}
/>
- ,
+ ,
);
});
diff --git a/docs/features.md b/docs/features.md
index bcc7d0d..36b904d 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -318,3 +318,13 @@ Multi-domain channel system and SEO features. Each channel serves a distinct dom
| F18.27 | JSON-LD structured data | Medium | Done | `StructuredDataBuilder` service generating schema.org JSON-LD blocks rendered in `{% block structured_data %}`. **Homepage:** `WebSite` with channel brand name. **CMS pages:** `WebPage` with title, date, publisher. **Archetypes:** `Article` with localized name/description, `hasPart` entries for deck variants (anchored by shortTag). **Events:** `Event` with dates, location, organizer, `EventCancelled` status. **Decks:** `CreativeWork` with owner, dates. Organization name reads from the channel's `brand_name` parameter. Twig functions: `structured_data()` (returns builder), `json_ld(data)` (encodes as safe HTML). Depends on F18.2. |
| F18.28 | Open Graph and Twitter Card meta tags | Medium | Done | Full Open Graph and Twitter Card meta tags on all public pages via a reusable `_partials/opengraph.html.twig` included from `{% block opengraph %}`. Tags: `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, `og:locale` (+ alternate), `og:site_name`, `twitter:card` (`summary_large_image` when image available), `twitter:title`, `twitter:description`, `twitter:image`. Page-specific images: CMS pages use `Page.ogImage`, decks use `mosaicImageUrl`, archetypes and events use site default. Consolidates existing partial OG tags from page and archetype templates. Depends on F18.25. |
| F18.29 | Locale-prefixed URL routing | High | Done | Add `/{_locale}/` prefix to all public editorial routes (archetypes, CMS pages) so that each language version has a distinct URL (e.g. `/en/archetypes/iron-thorns` vs `/fr/archetypes/iron-thorns`). The **homepage** (`/`) remains session-based for UX but its canonical points to the default locale (`/en/`); localized homepage routes (`/en/`, `/fr/`) also exist for SEO. Non-editorial routes (decks, events, auth, admin) retain the current session-based locale without URL prefix. A navbar locale switcher (`EN | FR`) lets users toggle between languages — on editorial routes it swaps the `_locale` in the URL directly; on session-based routes it goes through `LocaleSwitchController`. `LocaleListener` updated: route-level `_locale` now takes precedence over user/session preference. 301 redirects from all legacy unprefixed paths to `/en/` equivalents for SEO continuity. Sitemap generates entries for each locale for editorial content. robots.txt updated with locale-prefixed allow/disallow rules. Unblocks F18.26 (hreflang). |
+
+---
+
+## F20 — Theming
+
+OS- and brand-driven visual variants of the application UI.
+
+| ID | Feature | Priority | Status | Description |
+|---------|----------------------------------------------|----------|--------|-------------|
+| F20.1 | Dark theme following OS preference | Medium | Done | Auto dark mode that follows `prefers-color-scheme`. An inline `` script in `base.html.twig` mirrors the OS preference onto `` so popovers and inputs match the surrounding chrome. Applied to both the default theme and the `expandedtalks` channel theme. No user toggle, no persistence — strictly follows OS. |
diff --git a/templates/base.html.twig b/templates/base.html.twig
index 96835a2..71702a1 100644
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -11,6 +11,19 @@
+ {# F20.1 — Mirror the OS color scheme onto the document so Bootstrap and Mantine see it before first paint. #}
+
{% block title %}{{ channel_param('brand_name', 'Expanded Decks') }}{% endblock %}
{% block canonical %}{% endblock %}