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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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) + }) + }) + }) +})