diff --git a/ui/src/__tests__/quoteFormatters.test.js b/ui/src/__tests__/quoteFormatters.test.js index 0d02ce94..95005c87 100644 --- a/ui/src/__tests__/quoteFormatters.test.js +++ b/ui/src/__tests__/quoteFormatters.test.js @@ -319,7 +319,9 @@ describe('quoteFormatters', () => { for (const t of TAB_TYPES) { expect(t.icon).toMatch(/^mdi-/) expect(t.listField).toMatch(/s$/) - expect(t.color).toMatch(/^#[0-9A-F]{6}$/i) + // Theme-token expression (no hard-coded hex) so the donut and + // legend follow the active Vuetify theme. + expect(t.color).toMatch(/^rgb\(var\(--v-theme-[a-z-]+\)\)$/) } }) }) diff --git a/ui/src/quoteFormatters.js b/ui/src/quoteFormatters.js index 429a289d..fb1f8bda 100644 --- a/ui/src/quoteFormatters.js +++ b/ui/src/quoteFormatters.js @@ -92,17 +92,19 @@ export function scaleCost(cost, period) { /** * Tab metadata — drives the v-tabs/v-window structure of QuoteView AND - * the cost-breakdown donut. The colour is a fixed hex picked from - * Vuetify's default palette so plugin-prov stays theme-agnostic (the - * donut renders inside its own SVG, outside Vuetify's CSS chrome). + * the cost-breakdown donut. Colours are Vuetify semantic theme tokens + * (CSS variable expressions) so the donut follows the active theme — + * they must be applied through inline `style` bindings (`fill` / + * `background-color`), not SVG presentation attributes, because + * attribute values don't resolve `var()`. */ export const TAB_TYPES = [ - { key: 'instance', icon: 'mdi-server', listField: 'instances', color: '#1976D2' }, - { key: 'database', icon: 'mdi-database', listField: 'databases', color: '#388E3C' }, - { key: 'container', icon: 'mdi-docker', listField: 'containers', color: '#00ACC1' }, - { key: 'function', icon: 'mdi-lambda', listField: 'functions', color: '#7B1FA2' }, - { key: 'storage', icon: 'mdi-harddisk', listField: 'storages', color: '#F57C00' }, - { key: 'support', icon: 'mdi-lifebuoy', listField: 'supports', color: '#D32F2F' }, + { key: 'instance', icon: 'mdi-server', listField: 'instances', color: 'rgb(var(--v-theme-primary))' }, + { key: 'database', icon: 'mdi-database', listField: 'databases', color: 'rgb(var(--v-theme-success))' }, + { key: 'container', icon: 'mdi-docker', listField: 'containers', color: 'rgb(var(--v-theme-info))' }, + { key: 'function', icon: 'mdi-lambda', listField: 'functions', color: 'rgb(var(--v-theme-secondary))' }, + { key: 'storage', icon: 'mdi-harddisk', listField: 'storages', color: 'rgb(var(--v-theme-warning))' }, + { key: 'support', icon: 'mdi-lifebuoy', listField: 'supports', color: 'rgb(var(--v-theme-error))' }, ] /** diff --git a/ui/src/views/QuoteBreakdown.vue b/ui/src/views/QuoteBreakdown.vue index 39dda834..31ec7706 100644 --- a/ui/src/views/QuoteBreakdown.vue +++ b/ui/src/views/QuoteBreakdown.vue @@ -12,14 +12,17 @@
+ @@ -71,9 +74,11 @@ const props = defineProps({ const { t } = useI18nStore() -const size = 140 -const r = 60 -const ri = 36 +/* Slightly larger donut with a thinner ring — reads as a modern + * "annulus + centred KPI" rather than the legacy pie. */ +const size = 168 +const r = 76 +const ri = 57 const cx = size / 2 const cy = size / 2 @@ -99,13 +104,17 @@ const DRILL_DIMENSIONS = { storage: { field: 'type', getter: (r) => r.price?.type?.name || r.price?.type?.code }, } -/* Sub-segment palette. Picked to be distinct against any of the - * top-level colors so the eye can tell drilled-down from root. */ -const SUB_PALETTE = [ - '#1976D2', '#0288D1', '#00897B', '#43A047', '#7CB342', - '#C0CA33', '#FDD835', '#FFB300', '#FB8C00', '#F4511E', - '#E53935', '#D81B60', '#8E24AA', '#5E35B1', '#3949AB', -] +/* Sub-segment palette, derived from the theme's semantic tokens so the + * drilled view follows the active theme like everything else. The first + * pass cycles the 6 semantic colours pure, later passes soften them + * towards the surface — enough contrast between neighbours without any + * hard-coded colour. */ +const SUB_BASES = ['primary', 'info', 'success', 'warning', 'error', 'secondary'] +const SUB_PALETTE = [100, 70, 45].flatMap((mix) => + SUB_BASES.map((base) => mix === 100 + ? `rgb(var(--v-theme-${base}))` + : `color-mix(in srgb, rgb(var(--v-theme-${base})) ${mix}%, rgb(var(--v-theme-surface)))`), +) function isDrillable(key) { return !!DRILL_DIMENSIONS[key] @@ -126,7 +135,7 @@ watch(() => props.config, () => { drill.value = null }) const parentColor = computed(() => { const tab = TAB_TYPES.find((t) => t.key === drill.value) - return tab ? tab.color : '#888' + return tab ? tab.color : 'rgba(var(--v-theme-on-surface), .5)' }) const drillTitle = computed(() => { @@ -235,36 +244,62 @@ defineExpose({ drill, isDrillable, onSliceClick, breakdown, subBreakdown, active diff --git a/ui/src/views/QuoteView.vue b/ui/src/views/QuoteView.vue index aeef1f9b..122b14da 100644 --- a/ui/src/views/QuoteView.vue +++ b/ui/src/views/QuoteView.vue @@ -1,81 +1,94 @@
+
{{ error }}