Skip to content

Commit f1881b8

Browse files
feat(theme): refactor color tokens and add migration support (#691)
* feat(theme): refactor color tokens and add migration support * Merge branch 'main' into refactor/theme * fix(ui): update backgrounds to use dynamic color mixing for theming
1 parent 8d4b2b4 commit f1881b8

7 files changed

Lines changed: 119 additions & 25 deletions

File tree

src/main/ipc/handlers/theme.ts

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,20 @@ const THEME_TEMPLATE: ThemeFile = {
2727
author: '',
2828
type: 'light',
2929
colors: {
30-
'color-primary': 'hsl(277, 22%, 57%)',
31-
'color-bg': 'hsl(35, 67%, 96%)',
32-
'color-fg': 'hsl(245, 18%, 40%)',
33-
'color-text': 'hsl(245, 18%, 40%)',
34-
'color-text-muted': 'hsl(270, 10%, 53%)',
35-
'color-border': 'hsl(30, 24%, 88%)',
36-
'color-button': 'hsl(34, 52%, 91%)',
37-
'color-button-hover': 'hsl(34, 42%, 88%)',
38-
'color-list-selection': 'hsl(34, 38%, 89%)',
39-
'color-list-selection-fg': 'hsl(245, 18%, 40%)',
40-
'color-scrollbar': 'hsla(270, 14%, 73%, 0.5)',
30+
'primary': 'hsl(277, 22%, 57%)',
31+
'primary-foreground': 'hsl(0, 0%, 100%)',
32+
'background': 'hsl(35, 67%, 96%)',
33+
'foreground': 'hsl(245, 18%, 40%)',
34+
'accent': 'hsl(34, 38%, 89%)',
35+
'accent-hover': 'hsl(34, 42%, 92%)',
36+
'accent-foreground': 'hsl(245, 18%, 40%)',
37+
'muted': 'hsl(34, 52%, 91%)',
38+
'muted-foreground': 'hsl(270, 10%, 53%)',
39+
'card': 'hsl(35, 50%, 94%)',
40+
'popover': 'hsl(35, 67%, 96%)',
41+
'popover-foreground': 'hsl(245, 18%, 40%)',
42+
'border': 'hsl(30, 24%, 88%)',
43+
'scrollbar': 'hsla(270, 14%, 73%, 0.5)',
4144
},
4245
editorColors: {
4346
'editor-keyword': 'hsl(277, 22%, 57%)',
@@ -55,6 +58,21 @@ const THEME_TEMPLATE: ThemeFile = {
5558
},
5659
}
5760

61+
const TOKEN_MIGRATION_MAP: Record<string, string> = {
62+
'color-primary': 'primary',
63+
'color-bg': 'background',
64+
'color-fg': 'foreground',
65+
'color-text': 'foreground',
66+
'color-text-muted': 'muted-foreground',
67+
'color-border': 'border',
68+
'color-button': 'muted',
69+
'color-list-selection': 'accent',
70+
'color-list-selection-fg': 'accent-foreground',
71+
'color-scrollbar': 'scrollbar',
72+
}
73+
74+
const DROPPED_TOKENS = new Set(['color-button-hover'])
75+
5876
let themeWatcher: FSWatcher | null = null
5977
let themeWatcherTimer: NodeJS.Timeout | null = null
6078
let watchedThemesDir: string | null = null
@@ -224,15 +242,68 @@ function resolveThemeFilePath(id: string): string | null {
224242
return filePath
225243
}
226244

245+
function migrateThemeColors(colors: Record<string, string>): {
246+
migrated: Record<string, string>
247+
changed: boolean
248+
} {
249+
const result: Record<string, string> = {}
250+
let changed = false
251+
252+
for (const [key, value] of Object.entries(colors)) {
253+
if (DROPPED_TOKENS.has(key)) {
254+
changed = true
255+
continue
256+
}
257+
258+
const newKey = TOKEN_MIGRATION_MAP[key]
259+
260+
if (newKey) {
261+
result[newKey] = value
262+
changed = true
263+
}
264+
else {
265+
result[key] = value
266+
}
267+
}
268+
269+
return { migrated: result, changed }
270+
}
271+
227272
function readThemeFromFile(
228273
filePath: string,
229274
fileName: string,
230275
): ThemeFile | null {
231276
try {
232277
const content = readFileSync(filePath, 'utf8')
233278
const parsed = JSON.parse(content) as unknown
279+
const theme = parseThemeFile(parsed, fileName)
280+
281+
if (!theme) {
282+
return null
283+
}
284+
285+
if (theme.colors) {
286+
const { migrated, changed } = migrateThemeColors(theme.colors)
287+
288+
if (changed) {
289+
theme.colors = migrated
290+
291+
try {
292+
const raw = parsed as Record<string, unknown>
293+
raw.colors = migrated
294+
writeFileSync(filePath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8')
295+
console.warn(`[theme] Migrated ${fileName} to new token format`)
296+
}
297+
catch (writeError) {
298+
console.warn(
299+
`[theme] Failed to write migrated theme ${fileName}`,
300+
writeError,
301+
)
302+
}
303+
}
304+
}
234305

235-
return parseThemeFile(parsed, fileName)
306+
return theme
236307
}
237308
catch (error) {
238309
reportThemeIssue(fileName, 'Failed to read or parse JSON', error)

src/renderer/components/ui/input/variants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority'
22
import { cva } from 'class-variance-authority'
33

44
export const variants = cva(
5-
'w-full rounded-md focus:outline-none placeholder:text-muted-foreground py-0.5 px-2 border bg-background dark:bg-black/20 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
5+
'w-full rounded-md focus:outline-none placeholder:text-muted-foreground py-0.5 px-2 border bg-[color-mix(in_oklch,var(--foreground)_2%,var(--background))] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
66
{
77
variants: {
88
variant: {

src/renderer/components/ui/menu/FormSection.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ const props = withDefaults(defineProps<Props>(), {
3333
cn(
3434
'rounded-lg border p-5',
3535
props.variant === 'danger'
36-
? 'border-destructive/20 bg-destructive/5'
37-
: 'border-border bg-card',
36+
? 'border-destructive/20 bg-[color-mix(in_oklch,var(--destructive)_5%,var(--background))]'
37+
: 'border-border bg-[color-mix(in_oklch,var(--foreground)_4%,var(--background))]',
3838
)
3939
"
4040
>

src/renderer/components/ui/shadcn/button/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const buttonVariants = cva(
1212
destructive:
1313
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
1414
outline:
15-
'border bg-background shadow-xs hover:bg-accent-hover hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
15+
'border bg-[color-mix(in_oklch,var(--foreground)_2%,var(--background))] shadow-xs hover:bg-[color-mix(in_oklch,var(--foreground)_5%,var(--background))] hover:text-accent-foreground',
1616
secondary:
1717
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
1818
ghost:

src/renderer/components/ui/shadcn/select/SelectTrigger.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
2020
v-bind="forwardedProps"
2121
:class="
2222
cn(
23-
'border-input bg-background data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex h-7 w-fit items-center justify-between gap-2 rounded-md border px-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 [&>span]:truncate',
23+
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-7 w-fit items-center justify-between gap-2 rounded-md border bg-[color-mix(in_oklch,var(--foreground)_2%,var(--background))] px-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none hover:bg-[color-mix(in_oklch,var(--foreground)_5%,var(--background))] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 [&>span]:truncate',
2424
props.class,
2525
)
2626
"

src/renderer/composables/useTheme.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ const CUSTOM_STYLE_ID = 'masscode-custom-theme'
1414
const LIGHT_EDITOR_THEME = 'neo'
1515
const DARK_EDITOR_THEME = 'oceanic-next'
1616

17+
const TOKEN_MIGRATION_MAP: Record<string, string> = {
18+
'color-primary': 'primary',
19+
'color-bg': 'background',
20+
'color-fg': 'foreground',
21+
'color-text': 'foreground',
22+
'color-text-muted': 'muted-foreground',
23+
'color-border': 'border',
24+
'color-button': 'muted',
25+
'color-list-selection': 'accent',
26+
'color-list-selection-fg': 'accent-foreground',
27+
'color-scrollbar': 'scrollbar',
28+
}
29+
30+
const DROPPED_TOKENS = new Set(['color-button-hover'])
31+
1732
const storedThemeId = String(store.preferences.get('theme') || 'auto')
1833

1934
const colorMode = useColorMode()
@@ -85,8 +100,16 @@ function buildThemeCss(theme: ThemeFile): string {
85100

86101
if (theme.colors) {
87102
const colorVars = Object.entries(theme.colors)
88-
.filter(([key, value]) => isValidCssToken(key) && Boolean(value.trim()))
89-
.map(([key, value]) => ` --${key}: ${value};`)
103+
.filter(
104+
([key, value]) =>
105+
!DROPPED_TOKENS.has(key)
106+
&& isValidCssToken(key)
107+
&& Boolean(value.trim()),
108+
)
109+
.map(([key, value]) => {
110+
const resolvedKey = TOKEN_MIGRATION_MAP[key] ?? key
111+
return ` --${resolvedKey}: ${value};`
112+
})
90113

91114
if (colorVars.length) {
92115
chunks.push(`${themeSelector} {`)

src/renderer/styles.css

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
/* Core */
1212
--background: oklch(100% 0 0);
1313
--foreground: oklch(20% 0 0);
14-
--card: oklch(97% 0 0);
14+
--card: color-mix(in oklch, var(--foreground) 4%, var(--background));
1515
--card-foreground: oklch(20% 0 0);
16-
--popover: oklch(100% 0 0);
16+
--popover: var(--background);
1717
--popover-foreground: oklch(20% 0 0);
1818
--primary: oklch(50% 0.19 260);
1919
--primary-foreground: oklch(100% 0 0);
@@ -22,7 +22,7 @@
2222
--muted: oklch(97% 0 0);
2323
--muted-foreground: oklch(60% 0 0);
2424
--accent: oklch(92% 0 0);
25-
--accent-hover: oklch(96% 0 0);
25+
--accent-hover: color-mix(in oklch, var(--accent) 50%, var(--background));
2626
--accent-foreground: oklch(20% 0 0);
2727
--destructive: oklch(0.577 0.245 27.325);
2828
--border: oklch(90% 0 0);
@@ -55,9 +55,9 @@
5555
/* Core */
5656
--background: oklch(24.78% 0 0);
5757
--foreground: oklch(75% 0 0);
58-
--card: oklch(22% 0 0);
58+
--card: color-mix(in oklch, var(--foreground) 4%, var(--background));
5959
--card-foreground: oklch(75% 0 0);
60-
--popover: oklch(24.78% 0 0);
60+
--popover: var(--background);
6161
--popover-foreground: oklch(75% 0 0);
6262
--primary: oklch(50% 0.19 260);
6363
--primary-foreground: oklch(100% 0 0);
@@ -66,7 +66,7 @@
6666
--muted: oklch(27% 0 0);
6767
--muted-foreground: oklch(60% 0 0);
6868
--accent: oklch(32% 0 0);
69-
--accent-hover: oklch(28% 0 0);
69+
--accent-hover: color-mix(in oklch, var(--accent) 50%, var(--background));
7070
--accent-foreground: oklch(95% 0 0);
7171
--destructive: oklch(0.704 0.191 22.216);
7272
--border: oklch(30% 0 0);

0 commit comments

Comments
 (0)