feat: implement user preference management with persistence, reactive CSS variables, and layout customization

This commit is contained in:
Wonhee Han
2026-08-21 10:25:00 +09:00
parent dd01a8ec69
commit a8574be2fb
23 changed files with 2915 additions and 1146 deletions
+16 -3
View File
@@ -24,17 +24,30 @@ export const authOptions: NextAuthOptions = {
if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
// Admin identification logic
const adminEmail = process.env.ADMIN_EMAIL;
const isEmailAdmin = adminEmail && user.email === adminEmail;
const isAdmin = isEmailAdmin || user.email.endsWith("@checkflow.local") || user.email.startsWith("admin@");
const role = isAdmin ? "ADMIN" : "USER";
return { id: user.id, email: user.email, name: user.name, role };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.id = user.id;
if (user) {
token.id = user.id;
token.role = (user as { role?: string }).role;
}
return token;
},
session({ session, token }) {
if (session.user) session.user.id = token.id as string;
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
+213 -79
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
export type Language = "en" | "ko" | "ja";
@@ -34,6 +34,8 @@ export const translations = {
cancel: "Cancel",
save: "Save",
deleteListConfirm: "Delete this list and all its tasks?",
undoDelete: "Undo",
listDeleted: "List deleted",
importTasks: "Import Tasks",
importModalTitle: "Import Tasks",
targetList: "Target List",
@@ -49,7 +51,8 @@ export const translations = {
tags: "Tags",
trash: "Trash",
emptyTrash: "Empty Trash",
searchPlaceholder: "Search tasks, notes, tags (Ctrl+K)...",
searchPlaceholder: "Search tasks, notes, tags...",
searchExpandedPlaceholder: "Search anything — tasks, notes, tags (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "Settings",
@@ -104,82 +107,127 @@ export const translations = {
numberedList: "Numbered list",
checkbox: "Checkbox",
code: "Code",
// TickTick Mode Switcher & Quick Add
textMode: "Text Note Mode",
subtaskMode: "Checklist Mode",
switchToTextMode: "Switch to Markdown Note",
switchToSubtaskMode: "Switch to Checklist Sub-tasks",
moveToList: "Move to List",
quickAdd: "Quick Add",
quickToday: "Today",
quickTomorrow: "Tomorrow",
quickNextWeek: "Next Week",
selectDate: "Date",
selectPriority: "Priority",
selectTag: "Tag",
copied: "Copied!",
// Context Menu
editTask: "Edit",
duplicateTask: "Duplicate",
moveTask: "Move to…",
deleteTask: "Delete",
renameList: "Rename",
deleteList: "Delete list",
taskUndoHint: "Deleted. Tap to undo.",
listUndoHint: "List deleted. Tap to undo.",
// Labs, Kanban & Customization
labs: "Labs",
labsDesc: "Enable or disable experimental features individually.",
kanbanView: "Kanban Board",
listView: "List View",
enableKanban: "Kanban Board View",
enableKanbanDesc: "Switch between list and 3-column board (To Do / Doing / Done).",
swapBlocks: "Swap Position",
resetDefaults: "Reset to Defaults",
resetConfirm: "Reset all UI customizations to factory defaults?",
todoCol: "To Do",
inProgressCol: "In Progress",
doneCol: "Done",
accessRestricted: "Access Restricted",
adminRequired: "This console requires ADMIN role.",
},
ko: {
// Auth
appName: "CheckFlow",
tagline: "나만의 똑똑한 할 일 관리",
welcomeBack: "다시 오신 것을 환영합니다",
signInSubtitle: "계정에 로그인세요",
createAccount: "계정 생성",
createAccountSubtitle: "오늘부터 할 일을 체계적으로 정리해보세요",
tagline: "나만의 할 일 관리, 심플하게",
welcomeBack: "다시 돌아오셨네요 👋",
signInSubtitle: "이메일로 로그인해주세요",
createAccount: "계정 만들기",
createAccountSubtitle: "오늘부터 할 일을 깔끔하게 정리해볼까요?",
displayName: "이름 (닉네임)",
email: "이메일",
password: "비밀번호",
min8Chars: "8자 이상 입력",
min8Chars: "8자 이상 입력해주세요",
signIn: "로그인",
signingIn: "로그인 중...",
createBtn: "계정 생성",
creatingBtn: "계정 생성 중...",
signingIn: "로그인 중이에요...",
createBtn: "계정 만들기",
creatingBtn: "계정 만드는 중...",
alreadyHaveAccount: "이미 계정이 있으신가요?",
dontHaveAccount: "계정이 없으신가요?",
tryDemoMode: "체험 모드로 둘러보기 (DB 연결 불필요)",
demoBadge: "체험 모드",
dontHaveAccount: "아직 계정이 없으신가요?",
tryDemoMode: "로그인 없이 체험해보기",
demoBadge: "체험 ",
signOut: "로그아웃",
// Sidebar
lists: "목록",
newList: "새 목록",
listNamePlaceholder: "목록 이름",
create: "생성",
listNamePlaceholder: "목록 이름을 입력해주세요",
create: "만들기",
cancel: "취소",
save: "저장",
deleteListConfirm: "이 목록과 포함된 모든 할 일을 삭제하시겠습니까?",
importTasks: "할 일 가져오기 (Import)",
importModalTitle: "할 일 가져오기 (Import)",
targetList: "가져올 대상 목록",
deleteListConfirm: "이 목록을 삭제할까요? 안에 있는 할 일도 모두 사라져요.",
undoDelete: "되돌리기",
listDeleted: "목록이 삭제됐어요",
importTasks: "가져오기",
importModalTitle: "할 일 가져오기",
targetList: "가져올 목록 선택",
fileSelectLabel: "파일 선택 (TickTick CSV 또는 ICS)",
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar 다운로드",
tickTickExportHint: "TickTick: 설정 → 데이터 내보내기 → CSV 또는 iCalendar",
importBtn: "가져오기",
importing: "가져오는 중...",
theme: "화면 테마",
importing: "가져오는 중이에요...",
theme: "테마",
themeSystem: "시스템",
themeLight: "라이트",
themeDark: "다크",
language: "언어 (Language)",
language: "언어",
tags: "태그",
trash: "휴지통",
emptyTrash: "휴지통 비우기",
searchPlaceholder: "할 일, 메모, 태그 검색 (Ctrl+K)...",
searchPlaceholder: "검색...",
searchExpandedPlaceholder: "할 일, 메모, 태그 무엇이든 검색해보세요 (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "환경설정",
settingsModalTitle: "설정",
profile: "프로필",
preferences: "환경설정",
syncIntegrations: "외부 연동 (DAVx⁵)",
syncIntegrations: "외부 연동",
admin: "관리자",
trashRetention: "휴지통 보관 기간",
trashRetentionHint: "삭제된 항목은 지정한 기간 이후 영구적으로 삭제됩니다.",
days7: "7일",
days14: "14일",
days30: "30일 (권장)",
neverDelete: "자동 삭제 안 함 (수동 비우기만)",
trashRetention: "휴지통 자동 비우기",
trashRetentionHint: "설정한 기간이 지나면 휴지통이 자동으로 비워져요.",
days7: "7일 후 자동 삭제",
days14: "14일 후 자동 삭제",
days30: "30일 후 자동 삭제 (권장)",
neverDelete: "자동 삭제 안 함",
// Task List
tasks: "할 일",
hideDone: "완료 숨기기",
showDone: "완료 보기",
completedSection: "완료 항목",
addTaskPlaceholder: "새로운 할 일 추가...",
completedSection: "완료 항목",
addTaskPlaceholder: "할 일 추가해보세요...",
add: "추가",
noTasksYet: "아직 등록된 할 일이 없습니다. 아래에서 추가해보세요!",
selectListToStart: "목록을 선택하여 할 일을 시작하세요",
noTasksYet: "아직 할 일이 없어요. 아래에서 추가해볼까요? 😊",
selectListToStart: "왼쪽에서 목록을 선택해주세요",
loading: "불러오는 중...",
today: "오늘",
tomorrow: "내일",
// Task Detail
taskTitlePlaceholder: "할 일 제목",
taskTitlePlaceholder: "할 일 제목을 입력하세요",
priority: "우선순위",
priorityNone: "없음",
priorityLow: "낮음",
@@ -187,15 +235,15 @@ export const translations = {
priorityHigh: "높음",
dueDate: "마감일",
clear: "지우기",
notes: "메모",
notesPlaceholder: "상세 내용이나 기억해야 할 메모를 자유롭게 작성하세요…\n\n마크다운 지원: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
subtasks: "하위 할 일 (Sub-tasks)",
notes: "메모",
notesPlaceholder: "여기에 자유롭게 메모하세요…\n\n마크다운도 쓸 수 있어요: **굵게**, *기울임*, # 제목, - 리스트, - [ ] 체크박스",
subtasks: "하위 할 일",
addSubtaskPlaceholder: "하위 할 일 추가…",
deleteTaskConfirm: "이 할 일을 삭제하시겠습니까?",
deleteTaskConfirm: "이 할 일을 삭제할까요?",
autoSaved: "자동 저장됨",
saving: "저장 중…",
created: "생성일",
edited: "수정",
created: "만든 날",
edited: "수정한 날",
// Toolbars
bold: "굵게",
@@ -205,43 +253,87 @@ export const translations = {
numberedList: "번호 매기기",
checkbox: "체크박스",
code: "코드 블록",
// TickTick Mode Switcher & Quick Add
textMode: "텍스트 메모 모드",
subtaskMode: "체크리스트 모드",
switchToTextMode: "마크다운 메모 모드로 전환",
switchToSubtaskMode: "체크리스트 모드로 전환",
moveToList: "다른 목록으로 이동",
quickAdd: "빠른 추가",
quickToday: "오늘",
quickTomorrow: "내일",
quickNextWeek: "다음 주",
selectDate: "날짜",
selectPriority: "우선순위",
selectTag: "태그",
copied: "복사했어요!",
// Context Menu
editTask: "수정",
duplicateTask: "복제",
moveTask: "이동…",
deleteTask: "삭제",
renameList: "이름 변경",
deleteList: "목록 삭제",
taskUndoHint: "삭제했어요. 되돌릴 수 있어요.",
listUndoHint: "목록이 삭제됐어요. 되돌릴 수 있어요.",
// Labs, Kanban & Customization
labs: "실험실 🧪",
labsDesc: "아직 정식 출시 전인 기능들이에요. 원하는 걸 골라서 켜보세요!",
kanbanView: "칸반 보드",
listView: "리스트",
enableKanban: "칸반 보드 뷰",
enableKanbanDesc: "할 일을 보드 형태로 볼 수 있어요 (할 일 / 진행 중 / 완료).",
swapBlocks: "순서 바꾸기",
resetDefaults: "기본값으로 되돌리기",
resetConfirm: "모든 커스텀 설정을 기본값으로 되돌릴까요?",
todoCol: "할 일",
inProgressCol: "진행 중",
doneCol: "완료",
accessRestricted: "접근 권한 없음",
adminRequired: "이 페이지는 관리자만 볼 수 있어요.",
},
ja: {
// Auth
appName: "CheckFlow",
tagline: "シンプルタスク管理",
welcomeBack: "おかえりなさい",
signInSubtitle: "アカウントにサインイン",
createAccount: "アカウント作成",
createAccountSubtitle: "今日からタスクを整理しましょう",
tagline: "シンプルに、毎日のタスク",
welcomeBack: "おかえりなさい 👋",
signInSubtitle: "メールアドレスでサインインしてください",
createAccount: "アカウント作成",
createAccountSubtitle: "さあ、今日からタスクを整理しましょう",
displayName: "お名前",
email: "メールアドレス",
password: "パスワード",
min8Chars: "8文字以上",
min8Chars: "8文字以上で入力してください",
signIn: "サインイン",
signingIn: "サインイン中...",
createBtn: "アカウントを作成",
createBtn: "アカウントを作成する",
creatingBtn: "作成中...",
alreadyHaveAccount: "すでにアカウントをお持ちですか?",
dontHaveAccount: "アカウントをお持ちでないですか?",
tryDemoMode: "デモモードで試す (DB不要)",
demoBadge: "デモモード",
tryDemoMode: "ログインなしで試してみる",
demoBadge: "体験中",
signOut: "サインアウト",
// Sidebar
lists: "リスト",
newList: "新しいリスト",
listNamePlaceholder: "リスト名",
create: "作成",
listNamePlaceholder: "リスト名を入力してください",
create: "作成する",
cancel: "キャンセル",
save: "保存",
deleteListConfirm: "このリストとすべてのタスクを削除しますか?",
importTasks: "タスクのインポート",
importModalTitle: "タスクのインポート",
targetList: "対象リスト",
fileSelectLabel: "ファイル選択 (TickTick CSV または ICS)",
deleteListConfirm: "このリストを削除しますか?中のタスクもすべて消えます。",
undoDelete: "元に戻す",
listDeleted: "リストを削除しました",
importTasks: "インポート",
importModalTitle: "タスクをインポート",
targetList: "インポート先のリスト",
fileSelectLabel: "ファイルを選択(TickTick CSV または ICS",
tickTickExportHint: "TickTick: 設定 → エクスポート → CSV または iCalendar",
importBtn: "インポート",
importBtn: "インポートする",
importing: "インポート中...",
theme: "テーマ",
themeSystem: "システム",
@@ -251,19 +343,20 @@ export const translations = {
tags: "タグ",
trash: "ゴミ箱",
emptyTrash: "ゴミ箱を空にする",
searchPlaceholder: "タスク、メモ、タグを検索 (Ctrl+K)...",
searchPlaceholder: "検索...",
searchExpandedPlaceholder: "タスク、メモ、タグなど何でも検索できます (Ctrl+K)...",
// Settings Modal
settingsModalTitle: "設定",
profile: "プロフィール",
preferences: "環境設定",
syncIntegrations: "外部連携 (DAVx⁵)",
syncIntegrations: "外部連携",
admin: "管理者",
trashRetention: "ゴミ箱の保存期間",
trashRetentionHint: "削除されたタスクは指定した期間後に完全に削除されます。",
days7: "7日",
days14: "14日",
days30: "30日間 (推奨)",
trashRetention: "ゴミ箱の自動削除",
trashRetentionHint: "設定した期間が過ぎると、ゴミ箱が自動的に空になります。",
days7: "7日後に自動削除",
days14: "14日後に自動削除",
days30: "30日後に自動削除(推奨",
neverDelete: "自動削除しない",
// Task List
@@ -271,32 +364,32 @@ export const translations = {
hideDone: "完了を非表示",
showDone: "完了を表示",
completedSection: "完了済み",
addTaskPlaceholder: "タスクを追加...",
addTaskPlaceholder: "タスクを追加してみましょう...",
add: "追加",
noTasksYet: "タスクがまだありません。下から追加してください",
selectListToStart: "リストを選択してください",
noTasksYet: "まだタスクがありません。下から追加してみてください 😊",
selectListToStart: "左側からリストを選んでください",
loading: "読み込み中...",
today: "今日",
tomorrow: "明日",
// Task Detail
taskTitlePlaceholder: "タスク名",
taskTitlePlaceholder: "タスク名を入力してください",
priority: "優先度",
priorityNone: "なし",
priorityLow: "低",
priorityMedium: "",
priorityHigh: "高",
priorityLow: "低",
priorityMedium: "普通",
priorityHigh: "高",
dueDate: "期限",
clear: "クリア",
notes: "メモ",
notesPlaceholder: "詳細やメモを自由に入力してください…\n\nMarkdown対応: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
notesPlaceholder: "ここに自由にメモしてください…\n\nMarkdownも使えます: **太字**, *斜体*, # 見出し, - リスト, - [ ] チェックボックス",
subtasks: "サブタスク",
addSubtaskPlaceholder: "サブタスクを追加…",
deleteTaskConfirm: "このタスクを削除しますか?",
autoSaved: "自動保存済み",
autoSaved: "自動保存しました",
saving: "保存中…",
created: "作成日",
edited: "更新日",
created: "作成日",
edited: "更新日",
// Toolbars
bold: "太字",
@@ -306,6 +399,47 @@ export const translations = {
numberedList: "番号付きリスト",
checkbox: "チェックボックス",
code: "コードブロック",
// TickTick Mode Switcher & Quick Add
textMode: "テキストメモ",
subtaskMode: "チェックリスト",
switchToTextMode: "Markdownメモに切り替え",
switchToSubtaskMode: "チェックリストに切り替え",
moveToList: "別のリストに移動",
quickAdd: "クイック追加",
quickToday: "今日",
quickTomorrow: "明日",
quickNextWeek: "来週",
selectDate: "日付",
selectPriority: "優先度",
selectTag: "タグ",
copied: "コピーしました!",
accessRestricted: "アクセス制限",
adminRequired: "このページは管理者のみ閲覧できます。",
// Context Menu
editTask: "編集",
duplicateTask: "複製",
moveTask: "移動…",
deleteTask: "削除",
renameList: "名前を変更",
deleteList: "リストを削除",
taskUndoHint: "削除しました。元に戻せます。",
listUndoHint: "リストを削除しました。元に戻せます。",
// Labs, Kanban & Customization
labs: "実験室 🧪",
labsDesc: "まだ正式リリース前の機能です。好きなものを選んでオンにしてみてください!",
kanbanView: "カンバンボード",
listView: "リスト",
enableKanban: "カンバンボードビュー",
enableKanbanDesc: "タスクをボード形式で表示できます(未着手 / 進行中 / 完了)。",
swapBlocks: "順番を入れ替え",
resetDefaults: "デフォルトに戻す",
resetConfirm: "すべてのカスタム設定をデフォルトに戻しますか?",
todoCol: "未着手",
inProgressCol: "進行中",
doneCol: "完了",
},
};
+54
View File
@@ -0,0 +1,54 @@
/**
* useUserPrefs.ts
* React hook that subscribes to the global prefs store and provides
* live updates whenever any component calls savePrefs().
*/
"use client";
import { useState, useEffect, useCallback } from "react";
import {
UserPrefs,
loadPrefs,
savePrefs as persistPrefs,
resetPrefs as doReset,
prefsToCssVars,
} from "./userPrefs";
const EVENT = "checkflow:prefsChanged";
export function useUserPrefs() {
const [prefs, setPrefs] = useState<UserPrefs>(() => loadPrefs());
useEffect(() => {
const handler = (e: Event) => {
const next = (e as CustomEvent<UserPrefs>).detail;
setPrefs({ ...next });
};
window.addEventListener(EVENT, handler);
return () => window.removeEventListener(EVENT, handler);
}, []);
const updatePrefs = useCallback((patch: Partial<UserPrefs>) => {
const next = persistPrefs(patch);
setPrefs({ ...next });
}, []);
const resetToDefaults = useCallback(() => {
const next = doReset();
setPrefs({ ...next });
}, []);
return { prefs, updatePrefs, resetToDefaults };
}
/**
* PrefsStyleInjector
* Renders a <style> tag that applies CSS variable overrides
* derived from the current prefs. Place once in the root layout.
*/
export function usePrefsStyle(prefs: UserPrefs): string {
const vars = prefsToCssVars(prefs);
const body = Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`)
.join("\n");
return `:root {\n${body}\n}`;
}
+151
View File
@@ -0,0 +1,151 @@
/**
* userPrefs.ts
* Global, reactive user-preference store persisted in localStorage.
* All components that call `loadPrefs()` get the same snapshot.
* Emit the `checkflow:prefsChanged` event to notify all listeners.
*/
export interface LabsFeatures {
/** Each flag independently enables/disables a Labs feature */
kanbanBoard: boolean; // List ↔ Kanban view switcher shown in header
// Add more feature flags here as Labs grows
}
export interface UserPrefs {
/** Layout */
sidebarWidth: number; // 160420px
detailWidth: number; // 300760px
density: "compact" | "default" | "comfortable";
detailBlockOrder: ("subtasks" | "note")[];
detailSplitRatio: number; // 1585 %
/** View (driven by Labs kanbanBoard flag) */
viewMode: "list" | "kanban";
/** Labs feature toggles */
labs: LabsFeatures;
/** Appearance */
fontSize: "small" | "default" | "large";
accentHue: number; // 0360 (HSL)
saturation: number; // 20100 (%)
roundness: "sharp" | "default" | "round";
animationSpeed: "none" | "fast" | "default" | "slow";
}
export const DEFAULT_PREFS: UserPrefs = {
sidebarWidth: 260,
detailWidth: 520,
density: "default",
detailBlockOrder: ["subtasks", "note"],
detailSplitRatio: 45,
viewMode: "list",
labs: {
kanbanBoard: false,
},
fontSize: "default",
accentHue: 220,
saturation: 75,
roundness: "default",
animationSpeed: "default",
};
const KEY = "checkflow_prefs_v2";
const EVENT = "checkflow:prefsChanged";
export function loadPrefs(): UserPrefs {
if (typeof window === "undefined") return { ...DEFAULT_PREFS };
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...DEFAULT_PREFS };
const parsed = JSON.parse(raw);
// Deep merge labs to ensure new feature flags get defaults
return {
...DEFAULT_PREFS,
...parsed,
labs: { ...DEFAULT_PREFS.labs, ...(parsed.labs || {}) },
};
} catch {
return { ...DEFAULT_PREFS };
}
}
export function savePrefs(patch: Partial<UserPrefs>): UserPrefs {
const current = loadPrefs();
const next: UserPrefs = {
...current,
...patch,
// Deep merge labs
labs: patch.labs ? { ...current.labs, ...patch.labs } : current.labs,
};
try {
localStorage.setItem(KEY, JSON.stringify(next));
window.dispatchEvent(new CustomEvent(EVENT, { detail: next }));
} catch { /* storage full */ }
return next;
}
export function resetPrefs(): UserPrefs {
try {
localStorage.removeItem(KEY);
// Also remove legacy keys from v0.5
localStorage.removeItem("checkflow_block_order");
localStorage.removeItem("checkflow_split_ratio");
localStorage.removeItem("checkflow_view_mode");
window.dispatchEvent(new CustomEvent(EVENT, { detail: { ...DEFAULT_PREFS } }));
} catch { /* ignore */ }
return { ...DEFAULT_PREFS };
}
/** Compute CSS variable overrides from a UserPrefs snapshot */
export function prefsToCssVars(p: UserPrefs): Record<string, string> {
const sat = Math.round(p.saturation * 0.9);
const accent = `hsl(${p.accentHue}, ${sat}%, 54%)`;
const accentHover = `hsl(${p.accentHue}, ${sat}%, 46%)`;
const accentLight = `hsla(${p.accentHue}, ${sat}%, 54%, 0.1)`;
const accentMedium = `hsla(${p.accentHue}, ${sat}%, 54%, 0.18)`;
const radMap = {
sharp: { xs: "2px", sm: "4px", md: "6px", lg: "8px", xl: "10px" },
default: { xs: "4px", sm: "8px", md: "12px", lg: "16px", xl: "20px" },
round: { xs: "8px", sm: "14px", md: "18px", lg: "24px", xl: "28px" },
};
const r = radMap[p.roundness];
const fontMap = { small: "12px", default: "14px", large: "15px" };
const durMap = {
none: { fast: "0ms", normal: "0ms", slow: "0ms" },
fast: { fast: "60ms", normal: "100ms", slow: "160ms" },
default: { fast: "120ms", normal: "200ms", slow: "300ms" },
slow: { fast: "220ms", normal: "360ms", slow: "500ms" },
};
const d = durMap[p.animationSpeed];
const densityMap = {
compact: { taskPad: "6px 20px", gap: "2px" },
default: { taskPad: "10px 20px", gap: "4px" },
comfortable: { taskPad: "14px 20px", gap: "8px" },
};
const dn = densityMap[p.density];
return {
"--accent": accent,
"--accent-hover": accentHover,
"--accent-light": accentLight,
"--accent-medium": accentMedium,
"--radius-xs": r.xs,
"--radius-sm": r.sm,
"--radius-md": r.md,
"--radius-lg": r.lg,
"--radius-xl": r.xl,
"--font-size-base": fontMap[p.fontSize],
"--dur-fast": d.fast,
"--dur-normal": d.normal,
"--dur-slow": d.slow,
"--task-item-padding": dn.taskPad,
"--task-item-gap": dn.gap,
"--sidebar-width": `${p.sidebarWidth}px`,
"--detail-width": `${p.detailWidth}px`,
};
}