fix(ui): thème, textes et lisibilité restants de la revue
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m57s
CI / Deploy production (on server) (push) Successful in 24s

Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-08-30 21:13:07 +00:00
parent ebf6f16fde
commit afbb0dfc2d
77 changed files with 2842 additions and 1546 deletions

View File

@@ -0,0 +1,23 @@
import { getAllowedByokProviders } from '@/lib/byok'
import { getLivePublicModels } from '@/lib/ai/live-public-catalog'
import { providerDisplayName } from '@/lib/ai/provider-labels'
const ENDPOINT_ONLY = new Set(['custom', 'custom_openai', 'custom_anthropic', 'anthropic_custom'])
export type PublicByokProvider = {
id: string
name: string
models: string[]
}
/** Catalogue public : fournisseurs BYOK Business, hors adresses perso. */
export async function getPublicByokCatalog(): Promise<PublicByokProvider[]> {
const liveModels = await getLivePublicModels()
return getAllowedByokProviders('BUSINESS')
.filter((id) => !ENDPOINT_ONLY.has(id))
.map((id) => ({
id,
name: providerDisplayName(id),
models: liveModels[id] ?? [],
}))
}

View File

@@ -0,0 +1,205 @@
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'
import { redis } from '@/lib/redis'
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'
const REDIS_KEY = 'memento:public-byok-models:v2'
const REDIS_TTL_SEC = 12 * 60 * 60
const MEMORY_TTL_MS = 30 * 60 * 1000
const DISPLAY_PER_PROVIDER = 3
type OpenRouterModel = {
id?: string
created?: number
architecture?: {
modality?: string
output_modalities?: string[]
}
}
const VENDOR_TO_PROVIDER: Record<string, string> = {
openai: 'openai',
anthropic: 'anthropic',
google: 'google',
deepseek: 'deepseek',
minimax: 'minimax',
mistralai: 'mistral',
'z-ai': 'glm',
}
let memoryCache: { at: number; models: Record<string, string[]> } | null = null
function slugOf(id: string): string {
return (id.includes('/') ? id.split('/')[1] : id).replace(/:.*$/, '')
}
function isNoisyId(id: string): boolean {
const slug = slugOf(id)
if (/:(batch|free|nitro)/i.test(id)) return true
if (/embed/i.test(slug)) return true
if (/-fast$/i.test(slug)) return true
if (/-vision|-image|-audio|-preview|-exp|codex|oss|safeguard/i.test(slug)) return true
if (/gemma|lyria|voxtral|ministral|devstral|codestral/i.test(slug)) return true
if (/chat-latest/i.test(slug)) return true
if (/-\d{4,8}$/.test(slug)) return true
return false
}
function cleanliness(id: string): number {
const slug = slugOf(id)
let score = 0
if (/-pro$/i.test(slug)) score += 20
if (/-lite$/i.test(slug)) score += 5
return score
}
function isChatModel(model: OpenRouterModel): boolean {
const id = model.id ?? ''
if (!id || isNoisyId(id)) return false
const outputs = model.architecture?.output_modalities
if (outputs && !outputs.includes('text')) return false
return true
}
function familyKey(id: string): string {
return slugOf(id)
.replace(/-pro$/i, '')
.replace(/-latest$/i, '')
}
function nativeName(openRouterId: string): string {
const slug = openRouterId.includes('/') ? openRouterId.split('/')[1] : openRouterId
if (/^minimax-/i.test(slug)) {
return slug.replace(/^minimax-/i, 'MiniMax-').replace(/-m(\d)/i, '-M$1')
}
return slug
}
function pickLatestIds(models: OpenRouterModel[], keepPrefix: boolean): string[] {
const byFamily = new Map<string, OpenRouterModel[]>()
const order: string[] = []
const sorted = [...models].sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
for (const model of sorted) {
const id = model.id
if (!id) continue
const key = familyKey(id)
if (!byFamily.has(key)) {
byFamily.set(key, [])
order.push(key)
}
byFamily.get(key)!.push(model)
}
const picked: string[] = []
for (const key of order) {
const best = [...(byFamily.get(key) ?? [])].sort(
(a, b) => cleanliness(a.id ?? '') - cleanliness(b.id ?? ''),
)[0]
const id = best?.id
if (!id) continue
picked.push(keepPrefix ? id.replace(/:.*$/, '') : nativeName(id))
if (picked.length >= DISPLAY_PER_PROVIDER) break
}
return picked
}
function buildFromOpenRouter(models: OpenRouterModel[]): Record<string, string[]> {
const byVendor = new Map<string, OpenRouterModel[]>()
for (const model of models) {
if (!isChatModel(model) || !model.id?.includes('/')) continue
const vendor = model.id.split('/')[0]
const list = byVendor.get(vendor) ?? []
list.push(model)
byVendor.set(vendor, list)
}
const result: Record<string, string[]> = {}
for (const [vendor, provider] of Object.entries(VENDOR_TO_PROVIDER)) {
result[provider] = pickLatestIds(byVendor.get(vendor) ?? [], false)
}
const showcaseVendors = ['openai', 'anthropic', 'google']
result.openrouter = showcaseVendors
.map((vendor) => {
const latest = pickLatestIds(byVendor.get(vendor) ?? [], true)[0]
return latest
})
.filter(Boolean)
result.zai = [
result.openai?.[0],
result.anthropic?.[0],
result.google?.[0],
].filter(Boolean) as string[]
return result
}
async function fetchOpenRouterModels(): Promise<OpenRouterModel[]> {
const response = await fetch(OPENROUTER_MODELS_URL, {
headers: {
Accept: 'application/json',
'HTTP-Referer': process.env.NEXTAUTH_URL || 'https://memento-note.com',
'X-Title': 'Memento',
},
signal: AbortSignal.timeout(8000),
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`catalog ${response.status}`)
}
const data = (await response.json()) as { data?: OpenRouterModel[] }
return Array.isArray(data.data) ? data.data : []
}
function mergeWithFallback(live: Record<string, string[]>): Record<string, string[]> {
const merged: Record<string, string[]> = { ...PROVIDER_MODEL_SUGGESTIONS }
for (const [id, models] of Object.entries(live)) {
if (models.length > 0) merged[id] = models
}
return merged
}
async function readRedis(): Promise<Record<string, string[]> | null> {
try {
const raw = await redis.get(REDIS_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Record<string, string[]>
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
async function writeRedis(models: Record<string, string[]>): Promise<void> {
try {
await redis.set(REDIS_KEY, JSON.stringify(models), 'EX', REDIS_TTL_SEC)
} catch {
/* le cache mémoire suffit */
}
}
/** Noms de modèles à jour, lus périodiquement — jamais une liste figée seule. */
export async function getLivePublicModels(): Promise<Record<string, string[]>> {
if (memoryCache && Date.now() - memoryCache.at < MEMORY_TTL_MS) {
return memoryCache.models
}
const cached = await readRedis()
if (cached) {
memoryCache = { at: Date.now(), models: cached }
return cached
}
try {
const remote = await fetchOpenRouterModels()
const merged = mergeWithFallback(buildFromOpenRouter(remote))
memoryCache = { at: Date.now(), models: merged }
await writeRedis(merged)
return merged
} catch (error) {
console.warn('[live-public-catalog] lecture distante impossible, liste de secours', error)
const fallback = { ...PROVIDER_MODEL_SUGGESTIONS }
memoryCache = { at: Date.now(), models: fallback }
return fallback
}
}

View File

@@ -13,14 +13,15 @@ const PROVIDER_URLS: Record<string, string> = {
// Fallback popular models when live fetching fails or for providers without /models endpoint (e.g. Anthropic, Google)
export const PROVIDER_MODEL_SUGGESTIONS: Record<string, string[]> = {
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
google: ['gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.0-flash-exp'],
deepseek: ['deepseek-chat', 'deepseek-coder'],
minimax: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2-her'],
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest'],
glm: ['glm-4', 'glm-4-flash'],
openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'],
openai: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'],
anthropic: ['claude-opus-5', 'claude-sonnet-5', 'claude-fable-5'],
google: ['gemini-3.7-flash', 'gemini-3.6-flash', 'gemini-3.5-flash-lite'],
deepseek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat'],
minimax: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.5'],
mistral: ['mistral-medium-latest', 'mistral-small-latest', 'mistral-large-latest'],
glm: ['glm-5.3', 'glm-5.3-flash', 'glm-5.2'],
openrouter: ['openai/gpt-5.6-sol', 'anthropic/claude-opus-5', 'google/gemini-3.7-flash'],
zai: ['gpt-5.6-sol', 'claude-sonnet-5', 'gemini-3.7-flash'],
custom: [],
};

View File

@@ -0,0 +1,20 @@
/** Noms affichés des fournisseurs — une seule source pour réglages et page publique. */
export const PROVIDER_LABELS: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
minimax: 'MiniMax',
google: 'Google AI',
deepseek: 'DeepSeek',
openrouter: 'OpenRouter',
mistral: 'Mistral AI',
glm: 'GLM (Zhipu)',
zai: 'Zuki Journey',
anthropic_custom: 'Anthropic (custom)',
custom_openai: 'Compatible OpenAI',
custom_anthropic: 'Compatible Anthropic',
custom: 'Custom API',
}
export function providerDisplayName(provider: string): string {
return PROVIDER_LABELS[provider] ?? provider
}

View File

@@ -0,0 +1,56 @@
/** Avance une date dun mois, en gardant le jour du mois (ou le dernier jour si besoin). */
export function addUtcMonths(date: Date, months: number): Date {
const year = date.getUTCFullYear()
const month = date.getUTCMonth() + months
const day = date.getUTCDate()
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
return new Date(Date.UTC(
year,
month,
Math.min(day, lastDay),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
))
}
/**
* Recalcule la période en cours à partir de la date dorigine.
* Sans fiche de paiement, les dates étaient écrites une fois (souvent +1 an) et ne bougeaient plus.
*/
export function rollBillingPeriod(
periodStart: Date,
now: Date = new Date(),
): { currentPeriodStart: Date; currentPeriodEnd: Date } {
if (Number.isNaN(periodStart.getTime())) {
const start = new Date(now)
return { currentPeriodStart: start, currentPeriodEnd: addUtcMonths(start, 1) }
}
let start = new Date(periodStart)
let end = addUtcMonths(start, 1)
if (start.getTime() > now.getTime()) {
return { currentPeriodStart: start, currentPeriodEnd: end }
}
let guard = 0
while (end.getTime() <= now.getTime() && guard < 240) {
start = end
end = addUtcMonths(start, 1)
guard += 1
}
return { currentPeriodStart: start, currentPeriodEnd: end }
}
export function periodsDiffer(
a: { currentPeriodStart: Date; currentPeriodEnd: Date },
b: { currentPeriodStart: Date; currentPeriodEnd: Date },
): boolean {
return (
a.currentPeriodStart.getTime() !== b.currentPeriodStart.getTime()
|| a.currentPeriodEnd.getTime() !== b.currentPeriodEnd.getTime()
)
}

View File

@@ -167,9 +167,9 @@ export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
const data = raw as Partial<DashboardLayout> & { version?: number }
if (!Array.isArray(data.widgets) || data.widgets.length === 0) return getDefaultDashboardLayout()
// Layout périmé ou vide → preset canonique
// Layout périmé, vide, ou version inconnue (essai abandonné) → preset actuel
const incomingVersion = typeof data.version === 'number' ? data.version : 0
if (incomingVersion < DASHBOARD_LAYOUT_VERSION) {
if (incomingVersion !== DASHBOARD_LAYOUT_VERSION) {
return getDefaultDashboardLayout()
}

View File

@@ -0,0 +1,38 @@
const PLACEHOLDER_TITLES = new Set([
'untitled',
'sans titre',
'(sans titre)',
'(untitled)',
])
function usableTitle(title: string | null | undefined): string | null {
const trimmed = title?.trim()
if (!trimmed) return null
if (PLACEHOLDER_TITLES.has(trimmed.toLowerCase())) return null
return trimmed
}
/** Titre affichable : vrai titre, sinon premier extrait du contenu. */
export function pathNoteTitle(
title: string | null | undefined,
content?: string | null,
): string | null {
const named = usableTitle(title)
if (named) return named
if (!content?.trim()) return null
const plain = content
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!plain) return null
return plain.length > 80 ? `${plain.slice(0, 80).trim()}` : plain
}
/** Première note récente qui a un titre ou un extrait — évite un héros « Sans titre ». */
export function pickFocusNote<T extends { title: string | null; content: string }>(
notes: T[],
): T | undefined {
if (notes.length === 0) return undefined
return notes.find(n => pathNoteTitle(n.title, n.content)) ?? notes[0]
}

View File

@@ -1,4 +1,5 @@
import type { DashboardPath } from '@/lib/dashboard/path-types'
import { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
function excerpt(text: string, max = 120): string {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
@@ -45,14 +46,15 @@ export interface BriefingPathsInput {
/** Pistes instantanées à partir du briefing déjà chargé — sans requête lourde. */
export function buildFastPathsFromBriefing(input: BriefingPathsInput): DashboardPath[] {
const paths: DashboardPath[] = []
const focus = input.recentNotes[0]
const focus = pickFocusNote(input.recentNotes)
const continueTitle = focus ? pathNoteTitle(focus.title, focus.content) : null
if (focus) {
if (focus && continueTitle) {
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: focus.title || 'Untitled',
title: continueTitle,
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
@@ -63,11 +65,12 @@ export function buildFastPathsFromBriefing(input: BriefingPathsInput): Dashboard
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
.slice(0, 2)) {
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
const otherTitle = pathNoteTitle(other.title, ins.note1.id === focus.id ? ins.note2Excerpt : ins.note1Excerpt)
paths.push({
id: `connect-${focus.id}-${other.id}`,
type: 'connect',
priority: 88,
title: other.title || 'Untitled',
title: otherTitle || excerpt(ins.insight, 80),
description: ins.insight,
actionKey: 'compare',
noteId: focus.id,
@@ -80,11 +83,13 @@ export function buildFastPathsFromBriefing(input: BriefingPathsInput): Dashboard
const freshInsight = input.insights.find(i => !i.viewed)
if (freshInsight) {
const left = pathNoteTitle(freshInsight.note1.title, freshInsight.note1Excerpt)
const right = pathNoteTitle(freshInsight.note2.title, freshInsight.note2Excerpt)
paths.push({
id: `resurface-${freshInsight.id}`,
type: 'resurface',
priority: 85,
title: `${freshInsight.note1.title || '…'}${freshInsight.note2.title || '…'}`,
title: left && right ? `${left}${right}` : excerpt(freshInsight.insight, 80),
description: freshInsight.insight,
actionKey: 'openInsight',
insightId: freshInsight.id,
@@ -121,28 +126,6 @@ export function buildFastPathsFromBriefing(input: BriefingPathsInput): Dashboard
})
}
if (input.inboxCount > 0) {
paths.push({
id: 'organize-inbox',
type: 'organize',
priority: 60,
title: `${input.inboxCount} notes`,
description: 'inbox',
actionKey: 'organizeInbox',
})
}
if (input.dueFlashcards > 0) {
paths.push({
id: 'review-flashcards',
type: 'review',
priority: 55,
title: `${input.dueFlashcards}`,
description: 'flashcards',
actionKey: 'reviewCards',
})
}
paths.push({
id: 'daily-journal',
type: 'daily',

View File

@@ -3,6 +3,7 @@ import 'server-only'
import prisma from '@/lib/prisma'
import { clusteringService } from '@/lib/ai/services/clustering.service'
import type { DashboardPath } from '@/lib/dashboard/path-types'
import { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
@@ -103,11 +104,15 @@ function pathsFromInsights(
.slice(0, 2)) {
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
if (paths.some(p => p.note2Id === other.id && p.type === 'connect')) continue
const otherTitle = pathNoteTitle(
other.title,
ins.note1.id === focus.id ? ins.note2Excerpt : ins.note1Excerpt,
)
paths.push({
id: `connect-${focus.id}-${other.id}`,
type: 'connect',
priority: 90 - paths.length,
title: other.title || 'Untitled',
title: otherTitle || excerpt(ins.insight || ins.note2Excerpt || ins.note1Excerpt || '', 80),
description: ins.insight || ins.note2Excerpt || ins.note1Excerpt || '',
actionKey: 'compare',
noteId: focus.id,
@@ -152,7 +157,8 @@ async function findOpenLoops(userId: string, limit = 3): Promise<Array<{ id: str
export async function buildDashboardPaths(input: BuildPathsInput): Promise<DashboardPath[]> {
const paths: DashboardPath[] = []
const focus = input.recentNotes[0]
const focus = pickFocusNote(input.recentNotes)
const continueTitle = focus ? pathNoteTitle(focus.title, focus.content) : null
let focusClusterId: number | null = null
if (focus) {
@@ -162,16 +168,18 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
})
focusClusterId = member?.clusterId ?? null
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: focus.title || 'Untitled',
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
notebookId: focus.notebookId ?? undefined,
})
if (continueTitle) {
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: continueTitle,
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
notebookId: focus.notebookId ?? undefined,
})
}
pathsFromInsights(focus, input.insights, paths)
@@ -181,7 +189,7 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
id: `add-link-${focus.id}-${link.noteId}`,
type: 'add-link',
priority: 75,
title: link.noteTitle || 'Untitled',
title: pathNoteTitle(link.noteTitle, link.snippet) || excerpt(link.snippet, 80),
description: link.snippet,
actionKey: 'addLink',
noteId: focus.id,
@@ -198,7 +206,11 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
id: `resurface-${freshInsight.id}`,
type: 'resurface',
priority: 85,
title: `${freshInsight.note1.title || '…'}${freshInsight.note2.title || '…'}`,
title: (() => {
const left = pathNoteTitle(freshInsight.note1.title, freshInsight.note1Excerpt)
const right = pathNoteTitle(freshInsight.note2.title, freshInsight.note2Excerpt)
return left && right ? `${left}${right}` : excerpt(freshInsight.insight, 80)
})(),
description: freshInsight.insight,
actionKey: 'openInsight',
insightId: freshInsight.id,
@@ -257,28 +269,6 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
}
}
if (input.inboxCount > 0) {
paths.push({
id: 'organize-inbox',
type: 'organize',
priority: 70,
title: `${input.inboxCount} notes`,
description: 'inbox',
actionKey: 'organizeInbox',
})
}
if (input.dueFlashcards > 0) {
paths.push({
id: 'review-flashcards',
type: 'review',
priority: 65,
title: `${input.dueFlashcards}`,
description: 'flashcards',
actionKey: 'reviewCards',
})
}
paths.push({
id: 'daily-journal',
type: 'daily',

View File

@@ -37,7 +37,11 @@ export const THEME_INIT_SCRIPT = `(function () {
}
root.setAttribute('data-applied-theme', theme);
var accentStored = localStorage.getItem('accent-color');
root.style.setProperty('--color-brand-accent', accentStored || defaultAccent);
var effectiveAccent = defaultAccent || accentStored || '#A47148';
root.style.setProperty('--color-brand-accent', effectiveAccent);
if (defaultAccent && accentStored !== defaultAccent) {
try { localStorage.setItem('accent-color', defaultAccent); } catch (e) {}
}
} catch (e) {
console.error('Theme script error', e);
}

View File

@@ -0,0 +1,29 @@
'use client'
import { toast } from 'sonner'
import { restoreNote } from '@/app/actions/notes'
import { emitNoteChange } from '@/lib/note-change-sync'
import type { Note } from '@/lib/types'
export function showNoteTrashedToast(
note: Note,
t: (key: string) => string,
onRestored?: () => void,
) {
toast.success(t('notes.noteDeletedToast'), {
action: {
label: t('notes.undoDelete'),
onClick: async () => {
try {
await restoreNote(note.id)
const restored = { ...note, trashedAt: null }
emitNoteChange({ type: 'created', note: restored })
onRestored?.()
toast.success(t('trash.noteRestored'))
} catch {
toast.error(t('general.error'))
}
},
},
})
}

View File

@@ -10,6 +10,17 @@ export const MAX_EMBEDDING_CHARS = EMBEDDING_CHUNK_CHARS
const CLIP_FOOTER_PATTERN =
/<hr\s*\/?>\s*<p[^>]*>\s*<small>[\s\S]*?<\/small>\s*<\/p>\s*$/i
/** Seuil serveur : en dessous, pas de génération de titre (clic volontaire inclus). */
export const MIN_WORDS_FOR_TITLE_SUGGESTION = 10
/** Seuil automatique — aligné sur le texte des réglages (« après 50+ mots »). */
export const MIN_WORDS_FOR_AUTO_TITLE = 50
export function countPlainWords(htmlOrText: string): number {
const plain = stripHtmlToPlainText(htmlOrText)
if (!plain) return 0
return plain.split(/\s+/).filter((w) => w.length > 0).length
}
export function stripHtmlToPlainText(html: string): string {
if (!html) return ''
return html

View File

@@ -29,8 +29,12 @@ export function getThemeScript(serverTheme: string = 'light', serverAccentColor:
}
root.setAttribute('data-applied-theme', theme);
var accentStored = localStorage.getItem('accent-color');
var effectiveAccent = accentStored || ${JSON.stringify(defaultAccent)};
var serverAccent = ${JSON.stringify(defaultAccent)};
var effectiveAccent = serverAccent || accentStored || '#A47148';
root.style.setProperty('--color-brand-accent', effectiveAccent);
if (serverAccent && accentStored !== serverAccent) {
try { localStorage.setItem('accent-color', serverAccent); } catch (e) {}
}
} catch (e) {
console.error('Theme script error', e);
}