mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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', () => {
|
||||
|
||||
@@ -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%)`,
|
||||
|
||||
@@ -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
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user