[READ-ONLY] a fast, modern browser for the npm registry

fix: use oklch colours in charts (#278)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Alec Lloyd Probert
Daniel Roe
and committed by
GitHub
f0cf42e2 3d3d04e8

+466 -122
+118 -106
app/components/PackageDownloadAnalytics.vue
··· 3 3 import type { VueUiXyDatasetItem } from 'vue-data-ui' 4 4 import { VueUiXy } from 'vue-data-ui/vue-ui-xy' 5 5 import { useDebounceFn, useElementSize } from '@vueuse/core' 6 + import { useCssVariables } from '../composables/useColors' 7 + import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch } from '../utils/colors' 6 8 7 9 const { 8 10 weeklyDownloads, ··· 24 26 const { width } = useElementSize(rootEl) 25 27 26 28 onMounted(() => { 29 + rootEl.value = document.documentElement 27 30 resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' 28 31 }) 29 32 33 + const { colors } = useCssVariables( 34 + ['--bg', '--bg-subtle', '--bg-elevated', '--fg-subtle', '--border', '--border-subtle'], 35 + { 36 + element: rootEl, 37 + watchHtmlAttributes: true, 38 + watchResize: false, // set to true only if a var changes color on resize 39 + }, 40 + ) 41 + 30 42 watch( 31 43 () => colorMode.value, 32 44 value => { ··· 49 61 50 62 const accent = computed(() => { 51 63 const id = selectedAccentColor.value 52 - return id ? (oklchToHex(accentColorValueById.value[id]!) ?? '#8A8A8A') : '#8A8A8A' 64 + return id 65 + ? (accentColorValueById.value[id] ?? colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK) 66 + : (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK) 53 67 }) 54 68 55 69 const mobileBreakpointWidth = 640 56 70 57 71 const isMobile = computed(() => { 58 72 return width.value > 0 && width.value < mobileBreakpointWidth 59 - }) 60 - 61 - onMounted(() => { 62 - rootEl.value = document.documentElement 63 73 }) 64 74 65 75 type ChartTimeGranularity = 'daily' | 'weekly' | 'monthly' | 'yearly' ··· 444 454 a.remove() 445 455 } 446 456 447 - const config = computed(() => ({ 448 - theme: isDarkMode.value ? 'dark' : 'default', 449 - chart: { 450 - height: isMobile.value ? 850 : 600, 451 - userOptions: { 452 - buttons: { 453 - pdf: false, 454 - labels: false, 455 - fullscreen: false, 456 - table: false, 457 - tooltip: false, 458 - }, 459 - callbacks: { 460 - img: ({ imageUri }: { imageUri: string }) => { 461 - loadFile( 462 - imageUri, 463 - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.png`, 464 - ) 457 + const config = computed(() => { 458 + return { 459 + theme: isDarkMode.value ? 'dark' : 'default', 460 + chart: { 461 + height: isMobile.value ? 850 : 600, 462 + userOptions: { 463 + buttons: { 464 + pdf: false, 465 + labels: false, 466 + fullscreen: false, 467 + table: false, 468 + tooltip: false, 465 469 }, 466 - csv: (csvStr: string) => { 467 - const blob = new Blob([csvStr.replace('data:text/csv;charset=utf-8,', '')]) 468 - const url = URL.createObjectURL(blob) 469 - loadFile( 470 - url, 471 - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.csv`, 472 - ) 473 - URL.revokeObjectURL(url) 474 - }, 475 - svg: ({ blob }: { blob: Blob }) => { 476 - const url = URL.createObjectURL(blob) 477 - loadFile( 478 - url, 479 - `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.svg`, 480 - ) 481 - URL.revokeObjectURL(url) 470 + callbacks: { 471 + img: ({ imageUri }: { imageUri: string }) => { 472 + loadFile( 473 + imageUri, 474 + `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.png`, 475 + ) 476 + }, 477 + csv: (csvStr: string) => { 478 + const blob = new Blob([csvStr.replace('data:text/csv;charset=utf-8,', '')]) 479 + const url = URL.createObjectURL(blob) 480 + loadFile( 481 + url, 482 + `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.csv`, 483 + ) 484 + URL.revokeObjectURL(url) 485 + }, 486 + svg: ({ blob }: { blob: Blob }) => { 487 + const url = URL.createObjectURL(blob) 488 + loadFile( 489 + url, 490 + `${packageName}-${selectedGranularity.value}_${startDate.value}_${endDate.value}.svg`, 491 + ) 492 + URL.revokeObjectURL(url) 493 + }, 482 494 }, 483 495 }, 484 - }, 485 - backgroundColor: isDarkMode.value ? '#0A0A0A' : '#FFFFFF', 486 - grid: { 487 - stroke: isDarkMode.value ? '#4A4A4A' : '#a3a3a3', 488 - labels: { 489 - axis: { 490 - yLabel: $t('package.downloads.y_axis_label', { 491 - granularity: $t(`package.downloads.granularity_${selectedGranularity.value}`), 492 - }), 493 - xLabel: packageName, 494 - yLabelOffsetX: 12, 495 - fontSize: 24, 496 - }, 497 - xAxisLabels: { 498 - values: chartData.value?.dates, 499 - showOnlyAtModulo: true, 500 - modulo: 12, 501 - }, 502 - yAxis: { 503 - formatter, 504 - useNiceScale: true, 496 + backgroundColor: colors.value.bg, 497 + grid: { 498 + stroke: colors.value.border, 499 + labels: { 500 + axis: { 501 + yLabel: $t('package.downloads.y_axis_label', { 502 + granularity: $t(`package.downloads.granularity_${selectedGranularity.value}`), 503 + }), 504 + xLabel: packageName, 505 + yLabelOffsetX: 12, 506 + fontSize: 24, 507 + }, 508 + xAxisLabels: { 509 + values: chartData.value?.dates, 510 + showOnlyAtModulo: true, 511 + modulo: 12, 512 + }, 513 + yAxis: { 514 + formatter, 515 + useNiceScale: true, 516 + }, 505 517 }, 506 518 }, 507 - }, 508 - highlighter: { 509 - useLine: true, 510 - }, 511 - legend: { 512 - show: false, // As long as a single package is displayed 513 - }, 514 - tooltip: { 515 - borderColor: 'transparent', 516 - backdropFilter: false, 517 - backgroundColor: 'transparent', 518 - customFormat: ({ 519 - absoluteIndex, 520 - datapoint, 521 - }: { 522 - absoluteIndex: number 523 - datapoint: Record<string, any> 524 - }) => { 525 - if (!datapoint) return '' 526 - const displayValue = formatter({ value: datapoint[0]?.value ?? 0 }) 527 - return `<div class="flex flex-col font-mono text-xs p-3 border border-border rounded-md bg-white/10 dark:bg-[#0A0A0A]/10 backdrop-blur-md"> 519 + highlighter: { 520 + useLine: true, 521 + }, 522 + legend: { 523 + show: false, // As long as a single package is displayed 524 + }, 525 + tooltip: { 526 + borderColor: 'transparent', 527 + backdropFilter: false, 528 + backgroundColor: 'transparent', 529 + customFormat: ({ 530 + absoluteIndex, 531 + datapoint, 532 + }: { 533 + absoluteIndex: number 534 + datapoint: Record<string, any> 535 + }) => { 536 + if (!datapoint) return '' 537 + const displayValue = formatter({ value: datapoint[0]?.value ?? 0 }) 538 + return `<div class="flex flex-col font-mono text-xs p-3 border border-border rounded-md bg-[var(--bg)]/10 backdrop-blur-md"> 528 539 <span class="text-fg-subtle">${chartData.value?.dates[absoluteIndex]}</span> 529 540 <span class="text-xl">${displayValue}</span> 530 541 </div> 531 542 ` 543 + }, 532 544 }, 533 - }, 534 - zoom: { 535 - maxWidth: 500, 536 - customFormat: 537 - displayedGranularity.value !== 'weekly' 538 - ? undefined 539 - : ({ absoluteIndex, side }: { absoluteIndex: number; side: 'left' | 'right' }) => { 540 - const parts = extractDates(chartData.value.dates[absoluteIndex] ?? '') 541 - return side === 'left' ? parts[0] : parts.at(-1) 542 - }, 543 - highlightColor: isDarkMode.value ? '#2A2A2A' : '#E1E5E8', 544 - minimap: { 545 - show: true, 546 - lineColor: '#FAFAFA', 547 - selectedColor: accent.value, 548 - selectedColorOpacity: 0.06, 549 - frameColor: isDarkMode.value ? '#3A3A3A' : '#a3a3a3', 550 - }, 551 - preview: { 552 - fill: accent.value + 10, 553 - stroke: accent.value + 60, 554 - strokeWidth: 1, 555 - strokeDasharray: 3, 545 + zoom: { 546 + maxWidth: 500, 547 + customFormat: 548 + displayedGranularity.value !== 'weekly' 549 + ? undefined 550 + : ({ absoluteIndex, side }: { absoluteIndex: number; side: 'left' | 'right' }) => { 551 + const parts = extractDates(chartData.value.dates[absoluteIndex] ?? '') 552 + return side === 'left' ? parts[0] : parts.at(-1) 553 + }, 554 + highlightColor: colors.value.bgElevated, 555 + minimap: { 556 + show: true, 557 + lineColor: '#FAFAFA', 558 + selectedColor: accent.value, 559 + selectedColorOpacity: 0.06, 560 + frameColor: colors.value.border, 561 + }, 562 + preview: { 563 + fill: transparentizeOklch(accent.value, isDarkMode.value ? 0.95 : 0.92), 564 + stroke: transparentizeOklch(accent.value, 0.5), 565 + strokeWidth: 1, 566 + strokeDasharray: 3, 567 + }, 556 568 }, 557 569 }, 558 - }, 559 - })) 570 + } 571 + }) 560 572 </script> 561 573 562 574 <template>
+34 -9
app/components/PackageWeeklyDownloadStats.vue
··· 1 1 <script setup lang="ts"> 2 2 import { ref, computed, onMounted, watch } from 'vue' 3 3 import { VueUiSparkline } from 'vue-data-ui/vue-ui-sparkline' 4 + import { useCssVariables } from '../composables/useColors' 5 + import { OKLCH_NEUTRAL_FALLBACK, lightenOklch } from '../utils/colors' 4 6 5 7 const { packageName } = defineProps<{ 6 8 packageName: string ··· 19 21 20 22 const resolvedMode = ref<'light' | 'dark'>('light') 21 23 24 + const rootEl = shallowRef<HTMLElement | null>(null) 25 + 22 26 onMounted(() => { 27 + rootEl.value = document.documentElement 23 28 resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' 24 29 }) 25 30 ··· 31 36 { flush: 'sync' }, 32 37 ) 33 38 39 + const { colors } = useCssVariables( 40 + [ 41 + '--bg', 42 + '--fg', 43 + '--bg-subtle', 44 + '--bg-elevated', 45 + '--border-hover', 46 + '--fg-subtle', 47 + '--border', 48 + '--border-subtle', 49 + ], 50 + { 51 + element: rootEl, 52 + watchHtmlAttributes: true, 53 + watchResize: false, // set to true only if a var changes color on resize 54 + }, 55 + ) 56 + 34 57 const isDarkMode = computed(() => resolvedMode.value === 'dark') 35 58 36 59 const accentColorValueById = computed<Record<string, string>>(() => { ··· 43 66 44 67 const accent = computed(() => { 45 68 const id = selectedAccentColor.value 46 - return id ? (oklchToHex(accentColorValueById.value[id]!) ?? '#8A8A8A') : '#8A8A8A' 69 + return id 70 + ? (accentColorValueById.value[id] ?? colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK) 71 + : (colors.value.fgSubtle ?? OKLCH_NEUTRAL_FALLBACK) 47 72 }) 48 73 49 74 const pulseColor = computed(() => { 50 75 if (!selectedAccentColor.value) { 51 - return isDarkMode.value ? '#BFBFBF' : '#E0E0E0' 76 + return colors.value.fgSubtle 52 77 } 53 - return isDarkMode.value ? accent.value : lightenHex(accent.value, 0.5) 78 + return isDarkMode.value ? accent.value : lightenOklch(accent.value, 0.5) 54 79 }) 55 80 56 81 const weeklyDownloads = ref<WeeklyDownloadPoint[]>([]) ··· 99 124 backgroundColor: 'transparent', 100 125 animation: { show: false }, 101 126 area: { 102 - color: '#6A6A6A', 127 + color: colors.value.borderHover, 103 128 useGradient: false, 104 129 opacity: 10, 105 130 }, ··· 107 132 offsetX: -10, 108 133 fontSize: 28, 109 134 bold: false, 110 - color: isDarkMode.value ? '#8a8a8a' : '#696969', 135 + color: colors.value.fg, 111 136 }, 112 137 line: { 113 - color: isDarkMode.value ? '#4a4a4a' : '#525252', 138 + color: colors.value.borderHover, 114 139 pulse: { 115 140 show: true, 116 141 loop: true, // runs only once if false ··· 125 150 }, 126 151 plot: { 127 152 radius: 6, 128 - stroke: isDarkMode.value ? '#FAFAFA' : '#0A0A0A', 153 + stroke: isDarkMode.value ? 'oklch(0.985 0 0)' : 'oklch(0.145 0 0)', 129 154 }, 130 155 title: { 131 156 text: lastDatapoint.value, 132 157 fontSize: 12, 133 - color: isDarkMode.value ? '#8a8a8a' : '#696969', 158 + color: colors.value.fgSubtle, 134 159 bold: false, 135 160 }, 136 161 verticalIndicator: { 137 162 strokeDasharray: 0, 138 - color: isDarkMode.value ? '#FAFAFA' : '#525252', 163 + color: isDarkMode.value ? 'oklch(0.985 0 0)' : colors.value.fgSubtle, 139 164 }, 140 165 }, 141 166 }
+89
app/composables/useColors.ts
··· 1 + import { computed, type ComputedRef, type Ref, unref } from 'vue' 2 + import { useMutationObserver, useResizeObserver, useSupported } from '@vueuse/core' 3 + 4 + type CssVariableSource = HTMLElement | null | undefined | Ref<HTMLElement | null | undefined> 5 + 6 + type UseCssVariableOptions = { 7 + element?: CssVariableSource 8 + watchResize?: boolean 9 + watchHtmlAttributes?: boolean 10 + } 11 + 12 + function readCssVariable(element: HTMLElement, variableName: string): string { 13 + return getComputedStyle(element).getPropertyValue(variableName).trim() 14 + } 15 + 16 + function toCamelCase(cssVariable: string): string { 17 + return cssVariable.replace(/^--/, '').replace(/-([a-z0-9])/gi, (_, c) => c.toUpperCase()) 18 + } 19 + 20 + function resolveElement(element?: CssVariableSource): HTMLElement | null { 21 + if (typeof window === 'undefined' || typeof document === 'undefined') return null 22 + if (!element) return document.documentElement 23 + const resolved = unref(element) 24 + return resolved ?? document.documentElement 25 + } 26 + 27 + /** 28 + * Read multiple CSS custom properties at once and expose them as a reactive object. 29 + * 30 + * Each CSS variable name is normalized into a camelCase key: 31 + * - Leading `--` is removed 32 + * - kebab-case is converted to camelCase 33 + * 34 + * Example: 35 + * ```ts 36 + * useCssVariables(['--bg', '--fg-subtle']) 37 + * // => colors.value = { bg: '...', fgSubtle: '...' } 38 + * ``` 39 + * 40 + * The returned values are always resolved via `getComputedStyle`, meaning the 41 + * effective value is returned (after cascade, theme classes, etc.). 42 + * 43 + * Reactivity behavior: 44 + * - Updates automatically when the observed element changes 45 + * - Can react to theme toggles via `watchHtmlAttributes` 46 + * - Can react to responsive CSS variables via `watchResize` 47 + * 48 + * @param variables - List of CSS variable names (must include the leading `--`) 49 + * @param options - Configuration options 50 + * @param options.element - Element to read variables from (defaults to `:root`) 51 + * @param options.watchResize - Re-evaluate values on resize (useful for media-query-driven variables) 52 + * @param options.watchHtmlAttributes - Re-evaluate values when `<html>` attributes change 53 + * 54 + * @returns An object containing a reactive `colors` map, keyed by camelCase names 55 + */ 56 + export function useCssVariables( 57 + variables: readonly string[], 58 + options: UseCssVariableOptions = {}, 59 + ): { colors: ComputedRef<Record<string, string>> } { 60 + const isClientSupported = useSupported( 61 + () => typeof window !== 'undefined' && typeof document !== 'undefined', 62 + ) 63 + 64 + const elementComputed = computed(() => resolveElement(options.element)) 65 + 66 + const colors = computed<Record<string, string>>(() => { 67 + const element = elementComputed.value 68 + if (!element) return {} 69 + 70 + const result: Record<string, string> = {} 71 + for (const variable of variables) { 72 + result[toCamelCase(variable)] = readCssVariable(element, variable) 73 + } 74 + return result 75 + }) 76 + 77 + if (options.watchResize) { 78 + useResizeObserver(elementComputed, () => void colors.value) 79 + } 80 + 81 + if (options.watchHtmlAttributes && isClientSupported.value) { 82 + useMutationObserver(document.documentElement, () => void colors.value, { 83 + attributes: true, 84 + attributeFilter: ['class', 'style', 'data-theme'], 85 + }) 86 + } 87 + 88 + return { colors } 89 + }
+115
app/utils/colors.ts
··· 1 1 // Vue Data UI does not support CSS vars nor OKLCH for now 2 2 3 + /** 4 + * Default neutral OKLCH color used as fallback when CSS variables are unavailable (e.g., during SSR). 5 + * This matches the dark mode value of --fg-subtle defined in main.css. 6 + */ 7 + export const OKLCH_NEUTRAL_FALLBACK = 'oklch(0.633 0 0)' 8 + 3 9 /** Converts a 0-255 RGB component to a 2-digit hex string */ 4 10 const componentToHex = (value: number): string => 5 11 Math.round(Math.min(Math.max(0, value), 255)) ··· 82 88 83 89 return `#${linearToHex(linearToSrgb(r))}${linearToHex(linearToSrgb(g))}${linearToHex(linearToSrgb(bRgb))}` 84 90 } 91 + 92 + /** 93 + * Lighten an OKLCH color by a given factor. 94 + * 95 + * Works with strict TypeScript settings including `noUncheckedIndexedAccess`, 96 + * where `match[n]` is typed as `string | undefined`. 97 + * 98 + * @param oklch - Color in the form "oklch(L C H)" or "oklch(L C H / A)" 99 + * @param factor - Lightening force in range [0, 1] 100 + * @returns Lightened OKLCH color string (0.5 = 50% lighter) 101 + */ 102 + export function lightenOklch( 103 + oklch: string | null | undefined, 104 + factor: number, 105 + ): string | null | undefined { 106 + if (oklch == null) { 107 + return oklch 108 + } 109 + 110 + const input = oklch.trim() 111 + 112 + const match = input.match( 113 + /^oklch\(\s*([+-]?[\d.]+%?)\s+([+-]?[\d.]+)\s+([+-]?[\d.]+)(?:\s*\/\s*([+-]?[\d.]+%?))?\s*\)$/i, 114 + ) 115 + 116 + if (!match) { 117 + throw new Error('Invalid OKLCH color format') 118 + } 119 + 120 + const [, lightnessText, chromaText, hueText, alphaText] = match 121 + 122 + if (lightnessText === undefined || chromaText === undefined || hueText === undefined) { 123 + throw new Error('Invalid OKLCH color format') 124 + } 125 + 126 + let lightness = lightnessText.endsWith('%') 127 + ? Number.parseFloat(lightnessText) / 100 128 + : Number.parseFloat(lightnessText) 129 + let chroma = Number.parseFloat(chromaText) 130 + const hue = Number.parseFloat(hueText) 131 + const alpha = 132 + alphaText === undefined 133 + ? null 134 + : alphaText.endsWith('%') 135 + ? Number.parseFloat(alphaText) / 100 136 + : Number.parseFloat(alphaText) 137 + 138 + const clampedFactor = Math.min(Math.max(factor, 0), 1) 139 + lightness = lightness + (1 - lightness) * clampedFactor 140 + 141 + // Reduce chroma slightly as lightness increases 142 + chroma = chroma * (1 - clampedFactor * 0.3) 143 + 144 + lightness = Math.min(Math.max(lightness, 0), 1) 145 + chroma = Math.max(chroma, 0) 146 + 147 + return alpha === null 148 + ? `oklch(${lightness} ${chroma} ${hue})` 149 + : `oklch(${lightness} ${chroma} ${hue} / ${alpha})` 150 + } 151 + 152 + /** 153 + * Make an OKLCH color transparent by a given factor. 154 + * 155 + * @param oklch - Color in the form "oklch(L C H)" or "oklch(L C H / A)" 156 + * @param factor - Transparency factor in range [0, 1] 157 + * @returns OKLCH color string with adjusted alpha (0.5 = 50% transparency, 1 = fully transparent) 158 + */ 159 + export function transparentizeOklch( 160 + oklch: string | null | undefined, 161 + factor: number, 162 + fallback = 'oklch(0 0 0 / 0)', 163 + ): string { 164 + if (oklch == null) return fallback 165 + 166 + const input = oklch.trim() 167 + if (!input) return fallback 168 + 169 + const match = input.match( 170 + /^oklch\(\s*([+-]?[\d.]+%?)\s+([+-]?[\d.]+)\s+([+-]?[\d.]+)(?:\s*\/\s*([+-]?[\d.]+%?))?\s*\)$/i, 171 + ) 172 + 173 + if (!match) return fallback 174 + 175 + const [, lightnessText, chromaText, hueText, alphaText] = match 176 + 177 + if (lightnessText === undefined || chromaText === undefined || hueText === undefined) { 178 + return fallback 179 + } 180 + 181 + const lightness = lightnessText.endsWith('%') 182 + ? Math.min(Math.max(Number.parseFloat(lightnessText) / 100, 0), 1) 183 + : Math.min(Math.max(Number.parseFloat(lightnessText), 0), 1) 184 + 185 + const chroma = Math.max(0, Number.parseFloat(chromaText)) 186 + const hue = Number.parseFloat(hueText) 187 + 188 + const originalAlpha = 189 + alphaText === undefined 190 + ? 1 191 + : alphaText.endsWith('%') 192 + ? Math.min(Math.max(Number.parseFloat(alphaText) / 100, 0), 1) 193 + : Math.min(Math.max(Number.parseFloat(alphaText), 0), 1) 194 + 195 + const clampedFactor = Math.min(Math.max(factor, 0), 1) 196 + const alpha = Math.max(0, originalAlpha * (1 - clampedFactor)) 197 + 198 + return `oklch(${lightness} ${chroma} ${hue} / ${alpha})` 199 + }
+1 -1
package.json
··· 58 58 "validate-npm-package-name": "^7.0.2", 59 59 "virtua": "^0.48.3", 60 60 "vue": "3.5.27", 61 - "vue-data-ui": "^3.13.5" 61 + "vue-data-ui": "^3.13.6" 62 62 }, 63 63 "devDependencies": { 64 64 "@iconify-json/carbon": "1.2.18",
+6 -6
pnpm-lock.yaml
··· 102 102 specifier: 3.5.27 103 103 version: 3.5.27(typescript@5.9.3) 104 104 vue-data-ui: 105 - specifier: ^3.13.5 106 - version: 3.13.5(vue@3.5.27(typescript@5.9.3)) 105 + specifier: ^3.13.6 106 + version: 3.13.6(vue@3.5.27(typescript@5.9.3)) 107 107 devDependencies: 108 108 '@iconify-json/carbon': 109 109 specifier: 1.2.18 ··· 1597 1597 resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} 1598 1598 1599 1599 '@lunariajs/core@https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3': 1600 - resolution: {tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3} 1600 + resolution: {integrity: sha512-gjrzNz3XLFOUrDcmBxgaaF8Flh2yDpn8/RQyt+kyQen3sCjT04BnqJXLweper+xWyfvpOFK4Cqmy3wHvTyC1ww==, tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@f07e1a3} 1601 1601 version: 0.1.1 1602 1602 engines: {node: '>=18.17.0'} 1603 1603 ··· 8833 8833 vue-component-type-helpers@3.2.4: 8834 8834 resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} 8835 8835 8836 - vue-data-ui@3.13.5: 8837 - resolution: {integrity: sha512-RBdhoKAlraMoTU3ls7tRy9UCusNwBCvN9w0wF2R0Jwy/AX7cAejlfDDMKE4SN/Th/4ox2eFIZSCZ/CRco3nOxw==} 8836 + vue-data-ui@3.13.6: 8837 + resolution: {integrity: sha512-cfyL9kP4bOpbby2wvgCJvsKyRv/5aQHw2+UZIZ5/Vm4lt7yZe41ZU9kMMryd911hC6vuYLUB88bcIGFg1toWAA==} 8838 8838 peerDependencies: 8839 8839 jspdf: '>=3.0.1' 8840 8840 vue: '>=3.3.0' ··· 19339 19339 19340 19340 vue-component-type-helpers@3.2.4: {} 19341 19341 19342 - vue-data-ui@3.13.5(vue@3.5.27(typescript@5.9.3)): 19342 + vue-data-ui@3.13.6(vue@3.5.27(typescript@5.9.3)): 19343 19343 dependencies: 19344 19344 vue: 3.5.27(typescript@5.9.3) 19345 19345
+103
test/nuxt/composables/use-colors.spec.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest' 2 + import { computed, nextTick, defineComponent } from 'vue' 3 + import { mount } from '@vue/test-utils' 4 + import type * as VueUseCore from '@vueuse/core' 5 + 6 + const useSupportedMock = vi.hoisted(() => vi.fn()) 7 + const useMutationObserverMock = vi.hoisted(() => vi.fn()) 8 + const useResizeObserverMock = vi.hoisted(() => vi.fn()) 9 + 10 + let lastMutationObserverInstance: { 11 + observe: ReturnType<typeof vi.fn> 12 + disconnect: ReturnType<typeof vi.fn> 13 + takeRecords: ReturnType<typeof vi.fn> 14 + } | null = null 15 + 16 + const mutationObserverConstructorMock = vi.hoisted(() => 17 + vi.fn(function MutationObserver() { 18 + lastMutationObserverInstance = { 19 + observe: vi.fn(), 20 + disconnect: vi.fn(), 21 + takeRecords: vi.fn(), 22 + } 23 + return lastMutationObserverInstance 24 + }), 25 + ) 26 + 27 + vi.mock('@vueuse/core', async () => { 28 + const actual = await vi.importActual<typeof VueUseCore>('@vueuse/core') 29 + return { 30 + ...actual, 31 + useSupported: useSupportedMock, 32 + useMutationObserver: useMutationObserverMock, 33 + useResizeObserver: useResizeObserverMock, 34 + } 35 + }) 36 + 37 + function mockComputedStyle(values: Record<string, string>) { 38 + vi.stubGlobal('getComputedStyle', () => { 39 + return { 40 + getPropertyValue: (name: string) => values[name] ?? '', 41 + } as any 42 + }) 43 + } 44 + 45 + function mountWithSetup(run: () => void) { 46 + return mount( 47 + defineComponent({ 48 + name: 'TestHarness', 49 + setup() { 50 + run() 51 + return () => null 52 + }, 53 + }), 54 + { attachTo: document.body }, 55 + ) 56 + } 57 + 58 + describe('useCssVariables', () => { 59 + beforeEach(() => { 60 + vi.clearAllMocks() 61 + vi.resetModules() 62 + lastMutationObserverInstance = null 63 + vi.stubGlobal('MutationObserver', mutationObserverConstructorMock as any) 64 + }) 65 + 66 + it('does not attach html mutation observer when client is not supported', async () => { 67 + const { useCssVariables } = await import('../../../app/composables/useColors') 68 + 69 + useSupportedMock.mockReturnValueOnce(computed(() => false)) 70 + mockComputedStyle({ '--bg': 'oklch(1 0 0)' }) 71 + 72 + const wrapper = mountWithSetup(() => { 73 + const { colors } = useCssVariables(['--bg'], { watchHtmlAttributes: true }) 74 + expect(colors.value.bg).toBe('oklch(1 0 0)') 75 + }) 76 + 77 + await nextTick() 78 + 79 + expect(useMutationObserverMock).not.toHaveBeenCalled() 80 + expect(lastMutationObserverInstance).not.toBeNull() 81 + expect(lastMutationObserverInstance!.observe).toHaveBeenCalledTimes(1) 82 + 83 + wrapper.unmount() 84 + }) 85 + 86 + it('attaches html mutation observer when client is supported', async () => { 87 + const { useCssVariables } = await import('../../../app/composables/useColors') 88 + 89 + useSupportedMock.mockReturnValueOnce(computed(() => true)) 90 + mockComputedStyle({ '--bg': 'oklch(1 0 0)' }) 91 + 92 + const wrapper = mountWithSetup(() => { 93 + useCssVariables(['--bg'], { watchHtmlAttributes: true }) 94 + }) 95 + 96 + await nextTick() 97 + 98 + expect(lastMutationObserverInstance).not.toBeNull() 99 + expect(lastMutationObserverInstance!.observe).toHaveBeenCalledTimes(1) 100 + 101 + wrapper.unmount() 102 + }) 103 + })