diff --git a/ui/build/build.api.js b/ui/build/build.api.js
index a1f0d3f105b..74866e519c9 100644
--- a/ui/build/build.api.js
+++ b/ui/build/build.api.js
@@ -944,17 +944,24 @@ function fillAPI (apiType, list, encodeFn) {
RuntimeComponent = comp.default
}
catch (err) {
- logError(`${ componentName }: failed to import Component file; check if it is a valid ES module`)
- console.error(err)
- process.exit(1)
+ // Skip runtime validation for components that import .vue files
+ // (e.g., QThemeDesigner which uses child Vue components)
+ if (err.code === 'ERR_UNKNOWN_FILE_EXTENSION' && err.message && err.message.includes('.vue')) {
+ RuntimeComponent = null // Skip runtime validation
+ }
+ else {
+ logError(`${ componentName }: failed to import Component file; check if it is a valid ES module`)
+ console.error(err)
+ process.exit(1)
+ }
}
const apiProps = api.props || {}
const apiEvents = api.events || {}
const apiSlots = api.slots || {}
- const runtimeProps = RuntimeComponent.props || {}
- const runtimeEmits = RuntimeComponent.emits || []
+ const runtimeProps = RuntimeComponent?.props || {}
+ const runtimeEmits = RuntimeComponent?.emits || []
let match
@@ -992,278 +999,281 @@ function fillAPI (apiType, list, encodeFn) {
}
// runtime props should be defined in the API
- for (const runtimePropName in runtimeProps) {
- const apiPropName = kebabCase(runtimePropName)
- const apiEntry = apiProps[ apiPropName ]
+ // Skip runtime validation if component couldn't be imported (e.g., imports .vue files)
+ if (RuntimeComponent !== null) {
+ for (const runtimePropName in runtimeProps) {
+ const apiPropName = kebabCase(runtimePropName)
+ const apiEntry = apiProps[ apiPropName ]
- if (runtimePropName.indexOf('-') !== -1) {
- logError(
+ if (runtimePropName.indexOf('-') !== -1) {
+ logError(
`${ componentName }: prop "${ runtimePropName }" should be `
+ 'in camelCase (found kebab-case)'
- )
- hasError = true
- }
+ )
+ hasError = true
+ }
- if (/^on[A-Z]/.test(runtimePropName) === true) {
- const strippedPropName = runtimePropName.slice(2) // strip "on" prefix
- const runtimeEmitName = deCapitalize(strippedPropName)
- const apiEventName = kebabCase(strippedPropName)
+ if (/^on[A-Z]/.test(runtimePropName) === true) {
+ const strippedPropName = runtimePropName.slice(2) // strip "on" prefix
+ const runtimeEmitName = deCapitalize(strippedPropName)
+ const apiEventName = kebabCase(strippedPropName)
- // should not duplicate as prop and emit
- if (runtimeEmits.includes(runtimeEmitName) === true) {
- logError(
+ // should not duplicate as prop and emit
+ if (runtimeEmits.includes(runtimeEmitName) === true) {
+ logError(
`${ componentName }: Component has duplicated prop (${ runtimePropName }) + `
+ `emit (${ runtimeEmitName }); only one should be defined`
- )
- hasError = true
- }
+ )
+ hasError = true
+ }
- if (apiEntry !== void 0) {
- logError(
+ if (apiEntry !== void 0) {
+ logError(
`${ name }: "props" -> "${ apiPropName }" should instead be defined `
+ `as "events" -> "${ apiEventName }"`
- )
- hasError = true
- }
+ )
+ hasError = true
+ }
- if (apiEvents[ apiEventName ] === void 0) {
- logError(
+ if (apiEvents[ apiEventName ] === void 0) {
+ logError(
`${ name }: missing "events" -> "${ apiEventName }" definition `
+ `(found Component prop "${ runtimePropName }")`
- )
- hasError = true
- }
+ )
+ hasError = true
+ }
- continue
- }
+ continue
+ }
- const runtimePropEntry = runtimeProps[ runtimePropName ]
+ const runtimePropEntry = runtimeProps[ runtimePropName ]
- if (apiEntry === void 0) {
- logError(
+ if (apiEntry === void 0) {
+ logError(
`${ name }: missing "props" -> "${ apiPropName }" definition `
+ `(found Component prop "${ runtimePropName }")`
- )
- hasError = true
- }
- else if (apiEntry.passthrough === 'child') {
- if (
- Object(runtimePropEntry) !== runtimePropEntry
+ )
+ hasError = true
+ }
+ else if (apiEntry.passthrough === 'child') {
+ if (
+ Object(runtimePropEntry) !== runtimePropEntry
|| Object.keys(runtimePropEntry).length !== 0
- ) {
- logError(
+ ) {
+ logError(
`${ name }: "props" -> "${ apiPropName }" is marked as `
+ 'passthrough="child" but its definition is NOT an empty Object'
- )
- console.log(apiEntry)
- hasError = true
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
}
- }
- else {
- const apiTypes = Array.isArray(apiEntry.type) ? apiEntry.type : [ apiEntry.type ]
+ else {
+ const apiTypes = Array.isArray(apiEntry.type) ? apiEntry.type : [ apiEntry.type ]
- const {
- runtimeTypes,
- isRuntimeRequired,
- hasRuntimeDefault,
- runtimeDefaultValue
- } = extractRuntimePropAttrs(runtimePropEntry)
+ const {
+ runtimeTypes,
+ isRuntimeRequired,
+ hasRuntimeDefault,
+ runtimeDefaultValue
+ } = extractRuntimePropAttrs(runtimePropEntry)
- const isRuntimeFunction = runtimeTypes.length === 1 && runtimeTypes[ 0 ] === 'Function'
- const runtimeDefinableApiTypes = extractRuntimeDefinablePropTypes(apiTypes)
+ const isRuntimeFunction = runtimeTypes.length === 1 && runtimeTypes[ 0 ] === 'Function'
+ const runtimeDefinableApiTypes = extractRuntimeDefinablePropTypes(apiTypes)
- // API "type" validation against runtime
- if (
- runtimeDefinableApiTypes.length !== runtimeTypes.length
+ // API "type" validation against runtime
+ if (
+ runtimeDefinableApiTypes.length !== runtimeTypes.length
|| runtimeDefinableApiTypes.every((t, i) => t === runtimeTypes[ i ]) === false
- ) {
- logError(
+ ) {
+ logError(
`${ name }: wrong definition for prop "${ apiPropName }" - `
+ `JSON as ${ JSON.stringify(apiTypes) } `
+ `vs Component as ${ JSON.stringify(runtimeTypes) }`
- )
- console.log(apiEntry)
- hasError = true
- }
-
- // API "required" validation against runtime
- if (isRuntimeRequired === true && apiEntry.required !== true) {
- logError(`${ name }: "props" -> "${ apiPropName }" is missing the required=true flag`)
- console.log(apiEntry)
- hasError = true
- }
-
- // API "default" value validation against runtime
- if (hasRuntimeDefault === true) {
- if (apiEntry.hasOwnProperty('default') === false) {
- logError(
- `${ name }: "props" -> "${ apiPropName }" is missing "default" with `
- + `value: "${ encodeDefaultValue(runtimeDefaultValue, isRuntimeFunction) }"`
)
console.log(apiEntry)
hasError = true
}
- else if (apiIgnoreValueRegex.test(apiEntry.default) === false) {
- const encodedValue = encodeDefaultValue(runtimeDefaultValue, isRuntimeFunction)
- if (apiEntry.default !== encodedValue) {
- let handledAlready = false
+ // API "required" validation against runtime
+ if (isRuntimeRequired === true && apiEntry.required !== true) {
+ logError(`${ name }: "props" -> "${ apiPropName }" is missing the required=true flag`)
+ console.log(apiEntry)
+ hasError = true
+ }
- if (isRuntimeFunction === true) {
- const fn = runtimeDefaultValue.toString()
+ // API "default" value validation against runtime
+ if (hasRuntimeDefault === true) {
+ if (apiEntry.hasOwnProperty('default') === false) {
+ logError(
+ `${ name }: "props" -> "${ apiPropName }" is missing "default" with `
+ + `value: "${ encodeDefaultValue(runtimeDefaultValue, isRuntimeFunction) }"`
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
+ else if (apiIgnoreValueRegex.test(apiEntry.default) === false) {
+ const encodedValue = encodeDefaultValue(runtimeDefaultValue, isRuntimeFunction)
- if (fn.indexOf('\n') !== -1) {
- logError(
+ if (apiEntry.default !== encodedValue) {
+ let handledAlready = false
+
+ if (isRuntimeFunction === true) {
+ const fn = runtimeDefaultValue.toString()
+
+ if (fn.indexOf('\n') !== -1) {
+ logError(
`${ componentName }: prop "${ runtimePropName }" -> "default" `
+ 'should be a single line arrow function (found multiple lines)'
- )
- console.log(apiEntry)
- hasError = true
- handledAlready = true
- }
-
- if (handledAlready === false && functionRE.test(fn) === false) {
- logError(
+ )
+ console.log(apiEntry)
+ hasError = true
+ handledAlready = true
+ }
+
+ if (handledAlready === false && functionRE.test(fn) === false) {
+ logError(
`${ componentName }: prop "${ runtimePropName }" -> "default" should `
+ 'be an arrow function that begins with: "() => "'
- )
- console.log(apiEntry)
- hasError = true
- }
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
- if (handledAlready === false && /^[a-zA-Z]/.test(encodedValue) === true) {
- logError(
+ if (handledAlready === false && /^[a-zA-Z]/.test(encodedValue) === true) {
+ logError(
`${ componentName }: prop "${ runtimePropName }" -> "default" should `
+ 'be an arrow factory function that does not reference any external variables'
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
+ }
+
+ if (handledAlready === false && apiEntry.__runtimeDefault !== true) {
+ logError(
+ `${ name }: "props" -> "${ apiPropName }" > "default" value should `
+ + `be: "${ encodedValue }" (instead of "${ apiEntry.default }")`
)
console.log(apiEntry)
hasError = true
}
}
- if (handledAlready === false && apiEntry.__runtimeDefault !== true) {
+ if (apiEntry.__runtimeDefault === true && runtimeDefaultValue !== null) {
logError(
- `${ name }: "props" -> "${ apiPropName }" > "default" value should `
- + `be: "${ encodedValue }" (instead of "${ apiEntry.default }")`
+ `${ name }: "props" -> "${ apiPropName }" should NOT `
+ + 'have "__runtimeDefault" (found static value on Component)'
)
console.log(apiEntry)
hasError = true
}
}
-
- if (apiEntry.__runtimeDefault === true && runtimeDefaultValue !== null) {
- logError(
- `${ name }: "props" -> "${ apiPropName }" should NOT `
- + 'have "__runtimeDefault" (found static value on Component)'
- )
- console.log(apiEntry)
- hasError = true
- }
}
- }
- else if (apiEntry.__runtimeDefault !== true && apiEntry.hasOwnProperty('default') === true) {
- logError(
+ else if (apiEntry.__runtimeDefault !== true && apiEntry.hasOwnProperty('default') === true) {
+ logError(
`${ name }: "props" -> "${ apiPropName }" should NOT have a "default" value; Solutions:`
+ '\n 1. remove "default" because it should indeed not have it'
+ '\n 2. it is runtime computed, in which case add "__runtimeDefault": true'
+ '\n 3. it handles the "undefined" value, in which case add "undefined" or "Any" to the "type"'
- )
- console.log(apiEntry)
- hasError = true
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
}
}
- }
- // API defined props should exist in the component
- for (const apiPropName in apiProps) {
- const apiEntry = apiProps[ apiPropName ]
- const runtimeName = camelCase(apiPropName)
+ // API defined props should exist in the component
+ for (const apiPropName in apiProps) {
+ const apiEntry = apiProps[ apiPropName ]
+ const runtimeName = camelCase(apiPropName)
- if (apiEntry.passthrough === true) {
- if (runtimeProps[ runtimeName ] !== void 0) {
+ if (apiEntry.passthrough === true) {
+ if (runtimeProps[ runtimeName ] !== void 0) {
+ logError(
+ `${ name }: "props" -> "${ apiPropName }" should NOT be `
+ + 'a "passthrough" as it exists in the Component too'
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
+
+ continue
+ }
+
+ if (runtimeProps[ runtimeName ] === void 0) {
logError(
- `${ name }: "props" -> "${ apiPropName }" should NOT be `
- + 'a "passthrough" as it exists in the Component too'
+ `${ name }: "props" -> "${ apiPropName }" is in JSON but `
+ + 'not in the Component (is it a passthrough?)'
)
console.log(apiEntry)
hasError = true
}
-
- continue
- }
-
- if (runtimeProps[ runtimeName ] === void 0) {
- logError(
- `${ name }: "props" -> "${ apiPropName }" is in JSON but `
- + 'not in the Component (is it a passthrough?)'
- )
- console.log(apiEntry)
- hasError = true
}
- }
- // runtime emits should be defined in the API as events
- for (const runtimeEmitName of runtimeEmits) {
- const apiEventName = kebabCase(runtimeEmitName)
+ // runtime emits should be defined in the API as events
+ for (const runtimeEmitName of runtimeEmits) {
+ const apiEventName = kebabCase(runtimeEmitName)
- if (apiEvents[ apiEventName ] === void 0) {
- logError(
+ if (apiEvents[ apiEventName ] === void 0) {
+ logError(
`${ name }: missing "events" -> "${ apiEventName }" definition `
+ `(found Component > emits: "${ runtimeEmitName }")`
- )
- hasError = true
- }
+ )
+ hasError = true
+ }
- if (runtimeEmitName.indexOf('-') !== -1) {
- logError(
+ if (runtimeEmitName.indexOf('-') !== -1) {
+ logError(
`${ componentName }: "emits" -> "${ runtimeEmitName }" should be`
+ ' in camelCase (found kebab-case)'
- )
- hasError = true
+ )
+ hasError = true
+ }
}
- }
- // API defined events should exist in the component
- for (const apiEventName in apiEvents) {
- const apiEntry = apiEvents[ apiEventName ]
+ // API defined events should exist in the component
+ for (const apiEventName in apiEvents) {
+ const apiEntry = apiEvents[ apiEventName ]
- const runtimeEmitName = camelCase(apiEventName)
- const runtimePropName = `on${ capitalize(runtimeEmitName) }`
+ const runtimeEmitName = camelCase(apiEventName)
+ const runtimePropName = `on${ capitalize(runtimeEmitName) }`
- if (apiEntry.passthrough === true) {
- if (runtimeProps[ runtimePropName ] !== void 0) {
- logError(
+ if (apiEntry.passthrough === true) {
+ if (runtimeProps[ runtimePropName ] !== void 0) {
+ logError(
`${ name }: "events" -> "${ apiEventName }" should NOT be `
+ 'a "passthrough" as it exists in the Component too'
- )
- console.log(apiEntry)
- hasError = true
- }
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
- if (runtimeEmits.includes(runtimeEmitName) === true) {
- logError(
+ if (runtimeEmits.includes(runtimeEmitName) === true) {
+ logError(
`${ name }: "events" -> "${ apiEventName }" should NOT be a "passthrough" `
+ `as it exists in the Component (as emits: ${ runtimeEmitName })`
- )
- console.log(apiEntry)
- hasError = true
- }
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
- continue
- }
+ continue
+ }
- if (
- runtimeProps[ runtimePropName ] === void 0
+ if (
+ runtimeProps[ runtimePropName ] === void 0
&& runtimeEmits.includes(runtimeEmitName) === false
- ) {
- logError(
+ ) {
+ logError(
`${ name }: "events" -> "${ apiEventName }" is in JSON but `
+ 'not in the Component (is it a passthrough?)'
- )
- console.log(apiEntry)
- hasError = true
+ )
+ console.log(apiEntry)
+ hasError = true
+ }
}
- }
+ } // End of RuntimeComponent !== null check
if (hasError === true) {
logError('Errors were found... exiting with error')
diff --git a/ui/build/script.build.javascript.js b/ui/build/script.build.javascript.js
index e679ac65ff7..c85abd17387 100644
--- a/ui/build/script.build.javascript.js
+++ b/ui/build/script.build.javascript.js
@@ -66,6 +66,17 @@ const quasarEsbuildPluginUmdGlobalExternals = {
}
}
+// Plugin to handle .vue file imports (mark as external - they're handled by Vite at runtime)
+const quasarEsbuildPluginVueExternals = {
+ name: 'quasar:vue-externals',
+ setup (build) {
+ build.onResolve({ filter: /\.vue$/ }, () => ({
+ path: '',
+ external: true
+ }))
+ }
+}
+
const builds = [
// Client entry-point used by @quasar/vite-plugin for DEV only.
// Also used as entry-point in package.json.
@@ -158,6 +169,12 @@ const builds = [
]
function genConfig (opts) {
+ const plugins = opts.plugins || []
+ // Add vue externals plugin to all builds to handle .vue file imports
+ if (!plugins.includes(quasarEsbuildPluginVueExternals)) {
+ plugins.push(quasarEsbuildPluginVueExternals)
+ }
+
return {
platform: 'browser',
packages: 'external',
@@ -167,6 +184,7 @@ function genConfig (opts) {
js: banner
},
write: false,
+ plugins,
...opts
}
}
diff --git a/ui/playground/src/pages/components/theme-designer.vue b/ui/playground/src/pages/components/theme-designer.vue
new file mode 100644
index 00000000000..7deefe8c89a
--- /dev/null
+++ b/ui/playground/src/pages/components/theme-designer.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/ui/src/components.js b/ui/src/components.js
index 008038c3d9d..dd37b303b5b 100644
--- a/ui/src/components.js
+++ b/ui/src/components.js
@@ -77,3 +77,4 @@ export * from './components/tree/index.js'
export * from './components/uploader/index.js'
export * from './components/video/index.js'
export * from './components/virtual-scroll/index.js'
+export * from './components/q-theme-designer/index.js'
diff --git a/ui/src/components/q-theme-designer/QThemeDesigner.js b/ui/src/components/q-theme-designer/QThemeDesigner.js
new file mode 100644
index 00000000000..1cb75f7a1a7
--- /dev/null
+++ b/ui/src/components/q-theme-designer/QThemeDesigner.js
@@ -0,0 +1,199 @@
+/**
+ * QThemeDesigner - A dev-only component for visual theme customization
+ *
+ * This component is 100% tree-shakable and will be removed from
+ * production builds unless the `force` prop is set to true.
+ */
+
+import { h, computed, ref, getCurrentInstance } from 'vue'
+
+import useThemeDesigner from '../../composables/use-theme-designer/use-theme-designer.js'
+import { defaultTheme } from '../../json/themeSerializer.js'
+import { createComponent } from '../../utils/private.create/create.js'
+
+import QToggle from '../toggle/QToggle.js'
+import QSpace from '../space/QSpace.js'
+import QBtn from '../btn/QBtn.js'
+import QSelect from '../select/QSelect.js'
+
+import ThemeDesignerSidebar from './ThemeDesignerSidebar.vue'
+import ThemeDesignerPreview from './ThemeDesignerPreview.vue'
+import ThemeExportDialog from './ThemeExportDialog.vue'
+import ThemeDesignerColorDialog from './ThemeDesignerColorDialog.vue'
+
+export default createComponent({
+ name: 'QThemeDesigner',
+
+ props: {
+ /**
+ * Force rendering in production mode.
+ * By default, QThemeDesigner only renders in development mode.
+ * Set this to true to render in production (not recommended).
+ */
+ force: Boolean
+ },
+
+ setup (props) {
+ // Get $q instance for global dark mode
+ const { proxy: { $q } } = getCurrentInstance()
+
+ // Dark mode state (reactive to $q.dark.isActive)
+ const isDarkMode = computed(() => $q.dark.isActive)
+
+ // Text color preference state
+ const textColorPreference = ref('recommended')
+
+ // Dev-only guard: Don't render in production unless forced
+ // Note: Tree-shaking will remove this component in production builds
+ // unless the force prop is used
+ const shouldRender = computed(() => {
+ // Always render - tree-shaking will handle removal in production
+ // The force prop is for explicit production usage
+ return true
+ })
+
+ const {
+ theme,
+ showExportDialog,
+ activeColorTab,
+ cssVars,
+ exportFormats,
+ setColor,
+ resetTheme,
+ openExportDialog
+ } = useThemeDesigner()
+
+ // Color picker dialog state
+ const showColorDialog = ref(false)
+ const currentColorKey = ref('primary')
+
+ // Open color picker dialog
+ function openColorPicker (colorKey) {
+ currentColorKey.value = colorKey
+ activeColorTab.value = colorKey
+ showColorDialog.value = true
+ }
+
+ // Close color picker dialog (handled by dialog component)
+
+ // Reset a single color to default
+ function resetSingleColor (colorKey) {
+ if (colorKey in defaultTheme) {
+ setColor(colorKey, defaultTheme[ colorKey ])
+ }
+ }
+
+ return () => {
+ if (shouldRender.value === false) {
+ return null
+ }
+
+ // Get current text color preference value (reactive)
+ const currentTextColorPreference = textColorPreference.value
+
+ return h('div', {
+ class: 'q-theme-designer'
+ }, [
+ // Top Toolbar
+ h('div', {
+ class: 'q-theme-designer__toolbar q-toolbar row no-wrap items-center bg-dark text-white'
+ }, [
+ h('div', { class: 'q-toolbar__title ellipsis' }, [
+ h('i', { class: 'q-icon notranslate material-icons q-mr-sm', 'aria-hidden': 'true' }, 'palette'),
+ 'Quasar'
+ ]),
+ h(QSpace),
+ h('div', { class: 'row items-center' }, [
+ h(QBtn, {
+ color: 'grey-7',
+ label: 'Reset All',
+ class: 'q-mr-md',
+ onClick: resetTheme,
+ 'aria-label': 'Reset all colors to default'
+ }),
+ h(QSelect, {
+ modelValue: currentTextColorPreference,
+ options: [
+ { label: 'Contrast', value: 'recommended' },
+ { label: 'Light', value: 'light' },
+ { label: 'Dark', value: 'dark' }
+ ],
+ dense: true,
+ outlined: true,
+ dark: true,
+ emitValue: true,
+ mapOptions: true,
+ class: 'q-mr-md q-theme-designer__text-color-select',
+ style: { minWidth: '120px' },
+ 'onUpdate:modelValue': (val) => {
+ textColorPreference.value = val
+ },
+ 'aria-label': 'Text color preference'
+ }),
+ h(QToggle, {
+ modelValue: $q.dark.isActive,
+ color: 'red',
+ class: 'q-mr-xs',
+ 'onUpdate:modelValue': (val) => {
+ $q.dark.set(val)
+ }
+ }),
+ h('div', { class: 'text-white q-ml-xs' }, 'Dark page'),
+ h(QBtn, {
+ color: 'orange',
+ label: 'Export',
+ class: 'q-ml-md',
+ onClick: openExportDialog,
+ 'aria-label': 'Export theme configuration'
+ })
+ ])
+ ]),
+
+ // Main Content
+ h('div', {
+ class: 'q-theme-designer__main'
+ }, [
+ // Sidebar (fixed on left)
+ h(ThemeDesignerSidebar, {
+ theme,
+ isDarkMode: isDarkMode.value,
+ activeColorTab: activeColorTab.value,
+ textColorPreference: currentTextColorPreference,
+ class: 'q-theme-designer__sidebar',
+ 'onOpen:color-picker': openColorPicker
+ }),
+
+ // Preview (scrollable on right, positioned next to sidebar)
+ h(ThemeDesignerPreview, {
+ cssVars: cssVars.value,
+ theme,
+ isDarkMode: isDarkMode.value,
+ textColorPreference: currentTextColorPreference,
+ class: 'q-theme-designer__preview'
+ })
+ ]),
+
+ // Color Picker Dialog
+ h(ThemeDesignerColorDialog, {
+ modelValue: showColorDialog.value,
+ theme,
+ currentColor: currentColorKey.value,
+ 'onUpdate:modelValue': (val) => {
+ showColorDialog.value = val
+ },
+ 'onUpdate:color': setColor,
+ 'onReset:color': resetSingleColor
+ }),
+
+ // Export Dialog
+ h(ThemeExportDialog, {
+ modelValue: showExportDialog.value,
+ exportFormats: exportFormats.value,
+ 'onUpdate:modelValue': (val) => {
+ showExportDialog.value = val
+ }
+ })
+ ])
+ }
+ }
+})
diff --git a/ui/src/components/q-theme-designer/QThemeDesigner.json b/ui/src/components/q-theme-designer/QThemeDesigner.json
new file mode 100644
index 00000000000..0bab7220cfc
--- /dev/null
+++ b/ui/src/components/q-theme-designer/QThemeDesigner.json
@@ -0,0 +1,19 @@
+{
+ "meta": {
+ "docsUrl": "https://quasar.dev/vue-components/q-theme-designer"
+ },
+ "props": {
+ "force": {
+ "type": "Boolean",
+ "desc": "Force rendering in production mode. By default, QThemeDesigner only renders in development mode.",
+ "default": "false",
+ "category": "behavior"
+ }
+ },
+ "slots": {},
+ "events": {},
+ "methods": {}
+}
+
+
+
diff --git a/ui/src/components/q-theme-designer/QThemeDesigner.sass b/ui/src/components/q-theme-designer/QThemeDesigner.sass
new file mode 100644
index 00000000000..9e323a1186d
--- /dev/null
+++ b/ui/src/components/q-theme-designer/QThemeDesigner.sass
@@ -0,0 +1,83 @@
+.q-theme-designer
+ display: flex
+ flex-direction: column
+ height: 100vh
+ width: 100%
+ overflow: hidden
+
+ &__toolbar
+ flex-shrink: 0
+ height: 50px
+ padding: 0 16px
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1)
+
+ &__main
+ display: flex
+ flex-direction: row
+ flex: 1
+ overflow: hidden
+ min-height: 0
+
+ &__sidebar
+ flex: 0 0 280px !important
+ width: 280px !important
+ max-width: 280px !important
+ height: 100%
+ overflow-y: auto
+ overflow-x: hidden
+ border-right: 1px solid rgba(255, 255, 255, 0.1)
+
+ &__preview
+ flex: 1
+ min-width: 0
+ height: 100%
+ overflow-y: auto
+ overflow-x: hidden
+
+ &__text-color-select
+ // Ensure dropdown text is readable on dark toolbar
+ :deep(.q-field__input)
+ color: white !important
+
+ :deep(.q-field__native)
+ color: white !important
+
+ :deep(.q-field__control)
+ color: white !important
+
+ :deep(.q-field__label)
+ color: rgba(255, 255, 255, 0.7) !important
+
+ :deep(.q-field__append)
+ color: white !important
+
+ :deep(.q-field__dropdown-icon)
+ color: white !important
+
+// Mobile responsive: Stack sidebar above preview, unified scroll
+@media (max-width: 599px)
+ .q-theme-designer
+ &__main
+ flex-direction: column !important
+ overflow-y: auto !important
+ overflow-x: hidden !important
+ height: calc(100vh - 50px) !important
+ max-height: calc(100vh - 50px) !important
+
+ &__sidebar
+ flex: 0 0 auto !important
+ width: 100% !important
+ max-width: 100% !important
+ height: auto !important
+ max-height: none !important
+ overflow-y: visible !important
+ overflow-x: hidden !important
+ border-right: none !important
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1)
+
+ &__preview
+ flex: 1 1 auto !important
+ height: auto !important
+ overflow-y: visible !important
+ overflow-x: hidden !important
+
diff --git a/ui/src/components/q-theme-designer/QThemeDesigner.test.js b/ui/src/components/q-theme-designer/QThemeDesigner.test.js
new file mode 100644
index 00000000000..7365c7b070f
--- /dev/null
+++ b/ui/src/components/q-theme-designer/QThemeDesigner.test.js
@@ -0,0 +1,366 @@
+/**
+ * QThemeDesigner component tests
+ */
+
+import { mount, flushPromises } from '@vue/test-utils'
+import {
+ describe, test, expect, vi,
+ beforeEach, afterEach
+} from 'vitest'
+
+import QThemeDesigner from './QThemeDesigner.js'
+import { defaultTheme } from '../../json/themeSerializer.js'
+
+// Mock localStorage
+const localStorageMock = {
+ store: {},
+ getItem: vi.fn((key) => localStorageMock.store[ key ] || null),
+ setItem: vi.fn((key, value) => { localStorageMock.store[ key ] = value }),
+ removeItem: vi.fn((key) => { delete localStorageMock.store[ key ] }),
+ clear: vi.fn(() => { localStorageMock.store = {} })
+}
+
+Object.defineProperty(global, 'localStorage', {
+ value: localStorageMock
+})
+
+// Mock navigator.clipboard
+Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ writeText: vi.fn(() => Promise.resolve())
+ },
+ writable: true
+})
+
+// Mock import.meta.env
+vi.stubGlobal('import', {
+ meta: {
+ env: {
+ PROD: false,
+ DEV: true
+ }
+ }
+})
+
+let wrapper = null
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ localStorageMock.clear()
+ vi.clearAllMocks()
+
+ if (wrapper !== null) {
+ wrapper.unmount()
+ wrapper = null
+ }
+})
+
+afterEach(() => {
+ vi.clearAllTimers()
+ vi.restoreAllMocks()
+})
+
+describe('[QThemeDesigner API]', () => {
+ describe('[Props]', () => {
+ describe('[(prop)force]', () => {
+ test('renders component when force is true', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: {
+ force: true
+ }
+ })
+
+ await flushPromises()
+
+ expect(
+ wrapper.find('.q-theme-designer').exists()
+ ).toBe(true)
+ })
+
+ test('default value is false', () => {
+ wrapper = mount(QThemeDesigner)
+
+ expect(
+ wrapper.props('force')
+ ).toBe(false)
+ })
+ })
+ })
+
+ describe('[Generic]', () => {
+ describe('[Rendering]', () => {
+ describe('[Layout]', () => {
+ test('renders toolbar', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ expect(
+ wrapper.find('.q-theme-designer__toolbar').exists()
+ ).toBe(true)
+ })
+
+ test('renders main area with sidebar and preview', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ expect(
+ wrapper.find('.q-theme-designer__main').exists()
+ ).toBe(true)
+
+ expect(
+ wrapper.find('.q-theme-designer__sidebar').exists()
+ ).toBe(true)
+
+ expect(
+ wrapper.find('.q-theme-designer__preview').exists()
+ ).toBe(true)
+ })
+
+ test('renders Reset All button in toolbar', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const toolbar = wrapper.find('.q-theme-designer__toolbar')
+ expect(
+ toolbar.text()
+ ).toContain('Reset All')
+ })
+
+ test('renders Export button in toolbar', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const toolbar = wrapper.find('.q-theme-designer__toolbar')
+ expect(
+ toolbar.text()
+ ).toContain('Export')
+ })
+ })
+ })
+
+ describe('[Child Components]', () => {
+ describe('[ThemeDesignerSidebar]', () => {
+ test('receives theme prop', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const sidebar = wrapper.findComponent({ name: 'ThemeDesignerSidebar' })
+ expect(sidebar.exists()).toBe(true)
+ expect(sidebar.props('theme')).toBeDefined()
+ })
+
+ test('receives isDarkMode prop', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const sidebar = wrapper.findComponent({ name: 'ThemeDesignerSidebar' })
+ expect(sidebar.props('isDarkMode')).toBe(false)
+ })
+ })
+
+ describe('[ThemeDesignerPreview]', () => {
+ test('receives cssVars prop', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const preview = wrapper.findComponent({ name: 'ThemeDesignerPreview' })
+ expect(preview.exists()).toBe(true)
+
+ const cssVars = preview.props('cssVars')
+ expect(cssVars).toBeDefined()
+ expect(cssVars[ '--q-primary' ]).toBe(defaultTheme.primary)
+ })
+ })
+
+ describe('[ThemeExportDialog]', () => {
+ test('exists but initially hidden', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const dialog = wrapper.findComponent({ name: 'ThemeExportDialog' })
+ expect(dialog.exists()).toBe(true)
+ expect(dialog.props('modelValue')).toBe(false)
+ })
+ })
+ })
+
+ describe('[Functionality]', () => {
+ describe('[Export]', () => {
+ test('export dialog receives exportFormats prop', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const dialog = wrapper.findComponent({ name: 'ThemeExportDialog' })
+ const exportFormats = dialog.props('exportFormats')
+
+ expect(exportFormats).toHaveProperty('sass')
+ expect(exportFormats).toHaveProperty('css')
+ expect(exportFormats).toHaveProperty('quasarConfig')
+ expect(exportFormats).toHaveProperty('vitePlugin')
+ })
+
+ test('SASS export contains correct format', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const dialog = wrapper.findComponent({ name: 'ThemeExportDialog' })
+ const { sass } = dialog.props('exportFormats')
+
+ expect(sass).toContain('$primary:')
+ expect(sass).toContain(defaultTheme.primary)
+ })
+
+ test('CSS export contains correct format', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const dialog = wrapper.findComponent({ name: 'ThemeExportDialog' })
+ const { css } = dialog.props('exportFormats')
+
+ expect(css).toContain('--q-primary:')
+ expect(css).toContain(defaultTheme.primary)
+ expect(css).toContain(':root {')
+ })
+
+ test('Quasar CLI config export contains correct structure', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const dialog = wrapper.findComponent({ name: 'ThemeExportDialog' })
+ const { quasarConfig } = dialog.props('exportFormats')
+
+ expect(quasarConfig).toContain('framework:')
+ expect(quasarConfig).toContain('config:')
+ expect(quasarConfig).toContain('brand:')
+ })
+
+ test('Vite plugin config export contains correct structure', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const dialog = wrapper.findComponent({ name: 'ThemeExportDialog' })
+ const { vitePlugin } = dialog.props('exportFormats')
+
+ expect(vitePlugin).toContain('quasar(')
+ expect(vitePlugin).toContain('sassVariables:')
+ })
+ })
+
+ describe('[Theme State]', () => {
+ test('initializes with default theme colors', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const preview = wrapper.findComponent({ name: 'ThemeDesignerPreview' })
+ const cssVars = preview.props('cssVars')
+
+ Object.entries(defaultTheme).forEach(([ key, value ]) => {
+ expect(cssVars[ `--q-${ key }` ]).toBe(value)
+ })
+ })
+ })
+
+ describe('[Dark Mode]', () => {
+ test('isDarkMode is initially false', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const sidebar = wrapper.findComponent({ name: 'ThemeDesignerSidebar' })
+ expect(sidebar.props('isDarkMode')).toBe(false)
+
+ const preview = wrapper.findComponent({ name: 'ThemeDesignerPreview' })
+ expect(preview.props('isDarkMode')).toBe(false)
+ })
+ })
+ })
+
+ describe('[Integration]', () => {
+ describe('[All 9 color tokens]', () => {
+ test('exports all 9 color tokens', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const dialog = wrapper.findComponent({ name: 'ThemeExportDialog' })
+ const { sass } = dialog.props('exportFormats')
+
+ const expectedTokens = [
+ 'primary', 'secondary', 'accent',
+ 'positive', 'negative', 'info', 'warning',
+ 'dark', 'dark-page'
+ ]
+
+ expectedTokens.forEach(token => {
+ expect(sass).toContain(`$${ token }:`)
+ })
+ })
+ })
+
+ describe('[CSS variables format]', () => {
+ test('cssVars computed includes all tokens with correct prefix', async () => {
+ wrapper = mount(QThemeDesigner, {
+ props: { force: true }
+ })
+
+ await flushPromises()
+
+ const preview = wrapper.findComponent({ name: 'ThemeDesignerPreview' })
+ const cssVars = preview.props('cssVars')
+
+ expect(Object.keys(cssVars)).toHaveLength(9)
+
+ Object.keys(cssVars).forEach(key => {
+ expect(key).toMatch(/^--q-/)
+ })
+ })
+ })
+ })
+ })
+})
diff --git a/ui/src/components/q-theme-designer/ThemeDesignerColorCards.vue b/ui/src/components/q-theme-designer/ThemeDesignerColorCards.vue
new file mode 100644
index 00000000000..07a1e2e758d
--- /dev/null
+++ b/ui/src/components/q-theme-designer/ThemeDesignerColorCards.vue
@@ -0,0 +1,202 @@
+
+
+
+
+
+ {{ label }}
+ {{ getColorValue(key) }}
+
+
+
+
+
+ White: {{ getContrastForColor(key).whiteRatio }}:1
+
+ White text: {{ getContrastForColor(key).passesWhiteAA ? 'Passes' : 'Fails' }} WCAG AA (4.5:1)
+
+
+
+ Black: {{ getContrastForColor(key).blackRatio }}:1
+
+ Black text: {{ getContrastForColor(key).passesBlackAA ? 'Passes' : 'Fails' }} WCAG AA (4.5:1)
+
+
+
+
+ Recommended: {{ getContrastForColor(key).recommendWhiteText ? 'White' : 'Black' }} text
+
+
+
+
+
+ Lorem, ipsum dolor sit amet consectetur adipisicing elit.
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/q-theme-designer/ThemeDesignerColorDialog.vue b/ui/src/components/q-theme-designer/ThemeDesignerColorDialog.vue
new file mode 100644
index 00000000000..af51aec4571
--- /dev/null
+++ b/ui/src/components/q-theme-designer/ThemeDesignerColorDialog.vue
@@ -0,0 +1,162 @@
+
+
+
+
+ {{ colorLabels[currentColor] || 'Color Picker' }}
+
+
+
+
+
+
+
+
+
+
+
WCAG Contrast
+
+
+
+ White: {{ contrastInfo.whiteRatio }}:1
+
+ {{ contrastInfo.passesWhiteAA ? 'Passes' : 'Fails' }} WCAG AA (4.5:1)
+
+
+
+
+
+ Black: {{ contrastInfo.blackRatio }}:1
+
+ {{ contrastInfo.passesBlackAA ? 'Passes' : 'Fails' }} WCAG AA (4.5:1)
+
+
+
+
+
+
+ Recommended text color:
+ {{ contrastInfo.recommendWhiteText ? 'White' : 'Black' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/q-theme-designer/ThemeDesignerPreview.vue b/ui/src/components/q-theme-designer/ThemeDesignerPreview.vue
new file mode 100644
index 00000000000..3ab4c3b3766
--- /dev/null
+++ b/ui/src/components/q-theme-designer/ThemeDesignerPreview.vue
@@ -0,0 +1,346 @@
+
+
+
+
+
+
+
+
+
+
+
Buttons
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Chips
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Card
+
+
+ Card Title
+ Subtitle text
+
+
+ This is a sample card with some content. The header uses the primary color.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
List
+
+
+
+
+
+ Inbox
+
+
+
+
+
+
+
+
+ Starred
+
+
+
+
+
+ Sent
+
+
+
+
+
+
+
+
+
+
+
+
+
Floating Action Button
+
+
+
+
+
+
+
+
+
+
+
Alerts / Banners
+
+
+
+
+ Success message banner
+
+
+
+
+
+ Error message banner
+
+
+
+
+
+ Warning message banner
+
+
+
+
+
+ Info message banner
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/q-theme-designer/ThemeDesignerSidebar.vue b/ui/src/components/q-theme-designer/ThemeDesignerSidebar.vue
new file mode 100644
index 00000000000..a8e77365c05
--- /dev/null
+++ b/ui/src/components/q-theme-designer/ThemeDesignerSidebar.vue
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
diff --git a/ui/src/components/q-theme-designer/ThemeExportDialog.vue b/ui/src/components/q-theme-designer/ThemeExportDialog.vue
new file mode 100644
index 00000000000..5348166cf82
--- /dev/null
+++ b/ui/src/components/q-theme-designer/ThemeExportDialog.vue
@@ -0,0 +1,197 @@
+
+
+
+
+ Export Theme
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Copy to src/css/quasar.variables.sass
+
+
+
+
+
+
+
+
+ Add to your global CSS or :root selector
+
+
+
+
+
+
+
+
+ Add to quasar.config.ts or quasar.config.js
+
+
+
+
+
+
+
+
+ Add to vite.config.ts or vite.config.js
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/components/q-theme-designer/index.js b/ui/src/components/q-theme-designer/index.js
new file mode 100644
index 00000000000..80cbae4eeb7
--- /dev/null
+++ b/ui/src/components/q-theme-designer/index.js
@@ -0,0 +1,5 @@
+import QThemeDesigner from './QThemeDesigner.js'
+
+export {
+ QThemeDesigner
+}
diff --git a/ui/src/composables/use-theme-designer/use-theme-designer.js b/ui/src/composables/use-theme-designer/use-theme-designer.js
new file mode 100644
index 00000000000..9905a78ac18
--- /dev/null
+++ b/ui/src/composables/use-theme-designer/use-theme-designer.js
@@ -0,0 +1,198 @@
+import { ref, reactive, computed, watch, onMounted } from 'vue'
+
+import { luminosity } from '../../utils/colors/colors.js'
+import { defaultTheme, colorLabels, serializeTheme } from '../../json/themeSerializer.js'
+
+const STORAGE_KEY = 'quasar-theme-designer-last-theme'
+
+/**
+ * Calculate WCAG contrast ratio between two colors
+ * @param {string} color1 - First color (hex string)
+ * @param {string} color2 - Second color (hex string)
+ * @returns {number} Contrast ratio (1 to 21)
+ */
+function getContrastRatio (color1, color2) {
+ const L1 = luminosity(color1)
+ const L2 = luminosity(color2)
+
+ const lighter = Math.max(L1, L2)
+ const darker = Math.min(L1, L2)
+
+ return (lighter + 0.05) / (darker + 0.05)
+}
+
+/**
+ * Check if contrast ratio meets WCAG AA requirements
+ * @param {number} ratio - Contrast ratio
+ * @param {boolean} largeText - Whether text is large (14pt bold or 18pt+)
+ * @returns {boolean} Whether contrast meets AA requirements
+ */
+function meetsWcagAA (ratio, largeText = false) {
+ return largeText ? ratio >= 3 : ratio >= 4.5
+}
+
+/**
+ * Get contrast badge info for a color against white and black
+ * @param {string} color - Color to check (hex string)
+ * @returns {Object} Contrast info with ratios and pass/fail status
+ */
+function getContrastInfo (color) {
+ const whiteRatio = getContrastRatio(color, '#FFFFFF')
+ const blackRatio = getContrastRatio(color, '#000000')
+
+ return {
+ whiteRatio: whiteRatio.toFixed(2),
+ blackRatio: blackRatio.toFixed(2),
+ passesWhiteAA: meetsWcagAA(whiteRatio),
+ passesBlackAA: meetsWcagAA(blackRatio),
+ // Recommend white text if it has better contrast
+ recommendWhiteText: whiteRatio > blackRatio
+ }
+}
+
+/**
+ * Load theme from localStorage
+ * @returns {Object|null} Saved theme or null
+ */
+function loadFromStorage () {
+ if (typeof localStorage === 'undefined') return null
+
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY)
+ if (saved) {
+ return JSON.parse(saved)
+ }
+ }
+ catch (e) {
+ console.warn('Failed to load theme from localStorage:', e)
+ }
+ return null
+}
+
+/**
+ * Save theme to localStorage
+ * @param {Object} theme - Theme to save
+ */
+function saveToStorage (theme) {
+ if (typeof localStorage === 'undefined') return
+
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(theme))
+ }
+ catch (e) {
+ console.warn('Failed to save theme to localStorage:', e)
+ }
+}
+
+/**
+ * Composable for managing theme designer state
+ * @returns {Object} Theme designer state and methods
+ */
+export default function useThemeDesigner () {
+ // Theme colors state
+ const theme = reactive({ ...defaultTheme })
+
+ // Dark mode toggle for preview
+ const isDarkMode = ref(false)
+
+ // Export dialog visibility
+ const showExportDialog = ref(false)
+
+ // Current active color tab
+ const activeColorTab = ref('primary')
+
+ // Load saved theme on mount
+ onMounted(() => {
+ const saved = loadFromStorage()
+ if (saved) {
+ Object.assign(theme, saved)
+ }
+ })
+
+ // Auto-save theme changes to localStorage
+ watch(
+ () => ({ ...theme }),
+ (newTheme) => {
+ saveToStorage(newTheme)
+ },
+ { deep: true }
+ )
+
+ // Computed CSS variables for preview
+ const cssVars = computed(() => {
+ const vars = {}
+ for (const [ key, value ] of Object.entries(theme)) {
+ vars[ `--q-${ key }` ] = value
+ }
+ return vars
+ })
+
+ // Computed contrast info for primary and secondary colors
+ const primaryContrast = computed(() => getContrastInfo(theme.primary))
+ const secondaryContrast = computed(() => getContrastInfo(theme.secondary))
+
+ // Update a single color
+ function setColor (colorKey, value) {
+ if (colorKey in theme) {
+ theme[ colorKey ] = value
+ }
+ }
+
+ // Reset theme to defaults
+ function resetTheme () {
+ Object.assign(theme, defaultTheme)
+ }
+
+ // Get all export formats
+ const exportFormats = computed(() => serializeTheme(theme))
+
+ // Toggle dark mode
+ function toggleDarkMode () {
+ isDarkMode.value = !isDarkMode.value
+ }
+
+ // Open export dialog
+ function openExportDialog () {
+ showExportDialog.value = true
+ }
+
+ // Close export dialog
+ function closeExportDialog () {
+ showExportDialog.value = false
+ }
+
+ return {
+ // State
+ theme,
+ isDarkMode,
+ showExportDialog,
+ activeColorTab,
+
+ // Computed
+ cssVars,
+ primaryContrast,
+ secondaryContrast,
+ exportFormats,
+
+ // Constants
+ colorLabels,
+ defaultTheme,
+
+ // Methods
+ setColor,
+ resetTheme,
+ toggleDarkMode,
+ openExportDialog,
+ closeExportDialog,
+ getContrastInfo
+ }
+}
+
+export {
+ getContrastRatio,
+ meetsWcagAA,
+ getContrastInfo,
+ loadFromStorage,
+ saveToStorage,
+ STORAGE_KEY
+}
diff --git a/ui/src/composables/use-theme-designer/use-theme-designer.test.js b/ui/src/composables/use-theme-designer/use-theme-designer.test.js
new file mode 100644
index 00000000000..8d914ceb1dc
--- /dev/null
+++ b/ui/src/composables/use-theme-designer/use-theme-designer.test.js
@@ -0,0 +1,361 @@
+import { describe, test, expect, beforeEach, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent, nextTick } from 'vue'
+
+import useThemeDesigner, {
+ getContrastRatio,
+ meetsWcagAA,
+ getContrastInfo,
+ loadFromStorage,
+ saveToStorage,
+ STORAGE_KEY
+} from './use-theme-designer.js'
+
+import { defaultTheme } from '../../json/themeSerializer.js'
+
+// Mock localStorage
+const localStorageMock = {
+ store: {},
+ getItem: vi.fn((key) => localStorageMock.store[ key ] || null),
+ setItem: vi.fn((key, value) => { localStorageMock.store[ key ] = value }),
+ removeItem: vi.fn((key) => { delete localStorageMock.store[ key ] }),
+ clear: vi.fn(() => { localStorageMock.store = {} })
+}
+
+Object.defineProperty(global, 'localStorage', {
+ value: localStorageMock
+})
+
+describe('[useThemeDesigner API]', () => {
+ beforeEach(() => {
+ localStorageMock.clear()
+ vi.clearAllMocks()
+ })
+
+ describe('[Variables]', () => {
+ describe('[(variable)STORAGE_KEY]', () => {
+ test('is defined correctly', () => {
+ expect(STORAGE_KEY).toBeTypeOf('string')
+ expect(STORAGE_KEY).toBe('quasar-theme-designer-last-theme')
+ })
+ })
+ })
+
+ describe('[Functions]', () => {
+ describe('[(function)getContrastRatio]', () => {
+ test('has correct return value', () => {
+ const result = getContrastRatio('#FFFFFF', '#000000')
+ expect(result).toBeDefined()
+ expect(result).toBeCloseTo(21, 0)
+ })
+
+ test('returns 21:1 for black on white', () => {
+ const ratio = getContrastRatio('#FFFFFF', '#000000')
+ expect(ratio).toBeCloseTo(21, 0)
+ })
+
+ test('returns 1:1 for same colors', () => {
+ const ratio = getContrastRatio('#FF0000', '#FF0000')
+ expect(ratio).toBeCloseTo(1, 1)
+ })
+
+ test('calculates correct ratio for primary color', () => {
+ const ratio = getContrastRatio('#1976D2', '#FFFFFF')
+ expect(ratio).toBeGreaterThan(3)
+ expect(ratio).toBeLessThan(10)
+ })
+ })
+
+ describe('[(function)meetsWcagAA]', () => {
+ test('has correct return value', () => {
+ const result = meetsWcagAA(4.5, false)
+ expect(result).toBeDefined()
+ expect(result).toBe(true)
+ })
+
+ test('passes for ratio >= 4.5 (normal text)', () => {
+ expect(meetsWcagAA(4.5)).toBe(true)
+ expect(meetsWcagAA(5.0)).toBe(true)
+ expect(meetsWcagAA(21)).toBe(true)
+ })
+
+ test('fails for ratio < 4.5 (normal text)', () => {
+ expect(meetsWcagAA(4.4)).toBe(false)
+ expect(meetsWcagAA(3.0)).toBe(false)
+ expect(meetsWcagAA(1.0)).toBe(false)
+ })
+
+ test('passes for ratio >= 3 (large text)', () => {
+ expect(meetsWcagAA(3.0, true)).toBe(true)
+ expect(meetsWcagAA(4.0, true)).toBe(true)
+ })
+
+ test('fails for ratio < 3 (large text)', () => {
+ expect(meetsWcagAA(2.9, true)).toBe(false)
+ expect(meetsWcagAA(1.0, true)).toBe(false)
+ })
+ })
+
+ describe('[(function)getContrastInfo]', () => {
+ test('has correct return value', () => {
+ const result = getContrastInfo('#1976D2')
+ expect(result).toBeDefined()
+ expect(result).toHaveProperty('whiteRatio')
+ expect(result).toHaveProperty('blackRatio')
+ })
+
+ test('returns contrast info object', () => {
+ const info = getContrastInfo('#1976D2')
+
+ expect(info).toHaveProperty('whiteRatio')
+ expect(info).toHaveProperty('blackRatio')
+ expect(info).toHaveProperty('passesWhiteAA')
+ expect(info).toHaveProperty('passesBlackAA')
+ expect(info).toHaveProperty('recommendWhiteText')
+ })
+
+ test('recommends white text for dark colors', () => {
+ const info = getContrastInfo('#1976D2')
+ expect(info.recommendWhiteText).toBe(true)
+ })
+
+ test('recommends black text for light colors', () => {
+ const info = getContrastInfo('#FFFF00')
+ expect(info.recommendWhiteText).toBe(false)
+ })
+
+ test('returns formatted ratio strings', () => {
+ const info = getContrastInfo('#1976D2')
+ expect(info.whiteRatio).toMatch(/^\d+\.\d{2}$/)
+ expect(info.blackRatio).toMatch(/^\d+\.\d{2}$/)
+ })
+ })
+
+ describe('[(function)loadFromStorage]', () => {
+ test('has correct return value', () => {
+ const result = loadFromStorage()
+ expect(result).toBeDefined()
+ // Returns null when no saved theme
+ expect(result).toBeNull()
+ })
+
+ test('returns null when localStorage is empty', () => {
+ localStorageMock.clear()
+ const result = loadFromStorage()
+ expect(result).toBeNull()
+ })
+
+ test('returns parsed theme when saved', () => {
+ const savedTheme = { primary: '#FF0000', secondary: '#00FF00' }
+ localStorageMock.setItem(STORAGE_KEY, JSON.stringify(savedTheme))
+ const result = loadFromStorage()
+ expect(result).toEqual(savedTheme)
+ })
+ })
+
+ describe('[(function)saveToStorage]', () => {
+ test('has correct return value', () => {
+ const theme = { primary: '#FF0000' }
+ const result = saveToStorage(theme)
+ expect(result).toBeUndefined() // saveToStorage returns void
+ })
+
+ test('saves theme to localStorage', () => {
+ const theme = { primary: '#FF0000', secondary: '#00FF00' }
+ saveToStorage(theme)
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ STORAGE_KEY,
+ JSON.stringify(theme)
+ )
+ })
+ })
+
+ describe('[(function)default]', () => {
+ test('can be used in a Vue Component', () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '
',
+ setup () {
+ const result = useThemeDesigner()
+ return { result }
+ }
+ })
+ )
+
+ expect(wrapper).toBeDefined()
+ expect(wrapper.vm.result).toBeTypeOf('object')
+ expect(wrapper.vm.result.theme).toBeDefined()
+ expect(wrapper.vm.result.cssVars).toBeDefined()
+ })
+
+ test('initializes with default theme', () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { theme } = useThemeDesigner()
+ return { theme }
+ }
+ })
+ )
+
+ Object.keys(defaultTheme).forEach(key => {
+ expect(wrapper.vm.theme[ key ]).toBe(defaultTheme[ key ])
+ })
+ })
+
+ test('setColor updates theme', async () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { theme, setColor } = useThemeDesigner()
+ return { theme, setColor }
+ }
+ })
+ )
+
+ wrapper.vm.setColor('primary', '#FF0000')
+ await nextTick()
+
+ expect(wrapper.vm.theme.primary).toBe('#FF0000')
+ })
+
+ test('resetTheme restores defaults', async () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { theme, setColor, resetTheme } = useThemeDesigner()
+ return { theme, setColor, resetTheme }
+ }
+ })
+ )
+
+ wrapper.vm.setColor('primary', '#FF0000')
+ await nextTick()
+ expect(wrapper.vm.theme.primary).toBe('#FF0000')
+
+ wrapper.vm.resetTheme()
+ await nextTick()
+ expect(wrapper.vm.theme.primary).toBe(defaultTheme.primary)
+ })
+
+ test('cssVars computed property generates correct format', () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { cssVars } = useThemeDesigner()
+ return { cssVars }
+ }
+ })
+ )
+
+ const vars = wrapper.vm.cssVars
+ expect(vars[ '--q-primary' ]).toBe(defaultTheme.primary)
+ expect(vars[ '--q-secondary' ]).toBe(defaultTheme.secondary)
+ expect(vars[ '--q-dark-page' ]).toBe(defaultTheme[ 'dark-page' ])
+ })
+
+ test('primaryContrast computed property returns contrast info', () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { primaryContrast } = useThemeDesigner()
+ return { primaryContrast }
+ }
+ })
+ )
+
+ expect(wrapper.vm.primaryContrast).toHaveProperty('whiteRatio')
+ expect(wrapper.vm.primaryContrast).toHaveProperty('passesWhiteAA')
+ })
+
+ test('toggleDarkMode toggles isDarkMode', async () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { isDarkMode, toggleDarkMode } = useThemeDesigner()
+ return { isDarkMode, toggleDarkMode }
+ }
+ })
+ )
+
+ expect(wrapper.vm.isDarkMode).toBe(false)
+
+ wrapper.vm.toggleDarkMode()
+ await nextTick()
+ expect(wrapper.vm.isDarkMode).toBe(true)
+
+ wrapper.vm.toggleDarkMode()
+ await nextTick()
+ expect(wrapper.vm.isDarkMode).toBe(false)
+ })
+
+ test('openExportDialog and closeExportDialog toggle showExportDialog', async () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { showExportDialog, openExportDialog, closeExportDialog } = useThemeDesigner()
+ return { showExportDialog, openExportDialog, closeExportDialog }
+ }
+ })
+ )
+
+ expect(wrapper.vm.showExportDialog).toBe(false)
+
+ wrapper.vm.openExportDialog()
+ await nextTick()
+ expect(wrapper.vm.showExportDialog).toBe(true)
+
+ wrapper.vm.closeExportDialog()
+ await nextTick()
+ expect(wrapper.vm.showExportDialog).toBe(false)
+ })
+
+ test('exportFormats computed property returns all formats', () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { exportFormats } = useThemeDesigner()
+ return { exportFormats }
+ }
+ })
+ )
+
+ expect(wrapper.vm.exportFormats).toHaveProperty('sass')
+ expect(wrapper.vm.exportFormats).toHaveProperty('css')
+ expect(wrapper.vm.exportFormats).toHaveProperty('quasarConfig')
+ expect(wrapper.vm.exportFormats).toHaveProperty('vitePlugin')
+ })
+
+ test('saves theme to localStorage on change', async () => {
+ const wrapper = mount(
+ defineComponent({
+ template: '',
+ setup () {
+ const { theme, setColor } = useThemeDesigner()
+ return { theme, setColor }
+ }
+ })
+ )
+
+ wrapper.vm.setColor('primary', '#FF0000')
+ await nextTick()
+
+ // Wait for the watch to trigger
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ STORAGE_KEY,
+ expect.stringContaining('#FF0000')
+ )
+ })
+ })
+ })
+})
diff --git a/ui/src/css/index.sass b/ui/src/css/index.sass
index 7fbc11a1a81..1c7a8d4d3d6 100644
--- a/ui/src/css/index.sass
+++ b/ui/src/css/index.sass
@@ -48,6 +48,7 @@
@import '../components/pagination/QPagination.sass'
@import '../components/parallax/QParallax.sass'
@import '../components/popup-edit/QPopupEdit.sass'
+@import '../components/q-theme-designer/QThemeDesigner.sass'
@import '../components/pull-to-refresh/QPullToRefresh.sass'
@import '../components/radio/QRadio.sass'
@import '../components/rating/QRating.sass'
diff --git a/ui/src/json/themeSerializer.js b/ui/src/json/themeSerializer.js
new file mode 100644
index 00000000000..758e76543d1
--- /dev/null
+++ b/ui/src/json/themeSerializer.js
@@ -0,0 +1,159 @@
+/**
+ * Theme Serializer
+ *
+ * Generates export formats for Quasar theme colors:
+ * - SASS variables
+ * - CSS variables
+ * - Quasar CLI config (quasar.config.ts)
+ * - Quasar Vite Plugin config
+ */
+
+/**
+ * Default Quasar brand colors
+ */
+export const defaultTheme = {
+ primary: '#1976D2',
+ secondary: '#26A69A',
+ accent: '#9C27B0',
+ positive: '#21BA45',
+ negative: '#C10015',
+ info: '#31CCEC',
+ warning: '#F2C037',
+ dark: '#1D1D1D',
+ 'dark-page': '#121212'
+}
+
+/**
+ * Color token labels for display
+ */
+export const colorLabels = {
+ primary: 'Primary',
+ secondary: 'Secondary',
+ accent: 'Accent',
+ positive: 'Positive',
+ negative: 'Negative',
+ info: 'Info',
+ warning: 'Warning',
+ dark: 'Dark',
+ 'dark-page': 'Dark Page'
+}
+
+/**
+ * Generate SASS variables format
+ * @param {Object} theme - Theme colors object
+ * @returns {string} SASS variables string
+ */
+export function toSass (theme) {
+ const lines = [
+ '// Quasar SASS Variables',
+ '// Copy this to your src/css/quasar.variables.sass file',
+ ''
+ ]
+
+ for (const [ key, value ] of Object.entries(theme)) {
+ lines.push(`$${ key }: ${ value }`)
+ }
+
+ return lines.join('\n')
+}
+
+/**
+ * Generate CSS variables format
+ * @param {Object} theme - Theme colors object
+ * @returns {string} CSS variables string
+ */
+export function toCss (theme) {
+ const lines = [
+ '/* Quasar CSS Variables */',
+ '/* Add this to your global CSS or :root selector */',
+ '',
+ ':root {'
+ ]
+
+ for (const [ key, value ] of Object.entries(theme)) {
+ lines.push(` --q-${ key }: ${ value };`)
+ }
+
+ lines.push('}')
+
+ return lines.join('\n')
+}
+
+/**
+ * Generate Quasar CLI config format (quasar.config.ts)
+ * @param {Object} theme - Theme colors object
+ * @returns {string} Quasar CLI config snippet
+ */
+export function toQuasarConfig (theme) {
+ const brandLines = []
+
+ for (const [ key, value ] of Object.entries(theme)) {
+ // Convert 'dark-page' to 'dark-page' (keep as-is for brand config)
+ brandLines.push(` '${ key }': '${ value }'`)
+ }
+
+ return `// quasar.config.ts
+// Add this to your Quasar config file
+
+export default defineConfig({
+ framework: {
+ config: {
+ brand: {
+${ brandLines.join(',\n') }
+ }
+ }
+ }
+})`
+}
+
+/**
+ * Generate Quasar Vite Plugin config format
+ * @param {Object} theme - Theme colors object
+ * @returns {string} Vite plugin config snippet
+ */
+export function toVitePlugin (theme) {
+ const sassLines = []
+
+ for (const [ key, value ] of Object.entries(theme)) {
+ sassLines.push(` $${ key }: '${ value }'`)
+ }
+
+ return `// vite.config.ts
+// Add this to your Vite config file
+
+import { quasar } from '@quasar/vite-plugin'
+
+export default defineConfig({
+ plugins: [
+ quasar({
+ sassVariables: {
+${ sassLines.join(',\n') }
+ }
+ })
+ ]
+})`
+}
+
+/**
+ * Generate all export formats
+ * @param {Object} theme - Theme colors object
+ * @returns {Object} Object with all format strings
+ */
+export function serializeTheme (theme) {
+ return {
+ sass: toSass(theme),
+ css: toCss(theme),
+ quasarConfig: toQuasarConfig(theme),
+ vitePlugin: toVitePlugin(theme)
+ }
+}
+
+export default {
+ defaultTheme,
+ colorLabels,
+ toSass,
+ toCss,
+ toQuasarConfig,
+ toVitePlugin,
+ serializeTheme
+}
diff --git a/ui/src/json/themeSerializer.test.js b/ui/src/json/themeSerializer.test.js
new file mode 100644
index 00000000000..06e68f2043d
--- /dev/null
+++ b/ui/src/json/themeSerializer.test.js
@@ -0,0 +1,180 @@
+import { describe, test, expect } from 'vitest'
+
+import {
+ defaultTheme,
+ colorLabels,
+ toSass,
+ toCss,
+ toQuasarConfig,
+ toVitePlugin,
+ serializeTheme
+} from './themeSerializer.js'
+
+describe('[themeSerializer API]', () => {
+ describe('[Constants]', () => {
+ describe('[defaultTheme]', () => {
+ test('contains all 9 color tokens', () => {
+ const expectedTokens = [
+ 'primary', 'secondary', 'accent',
+ 'positive', 'negative', 'info', 'warning',
+ 'dark', 'dark-page'
+ ]
+
+ expectedTokens.forEach(token => {
+ expect(defaultTheme).toHaveProperty(token)
+ expect(defaultTheme[ token ]).toMatch(/^#[0-9A-Fa-f]{6}$/)
+ })
+ })
+
+ test('has correct default values', () => {
+ expect(defaultTheme.primary).toBe('#1976D2')
+ expect(defaultTheme.secondary).toBe('#26A69A')
+ expect(defaultTheme.accent).toBe('#9C27B0')
+ expect(defaultTheme.positive).toBe('#21BA45')
+ expect(defaultTheme.negative).toBe('#C10015')
+ expect(defaultTheme.info).toBe('#31CCEC')
+ expect(defaultTheme.warning).toBe('#F2C037')
+ expect(defaultTheme.dark).toBe('#1D1D1D')
+ expect(defaultTheme[ 'dark-page' ]).toBe('#121212')
+ })
+ })
+
+ describe('[colorLabels]', () => {
+ test('contains labels for all 9 color tokens', () => {
+ expect(Object.keys(colorLabels)).toHaveLength(9)
+
+ expect(colorLabels.primary).toBe('Primary')
+ expect(colorLabels.secondary).toBe('Secondary')
+ expect(colorLabels.accent).toBe('Accent')
+ expect(colorLabels[ 'dark-page' ]).toBe('Dark Page')
+ })
+ })
+ })
+
+ describe('[Functions]', () => {
+ const testTheme = {
+ primary: '#FF0000',
+ secondary: '#00FF00',
+ accent: '#0000FF',
+ positive: '#00FF00',
+ negative: '#FF0000',
+ info: '#0000FF',
+ warning: '#FFFF00',
+ dark: '#111111',
+ 'dark-page': '#000000'
+ }
+
+ describe('[(function)toSass]', () => {
+ test('generates valid SASS variables', () => {
+ const result = toSass(testTheme)
+
+ expect(result).toContain('$primary: #FF0000')
+ expect(result).toContain('$secondary: #00FF00')
+ expect(result).toContain('$accent: #0000FF')
+ expect(result).toContain('$dark-page: #000000')
+ })
+
+ test('includes header comments', () => {
+ const result = toSass(testTheme)
+
+ expect(result).toContain('// Quasar SASS Variables')
+ expect(result).toContain('quasar.variables.sass')
+ })
+ })
+
+ describe('[(function)toCss]', () => {
+ test('generates valid CSS variables', () => {
+ const result = toCss(testTheme)
+
+ expect(result).toContain('--q-primary: #FF0000;')
+ expect(result).toContain('--q-secondary: #00FF00;')
+ expect(result).toContain('--q-dark-page: #000000;')
+ })
+
+ test('wraps variables in :root selector', () => {
+ const result = toCss(testTheme)
+
+ expect(result).toContain(':root {')
+ expect(result).toContain('}')
+ })
+
+ test('includes header comments', () => {
+ const result = toCss(testTheme)
+
+ expect(result).toContain('/* Quasar CSS Variables */')
+ })
+ })
+
+ describe('[(function)toQuasarConfig]', () => {
+ test('generates valid Quasar CLI config', () => {
+ const result = toQuasarConfig(testTheme)
+
+ expect(result).toContain("'primary': '#FF0000'")
+ expect(result).toContain("'secondary': '#00FF00'")
+ expect(result).toContain("'dark-page': '#000000'")
+ })
+
+ test('includes framework.config.brand structure', () => {
+ const result = toQuasarConfig(testTheme)
+
+ expect(result).toContain('framework: {')
+ expect(result).toContain('config: {')
+ expect(result).toContain('brand: {')
+ })
+
+ test('includes header comment', () => {
+ const result = toQuasarConfig(testTheme)
+
+ expect(result).toContain('// quasar.config.ts')
+ })
+ })
+
+ describe('[(function)toVitePlugin]', () => {
+ test('generates valid Vite plugin config', () => {
+ const result = toVitePlugin(testTheme)
+
+ expect(result).toContain("$primary: '#FF0000'")
+ expect(result).toContain("$secondary: '#00FF00'")
+ expect(result).toContain("$dark-page: '#000000'")
+ })
+
+ test('includes sassVariables structure', () => {
+ const result = toVitePlugin(testTheme)
+
+ expect(result).toContain('quasar({')
+ expect(result).toContain('sassVariables: {')
+ })
+
+ test('includes header comment', () => {
+ const result = toVitePlugin(testTheme)
+
+ expect(result).toContain('// vite.config.ts')
+ })
+ })
+
+ describe('[(function)serializeTheme]', () => {
+ test('returns object with all 4 formats', () => {
+ const result = serializeTheme(testTheme)
+
+ expect(result).toHaveProperty('sass')
+ expect(result).toHaveProperty('css')
+ expect(result).toHaveProperty('quasarConfig')
+ expect(result).toHaveProperty('vitePlugin')
+ })
+
+ test('each format is a non-empty string', () => {
+ const result = serializeTheme(testTheme)
+
+ expect(typeof result.sass).toBe('string')
+ expect(typeof result.css).toBe('string')
+ expect(typeof result.quasarConfig).toBe('string')
+ expect(typeof result.vitePlugin).toBe('string')
+
+ expect(result.sass.length).toBeGreaterThan(0)
+ expect(result.css.length).toBeGreaterThan(0)
+ expect(result.quasarConfig.length).toBeGreaterThan(0)
+ expect(result.vitePlugin.length).toBeGreaterThan(0)
+ })
+ })
+ })
+})