From de13d3925c495e2da272cafdc41b7a05d2367bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9la=20Kellner?= Date: Sat, 22 Aug 2026 19:04:59 +0200 Subject: [PATCH 1/3] feat(frontend): dark mode a publikus oldalon es az adminban Az oldal csak vilagos temat tudott: a prefers-color-scheme nulla talalat volt a repoban, a base.css :root blokkja egyetlen fix palettat definialt. Rendszerszintu dark mode mellett ez kiugro, es a latogatok egy resze szamara kellemetlen. A szinek mar tokenekben voltak, de a tokenek mellett 59 fix szin allt a komponensek scoped style blokkjaiban - tulnyomo reszt "background: #fff" es "color: #fff". Ezek nem tudnak temat kovetni, es mivel minden style blokk scoped (nincs :deep sem :global az egesz repoban), a tokenek oroklodese az EGYETLEN csatorna a komponensek fele. Ezert a munka nagyobb resze nem uj paletta, hanem a fix szinek visszavezetese tokenre. - base.css: a vilagos paletta 17 tokenje 40-re bovult, es megkapta a parjat. Uj tokenek arra, aminek eddig nem volt: --on-accent / --on-ink / --on-danger (elotersznin egy kitoltott felszinen), --surface-alpha, --nav-bg, --overlay, --shadow-sm, --scrollbar-thumb, --hatch(-strong), --border-4, --accent-bg-strong, --danger-bg / --danger-border, --warn-bg / --warn-ink, --code-*-rgb, --viz-1..8 / --viz-other. - A sotet blokk ket szelektor alatt all: @media (prefers-color-scheme: dark) :root:not([data-theme='light']) a rendszer-preferencia, es [data-theme='dark'] a kezi valasztas. A media blokk azert van :not()-tal szukitve, hogy a kifejezett "vilagos" nyerjen egy sotetre allitott gepen is. A duplikacio szandekos: egy szabaly nem lehet egyszerre a media queryn belul es kivul, a light-dark() pedig egy regebbi bongeszon az OSSZES tokent egyszerre ervenytelenne teszi - az nem vilagos oldal, hanem csupasz. - A sotet paletta nem a vilagos invertalasa, minden token a SZEREPET tartja. A gray skala ezert fordul meg (a --gray-700 a legerosebb masodlagos szoveg mindket temaban, tehat sotet hatteren a legvilagosabb), a felszinek viszont a vilagos paletta SORRENDJET tartjak: --surface > --bg > --surface-3 > --surface-2 vilagossag szerint, tehat egy kartya tovabbra is kiemelkedik a lapbol, egy halk kitoltes (hover sor, progress sav, toggle vajata) tovabbra is besuvad. Az invertalas minden kiemelt chipet bemelyedesse forditott volna. - Kontraszt (WCAG AA) a sotet paletta minden szoveg-tokenjere merve, a szamok a base.css kommentjeben. A --danger ket tokenre valt szet: szoveghez eleg vilagos voros nem tud feher feliratot vinni, ezert a szolid destruktiv gomb --danger-solid(-hover)-t hasznal. - Uj shared composable: useTheme (pf_theme kulcs, useLang mintajara). Harom allapot, nem ketto - a "system" onallo valasztas, es egy ket-allasu kapcsolo az elso kattintassal csendben lepinnelne a temat. System modban a data-theme attributum NINCS kiirva: igy a media query dont, es a rendszerbeallitas menet kozbeni valtasa JavaScript nelkul is atszinezi az oldalt (a composable csak a theme-color metat koveti). - public/theme.js mindket appban: a tarolt temat a stylesheet elott irja ki, tehat a kifejezett valasztas nem villantja fel a masik palettat. Kulon fajl es nem inline script, mert a CSP script-src 'self' nonce nelkul - ugyanaz az indok, mint az /error.js-nel. A -ben blokkolva, a Vite altal beszurt stylesheet elott. - ThemeToggle a publikus nav-ba a LangToggle melle, es a DetailNav-ba (ott a showLang-tol fuggetlenul: a nyelv egynyelvu lapon elrejtozik, a tema oldalszintu preferencia). Az adminban a sidebar lababa, oszlopba igazitva. A cimkek a uiCommon-ban, mert mindket app hasznalja. - Turnstile: a widget temaja eddig hard-kodolt "light" volt, a komment szerint "the site has no dark mode". Most a feloldott temara van kotve - nem 'auto'-ra, mert az a rendszert koveti, ami epp akkor rossz valasz, amikor a latogato felulirta. Temavaltasra a widget ujrarendelodik (a Turnstile-nak nincs API-ja elo widget atszinezesere), tehat a kihivas ujra kiadodik. - SkillsDonut: a nyolcas kategorikus paletta a base.css-be kerult temankent, es a szeletek :style-on kapjak a szint, nem a stroke attributumon - SVG presentation attributumban a var() nem behelyettesitodik, stroke="var(--viz-1)" semmit nem rajzolna. - CodeBackdrop: a nyolc kulonbozo alfa miatt a ket arnyalat csatorna- harmaskent jon a tokenbol, rgb(var(--code-ink-rgb) / 3%) formaban. A maszkban levo #000 marad: a maszk luminanciat olvas, nem szint. - error.html: sajat, bundle nelkuli paletta-masolata megkapta a sotet parjat, es betolti a /theme.js-t. Ez az az oldal, ahova a latogato akkor kerul, amikor mar valami baj van - vilagos villanas nelkul. - Ket szandekos kivetel maradt fix szinnek, kommenttel: a JPEG lapositas feher alapja az imageEdit.ts-ben (a tarolt kepbe sul bele, nem kovetheti a szerkeszto temajat) es a CodeBackdrop maszkja. - A site.webmanifest theme_color/background_color valtozatlan: a manifest nem tud temara reagalni, telepitett PWA-nal a splash igy vilagos marad. - A ThemeToggle harom glyphje utan U+FE0E (text presentation selector) all: egyik sincs a betoltott fontsource latin subsetekben, tehat platform-fallback fontbol jonnek, es a U+2600-nak van emoji formaja is - enelkul a monokrom pill egyik gombja Androidon szines nap lenne. Closes #50 --- portfoliocms.admin.client/index.html | 7 + portfoliocms.admin.client/public/theme.js | 20 ++ .../src/components/admin/AdminDashboard.vue | 6 +- .../src/components/admin/AdminExpPanel.vue | 4 +- .../src/components/admin/AdminListPanel.vue | 10 +- .../src/components/admin/AdminSidebar.vue | 8 +- .../src/components/admin/AdminSingleForm.vue | 4 +- .../src/components/admin/AdminStatsPanel.vue | 14 +- .../src/components/admin/CoverImagePicker.vue | 6 +- .../src/components/admin/EditorModal.vue | 22 +- .../src/components/admin/PeriodPicker.vue | 4 +- .../components/admin/ProfileImageCropper.vue | 6 +- .../src/components/admin/RichTextEditor.vue | 2 +- .../src/components/admin/TagPicker.vue | 2 +- .../src/components/admin/ThemeToggle.vue | 66 ++++++ .../src/components/admin/WorldMap.vue | 2 +- .../src/utils/imageEdit.ts | 2 + .../src/views/AdminView.vue | 2 +- portfoliocms.client/index.html | 9 + portfoliocms.client/public/error.html | 61 +++++- portfoliocms.client/public/theme.js | 20 ++ .../src/components/common/CodeBackdrop.vue | 24 ++- .../src/components/common/DetailNav.vue | 8 +- .../src/components/common/LangToggle.vue | 4 +- .../src/components/common/SiteNav.vue | 7 +- .../src/components/common/StatusPanel.vue | 2 +- .../src/components/common/ThemeToggle.vue | 69 +++++++ .../src/components/home/AboutSection.vue | 2 +- .../src/components/home/ContactSection.vue | 6 +- .../src/components/home/HeroSection.vue | 6 +- .../src/components/home/SkillsDonut.vue | 23 ++- .../src/components/home/SkillsSection.vue | 2 +- .../src/components/home/WorkSection.vue | 10 +- .../__tests__/useTurnstile.spec.ts | 54 +++++ .../src/composables/useTurnstile.ts | 21 +- portfoliocms.client/src/views/ArticleView.vue | 8 +- .../src/views/TagArchiveView.vue | 2 +- portfoliocms.shared/assets/base.css | 195 +++++++++++++++++- .../composables/__tests__/useTheme.spec.ts | 124 +++++++++++ portfoliocms.shared/composables/useTheme.ts | 76 +++++++ portfoliocms.shared/i18n/ui.ts | 6 + 41 files changed, 831 insertions(+), 95 deletions(-) create mode 100644 portfoliocms.admin.client/public/theme.js create mode 100644 portfoliocms.admin.client/src/components/admin/ThemeToggle.vue create mode 100644 portfoliocms.client/public/theme.js create mode 100644 portfoliocms.client/src/components/common/ThemeToggle.vue create mode 100644 portfoliocms.shared/composables/__tests__/useTheme.spec.ts create mode 100644 portfoliocms.shared/composables/useTheme.ts diff --git a/portfoliocms.admin.client/index.html b/portfoliocms.admin.client/index.html index 6aa57c1..07587e3 100644 --- a/portfoliocms.admin.client/index.html +++ b/portfoliocms.admin.client/index.html @@ -2,9 +2,16 @@ + + + + + + Admin — Portfólió diff --git a/portfoliocms.admin.client/public/theme.js b/portfoliocms.admin.client/public/theme.js new file mode 100644 index 0000000..51bbef9 --- /dev/null +++ b/portfoliocms.admin.client/public/theme.js @@ -0,0 +1,20 @@ +// Applies the stored colour theme before the stylesheet is, so an explicit choice does not flash +// the other palette first. Loaded from above the bundle: a separate file rather than an +// inline script because the CSP is script-src 'self' with no nonce. +// +// Only the attribute is set here. Everything else about the theme — the value, the toggle, the +// theme-color meta tag — belongs to portfoliocms.shared/composables/useTheme.ts, which reads the +// same 'pf_theme' key; this file is the paint-critical half of it and nothing more. +// +// No stored value, or "system": the attribute stays off and the @media (prefers-color-scheme) +// block in base.css decides, so the common case needs no JavaScript at all. +(function () { + try { + var stored = localStorage.getItem('pf_theme'); + if (stored === 'dark' || stored === 'light') { + document.documentElement.setAttribute('data-theme', stored); + } + } catch { + /* private mode: the system preference stands */ + } +})(); diff --git a/portfoliocms.admin.client/src/components/admin/AdminDashboard.vue b/portfoliocms.admin.client/src/components/admin/AdminDashboard.vue index d075249..b620cbe 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminDashboard.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminDashboard.vue @@ -133,7 +133,7 @@ async function remove(m: ContactMessageListItem) { .stat { padding: 20px; - background: #fff; + background: var(--surface); border: 1px solid var(--border-2); border-radius: 12px; } @@ -151,7 +151,7 @@ async function remove(m: ContactMessageListItem) { } .activity { - background: #fff; + background: var(--surface); border: 1px solid var(--border-2); border-radius: 12px; overflow: hidden; @@ -200,7 +200,7 @@ async function remove(m: ContactMessageListItem) { } .status.new { - color: #fff; + color: var(--on-accent); background: var(--accent); } diff --git a/portfoliocms.admin.client/src/components/admin/AdminExpPanel.vue b/portfoliocms.admin.client/src/components/admin/AdminExpPanel.vue index 12cb574..7e19d8a 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminExpPanel.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminExpPanel.vue @@ -72,7 +72,7 @@ const rows = (items: (typeof content.experiences)[number][]): AdminRow[] => } .group { - background: #fff; + background: var(--surface); border: 1px solid var(--border-2); border-radius: 12px; overflow: hidden; @@ -115,7 +115,7 @@ const rows = (items: (typeof content.experiences)[number][]): AdminRow[] => .add { font-size: 13px; font-weight: 500; - color: #fff; + color: var(--on-accent); background: var(--accent); border: none; border-radius: 8px; diff --git a/portfoliocms.admin.client/src/components/admin/AdminListPanel.vue b/portfoliocms.admin.client/src/components/admin/AdminListPanel.vue index f73def9..02f56df 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminListPanel.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminListPanel.vue @@ -47,7 +47,7 @@ const config = useConfigStore() diff --git a/portfoliocms.admin.client/src/components/admin/AdminSidebar.vue b/portfoliocms.admin.client/src/components/admin/AdminSidebar.vue index ea3470a..68f9dbd 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminSidebar.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminSidebar.vue @@ -5,6 +5,7 @@ import { useLang } from '@shared/composables/useLang' import { ui } from '@/i18n/ui' import { useContentStore } from '@shared/stores/content' import { useConfigStore } from '@/stores/config' +import ThemeToggle from './ThemeToggle.vue' export type AdminTab = 'dash' | 'stats' | 'about' | 'skills' | 'experience' | 'works' | 'contact' @@ -60,6 +61,7 @@ const items = computed(() => [
+ ↗ {{ tr(ui.viewSite) }} @@ -71,7 +73,7 @@ const items = computed(() => [ .sidebar { border-right: 1px solid var(--border); padding: 22px 16px; - background: #fff; + background: var(--surface); display: flex; flex-direction: column; } @@ -94,7 +96,7 @@ const items = computed(() => [ align-items: center; justify-content: center; font-weight: 600; - color: #fff; + color: var(--on-accent); } .brand-title { @@ -156,7 +158,7 @@ const items = computed(() => [ } .count.active { - background: #fff; + background: var(--surface); } .foot { diff --git a/portfoliocms.admin.client/src/components/admin/AdminSingleForm.vue b/portfoliocms.admin.client/src/components/admin/AdminSingleForm.vue index b692f29..a3afce0 100644 --- a/portfoliocms.admin.client/src/components/admin/AdminSingleForm.vue +++ b/portfoliocms.admin.client/src/components/admin/AdminSingleForm.vue @@ -258,7 +258,7 @@ async function save() { diff --git a/portfoliocms.admin.client/src/components/admin/WorldMap.vue b/portfoliocms.admin.client/src/components/admin/WorldMap.vue index de0d570..a1b09c1 100644 --- a/portfoliocms.admin.client/src/components/admin/WorldMap.vue +++ b/portfoliocms.admin.client/src/components/admin/WorldMap.vue @@ -117,7 +117,7 @@ svg { position: absolute; pointer-events: none; background: var(--ink); - color: #fff; + color: var(--on-ink); font-size: 12px; padding: 4px 9px; border-radius: 6px; diff --git a/portfoliocms.admin.client/src/utils/imageEdit.ts b/portfoliocms.admin.client/src/utils/imageEdit.ts index db7712c..326a14d 100644 --- a/portfoliocms.admin.client/src/utils/imageEdit.ts +++ b/portfoliocms.admin.client/src/utils/imageEdit.ts @@ -20,6 +20,8 @@ export function cropImageToBase64( if (!ctx) throw new Error('Canvas 2D context unavailable') // JPEG has no alpha channel — transparent source pixels would render black. The formats that // do have one are left transparent: a white plate under a logo is just as wrong. + // Deliberately a literal and not a theme token: this ends up baked into a stored image every + // visitor sees, so it must not depend on which theme the editor happened to be using. if (mime === 'image/jpeg') { ctx.fillStyle = '#fff' ctx.fillRect(0, 0, output.width, output.height) diff --git a/portfoliocms.admin.client/src/views/AdminView.vue b/portfoliocms.admin.client/src/views/AdminView.vue index ce57f90..6ac595e 100644 --- a/portfoliocms.admin.client/src/views/AdminView.vue +++ b/portfoliocms.admin.client/src/views/AdminView.vue @@ -308,7 +308,7 @@ h1 { .add { font-size: 14px; font-weight: 500; - color: #fff; + color: var(--on-accent); background: var(--accent); border: none; border-radius: 9px; diff --git a/portfoliocms.client/index.html b/portfoliocms.client/index.html index 94af114..ec4ac99 100644 --- a/portfoliocms.client/index.html +++ b/portfoliocms.client/index.html @@ -4,10 +4,19 @@ + + + + + + + + diff --git a/portfoliocms.client/src/components/common/DetailNav.vue b/portfoliocms.client/src/components/common/DetailNav.vue index d384e6a..6a75ef8 100644 --- a/portfoliocms.client/src/components/common/DetailNav.vue +++ b/portfoliocms.client/src/components/common/DetailNav.vue @@ -3,6 +3,7 @@ import { ref } from 'vue' import { useLang } from '@shared/composables/useLang' import { ui } from '@/i18n/ui' import LangToggle from './LangToggle.vue' +import ThemeToggle from './ThemeToggle.vue' withDefaults(defineProps<{ showLang?: boolean }>(), { showLang: true }) @@ -27,6 +28,9 @@ function copyLink() {