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 %}