fix(cover): make placeholder palettes react to theme accent

Unify accent metadata in one client module to prevent hue and tone drift across theme picker, chart palettes, and cover placeholders.

Guard placeholder theme-store access when Pinia is unavailable so non-store mounts remain stable while preserving reactive updates when a store is active.
This commit is contained in:
neon
2026-05-31 06:35:42 +00:00
parent cc7cbb13a1
commit 0b3a96cbe3
9 changed files with 441 additions and 133 deletions
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getActivePinia, storeToRefs } from 'pinia'
import { bookCoverPalette } from '../lib/book-cover'
import { useThemeStore } from '@/stores/theme'
const props = defineProps<{
title: string | null
@@ -9,7 +11,15 @@ const props = defineProps<{
seed: string
}>()
const p = computed(() => bookCoverPalette(props.seed))
const activePinia = getActivePinia()
const themeRefs = activePinia ? storeToRefs(useThemeStore(activePinia)) : null
const p = computed(() =>
bookCoverPalette(props.seed, {
accent: themeRefs?.accent.value,
isDark: themeRefs?.theme.value === 'dark',
}),
)
// Portrait for ebook (200×300), square for audiobook (300×300).
// The container background uses the same palette, so any letterboxing from
@@ -0,0 +1,78 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BookCoverPlaceholder from '../BookCoverPlaceholder.vue'
import { useThemeStore } from '@/stores/theme'
type PaletteResult = {
gradient: string
from: string
to: string
color: string
accent: string
textMuted: string
}
const paletteSpy = vi.fn<() => PaletteResult>(() => ({
gradient: 'linear-gradient(135deg, oklch(0.6 0.18 210) 0%, oklch(0.45 0.18 210) 100%)',
from: 'oklch(0.6 0.18 210)',
to: 'oklch(0.45 0.18 210)',
color: 'oklch(0.99 0.01 210)',
accent: 'oklch(0.86 0.08 210)',
textMuted: 'oklch(0.9 0.02 210)',
}))
vi.mock('@/features/book/lib/book-cover', () => ({
bookCoverPalette: (...args: Parameters<typeof paletteSpy>) => paletteSpy(...args),
}))
describe('BookCoverPlaceholder', () => {
beforeEach(() => {
paletteSpy.mockClear()
})
it('recomputes palette when accent changes', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = mount(BookCoverPlaceholder, {
props: {
title: 'Dune',
authorLine: 'Frank Herbert',
isAudio: false,
seed: 'dune-seed',
},
global: { plugins: [pinia] },
})
const store = useThemeStore(pinia)
const initialCalls = paletteSpy.mock.calls.length
store.setAccent(store.accent === 'blue' ? 'rose' : 'blue')
await nextTick()
expect(paletteSpy.mock.calls.length).toBeGreaterThan(initialCalls)
wrapper.unmount()
})
it('recomputes palette when light/dark theme changes', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const wrapper = mount(BookCoverPlaceholder, {
props: {
title: 'Dune',
authorLine: 'Frank Herbert',
isAudio: false,
seed: 'dune-seed',
},
global: { plugins: [pinia] },
})
const store = useThemeStore(pinia)
const initialCalls = paletteSpy.mock.calls.length
store.setTheme(store.theme === 'dark' ? 'light' : 'dark')
await nextTick()
expect(paletteSpy.mock.calls.length).toBeGreaterThan(initialCalls)
wrapper.unmount()
})
})
@@ -1,6 +1,23 @@
import { describe, it, expect } from 'vitest'
import { bookCoverStyle, bookCoverPalette, titleFontSizeClass } from './book-cover'
function extractHue(color: string): number {
const match = color.match(/oklch\([^)]+\s(-?\d+(?:\.\d+)?)\)/)
if (!match) throw new Error(`Could not extract hue from color: ${color}`)
return Number.parseFloat(match[1]!)
}
function extractChroma(color: string): number {
const match = color.match(/oklch\(\s*-?\d+(?:\.\d+)?\s+(-?\d+(?:\.\d+)?)\s+-?\d+(?:\.\d+)?\s*\)/)
if (!match) throw new Error(`Could not extract chroma from color: ${color}`)
return Number.parseFloat(match[1]!)
}
function hueDistance(a: number, b: number): number {
const delta = Math.abs(a - b) % 360
return delta > 180 ? 360 - delta : delta
}
describe('bookCoverStyle', () => {
it('returns background and color properties', () => {
const style = bookCoverStyle('Dune')
@@ -80,7 +97,48 @@ describe('bookCoverPalette', () => {
for (let i = 0; i < 50; i++) {
hues.add(bookCoverPalette(`seed-${i}`).accent)
}
expect(hues.size).toBeGreaterThan(10)
expect(hues.size).toBeGreaterThanOrEqual(10)
})
it('anchors placeholder hue near the supplied theme hue', () => {
const themeHue = 142
for (let i = 0; i < 50; i++) {
const palette = bookCoverPalette(`seed-${i}`, { themeHue })
const hue = extractHue(palette.from)
expect([0, 12, 24, 36]).toContain(hueDistance(hue, themeHue))
}
})
it('uses --tint-h from CSS variables when no theme hue is provided', () => {
const root = document.documentElement
const prev = root.style.getPropertyValue('--tint-h')
root.style.setProperty('--tint-h', '118')
try {
const palette = bookCoverPalette('seed-css-hue')
const hue = extractHue(palette.from)
expect([0, 12, 24, 36]).toContain(hueDistance(hue, 118))
} finally {
if (prev) root.style.setProperty('--tint-h', prev)
else root.style.removeProperty('--tint-h')
}
})
it('falls back to default hue if --tint-h is not a number', () => {
const root = document.documentElement
const prev = root.style.getPropertyValue('--tint-h')
root.style.setProperty('--tint-h', 'invalid')
try {
expect(bookCoverPalette('seed-fallback')).toEqual(bookCoverPalette('seed-fallback', { themeHue: 263 }))
} finally {
if (prev) root.style.setProperty('--tint-h', prev)
else root.style.removeProperty('--tint-h')
}
})
it('uses lower chroma for pastel accents than vivid accents', () => {
const vivid = bookCoverPalette('seed-tone-compare', { themeHue: 142, accent: 'blue', isDark: false })
const pastel = bookCoverPalette('seed-tone-compare', { themeHue: 142, accent: 'mint', isDark: false })
expect(extractChroma(pastel.from)).toBeLessThan(extractChroma(vivid.from))
})
it('always produces vibrant backgrounds', () => {
+71 -9
View File
@@ -1,3 +1,5 @@
import { isPastelAccent } from '@/lib/theme-accent-meta'
export interface BookCoverPalette {
gradient: string
from: string
@@ -7,6 +9,17 @@ export interface BookCoverPalette {
textMuted: string
}
const DEFAULT_THEME_HUE = 263
const THEME_HUE_OFFSETS = [-36, -24, -12, 0, 12, 24, 36] as const
type CoverTone = 'vivid' | 'pastel'
export interface BookCoverPaletteOptions {
themeHue?: number
accent?: string | null
isDark?: boolean
}
function seedHash(seed: string): number {
let h = 5381
for (let i = 0; i < seed.length; i++) {
@@ -15,29 +28,78 @@ function seedHash(seed: string): number {
return h
}
export function bookCoverPalette(seed: string): BookCoverPalette {
function normalizeHue(hue: number): number {
const value = hue % 360
return value < 0 ? value + 360 : value
}
function resolveThemeHue(fallbackHue = DEFAULT_THEME_HUE): number {
if (typeof document === 'undefined') return fallbackHue
const raw = getComputedStyle(document.documentElement).getPropertyValue('--tint-h').trim()
const hue = Number.parseFloat(raw)
if (!Number.isFinite(hue)) return fallbackHue
return normalizeHue(hue)
}
function resolveAccentFromDocument(): string | null {
if (typeof document === 'undefined') return null
for (const className of document.documentElement.classList) {
if (className.startsWith('accent-')) return className.slice('accent-'.length)
}
return null
}
function resolveIsDarkFromDocument(): boolean {
return typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
}
function resolveTone(accent: string | null): CoverTone {
return isPastelAccent(accent) ? 'pastel' : 'vivid'
}
function resolvePaletteOptions(themeHueOrOptions?: number | BookCoverPaletteOptions): {
themeHue?: number
accent: string | null
isDark: boolean
} {
if (typeof themeHueOrOptions === 'number') {
return { themeHue: themeHueOrOptions, accent: resolveAccentFromDocument(), isDark: resolveIsDarkFromDocument() }
}
return {
themeHue: themeHueOrOptions?.themeHue,
accent: themeHueOrOptions?.accent ?? resolveAccentFromDocument(),
isDark: themeHueOrOptions?.isDark ?? resolveIsDarkFromDocument(),
}
}
export function bookCoverPalette(seed: string, themeHueOrOptions?: number | BookCoverPaletteOptions): BookCoverPalette {
const options = resolvePaletteOptions(themeHueOrOptions)
const n = seedHash(seed)
const hue = (n * 137.508) % 360
const baseHue = normalizeHue(options.themeHue ?? resolveThemeHue())
const hueOffset = THEME_HUE_OFFSETS[n % THEME_HUE_OFFSETS.length] ?? 0
const hue = normalizeHue(baseHue + hueOffset)
const tone = resolveTone(options.accent)
const n2 = (n >>> 8) & 0xff
const n3 = (n >>> 16) & 0xff
// Base Lightness: 0.45 to 0.75 (Clean and vibrant)
const baseL = 0.45 + (n2 % 6) * 0.05
const baseC = 0.12 + (n3 % 5) * 0.02
const baseL = tone === 'pastel' ? (options.isDark ? 0.56 : 0.66) + (n2 % 5) * 0.03 : (options.isDark ? 0.46 : 0.45) + (n2 % 6) * 0.05
const baseC = tone === 'pastel' ? (options.isDark ? 0.05 : 0.04) + (n3 % 4) * 0.012 : (options.isDark ? 0.11 : 0.12) + (n3 % 5) * 0.02
const from = `oklch(${baseL.toFixed(3)} ${baseC.toFixed(3)} ${Math.round(hue)})`
// Gradient: 20% lightness drop
const toL = baseL - 0.2
const toL = baseL - (tone === 'pastel' ? 0.16 : 0.2)
const to = `oklch(${toL.toFixed(3)} ${baseC.toFixed(3)} ${Math.round(hue)})`
// Text: Purest tint
const color = `oklch(0.99 0.01 ${Math.round(hue)})`
// Accent: Brightest highlight
const accentL = Math.min(0.95, baseL + 0.35)
const accent = `oklch(${accentL.toFixed(3)} ${Math.max(0.02, baseC - 0.08).toFixed(3)} ${Math.round(hue)})`
const accentL = Math.min(0.95, baseL + (tone === 'pastel' ? 0.2 : 0.35))
const accent = `oklch(${accentL.toFixed(3)} ${Math.max(0.02, baseC - (tone === 'pastel' ? 0.02 : 0.08)).toFixed(3)} ${Math.round(hue)})`
const textMuted = `oklch(0.9 0.02 ${Math.round(hue)})`
const textMuted = `oklch(0.9 ${tone === 'pastel' ? '0.012' : '0.020'} ${Math.round(hue)})`
return {
gradient: `linear-gradient(135deg, ${from} 0%, ${to} 100%)`,
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { getThemePalette, getBookorbitThemeName, initChartThemes } from './echarts'
import { DEFAULT_ACCENT } from './theme-accent-meta'
function isHexColor(v: string): boolean {
return /^#[0-9a-f]{6}$/i.test(v)
}
describe('getThemePalette', () => {
it('returns a 10-color hex palette', () => {
const palette = getThemePalette('dark', 'blue')
expect(palette).toHaveLength(10)
expect(palette.every(isHexColor)).toBe(true)
})
it('falls back to the default accent when accent is unknown', () => {
expect(getThemePalette('light', 'unknown-accent')).toEqual(getThemePalette('light', DEFAULT_ACCENT))
})
it('produces different palettes for light and dark mode', () => {
const lightPalette = getThemePalette('light', 'blue')
const darkPalette = getThemePalette('dark', 'blue')
expect(lightPalette).not.toEqual(darkPalette)
})
})
describe('getBookorbitThemeName', () => {
it('uses normalized accent names in theme keys', () => {
expect(getBookorbitThemeName('dark', 'blue')).toBe('bookorbit-dark-blue')
expect(getBookorbitThemeName('light', 'mint')).toBe('bookorbit-light-mint')
})
it('falls back to default accent for invalid names', () => {
expect(getBookorbitThemeName('dark', 'bad-accent')).toBe(`bookorbit-dark-${DEFAULT_ACCENT}`)
})
})
describe('initChartThemes', () => {
it('is idempotent when called repeatedly', () => {
expect(() => {
initChartThemes()
initChartThemes()
}).not.toThrow()
})
})
+10 -83
View File
@@ -28,6 +28,8 @@ import {
TooltipComponent,
VisualMapComponent,
} from 'echarts/components'
import { ACCENT_IDS, type Accent } from '@bookorbit/types'
import { ACCENT_HUE, ACCENT_PRIMARY, DEFAULT_ACCENT, resolveAccent } from '@/lib/theme-accent-meta'
// SVG renderer: events fire on real DOM elements, not via canvas hit-test.
// This eliminates the cursor-flicker / hover-disappear bug that canvas
@@ -73,82 +75,7 @@ export function readCssColor(varName: string): string {
type ProjectxThemeMode = 'light' | 'dark'
// --primary oklch(L C H) per accent, sourced from accents.css.
// [lightL, lightC, darkL, darkC] — hue is always the tint-h value.
type PrimaryDef = [lightL: number, lightC: number, darkL: number, darkC: number]
const ACCENT_PRIMARY: Record<string, PrimaryDef> = {
white: [0.2, 0, 0.985, 0],
grey: [0.55, 0, 0.75, 0],
rose: [0.57, 0.24, 0.73, 0.2],
orange: [0.64, 0.22, 0.78, 0.18],
amber: [0.72, 0.17, 0.82, 0.17],
yellow: [0.75, 0.18, 0.84, 0.16],
lime: [0.64, 0.2, 0.77, 0.18],
green: [0.527, 0.18, 0.72, 0.18],
emerald: [0.52, 0.17, 0.72, 0.15],
teal: [0.52, 0.18, 0.74, 0.16],
cyan: [0.52, 0.2, 0.75, 0.17],
sky: [0.54, 0.21, 0.75, 0.18],
blue: [0.487, 0.25, 0.72, 0.2],
indigo: [0.51, 0.26, 0.72, 0.22],
violet: [0.491, 0.27, 0.72, 0.23],
fuchsia: [0.56, 0.27, 0.75, 0.22],
pink: [0.56, 0.26, 0.75, 0.22],
coral: [0.6, 0.12, 0.76, 0.1],
peach: [0.63, 0.11, 0.78, 0.09],
butter: [0.66, 0.13, 0.8, 0.11],
lemon: [0.7, 0.12, 0.82, 0.1],
celadon: [0.61, 0.1, 0.76, 0.08],
sage: [0.52, 0.1, 0.72, 0.09],
mint: [0.6, 0.1, 0.75, 0.09],
seafoam: [0.61, 0.09, 0.76, 0.08],
powder: [0.59, 0.1, 0.75, 0.09],
mist: [0.56, 0.1, 0.74, 0.09],
periwinkle: [0.54, 0.11, 0.73, 0.1],
wisteria: [0.55, 0.1, 0.73, 0.09],
lavender: [0.55, 0.11, 0.73, 0.1],
orchid: [0.56, 0.11, 0.74, 0.1],
blush: [0.54, 0.11, 0.73, 0.1],
}
const ACCENT_HUE: Record<string, number> = {
white: 0,
grey: 0,
rose: 15,
orange: 42,
amber: 70,
yellow: 95,
lime: 118,
green: 142,
emerald: 162,
teal: 180,
cyan: 197,
sky: 213,
blue: 263,
indigo: 276,
violet: 292,
fuchsia: 312,
pink: 328,
coral: 25,
peach: 38,
butter: 80,
lemon: 100,
celadon: 122,
sage: 142,
mint: 158,
seafoam: 178,
powder: 205,
mist: 218,
periwinkle: 265,
wisteria: 285,
lavender: 300,
orchid: 315,
blush: 345,
}
const THEME_MODES: ProjectxThemeMode[] = ['light', 'dark']
const DEFAULT_ACCENT = 'blue'
// Staggered hue offsets so adjacent chart series have more contrast.
const HUE_OFFSETS = [0, 72, 144, 216, 288, 36, 108, 180, 252, 324]
@@ -182,20 +109,20 @@ function oklchToHex(L: number, C: number, H: number): string {
}
export function getThemePalette(mode: ProjectxThemeMode, accent: string, chromaScale = 1, lightnessScale = 1): string[] {
const resolvedAccent = accent in ACCENT_PRIMARY ? accent : DEFAULT_ACCENT
const def = ACCENT_PRIMARY[resolvedAccent]!
const resolvedAccent = resolveAccent(accent)
const def = ACCENT_PRIMARY[resolvedAccent]
const dark = mode === 'dark'
const L = (dark ? def[2] : def[0]) * lightnessScale
const C = (dark ? def[3] : def[1]) * chromaScale
const H = ACCENT_HUE[resolvedAccent]!
const H = ACCENT_HUE[resolvedAccent]
return HUE_OFFSETS.map((off) => oklchToHex(L, C, H + off))
}
function buildTheme(accent: string, dark: boolean) {
const def = ACCENT_PRIMARY[accent] ?? ACCENT_PRIMARY[DEFAULT_ACCENT]!
function buildTheme(accent: Accent, dark: boolean) {
const def = ACCENT_PRIMARY[accent]
const L = dark ? def[2] : def[0]
const C = dark ? def[3] : def[1]
const H = ACCENT_HUE[accent] ?? ACCENT_HUE[DEFAULT_ACCENT]!
const H = ACCENT_HUE[accent]
const colors = HUE_OFFSETS.map((off) => oklchToHex(L, C, H + off))
@@ -226,14 +153,14 @@ function buildTheme(accent: string, dark: boolean) {
}
export function getBookorbitThemeName(mode: ProjectxThemeMode = 'dark', accent: string = DEFAULT_ACCENT): string {
const resolvedAccent = accent in ACCENT_PRIMARY ? accent : DEFAULT_ACCENT
const resolvedAccent = resolveAccent(accent)
return `bookorbit-${mode}-${resolvedAccent}`
}
export function initChartThemes(): void {
if (themeRegistered) return
for (const mode of THEME_MODES) {
for (const accent of Object.keys(ACCENT_PRIMARY)) {
for (const accent of ACCENT_IDS) {
registerTheme(getBookorbitThemeName(mode, accent), buildTheme(accent, mode === 'dark'))
}
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest'
import { ACCENT_IDS, type Accent } from '@bookorbit/types'
import {
ACCENT_HUE,
ACCENT_OPTIONS,
ACCENT_PASTEL,
ACCENT_PRIMARY,
ACCENT_VIVID,
DEFAULT_ACCENT,
isPastelAccent,
resolveAccent,
} from './theme-accent-meta'
describe('resolveAccent', () => {
it('returns default accent for nullish or invalid values', () => {
expect(resolveAccent(undefined)).toBe(DEFAULT_ACCENT)
expect(resolveAccent(null)).toBe(DEFAULT_ACCENT)
expect(resolveAccent('')).toBe(DEFAULT_ACCENT)
expect(resolveAccent('not-a-real-accent')).toBe(DEFAULT_ACCENT)
})
it('returns valid accent values unchanged', () => {
expect(resolveAccent('blue')).toBe('blue')
expect(resolveAccent('mint')).toBe('mint')
})
})
describe('isPastelAccent', () => {
it('classifies accent tone correctly', () => {
expect(isPastelAccent('mint')).toBe(true)
expect(isPastelAccent('blue')).toBe(false)
expect(isPastelAccent('unknown')).toBe(false)
})
})
describe('accent option collections', () => {
it('contains every accent id exactly once across all options', () => {
const ids = ACCENT_OPTIONS.map((opt) => opt.id)
expect(ids.length).toBe(ACCENT_IDS.length)
expect(new Set(ids).size).toBe(ids.length)
expect(new Set(ids)).toEqual(new Set(ACCENT_IDS))
})
it('partitions vivid and pastel options without overlap', () => {
const vividIds = new Set(ACCENT_VIVID.map((opt) => opt.id))
const pastelIds = new Set(ACCENT_PASTEL.map((opt) => opt.id))
const overlap = [...vividIds].filter((id) => pastelIds.has(id))
expect(overlap).toHaveLength(0)
expect(vividIds.size + pastelIds.size).toBe(ACCENT_IDS.length)
})
it('keeps each tone group sorted by hue', () => {
const vividHues = ACCENT_VIVID.map((opt) => ACCENT_HUE[opt.id])
const pastelHues = ACCENT_PASTEL.map((opt) => ACCENT_HUE[opt.id])
expect(vividHues).toEqual([...vividHues].sort((a, b) => a - b))
expect(pastelHues).toEqual([...pastelHues].sort((a, b) => a - b))
})
})
describe('accent metadata records', () => {
it('defines hue and primary values for every accent', () => {
for (const accent of ACCENT_IDS) {
const hue = ACCENT_HUE[accent]
const primary = ACCENT_PRIMARY[accent]
expect(Number.isFinite(hue)).toBe(true)
expect(primary).toHaveLength(4)
expect(primary.every((v) => Number.isFinite(v))).toBe(true)
}
})
it('primary tuples keep chroma non-negative in both modes', () => {
for (const accent of ACCENT_IDS as readonly Accent[]) {
const primary = ACCENT_PRIMARY[accent]
expect(primary[1]).toBeGreaterThanOrEqual(0)
expect(primary[3]).toBeGreaterThanOrEqual(0)
}
})
})
+88
View File
@@ -0,0 +1,88 @@
import { ACCENT_IDS, type Accent } from '@bookorbit/types'
export type AccentTone = 'vivid' | 'pastel'
export type AccentPrimaryDef = readonly [lightL: number, lightC: number, darkL: number, darkC: number]
export interface AccentOption {
id: Accent
label: string
color: string
}
interface AccentMetaDefinition {
label: string
color: string
tone: AccentTone
hue: number
primary: AccentPrimaryDef
}
const ACCENT_META_BY_ID: Record<Accent, AccentMetaDefinition> = {
white: { label: 'White', color: '#fafafa', tone: 'vivid', hue: 0, primary: [0.2, 0, 0.985, 0] },
grey: { label: 'Grey', color: '#737373', tone: 'pastel', hue: 0, primary: [0.55, 0, 0.75, 0] },
rose: { label: 'Rose', color: '#e11d48', tone: 'vivid', hue: 15, primary: [0.57, 0.24, 0.73, 0.2] },
orange: { label: 'Orange', color: '#ea580c', tone: 'vivid', hue: 42, primary: [0.64, 0.22, 0.78, 0.18] },
amber: { label: 'Amber', color: '#d97706', tone: 'vivid', hue: 70, primary: [0.72, 0.17, 0.82, 0.17] },
yellow: { label: 'Yellow', color: '#ca8a04', tone: 'vivid', hue: 95, primary: [0.75, 0.18, 0.84, 0.16] },
lime: { label: 'Lime', color: '#65a30d', tone: 'vivid', hue: 118, primary: [0.64, 0.2, 0.77, 0.18] },
green: { label: 'Green', color: '#16a34a', tone: 'vivid', hue: 142, primary: [0.527, 0.18, 0.72, 0.18] },
emerald: { label: 'Emerald', color: '#059669', tone: 'vivid', hue: 162, primary: [0.52, 0.17, 0.72, 0.15] },
teal: { label: 'Teal', color: '#0d9488', tone: 'vivid', hue: 180, primary: [0.52, 0.18, 0.74, 0.16] },
cyan: { label: 'Cyan', color: '#0891b2', tone: 'vivid', hue: 197, primary: [0.52, 0.2, 0.75, 0.17] },
sky: { label: 'Sky', color: '#0284c7', tone: 'vivid', hue: 213, primary: [0.54, 0.21, 0.75, 0.18] },
blue: { label: 'Blue', color: '#2563eb', tone: 'vivid', hue: 263, primary: [0.487, 0.25, 0.72, 0.2] },
indigo: { label: 'Indigo', color: '#4338ca', tone: 'vivid', hue: 276, primary: [0.51, 0.26, 0.72, 0.22] },
violet: { label: 'Violet', color: '#7c3aed', tone: 'vivid', hue: 292, primary: [0.491, 0.27, 0.72, 0.23] },
fuchsia: { label: 'Fuchsia', color: '#c026d3', tone: 'vivid', hue: 312, primary: [0.56, 0.27, 0.75, 0.22] },
pink: { label: 'Pink', color: '#db2777', tone: 'vivid', hue: 328, primary: [0.56, 0.26, 0.75, 0.22] },
coral: { label: 'Coral', color: '#e8968a', tone: 'pastel', hue: 25, primary: [0.6, 0.12, 0.76, 0.1] },
peach: { label: 'Peach', color: '#e8b08a', tone: 'pastel', hue: 38, primary: [0.63, 0.11, 0.78, 0.09] },
butter: { label: 'Butter', color: '#d4be7a', tone: 'pastel', hue: 80, primary: [0.66, 0.13, 0.8, 0.11] },
lemon: { label: 'Lemon', color: '#d4d07a', tone: 'pastel', hue: 100, primary: [0.7, 0.12, 0.82, 0.1] },
celadon: { label: 'Celadon', color: '#a0c8a0', tone: 'pastel', hue: 122, primary: [0.61, 0.1, 0.76, 0.08] },
sage: { label: 'Sage', color: '#92ad91', tone: 'pastel', hue: 142, primary: [0.52, 0.1, 0.72, 0.09] },
mint: { label: 'Mint', color: '#96c8b8', tone: 'pastel', hue: 158, primary: [0.6, 0.1, 0.75, 0.09] },
seafoam: { label: 'Seafoam', color: '#96c4bc', tone: 'pastel', hue: 178, primary: [0.61, 0.09, 0.76, 0.08] },
powder: { label: 'Powder', color: '#90b8d0', tone: 'pastel', hue: 205, primary: [0.59, 0.1, 0.75, 0.09] },
mist: { label: 'Mist', color: '#8aacc8', tone: 'pastel', hue: 218, primary: [0.56, 0.1, 0.74, 0.09] },
periwinkle: { label: 'Periwinkle', color: '#9fa8d8', tone: 'pastel', hue: 265, primary: [0.54, 0.11, 0.73, 0.1] },
wisteria: { label: 'Wisteria', color: '#b0a0d0', tone: 'pastel', hue: 285, primary: [0.55, 0.1, 0.73, 0.09] },
lavender: { label: 'Lavender', color: '#b8a8d4', tone: 'pastel', hue: 300, primary: [0.55, 0.11, 0.73, 0.1] },
orchid: { label: 'Orchid', color: '#c8a8c8', tone: 'pastel', hue: 315, primary: [0.56, 0.11, 0.74, 0.1] },
blush: { label: 'Blush', color: '#c8a0b4', tone: 'pastel', hue: 345, primary: [0.54, 0.11, 0.73, 0.1] },
}
export const DEFAULT_ACCENT: Accent = 'blue'
export function resolveAccent(accent: string | null | undefined): Accent {
if (!accent) return DEFAULT_ACCENT
return (ACCENT_IDS as readonly string[]).includes(accent) ? (accent as Accent) : DEFAULT_ACCENT
}
export function isPastelAccent(accent: string | null | undefined): boolean {
const id = resolveAccent(accent)
return ACCENT_META_BY_ID[id].tone === 'pastel'
}
const ALL_ACCENT_OPTIONS: readonly AccentOption[] = ACCENT_IDS.map((id) => ({
id,
label: ACCENT_META_BY_ID[id].label,
color: ACCENT_META_BY_ID[id].color,
}))
const byHue = (a: AccentOption, b: AccentOption) => ACCENT_META_BY_ID[a.id].hue - ACCENT_META_BY_ID[b.id].hue
export const ACCENT_VIVID: readonly AccentOption[] = [...ALL_ACCENT_OPTIONS].filter((opt) => ACCENT_META_BY_ID[opt.id].tone === 'vivid').sort(byHue)
export const ACCENT_PASTEL: readonly AccentOption[] = [...ALL_ACCENT_OPTIONS].filter((opt) => ACCENT_META_BY_ID[opt.id].tone === 'pastel').sort(byHue)
export const ACCENT_OPTIONS: readonly AccentOption[] = [...ACCENT_VIVID, ...ACCENT_PASTEL]
export const ACCENT_HUE: Record<Accent, number> = Object.fromEntries(ACCENT_IDS.map((id) => [id, ACCENT_META_BY_ID[id].hue])) as Record<
Accent,
number
>
export const ACCENT_PRIMARY: Record<Accent, AccentPrimaryDef> = Object.fromEntries(
ACCENT_IDS.map((id) => [id, ACCENT_META_BY_ID[id].primary]),
) as Record<Accent, AccentPrimaryDef>
+1 -39
View File
@@ -3,45 +3,7 @@ import { ref, watch } from 'vue'
import { BACKGROUND_IDS, type Background, ACCENT_IDS, type Accent, RADIUS_IDS, type Radius, THEME_IDS, type Theme } from '@bookorbit/types'
import { storage } from '@/services/storage'
export const ACCENT_VIVID: { id: Accent; label: string; color: string }[] = [
{ id: 'white', label: 'White', color: '#fafafa' },
{ id: 'rose', label: 'Rose', color: '#e11d48' },
{ id: 'orange', label: 'Orange', color: '#ea580c' },
{ id: 'amber', label: 'Amber', color: '#d97706' },
{ id: 'yellow', label: 'Yellow', color: '#ca8a04' },
{ id: 'lime', label: 'Lime', color: '#65a30d' },
{ id: 'green', label: 'Green', color: '#16a34a' },
{ id: 'emerald', label: 'Emerald', color: '#059669' },
{ id: 'teal', label: 'Teal', color: '#0d9488' },
{ id: 'cyan', label: 'Cyan', color: '#0891b2' },
{ id: 'sky', label: 'Sky', color: '#0284c7' },
{ id: 'blue', label: 'Blue', color: '#2563eb' },
{ id: 'indigo', label: 'Indigo', color: '#4338ca' },
{ id: 'violet', label: 'Violet', color: '#7c3aed' },
{ id: 'fuchsia', label: 'Fuchsia', color: '#c026d3' },
{ id: 'pink', label: 'Pink', color: '#db2777' },
]
export const ACCENT_PASTEL: { id: Accent; label: string; color: string }[] = [
{ id: 'grey', label: 'Grey', color: '#737373' },
{ id: 'coral', label: 'Coral', color: '#e8968a' },
{ id: 'peach', label: 'Peach', color: '#e8b08a' },
{ id: 'butter', label: 'Butter', color: '#d4be7a' },
{ id: 'lemon', label: 'Lemon', color: '#d4d07a' },
{ id: 'celadon', label: 'Celadon', color: '#a0c8a0' },
{ id: 'sage', label: 'Sage', color: '#92ad91' },
{ id: 'mint', label: 'Mint', color: '#96c8b8' },
{ id: 'seafoam', label: 'Seafoam', color: '#96c4bc' },
{ id: 'powder', label: 'Powder', color: '#90b8d0' },
{ id: 'mist', label: 'Mist', color: '#8aacc8' },
{ id: 'periwinkle', label: 'Periwinkle', color: '#9fa8d8' },
{ id: 'wisteria', label: 'Wisteria', color: '#b0a0d0' },
{ id: 'lavender', label: 'Lavender', color: '#b8a8d4' },
{ id: 'orchid', label: 'Orchid', color: '#c8a8c8' },
{ id: 'blush', label: 'Blush', color: '#c8a0b4' },
]
export const ACCENT_OPTIONS = [...ACCENT_VIVID, ...ACCENT_PASTEL]
export { ACCENT_VIVID, ACCENT_PASTEL, ACCENT_OPTIONS } from '@/lib/theme-accent-meta'
export const RADIUS_OPTIONS: { id: Radius; label: string }[] = [
{ id: 'sharp', label: 'Sharp' },