diff --git a/frontend/src/components/artist/ArtistHeader.vue b/frontend/src/components/artist/ArtistHeader.vue index 2c63210..992622a 100644 --- a/frontend/src/components/artist/ArtistHeader.vue +++ b/frontend/src/components/artist/ArtistHeader.vue @@ -103,9 +103,9 @@ const stats = computed(() => { padding: 0.5rem 1rem; background: linear-gradient( to bottom, - rgba(20, 23, 26, 0.92) 0%, - rgba(20, 23, 26, 0.65) 60%, - rgba(20, 23, 26, 0) 100% + rgba(var(--fc-chrome-rgb), 0.92) 0%, + rgba(var(--fc-chrome-rgb), 0.65) 60%, + rgba(var(--fc-chrome-rgb), 0) 100% ); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); diff --git a/frontend/src/components/gallery/GalleryFilterBar.vue b/frontend/src/components/gallery/GalleryFilterBar.vue index 0c5dc39..7217b2b 100644 --- a/frontend/src/components/gallery/GalleryFilterBar.vue +++ b/frontend/src/components/gallery/GalleryFilterBar.vue @@ -329,7 +329,7 @@ function pushFilter(mutate) { opaque than the bar/nav so the controls stay legible. */ .fc-filterbar-wrap :deep(.v-field), .fc-filterbar-wrap :deep(.v-btn-group) { - background-color: rgba(20, 23, 26, 0.72); + background-color: rgba(var(--v-theme-background), 0.72); } /* Media toggle (All / Images / Videos) as ONE cohesive segmented control. FC's global VBtn { rounded: 'pill' } default made Vuetify 4 pill-round each diff --git a/frontend/src/components/modal/ImageViewer.vue b/frontend/src/components/modal/ImageViewer.vue index 1ff8d69..f3db735 100644 --- a/frontend/src/components/modal/ImageViewer.vue +++ b/frontend/src/components/modal/ImageViewer.vue @@ -213,7 +213,7 @@ function nextFrame() { --fc-side-w: 320px; /* Obsidian haze (#14171A = 20,23,26) — same palette as TopNav, mid-opacity + blur so the page behind shows through faintly. */ - background: rgba(20, 23, 26, 0.65); + background: rgba(var(--v-theme-background), 0.65); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); outline: none; @@ -221,7 +221,7 @@ function nextFrame() { } .fc-viewer__close, .fc-viewer__nav { position: absolute; top: 50%; - background: rgba(20, 23, 26, 0.7); + background: rgba(var(--v-theme-background), 0.7); color: rgb(var(--v-theme-parchment, 232 228 216)); border: 1px solid rgb(var(--v-theme-surface-light)); border-radius: 50%; @@ -359,7 +359,7 @@ function nextFrame() { z-index: 1; /* Opaque obsidian so the scrolling panel never bleeds through the haze behind the pinned image. */ - background: rgb(20, 23, 26); + background: rgb(var(--v-theme-background)); } .fc-viewer__side { width: 100%; diff --git a/frontend/src/views/ExploreView.vue b/frontend/src/views/ExploreView.vue index 611c95d..0e99410 100644 --- a/frontend/src/views/ExploreView.vue +++ b/frontend/src/views/ExploreView.vue @@ -369,7 +369,7 @@ onUnmounted(() => { /* Center viewer. */ .fc-ex__viewer { - background: rgb(20, 23, 26); + background: rgb(var(--v-theme-background)); display: flex; flex-direction: column; min-width: 0; min-height: 0; } .fc-ex__canvas { flex: 1 1 auto; display: flex; min-height: 0; min-width: 0; } diff --git a/frontend/src/views/SeriesReaderView.vue b/frontend/src/views/SeriesReaderView.vue index a00d1d0..67d2d53 100644 --- a/frontend/src/views/SeriesReaderView.vue +++ b/frontend/src/views/SeriesReaderView.vue @@ -276,7 +276,7 @@ onUnmounted(() => { .fc-reader__thumb img { width: 100%; height: auto; display: block; } .fc-reader__thumbnum { position: absolute; bottom: 4px; right: 4px; - background: rgba(20, 23, 26, 0.8); color: rgb(var(--v-theme-on-surface)); + background: rgba(var(--v-theme-background), 0.8); color: rgb(var(--v-theme-on-surface)); padding: 1px 5px; border-radius: 4px; font-size: 0.7rem; } .fc-reader__content { @@ -302,7 +302,7 @@ onUnmounted(() => { .fc-reader__indicator { position: fixed; bottom: 1.5rem; right: 1.5rem; padding: 4px 10px; border-radius: 999px; - background: rgba(20, 23, 26, 0.6); + background: rgba(var(--v-theme-background), 0.6); border: 1px solid rgb(var(--v-theme-surface-light)); font-size: 0.8rem; color: rgb(var(--v-theme-on-surface-variant)); z-index: 100; pointer-events: none; diff --git a/frontend/src/views/SeriesView.vue b/frontend/src/views/SeriesView.vue index f69f74b..dd814cb 100644 --- a/frontend/src/views/SeriesView.vue +++ b/frontend/src/views/SeriesView.vue @@ -338,18 +338,18 @@ onMounted(() => { position: absolute; top: 6px; left: 6px; display: inline-flex; align-items: center; gap: 2px; padding: 1px 6px; border-radius: 999px; font-size: 11px; - background: rgba(20, 23, 26, 0.75); + background: rgba(var(--v-theme-background), 0.75); color: rgb(var(--v-theme-warning, var(--v-theme-accent))); } /* Kebab sits top-right of the cover, opposite the gap badge. Tinted backing so it stays legible over any cover image. */ .fc-sbcard__kebab { position: absolute; top: 4px; right: 4px; - background: rgba(20, 23, 26, 0.6) !important; + background: rgba(var(--v-theme-background), 0.6) !important; border-radius: 50%; color: rgb(var(--v-theme-on-surface)); } -.fc-sbcard__kebab:hover { background: rgba(20, 23, 26, 0.85) !important; } +.fc-sbcard__kebab:hover { background: rgba(var(--v-theme-background), 0.85) !important; } .fc-sbcard__body { padding: 8px 10px; } .fc-sbcard__name { font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600; diff --git a/frontend/test/paletteLiterals.spec.js b/frontend/test/paletteLiterals.spec.js new file mode 100644 index 0000000..8b80eb2 --- /dev/null +++ b/frontend/test/paletteLiterals.spec.js @@ -0,0 +1,40 @@ +// The obsidian surface written out as a number instead of read from its token. +// +// fabled-tokens.js is the palette's single copy ("do not hand-edit hexes; +// re-mirror here"). Thirteen hand-typed `rgba(20, 23, 26, …)` across six +// components meant a changed obsidian would miss most of the UI (#3108). +// Components read `--v-theme-background` (Vuetify maps background → obsidian) +// or, for nav-style chrome, `--fc-chrome-rgb`. Comments may still name it. + +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +const SRC = fileURLToPath(new URL('../src', import.meta.url)) +const LITERAL = /rgba?\(\s*20\s*,\s*23\s*,\s*26\b/ + +function vueFiles (dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name) + if (statSync(path).isDirectory()) return vueFiles(path) + return name.endsWith('.vue') ? [path] : [] + }) +} + +function stripComments (text) { + return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(//g, '') +} + +describe('palette literals', () => { + it('no component hand-types the obsidian surface', () => { + const offenders = vueFiles(SRC).flatMap((path) => + stripComments(readFileSync(path, 'utf8')) + .split('\n') + .filter((line) => LITERAL.test(line)) + .map((line) => `${relative(SRC, path)}: ${line.trim()}`) + ) + expect(offenders).toEqual([]) + }) +})