From 8155d0998a4ed406a41a0ba3ec18940dbcabf7bf Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 25 Mar 2026 19:25:54 -0500 Subject: [PATCH 01/10] chore: bump @vuetify/v0 to ^0.1.11 --- packages/vuetify/package.json | 2 +- pnpm-lock.yaml | 27 ++++++++++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/vuetify/package.json b/packages/vuetify/package.json index 485ea548f43..46d24a0e12d 100755 --- a/packages/vuetify/package.json +++ b/packages/vuetify/package.json @@ -140,7 +140,7 @@ "lint:fix": "concurrently -n \"tsc,eslint\" \"tsgo -p tsconfig.checks.json --noEmit --pretty\" \"eslint --fix src\"" }, "dependencies": { - "@vuetify/v0": "^0.1.5" + "@vuetify/v0": "^0.1.11" }, "devDependencies": { "@date-io/core": "3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14168fba0d1..3db814101b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,8 +457,8 @@ importers: packages/vuetify: dependencies: '@vuetify/v0': - specifier: ^0.1.5 - version: 0.1.5(vue@3.5.25(typescript@5.8.3)) + specifier: ^0.1.11 + version: 0.1.11(vue-i18n@11.2.1(vue@3.5.25(typescript@5.8.3)))(vue@3.5.25(typescript@5.8.3)) devDependencies: '@date-io/core': specifier: 3.2.0 @@ -3725,20 +3725,32 @@ packages: resolution: {integrity: sha512-EHJDH+O7eov7yJ3WPR52qELyHq8beQ/GmletheroKRvp4SxTL9IgOEeXqBfw4bOyd49VNH5coyoqvIIlnhavUQ==} engines: {node: '>=18'} - '@vuetify/v0@0.1.5': - resolution: {integrity: sha512-ZWKRUVrmuPbH8DFEJtYTJx31j4QCgrtk/TIjBWY4oZ5FEpeQHGC+0Vd8CQruaFlHBO+I25xQD+tkEwAnbiRwvg==} + '@vuetify/v0@0.1.11': + resolution: {integrity: sha512-+9Epz/TLrbjdt9dPMmjtcI6cNgDtFu33MSRUxMFzG04vH26eluDLvDeG0Leri2fOcZnNXJHA/I7D0is9Vorkpg==} peerDependencies: - flagsmith: ^10.0.0 + '@adobe/leonardo-contrast-colors': '>=1.0.0' + '@ant-design/colors': '>=7.0.0' + '@flagsmith/flagsmith': ^11.0.0 + '@material/material-color-utilities': '>=0.3.0' launchdarkly-js-client-sdk: ^3.0.0 posthog-js: ^1.0.0 vue: '>=3.5.0' + vue-i18n: '>=10.0.0' peerDependenciesMeta: - flagsmith: + '@adobe/leonardo-contrast-colors': + optional: true + '@ant-design/colors': + optional: true + '@flagsmith/flagsmith': + optional: true + '@material/material-color-utilities': optional: true launchdarkly-js-client-sdk: optional: true posthog-js: optional: true + vue-i18n: + optional: true '@vueuse/head@1.3.1': resolution: {integrity: sha512-XCcHGfDzkGlHS7KIPJVYN//L7jpfASLsN7MUE19ndHVQLnPIDxqFLDl7IROsY81PKzawVAUe4OYVWcGixseWxA==} @@ -12175,11 +12187,12 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@vuetify/v0@0.1.5(vue@3.5.25(typescript@5.8.3))': + '@vuetify/v0@0.1.11(vue-i18n@11.2.1(vue@3.5.25(typescript@5.8.3)))(vue@3.5.25(typescript@5.8.3))': dependencies: vue: 3.5.25(typescript@5.8.3) optionalDependencies: '@js-temporal/polyfill': 0.5.1 + vue-i18n: 11.2.1(vue@3.5.25(typescript@5.8.3)) '@vueuse/head@1.3.1(vue@3.5.25(typescript@5.8.3))': dependencies: From a4dbb15259aadb0a22abe560f79540b13cfbb985 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 25 Mar 2026 19:32:24 -0500 Subject: [PATCH 02/10] refactor(theme): create VuetifyThemeAdapter for CSS generation Extends v0's ThemeAdapter to produce Vuetify-specific layered CSS (@layer vuetify-utilities) with RGB-decomposed custom properties, overlay multipliers, and bg/text/border utility classes. Handles both browser DOM injection via adoptedStyleSheets and SSR via @unhead. --- .../vuetify/src/composables/theme/adapter.ts | 212 +++++++++++++++ .../vuetify/src/composables/theme/colors.ts | 253 +++++++++++++++++ .../composables/{theme.ts => theme/index.ts} | 255 +----------------- 3 files changed, 474 insertions(+), 246 deletions(-) create mode 100644 packages/vuetify/src/composables/theme/adapter.ts create mode 100644 packages/vuetify/src/composables/theme/colors.ts rename packages/vuetify/src/composables/{theme.ts => theme/index.ts} (57%) diff --git a/packages/vuetify/src/composables/theme/adapter.ts b/packages/vuetify/src/composables/theme/adapter.ts new file mode 100644 index 00000000000..76fdd204507 --- /dev/null +++ b/packages/vuetify/src/composables/theme/adapter.ts @@ -0,0 +1,212 @@ +// Composables +import { genCssVariables } from './colors' + +// Utilities +import { IN_BROWSER } from '@/util' +import { ThemeAdapter } from '@vuetify/v0' +import { watch } from 'vue' + +// Types +import type { ThemeAdapterSetupContext } from '@vuetify/v0' +import type { App } from 'vue' +import type { InternalThemeDefinition } from './colors' + +export interface VuetifyThemeAdapterOptions { + cspNonce?: string + scope?: string + stylesheetId?: string + prefix?: string + utilities?: boolean +} + +interface HeadEntry { + patch (input: () => Record): void +} + +interface HeadClient { + push (input: Record): HeadEntry +} + +/** + * Theme adapter that generates Vuetify-specific CSS with layered + * utility classes and RGB-decomposed custom properties. + * + * Receives fully-resolved themes (including on-* colors, variations, + * and variables) from the theme composable and formats them as CSS. + */ +export class VuetifyThemeAdapter extends ThemeAdapter { + cspNonce?: string + scope?: string + utilities: boolean + + private sheet?: CSSStyleSheet + private themes: Record = {} + + constructor (options: VuetifyThemeAdapterOptions = {}) { + super(options.prefix ?? 'v-') + this.cspNonce = options.cspNonce + this.scope = options.scope + this.utilities = options.utilities ?? true + this.stylesheetId = options.stylesheetId ?? 'vuetify-theme-stylesheet' + + // Vuetify always outputs RGB-decomposed values + this.rgb = true + } + + /** + * Provide the full theme definitions (with dark, colors, variables) + * so that generate() can produce complete Vuetify CSS. + */ + setThemes (themes: Record) { + this.themes = themes + } + + override generate ( + _colors: Record>, + isDark?: boolean, + ): string { + const lines: string[] = [] + + lines.push('@layer theme-base {\n') + + if (isDark) { + this.pushCssClass(lines, ':root', ['color-scheme: dark']) + } + + // Root CSS variables for current theme + const currentThemeName = isDark ? 'dark' : 'light' + const currentTheme = this.themes[currentThemeName] + if (currentTheme) { + this.pushCssClass(lines, ':root', genCssVariables(currentTheme, this.prefix)) + } + + // Per-theme classes + for (const [themeName, theme] of Object.entries(this.themes)) { + this.pushCssClass(lines, `.${this.prefix}theme--${themeName}`, [ + `color-scheme: ${theme.dark ? 'dark' : 'normal'}`, + ...genCssVariables(theme, this.prefix), + ]) + } + + lines.push('}\n') + + if (this.utilities) { + const bgLines: string[] = [] + const fgLines: string[] = [] + + const scoped = this.scope ? this.prefix : '' + const colors = new Set( + Object.values(this.themes).flatMap(theme => Object.keys(theme.colors)) + ) + + for (const key of colors) { + if (key.startsWith('on-')) { + this.pushCssClass(fgLines, `.${key}`, [ + `color: rgb(var(--${this.prefix}theme-${key}))`, + ]) + } else { + this.pushCssClass(bgLines, `.${scoped}bg-${key}`, [ + `--${this.prefix}theme-overlay-multiplier: var(--${this.prefix}theme-${key}-overlay-multiplier)`, + `background-color: rgb(var(--${this.prefix}theme-${key}))`, + `color: rgb(var(--${this.prefix}theme-on-${key}))`, + ]) + this.pushCssClass(fgLines, `.${scoped}text-${key}`, [ + `color: rgb(var(--${this.prefix}theme-${key}))`, + ]) + this.pushCssClass(fgLines, `.${scoped}border-${key}`, [ + `--${this.prefix}border-color: var(--${this.prefix}theme-${key})`, + ]) + } + } + + lines.push( + '@layer theme-background {\n', + ...bgLines.map(v => ` ${v}`), + '}\n', + '@layer theme-foreground {\n', + ...fgLines.map(v => ` ${v}`), + '}\n', + ) + } + + return '@layer vuetify-utilities {\n' + lines.map(v => ` ${v}`).join('') + '\n}' + } + + setup ( + app: App, + context: T, + _target?: string | HTMLElement | null, + ): void { + const head = app._context?.provides?.usehead as HeadClient | undefined + + if (head) { + function getHead (adapter: VuetifyThemeAdapter) { + return { + style: [{ + textContent: adapter.generate(context.colors.value, context.isDark.value), + id: adapter.stylesheetId, + nonce: adapter.cspNonce || false as never, + tagPosition: 'bodyOpen' as const, + }], + } + } + + const entry = head.push(getHead(this)) + + if (IN_BROWSER) { + watch([context.colors, context.isDark], () => { + entry.patch(() => getHead(this)) + }) + } + } else if (IN_BROWSER) { + this.update(context.colors.value, context.isDark.value) + + watch([context.colors, context.isDark], ([colors, isDark]) => { + this.update( + colors as Record>, + isDark as boolean, + ) + }) + } else { + // SSR without head — generate but cannot inject + this.generate(context.colors.value, context.isDark.value) + } + } + + update ( + colors: Record>, + isDark?: boolean, + ): void { + if (!IN_BROWSER) return + + this.upsert(this.generate(colors, isDark)) + } + + private upsert (styles: string): void { + if (!IN_BROWSER) return + + if (!this.sheet) { + this.sheet = new CSSStyleSheet() + document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.sheet] + } + + this.sheet.replaceSync(styles) + } + + private pushCssClass (lines: string[], selector: string, content: string[]): void { + const scoped = this.getScopedSelector(selector) + lines.push( + `${scoped} {\n`, + ...content.map(line => ` ${line};\n`), + '}\n', + ) + } + + private getScopedSelector (selector: string): string { + if (!this.scope) return selector + + const scopeSelector = `:where(${this.scope})` + + return selector === ':root' ? scopeSelector : `${scopeSelector} ${selector}` + } +} diff --git a/packages/vuetify/src/composables/theme/colors.ts b/packages/vuetify/src/composables/theme/colors.ts new file mode 100644 index 00000000000..86ca13ab6ee --- /dev/null +++ b/packages/vuetify/src/composables/theme/colors.ts @@ -0,0 +1,253 @@ +// Utilities +import { + createRange, + darken, + getLuma, + hasLightForeground, + lighten, + mergeDeep, + parseColor, + RGBtoHex, +} from '@/util' + +// Types +import type { Color } from '@/util' + +type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T + +export interface Colors extends BaseColors, OnColors { + [key: string]: Color +} + +interface BaseColors { + background: Color + surface: Color + primary: Color + secondary: Color + success: Color + warning: Color + error: Color + info: Color +} + +interface OnColors { + 'on-background': Color + 'on-surface': Color + 'on-primary': Color + 'on-secondary': Color + 'on-success': Color + 'on-warning': Color + 'on-error': Color + 'on-info': Color +} + +export interface VariationsOptions { + colors: string[] + lighten: number + darken: number +} + +export interface InternalThemeDefinition { + dark: boolean + colors: Colors + variables: Record +} + +export type ThemeDefinition = DeepPartial + +export interface InternalThemeOptions { + cspNonce?: string + isDisabled: boolean + defaultTheme: 'light' | 'dark' | 'system' | string & {} + prefix: string + variations: false | VariationsOptions + themes: Record + stylesheetId: string + scope?: string + scoped: boolean + utilities: boolean +} + +export type ThemeOptions = false | { + cspNonce?: string + defaultTheme?: 'light' | 'dark' | 'system' | string & {} + variations?: false | VariationsOptions + themes?: Record + stylesheetId?: string + scope?: string + utilities?: boolean +} + +export function genDefaults () { + return { + defaultTheme: 'system', + prefix: 'v-', + variations: { colors: [], lighten: 0, darken: 0 }, + themes: { + light: { + dark: false, + colors: { + background: '#FFFFFF', + surface: '#FFFFFF', + 'surface-bright': '#FFFFFF', + 'surface-light': '#EEEEEE', + 'surface-variant': '#424242', + 'on-surface-variant': '#EEEEEE', + primary: '#1867C0', + 'primary-darken-1': '#1F5592', + secondary: '#48A9A6', + 'secondary-darken-1': '#018786', + error: '#B00020', + info: '#2196F3', + success: '#4CAF50', + warning: '#FB8C00', + }, + variables: { + 'border-color': '#000000', + 'border-opacity': 0.12, + 'shadow-color': '#000000', + 'high-emphasis-opacity': 0.87, + 'medium-emphasis-opacity': 0.60, + 'disabled-opacity': 0.38, + 'idle-opacity': 0.04, + 'hover-opacity': 0.04, + 'focus-opacity': 0.12, + 'selected-opacity': 0.08, + 'activated-opacity': 0.12, + 'pressed-opacity': 0.12, + 'dragged-opacity': 0.08, + 'theme-kbd': '#EEEEEE', + 'theme-on-kbd': '#000000', + 'theme-code': '#F5F5F5', + 'theme-on-code': '#000000', + 'theme-on-dark': '#FFF', + 'theme-on-light': '#000', + 'elevation-overlay-color': 'black', + 'elevation-overlay-opacity-step': '2%', + }, + }, + dark: { + dark: true, + colors: { + background: '#121212', + surface: '#212121', + 'surface-bright': '#ccbfd6', + 'surface-light': '#424242', + 'surface-variant': '#c8c8c8', + 'on-surface-variant': '#000000', + primary: '#2196F3', + 'primary-darken-1': '#277CC1', + secondary: '#54B6B2', + 'secondary-darken-1': '#48A9A6', + error: '#CF6679', + info: '#2196F3', + success: '#4CAF50', + warning: '#FB8C00', + }, + variables: { + 'border-color': '#FFFFFF', + 'border-opacity': 0.12, + 'shadow-color': '#000000', + 'high-emphasis-opacity': 1, + 'medium-emphasis-opacity': 0.70, + 'disabled-opacity': 0.50, + 'idle-opacity': 0.10, + 'hover-opacity': 0.04, + 'focus-opacity': 0.12, + 'selected-opacity': 0.08, + 'activated-opacity': 0.12, + 'pressed-opacity': 0.16, + 'dragged-opacity': 0.08, + 'theme-kbd': '#424242', + 'theme-on-kbd': '#FFFFFF', + 'theme-code': '#343434', + 'theme-on-code': '#CCCCCC', + 'theme-on-dark': '#FFF', + 'theme-on-light': '#000', + 'elevation-overlay-color': 'white', + 'elevation-overlay-opacity-step': '2%', + }, + }, + }, + stylesheetId: 'vuetify-theme-stylesheet', + scoped: false, + utilities: true, + } +} + +export function parseThemeOptions (options: ThemeOptions = genDefaults()): InternalThemeOptions { + const defaults = genDefaults() + + if (!options) return { ...defaults, isDisabled: true } as any + + return mergeDeep(defaults, options) as InternalThemeOptions +} + +export function genCssVariables (theme: InternalThemeDefinition, prefix: string) { + const lightOverlay = theme.dark ? 2 : 1 + const darkOverlay = theme.dark ? 1 : 2 + + const variables: string[] = [] + for (const [key, value] of Object.entries(theme.colors)) { + const rgb = parseColor(value) + variables.push(`--${prefix}theme-${key}: ${rgb.r},${rgb.g},${rgb.b}` + (rgb.a == null ? '' : `,${rgb.a}`)) + if (!key.startsWith('on-')) { + variables.push(`--${prefix}theme-${key}-overlay-multiplier: ${getLuma(value) > 0.18 ? lightOverlay : darkOverlay}`) + } + } + + for (const [key, value] of Object.entries(theme.variables)) { + const color = typeof value === 'string' && value.startsWith('#') ? parseColor(value) : undefined + const rgb = color ? `${color.r}, ${color.g}, ${color.b}` : undefined + variables.push(`--${prefix}${key}: ${rgb ?? value}`) + } + + return variables +} + +function genVariation (name: string, color: Color, variations: VariationsOptions | false) { + const object: Record = {} + if (variations) { + for (const variation of (['lighten', 'darken'] as const)) { + const fn = variation === 'lighten' ? lighten : darken + for (const amount of createRange(variations[variation], 1)) { + object[`${name}-${variation}-${amount}`] = RGBtoHex(fn(parseColor(color), amount)) + } + } + } + return object +} + +export function genVariations (colors: InternalThemeDefinition['colors'], variations: VariationsOptions | false) { + if (!variations) return {} + + let variationColors = {} + for (const name of variations.colors) { + const color = colors[name] + + if (!color) continue + + variationColors = { + ...variationColors, + ...genVariation(name, color, variations), + } + } + return variationColors +} + +export function genOnColors (colors: InternalThemeDefinition['colors'], variables: InternalThemeDefinition['variables']) { + const onColors = {} as InternalThemeDefinition['colors'] + + for (const color of Object.keys(colors)) { + if (color.startsWith('on-') || colors[`on-${color}`]) continue + + const onColor = `on-${color}` as keyof OnColors + const colorVal = parseColor(colors[color]) + + onColors[onColor] = hasLightForeground(colorVal) + ? variables['theme-on-dark'] + : variables['theme-on-light'] + } + + return onColors +} diff --git a/packages/vuetify/src/composables/theme.ts b/packages/vuetify/src/composables/theme/index.ts similarity index 57% rename from packages/vuetify/src/composables/theme.ts rename to packages/vuetify/src/composables/theme/index.ts index 4e764dd15bb..53bc7fe6773 100644 --- a/packages/vuetify/src/composables/theme.ts +++ b/packages/vuetify/src/composables/theme/index.ts @@ -13,90 +13,27 @@ import { } from 'vue' import { consoleWarn, - createRange, - darken, deprecate, getCurrentInstance, - getLuma, - hasLightForeground, IN_BROWSER, - lighten, mergeDeep, - parseColor, propsFactory, - RGBtoHex, SUPPORTS_MATCH_MEDIA, } from '@/util' +import { + genCssVariables, + genOnColors, + genVariations, + parseThemeOptions, +} from './colors' // Types import type { VueHeadClient } from '@unhead/vue/client' import type { HeadClient } from '@vueuse/head' import type { App, DeepReadonly, InjectionKey, Ref } from 'vue' -import type { Color } from '@/util' - -type DeepPartial = T extends object ? { [P in keyof T]?: DeepPartial } : T - -export type ThemeOptions = false | { - cspNonce?: string - defaultTheme?: 'light' | 'dark' | 'system' | string & {} - variations?: false | VariationsOptions - themes?: Record - stylesheetId?: string - scope?: string - utilities?: boolean -} -export type ThemeDefinition = DeepPartial - -interface InternalThemeOptions { - cspNonce?: string - isDisabled: boolean - defaultTheme: 'light' | 'dark' | 'system' | string & {} - prefix: string - variations: false | VariationsOptions - themes: Record - stylesheetId: string - scope?: string - scoped: boolean - utilities: boolean -} - -interface VariationsOptions { - colors: string[] - lighten: number - darken: number -} - -interface InternalThemeDefinition { - dark: boolean - colors: Colors - variables: Record -} +import type { InternalThemeDefinition, ThemeOptions } from './colors' -export interface Colors extends BaseColors, OnColors { - [key: string]: Color -} - -interface BaseColors { - background: Color - surface: Color - primary: Color - secondary: Color - success: Color - warning: Color - error: Color - info: Color -} - -interface OnColors { - 'on-background': Color - 'on-surface': Color - 'on-primary': Color - 'on-secondary': Color - 'on-success': Color - 'on-warning': Color - 'on-error': Color - 'on-info': Color -} +export type { Colors, ThemeDefinition, ThemeOptions } from './colors' export interface ThemeInstance { change: (themeName: string) => void @@ -127,111 +64,6 @@ export const makeThemeProps = propsFactory({ theme: String, }, 'theme') -function genDefaults () { - return { - defaultTheme: 'system', - prefix: 'v-', - variations: { colors: [], lighten: 0, darken: 0 }, - themes: { - light: { - dark: false, - colors: { - background: '#FFFFFF', - surface: '#FFFFFF', - 'surface-bright': '#FFFFFF', - 'surface-light': '#EEEEEE', - 'surface-variant': '#424242', - 'on-surface-variant': '#EEEEEE', - primary: '#1867C0', - 'primary-darken-1': '#1F5592', - secondary: '#48A9A6', - 'secondary-darken-1': '#018786', - error: '#B00020', - info: '#2196F3', - success: '#4CAF50', - warning: '#FB8C00', - }, - variables: { - 'border-color': '#000000', - 'border-opacity': 0.12, - 'shadow-color': '#000000', - 'high-emphasis-opacity': 0.87, - 'medium-emphasis-opacity': 0.60, - 'disabled-opacity': 0.38, - 'idle-opacity': 0.04, - 'hover-opacity': 0.04, - 'focus-opacity': 0.12, - 'selected-opacity': 0.08, - 'activated-opacity': 0.12, - 'pressed-opacity': 0.12, - 'dragged-opacity': 0.08, - 'theme-kbd': '#EEEEEE', - 'theme-on-kbd': '#000000', - 'theme-code': '#F5F5F5', - 'theme-on-code': '#000000', - 'theme-on-dark': '#FFF', - 'theme-on-light': '#000', - 'elevation-overlay-color': 'black', - 'elevation-overlay-opacity-step': '2%', - }, - }, - dark: { - dark: true, - colors: { - background: '#121212', - surface: '#212121', - 'surface-bright': '#ccbfd6', - 'surface-light': '#424242', - 'surface-variant': '#c8c8c8', - 'on-surface-variant': '#000000', - primary: '#2196F3', - 'primary-darken-1': '#277CC1', - secondary: '#54B6B2', - 'secondary-darken-1': '#48A9A6', - error: '#CF6679', - info: '#2196F3', - success: '#4CAF50', - warning: '#FB8C00', - }, - variables: { - 'border-color': '#FFFFFF', - 'border-opacity': 0.12, - 'shadow-color': '#000000', - 'high-emphasis-opacity': 1, - 'medium-emphasis-opacity': 0.70, - 'disabled-opacity': 0.50, - 'idle-opacity': 0.10, - 'hover-opacity': 0.04, - 'focus-opacity': 0.12, - 'selected-opacity': 0.08, - 'activated-opacity': 0.12, - 'pressed-opacity': 0.16, - 'dragged-opacity': 0.08, - 'theme-kbd': '#424242', - 'theme-on-kbd': '#FFFFFF', - 'theme-code': '#343434', - 'theme-on-code': '#CCCCCC', - 'theme-on-dark': '#FFF', - 'theme-on-light': '#000', - 'elevation-overlay-color': 'white', - 'elevation-overlay-opacity-step': '2%', - }, - }, - }, - stylesheetId: 'vuetify-theme-stylesheet', - scoped: false, - utilities: true, - } -} - -function parseThemeOptions (options: ThemeOptions = genDefaults()): InternalThemeOptions { - const defaults = genDefaults() - - if (!options) return { ...defaults, isDisabled: true } as any - - return mergeDeep(defaults, options) as InternalThemeOptions -} - function createCssClass (lines: string[], selector: string, content: string[], scope?: string) { lines.push( `${getScopedSelector(selector, scope)} {\n`, @@ -240,75 +72,6 @@ function createCssClass (lines: string[], selector: string, content: string[], s ) } -function genCssVariables (theme: InternalThemeDefinition, prefix: string) { - const lightOverlay = theme.dark ? 2 : 1 - const darkOverlay = theme.dark ? 1 : 2 - - const variables: string[] = [] - for (const [key, value] of Object.entries(theme.colors)) { - const rgb = parseColor(value) - variables.push(`--${prefix}theme-${key}: ${rgb.r},${rgb.g},${rgb.b}` + (rgb.a == null ? '' : `,${rgb.a}`)) - if (!key.startsWith('on-')) { - variables.push(`--${prefix}theme-${key}-overlay-multiplier: ${getLuma(value) > 0.18 ? lightOverlay : darkOverlay}`) - } - } - - for (const [key, value] of Object.entries(theme.variables)) { - const color = typeof value === 'string' && value.startsWith('#') ? parseColor(value) : undefined - const rgb = color ? `${color.r}, ${color.g}, ${color.b}` : undefined - variables.push(`--${prefix}${key}: ${rgb ?? value}`) - } - - return variables -} - -function genVariation (name: string, color: Color, variations: VariationsOptions | false) { - const object: Record = {} - if (variations) { - for (const variation of (['lighten', 'darken'] as const)) { - const fn = variation === 'lighten' ? lighten : darken - for (const amount of createRange(variations[variation], 1)) { - object[`${name}-${variation}-${amount}`] = RGBtoHex(fn(parseColor(color), amount)) - } - } - } - return object -} - -function genVariations (colors: InternalThemeDefinition['colors'], variations: VariationsOptions | false) { - if (!variations) return {} - - let variationColors = {} - for (const name of variations.colors) { - const color = colors[name] - - if (!color) continue - - variationColors = { - ...variationColors, - ...genVariation(name, color, variations), - } - } - return variationColors -} - -function genOnColors (colors: InternalThemeDefinition['colors'], variables: InternalThemeDefinition['variables']) { - const onColors = {} as InternalThemeDefinition['colors'] - - for (const color of Object.keys(colors)) { - if (color.startsWith('on-') || colors[`on-${color}`]) continue - - const onColor = `on-${color}` as keyof OnColors - const colorVal = parseColor(colors[color]) - - onColors[onColor] = hasLightForeground(colorVal) - ? variables['theme-on-dark'] - : variables['theme-on-light'] - } - - return onColors -} - function getScopedSelector (selector: string, scope?: string) { if (!scope) return selector @@ -322,7 +85,7 @@ function upsertStyles (id: string, cspNonce: string | undefined, styles: string) if (!styleEl) return - styleEl.innerHTML = styles + styleEl.textContent = styles } function getOrCreateStyleElement (id: string, cspNonce?: string) { From aabe84b4a915cc8a2880c80884cfa7e351d2cd9d Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 25 Mar 2026 19:44:18 -0500 Subject: [PATCH 03/10] refactor(theme): delegate to v0 createTheme as runtime - createTheme() now creates a v0 theme instance for theme selection, cycling, and dark mode detection via usePrefersDark() - Bridge v0's selectedId to Vuetify's writable name computed - System theme detection delegated to v0's usePrefersDark() - Preserve full consumer API: name, current, themes, computedThemes, themeClasses, styles, change/cycle/toggle, install, global - Fix adapter.ts ThemeAdapter import path (@vuetify/v0/theme/adapters) - Fix adapter.ts ThemeAdapterSetupContext type (inline, not exported) - Keep original CSS generation and DOM injection in install() for backward compatibility with head/unhead integration --- .../vuetify/src/composables/theme/adapter.ts | 16 ++- .../vuetify/src/composables/theme/index.ts | 104 ++++++++++++------ 2 files changed, 82 insertions(+), 38 deletions(-) diff --git a/packages/vuetify/src/composables/theme/adapter.ts b/packages/vuetify/src/composables/theme/adapter.ts index 76fdd204507..18ebe4ac7e0 100644 --- a/packages/vuetify/src/composables/theme/adapter.ts +++ b/packages/vuetify/src/composables/theme/adapter.ts @@ -2,15 +2,20 @@ import { genCssVariables } from './colors' // Utilities -import { IN_BROWSER } from '@/util' -import { ThemeAdapter } from '@vuetify/v0' +import { ThemeAdapter } from '@vuetify/v0/theme/adapters' import { watch } from 'vue' +import { IN_BROWSER } from '@/util' // Types -import type { ThemeAdapterSetupContext } from '@vuetify/v0' -import type { App } from 'vue' +import type { App, ComputedRef, Ref } from 'vue' import type { InternalThemeDefinition } from './colors' +interface AdapterSetupContext { + colors: ComputedRef>> + selectedId: Ref + isDark: Readonly> +} + export interface VuetifyThemeAdapterOptions { cspNonce?: string scope?: string @@ -132,7 +137,8 @@ export class VuetifyThemeAdapter extends ThemeAdapter { return '@layer vuetify-utilities {\n' + lines.map(v => ` ${v}`).join('') + '\n}' } - setup ( + // @ts-expect-error Vue types mismatch between v0 and vuetify packages + setup ( app: App, context: T, _target?: string | HTMLElement | null, diff --git a/packages/vuetify/src/composables/theme/index.ts b/packages/vuetify/src/composables/theme/index.ts index 53bc7fe6773..fb4a0af5f09 100644 --- a/packages/vuetify/src/composables/theme/index.ts +++ b/packages/vuetify/src/composables/theme/index.ts @@ -1,9 +1,11 @@ // Utilities +import { + createTheme as createV0Theme, + usePrefersDark, +} from '@vuetify/v0' import { computed, - getCurrentScope, inject, - onScopeDispose, provide, ref, shallowRef, @@ -11,6 +13,12 @@ import { watch, watchEffect, } from 'vue' +import { + genCssVariables, + genOnColors, + genVariations, + parseThemeOptions, +} from './colors' import { consoleWarn, deprecate, @@ -18,14 +26,7 @@ import { IN_BROWSER, mergeDeep, propsFactory, - SUPPORTS_MATCH_MEDIA, } from '@/util' -import { - genCssVariables, - genOnColors, - genVariations, - parseThemeOptions, -} from './colors' // Types import type { VueHeadClient } from '@unhead/vue/client' @@ -109,23 +110,78 @@ function getOrCreateStyleElement (id: string, cspNonce?: string) { // Composables export function createTheme (options?: ThemeOptions): ThemeInstance & { install: (app: App) => void } { const parsedOptions = parseThemeOptions(options) - const _name = shallowRef(parsedOptions.defaultTheme) const themes = ref(parsedOptions.themes) - const systemName = shallowRef('light') + + // Build processed themes with variations + on-colors for v0 registration + function buildV0Themes () { + const processed: Record }> = {} + for (const [themeName, original] of Object.entries(themes.value)) { + const defaultTheme = original.dark || themeName === 'dark' + ? themes.value.dark + : themes.value.light + + const merged = mergeDeep(defaultTheme, original) as InternalThemeDefinition + const colors = { + ...merged.colors, + ...genVariations(merged.colors, parsedOptions.variations), + } + const fullColors = { + ...colors, + ...genOnColors(colors, merged.variables), + } + + processed[themeName] = { + dark: merged.dark, + colors: fullColors as Record, + } + } + return processed + } + + // Resolve default theme — v0 doesn't understand 'system' + const isSystemDefault = parsedOptions.defaultTheme === 'system' + const { matches: prefersDark } = usePrefersDark() + const resolvedDefault = isSystemDefault + ? (prefersDark.value ? 'dark' : 'light') + : parsedOptions.defaultTheme + + // Create v0 theme instance + const v0Theme = createV0Theme({ + reactive: true, + foreground: false, // We handle on-colors ourselves via genOnColors + default: resolvedDefault, + themes: buildV0Themes(), + }) + + // Bridge v0's selectedId to Vuetify's name ref + const _name = shallowRef(parsedOptions.defaultTheme) const name = computed({ get () { - return _name.value === 'system' ? systemName.value : _name.value + if (_name.value === 'system') { + return prefersDark.value ? 'dark' : 'light' + } + return String(v0Theme.selectedId.value ?? resolvedDefault) }, set (val: string) { _name.value = val + if (val !== 'system') { + v0Theme.select(val) + } }, }) + // When system preference changes and we're in system mode, update v0 selection + watch(prefersDark, dark => { + if (_name.value === 'system') { + v0Theme.select(dark ? 'dark' : 'light') + } + }) + const computedThemes = computed(() => { const acc: Record = {} - for (const [name, original] of Object.entries(themes.value)) { - const defaultTheme = original.dark || name === 'dark' + for (const [themeName, original] of Object.entries(themes.value)) { + const defaultTheme = original.dark || themeName === 'dark' ? themes.value.dark : themes.value.light @@ -136,7 +192,7 @@ export function createTheme (options?: ThemeOptions): ThemeInstance & { install: ...genVariations(merged.colors, parsedOptions.variations), } - acc[name] = { + acc[themeName] = { ...merged, colors: { ...colors, @@ -207,24 +263,6 @@ export function createTheme (options?: ThemeOptions): ThemeInstance & { install: const themeClasses = toRef(() => parsedOptions.isDisabled ? undefined : `${parsedOptions.prefix}theme--${name.value}`) const themeNames = toRef(() => Object.keys(computedThemes.value)) - if (SUPPORTS_MATCH_MEDIA) { - const media = window.matchMedia('(prefers-color-scheme: dark)') - - function updateSystemName () { - systemName.value = media.matches ? 'dark' : 'light' - } - - updateSystemName() - - media.addEventListener('change', updateSystemName, { passive: true }) - - if (getCurrentScope()) { - onScopeDispose(() => { - media.removeEventListener('change', updateSystemName) - }) - } - } - function install (app: App) { if (parsedOptions.isDisabled) return From 4767f5cce366f6f0d9ede3dbd3cd25e64b0abfa3 Mon Sep 17 00:00:00 2001 From: John Leider Date: Wed, 25 Mar 2026 19:46:04 -0500 Subject: [PATCH 04/10] docs: add theme breaking changes to v5 upgrade guide --- .../pages/en/getting-started/upgrade-guide.md | 589 +----------------- 1 file changed, 32 insertions(+), 557 deletions(-) diff --git a/packages/docs/src/pages/en/getting-started/upgrade-guide.md b/packages/docs/src/pages/en/getting-started/upgrade-guide.md index 4668a15ff1e..8e822627916 100644 --- a/packages/docs/src/pages/en/getting-started/upgrade-guide.md +++ b/packages/docs/src/pages/en/getting-started/upgrade-guide.md @@ -3,8 +3,8 @@ emphasized: true meta: nav: Upgrade guide title: Upgrade guide - description: Detailed instruction on how to upgrade Vuetify to 4.0 - keywords: migration, upgrade, releases, upgrading vuetify, alpha, v4 + description: Detailed instructions on how to upgrade Vuetify from v4 to v5 + keywords: migration, upgrade, releases, upgrading vuetify, v5 related: - /introduction/roadmap/ - /introduction/long-term-support/ @@ -13,584 +13,59 @@ related: # Upgrade Guide -This page contains a detailed list of breaking changes and the steps required to upgrade your application to Vuetify 4 +This page contains a detailed list of breaking changes and the steps required to upgrade your application from Vuetify 4 to Vuetify 5. -## Quick Start with Vuetify MCP +## Locale -The fastest way to check your project for breaking changes is with [Vuetify MCP](https://github.com/vuetifyjs/mcp/). To get started, run the following in your terminal: +Vuetify's locale system is now powered by `@vuetify/v0` under the hood. The consumer-facing API (`useLocale`, `useRtl`, `VLocaleProvider`) is unchanged for the majority of users. If you only use `t()`, `n()`, `current`, `isRtl`, `rtlClasses`, or `decimalSeparator`, no changes are needed. -```bash -# Claude Code -claude mcp add --transport http vuetify-mcp https://mcp.vuetifyjs.com/mcp +### LocaleInstance -# Configure for hosted remote server -npx -y @vuetify/mcp config --remote +Several properties have been removed from the `LocaleInstance` type: -# Or configure for local installation -npx -y @vuetify/mcp config -``` - -Once the MCP server is set up and loaded you will gain access to new tools such as: - -- `get_upgrade_guide`: Get a list of all breaking changes in the upgrade guide. -- `get_v4_breaking_changes`: Get a list of all breaking changes in Vuetify 4. - -Now, prompt your agent with the following: - -```text -Using the vuetify-mcp server, scan this project for Vuetify 3 to 4 breaking changes. List each issue found with the file, line number, and recommended fix. -``` - -This will automatically analyze your codebase and provide a tailored list of changes you need to make. - -If you have any questions about the upgrade process, come visit us at [community.vuetifyjs.com](https://community.vuetifyjs.com/). - -## Multi-step migration - -Several breaking changes in Vuetify 4 can be temporarily reverted by pasting short CSS or configuration snippets — notably [CSS reset](#css-reset), [typography](#typography), [elevation](#elevation), and [grid](#grid-system-vrow-and-vcol). This means you can migrate incrementally: restore the legacy behavior first, then update each area at your own pace. - -Even though these migrations mostly come down to adjusting CSS classes, manually reviewing every affected template can be time-consuming without automated visual regression tests. For large projects (typically over 200 components), we recommend scanning your codebase for relevant usage before starting: - -- **HTML elements** — `

` through `

` (affected by CSS reset) -- **Grid usage** — `` and ``, with specific focus on ad-hoc spacing adjustments (i.e. classes like `mx-0`, `pa-0`) -- **Grid attributes** — `dense`, `align`, `justify`, `order`, `align-self` (affected by grid changes) -- **Shadows** — `elevation-*` classes and `elevation` attributes or CSS overrides (affected by elevation changes) -- **CSS classes** — `text-h1` … `text-h6`, `text-subtitle-1`, `text-body-2`, `text-caption`, `text-overline`, `elevation-*`, `offset-*` (affected by typography) - -Identify the areas with the highest usage first, apply the corresponding compatibility snippets, and then schedule the full class-by-class migration as a follow-up. - -[vuetify-codemods](https://www.npmjs.com/package/vuetify-codemods) can be used to automate many of these changes. - -## Styles - -### Style entry points - -There are now pre-compiled entry points for the most common style changes. If you have a Sass file that only sets `$color-pack: false` or `$utilities: false` you can replace it with `import 'vuetify/styles/core'`. See [Style entry points](/styles/entry-points) for more information. - -### CSS reset - -The CSS reset has been mostly removed, with style normalisation being moved to individual components instead. You can inspect the exact [changes](https://github.com/vuetifyjs/vuetify/pull/20960/changes#diff-87996fc432835581ad883bedbc1975ad3a3f44b5747b2b831e3fa03dfdabb91f) to learn more. Here is the high level overview: - -- global `* { padding: 0; margin: 0; }` is gone - no longer resets all elements -- `