From 11d55ea07fec42ba379f3a0838b22fc49e3e9b45 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 9 May 2026 11:02:13 +0200 Subject: [PATCH 01/10] refactor: move color variables into the composable --- app/composables/useColors.ts | 71 ++---- test/nuxt/composables/use-colors.spec.ts | 103 --------- test/unit/app/composables/use-colors.spec.ts | 219 +++++++++++++++++++ 3 files changed, 242 insertions(+), 151 deletions(-) delete mode 100644 test/nuxt/composables/use-colors.spec.ts create mode 100644 test/unit/app/composables/use-colors.spec.ts diff --git a/app/composables/useColors.ts b/app/composables/useColors.ts index 3b66e25fd1..4cf3c04c45 100644 --- a/app/composables/useColors.ts +++ b/app/composables/useColors.ts @@ -1,13 +1,21 @@ -import { computed, type ComputedRef, type Ref, unref } from 'vue' +import { computed, type ComputedRef, type Ref, type ShallowRef, unref } from 'vue' import { useMutationObserver, useResizeObserver, useSupported } from '@vueuse/core' type CssVariableSource = HTMLElement | null | undefined | Ref -type UseCssVariableOptions = { - element?: CssVariableSource - watchResize?: boolean - watchHtmlAttributes?: boolean -} +// Add existing css variables to expose in component scripts +const colorVariables = [ + '--accent', + '--bg', + '--bg-elevated', + '--bg-subtle', + '--border', + '--border-hover', + '--border-subtle', + '--fg', + '--fg-muted', + '--fg-subtle', +] as const function readCssVariable(element: HTMLElement, variableName: string): string { return getComputedStyle(element).getPropertyValue(variableName).trim() @@ -17,65 +25,32 @@ function toCamelCase(cssVariable: string): string { return cssVariable.replace(/^--/, '').replace(/-([a-z0-9])/gi, (_, c) => c.toUpperCase()) } -function resolveElement(element?: CssVariableSource): HTMLElement | null { +function resolveElement(element: CssVariableSource): HTMLElement | null { if (typeof window === 'undefined' || typeof document === 'undefined') return null - if (!element) return document.documentElement const resolved = unref(element) return resolved ?? document.documentElement } -/** - * Read multiple CSS custom properties at once and expose them as a reactive object. - * - * Each CSS variable name is normalized into a camelCase key: - * - Leading `--` is removed - * - kebab-case is converted to camelCase - * - * Example: - * ```ts - * useCssVariables(['--bg', '--fg-subtle']) - * // => colors.value = { bg: '...', fgSubtle: '...' } - * ``` - * - * The returned values are always resolved via `getComputedStyle`, meaning the - * effective value is returned (after cascade, theme classes, etc.). - * - * Reactivity behavior: - * - Updates automatically when the observed element changes - * - Can react to theme toggles via `watchHtmlAttributes` - * - Can react to responsive CSS variables via `watchResize` - * - * @param variables - List of CSS variable names (must include the leading `--`) - * @param options - Configuration options - * @param options.element - Element to read variables from (defaults to `:root`) - * @param options.watchResize - Re-evaluate values on resize (useful for media-query-driven variables) - * @param options.watchHtmlAttributes - Re-evaluate values when `` attributes change - * - * @returns An object containing a reactive `colors` map, keyed by camelCase names - */ -export function useCssVariables( - variables: readonly string[], - options: UseCssVariableOptions = {}, +export function useColors( + element: ShallowRef, + options: { watchHtmlAttributes?: boolean; watchResize?: boolean } = {}, ): { colors: ComputedRef> } { const isClientSupported = useSupported( () => typeof window !== 'undefined' && typeof document !== 'undefined', ) - const elementComputed = computed(() => resolveElement(options.element)) - const colors = computed>(() => { - const element = elementComputed.value - if (!element) return {} - + const resolvedElement = resolveElement(element) + if (!resolvedElement) return {} const result: Record = {} - for (const variable of variables) { - result[toCamelCase(variable)] = readCssVariable(element, variable) + for (const variable of colorVariables) { + result[toCamelCase(variable)] = readCssVariable(resolvedElement, variable) } return result }) if (options.watchResize) { - useResizeObserver(elementComputed, () => void colors.value) + useResizeObserver(element, () => void colors.value) } if (options.watchHtmlAttributes && isClientSupported.value) { diff --git a/test/nuxt/composables/use-colors.spec.ts b/test/nuxt/composables/use-colors.spec.ts deleted file mode 100644 index 1a00b199ae..0000000000 --- a/test/nuxt/composables/use-colors.spec.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { computed, nextTick, defineComponent } from 'vue' -import { mount } from '@vue/test-utils' -import type * as VueUseCore from '@vueuse/core' - -const useSupportedMock = vi.hoisted(() => vi.fn()) -const useMutationObserverMock = vi.hoisted(() => vi.fn()) -const useResizeObserverMock = vi.hoisted(() => vi.fn()) - -let lastMutationObserverInstance: { - observe: ReturnType - disconnect: ReturnType - takeRecords: ReturnType -} | null = null - -const mutationObserverConstructorMock = vi.hoisted(() => - vi.fn(function MutationObserver() { - lastMutationObserverInstance = { - observe: vi.fn(), - disconnect: vi.fn(), - takeRecords: vi.fn(), - } - return lastMutationObserverInstance - }), -) - -vi.mock('@vueuse/core', async () => { - const actual = await vi.importActual('@vueuse/core') - return { - ...actual, - useSupported: useSupportedMock, - useMutationObserver: useMutationObserverMock, - useResizeObserver: useResizeObserverMock, - } -}) - -function mockComputedStyle(values: Record) { - vi.stubGlobal('getComputedStyle', () => { - return { - getPropertyValue: (name: string) => values[name] ?? '', - } as any - }) -} - -function mountWithSetup(run: () => void) { - return mount( - defineComponent({ - name: 'TestHarness', - setup() { - run() - return () => null - }, - }), - { attachTo: document.body }, - ) -} - -describe('useCssVariables', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.resetModules() - lastMutationObserverInstance = null - vi.stubGlobal('MutationObserver', mutationObserverConstructorMock as any) - }) - - it('does not attach html mutation observer when client is not supported', async () => { - const { useCssVariables } = await import('~/composables/useColors') - - useSupportedMock.mockReturnValueOnce(computed(() => false)) - mockComputedStyle({ '--bg': 'oklch(1 0 0)' }) - - const wrapper = mountWithSetup(() => { - const { colors } = useCssVariables(['--bg'], { watchHtmlAttributes: true }) - expect(colors.value.bg).toBe('oklch(1 0 0)') - }) - - await nextTick() - - expect(useMutationObserverMock).not.toHaveBeenCalled() - expect(lastMutationObserverInstance).not.toBeNull() - expect(lastMutationObserverInstance!.observe).toHaveBeenCalledTimes(1) - - wrapper.unmount() - }) - - it('attaches html mutation observer when client is supported', async () => { - const { useCssVariables } = await import('~/composables/useColors') - - useSupportedMock.mockReturnValueOnce(computed(() => true)) - mockComputedStyle({ '--bg': 'oklch(1 0 0)' }) - - const wrapper = mountWithSetup(() => { - useCssVariables(['--bg'], { watchHtmlAttributes: true }) - }) - - await nextTick() - - expect(lastMutationObserverInstance).not.toBeNull() - expect(lastMutationObserverInstance!.observe).toHaveBeenCalledTimes(1) - - wrapper.unmount() - }) -}) diff --git a/test/unit/app/composables/use-colors.spec.ts b/test/unit/app/composables/use-colors.spec.ts new file mode 100644 index 0000000000..6eb3ae893f --- /dev/null +++ b/test/unit/app/composables/use-colors.spec.ts @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { computed, defineComponent, nextTick, shallowRef } from 'vue' +import { mount } from '@vue/test-utils' +import { useColors } from '~/composables/useColors' + +const useSupportedMock = vi.hoisted(() => vi.fn()) +const useMutationObserverMock = vi.hoisted(() => vi.fn()) +const useResizeObserverMock = vi.hoisted(() => vi.fn()) + +vi.mock('@vueuse/core', () => { + return { + useSupported: (callback: () => boolean) => { + useSupportedMock(callback) + return computed(() => callback()) + }, + useMutationObserver: useMutationObserverMock, + useResizeObserver: useResizeObserverMock, + } +}) + +function mockComputedStyle(values: Record) { + vi.stubGlobal('getComputedStyle', (element: HTMLElement) => ({ + getPropertyValue: (name: string) => values[`${element.id}:${name}`] ?? values[name] ?? '', + })) +} + +function mountWithSetup(run: () => void) { + return mount( + defineComponent({ + name: 'TestHarness', + setup() { + run() + return () => null + }, + }), + { attachTo: document.body }, + ) +} + +describe('useColors', () => { + beforeEach(() => { + vi.clearAllMocks() + useSupportedMock.mockReturnValue(computed(() => true)) + }) + + afterEach(() => { + vi.unstubAllGlobals() + + if (typeof document !== 'undefined') { + document.body.innerHTML = '' + document.documentElement.removeAttribute('id') + } + }) + + it('reads the configured color variables from the provided element', () => { + const element = document.createElement('div') + element.id = 'chart' + document.body.appendChild(element) + + mockComputedStyle({ + '--accent': '#42b883', + '--bg': '#ffffff', + '--bg-elevated': '#f8f8f8', + '--bg-subtle': '#f2f2f2', + '--border': '#d9d9d9', + '--border-hover': '#bdbdbd', + '--border-subtle': '#eeeeee', + '--fg': '#1a1a1a', + '--fg-muted': '#666666', + '--fg-subtle': '#999999', + }) + + const wrapper = mountWithSetup(() => { + const elementReference = shallowRef(element) + const { colors } = useColors(elementReference) + + expect(colors.value).toEqual({ + accent: '#42b883', + bg: '#ffffff', + bgElevated: '#f8f8f8', + bgSubtle: '#f2f2f2', + border: '#d9d9d9', + borderHover: '#bdbdbd', + borderSubtle: '#eeeeee', + fg: '#1a1a1a', + fgMuted: '#666666', + fgSubtle: '#999999', + }) + }) + + wrapper.unmount() + }) + + it('falls back to the document element when the provided ref value is null', () => { + document.documentElement.id = 'root' + + mockComputedStyle({ + 'root:--accent': '#42b883', + 'root:--bg': '#ffffff', + 'root:--bg-elevated': '#f8f8f8', + 'root:--bg-subtle': '#f2f2f2', + 'root:--border': '#d9d9d9', + 'root:--border-hover': '#bdbdbd', + 'root:--border-subtle': '#eeeeee', + 'root:--fg': '#1a1a1a', + 'root:--fg-muted': '#666666', + 'root:--fg-subtle': '#999999', + }) + + const wrapper = mountWithSetup(() => { + const elementReference = shallowRef(null) + const { colors } = useColors(elementReference) + expect(colors.value).toEqual({ + accent: '#42b883', + bg: '#ffffff', + bgElevated: '#f8f8f8', + bgSubtle: '#f2f2f2', + border: '#d9d9d9', + borderHover: '#bdbdbd', + borderSubtle: '#eeeeee', + fg: '#1a1a1a', + fgMuted: '#666666', + fgSubtle: '#999999', + }) + }) + wrapper.unmount() + }) + + it('reacts when the provided element ref changes', async () => { + const firstElement = document.createElement('div') + firstElement.id = 'first' + const secondElement = document.createElement('div') + secondElement.id = 'second' + mockComputedStyle({ + 'first:--accent': '#111111', + 'second:--accent': '#222222', + }) + let colorsValue: ReturnType['colors'] + const elementReference = shallowRef(firstElement) + const wrapper = mountWithSetup(() => { + const { colors } = useColors(elementReference) + colorsValue = colors + }) + expect(colorsValue!.value.accent).toBe('#111111') + elementReference.value = secondElement + await nextTick() + expect(colorsValue!.value.accent).toBe('#222222') + wrapper.unmount() + }) + + it('attaches an html mutation observer when enabled and client is supported', async () => { + const element = document.createElement('div') + element.id = 'mutation' + const elementReference = shallowRef(element) + mockComputedStyle({ + 'mutation:--accent': '#111111', + }) + const wrapper = mountWithSetup(() => { + const { colors } = useColors(elementReference, { watchHtmlAttributes: true }) + expect(colors.value.accent).toBe('#111111') + }) + await nextTick() + expect(useMutationObserverMock).toHaveBeenCalledTimes(1) + mockComputedStyle({ 'mutation:--accent': '#222222' }) + const mutationCallback = useMutationObserverMock.mock.calls?.[0]?.[1] + expect(() => mutationCallback()).not.toThrow() + wrapper.unmount() + }) + + it('does not attach an html mutation observer when client is not supported', () => { + const originalWindow = globalThis.window + vi.stubGlobal('window', undefined) + const elementReference = shallowRef(document.createElement('div')) + mockComputedStyle({}) + useColors(elementReference, { watchHtmlAttributes: true }) + expect(useMutationObserverMock).not.toHaveBeenCalled() + vi.stubGlobal('window', originalWindow) + }) + + it('attaches a resize observer when enabled', async () => { + const element = document.createElement('div') + element.id = 'resize' + const elementReference = shallowRef(element) + mockComputedStyle({ 'resize:--accent': '#111111' }) + const wrapper = mountWithSetup(() => { + const { colors } = useColors(elementReference, { watchResize: true }) + expect(colors.value.accent).toBe('#111111') + }) + await nextTick() + expect(useResizeObserverMock).toHaveBeenCalledTimes(1) + expect(useResizeObserverMock).toHaveBeenCalledWith(expect.any(Object), expect.any(Function)) + mockComputedStyle({ 'resize:--accent': '#222222' }) + const resizeCallback = useResizeObserverMock.mock.calls?.[0]?.[1] + expect(() => resizeCallback()).not.toThrow() + wrapper.unmount() + }) + + it('does not attach observers by default', async () => { + const elementReference = shallowRef(document.createElement('div')) + mockComputedStyle({}) + const wrapper = mountWithSetup(() => { + useColors(elementReference) + }) + await nextTick() + expect(useMutationObserverMock).not.toHaveBeenCalled() + expect(useResizeObserverMock).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('returns an empty color object when window or document is unavailable', () => { + vi.stubGlobal('window', undefined) + vi.stubGlobal('document', undefined) + const elementReference = shallowRef(null) + const { colors } = useColors(elementReference) + expect(colors.value).toEqual({}) + }) +}) From 577c9d925fd616587ccd070e6eb015fc88cbcec5 Mon Sep 17 00:00:00 2001 From: graphieros Date: Sat, 9 May 2026 11:02:53 +0200 Subject: [PATCH 02/10] refactor: useColors in chart components --- app/components/Chart/SplitSparkline.vue | 20 ++---------------- app/components/Compare/FacetBarChart.vue | 19 ++--------------- app/components/Compare/FacetScatterChart.vue | 21 ++----------------- app/components/Package/TimelineChart.vue | 20 ++---------------- app/components/Package/TrendsChart.vue | 20 ++---------------- .../Package/VersionDistribution.vue | 11 ++-------- .../Package/WeeklyDownloadStats.vue | 20 ++---------------- 7 files changed, 14 insertions(+), 117 deletions(-) diff --git a/app/components/Chart/SplitSparkline.vue b/app/components/Chart/SplitSparkline.vue index e63ea4e4f5..7b9fee5c6f 100644 --- a/app/components/Chart/SplitSparkline.vue +++ b/app/components/Chart/SplitSparkline.vue @@ -5,7 +5,7 @@ import { type VueUiSparklineDatasetItem, } from 'vue-data-ui/vue-ui-sparkline' import { VueUiPatternSeed } from 'vue-data-ui/vue-ui-pattern-seed' -import { useCssVariables } from '~/composables/useColors' +import { useColors } from '~/composables/useColors' import type { VueUiXyDatasetItem } from 'vue-data-ui/vue-ui-xy' import { getPalette, lightenColor } from 'vue-data-ui/utils' import { CHART_PATTERN_CONFIG } from '~/utils/charts' @@ -50,23 +50,7 @@ watch( { flush: 'sync', immediate: true }, ) -const { colors } = useCssVariables( - [ - '--bg', - '--fg', - '--bg-subtle', - '--bg-elevated', - '--border-hover', - '--fg-subtle', - '--border', - '--border-subtle', - ], - { - element: rootEl, - watchHtmlAttributes: true, - watchResize: false, // set to true only if a var changes color on resize - }, -) +const { colors } = useColors(rootEl) const isDarkMode = computed(() => resolvedMode.value === 'dark') diff --git a/app/components/Compare/FacetBarChart.vue b/app/components/Compare/FacetBarChart.vue index c48f9ad8e4..f8393c14b0 100644 --- a/app/components/Compare/FacetBarChart.vue +++ b/app/components/Compare/FacetBarChart.vue @@ -9,6 +9,7 @@ import { VueUiPatternSeed } from 'vue-data-ui/vue-ui-pattern-seed' import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' import { createPatternDef } from 'vue-data-ui/utils' import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' +import { useColors } from '~/composables/useColors' import { loadFile, @@ -40,23 +41,7 @@ const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpoin const chartKey = ref(0) -const { colors } = useCssVariables( - [ - '--bg', - '--fg', - '--bg-subtle', - '--bg-elevated', - '--fg-subtle', - '--fg-muted', - '--border', - '--border-subtle', - ], - { - element: rootEl, - watchHtmlAttributes: true, - watchResize: false, - }, -) +const { colors } = useColors(rootEl) const watermarkColors = computed(() => ({ fg: colors.value.fg ?? OKLCH_NEUTRAL_FALLBACK, diff --git a/app/components/Compare/FacetScatterChart.vue b/app/components/Compare/FacetScatterChart.vue index 9f3cdc332b..2d5442c3e0 100644 --- a/app/components/Compare/FacetScatterChart.vue +++ b/app/components/Compare/FacetScatterChart.vue @@ -9,6 +9,7 @@ import { } from 'vue-data-ui/vue-ui-scatter' import { buildCompareScatterChartDataset } from '~/utils/compare-scatter-chart' import { loadFile, copyAltTextForCompareScatterChart } from '~/utils/charts' +import { useColors } from '~/composables/useColors' import('vue-data-ui/style.css') @@ -26,25 +27,7 @@ const { copy, copied } = useClipboard() const mobileBreakpointWidth = 640 const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) -const { colors } = useCssVariables( - [ - '--bg', - '--fg', - '--bg-subtle', - '--bg-elevated', - '--fg-subtle', - '--fg-muted', - '--border', - '--border-subtle', - '--border-hover', - '--accent', - ], - { - element: rootEl, - watchHtmlAttributes: true, - watchResize: false, - }, -) +const { colors } = useColors(rootEl) const watermarkColors = computed(() => ({ fg: colors.value.fg ?? OKLCH_NEUTRAL_FALLBACK, diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index 53b0be9727..ed900fe42b 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -19,6 +19,7 @@ import { import type { TimelineVersion, SubEvent } from '~~/server/api/registry/timeline/[...pkg].get' import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' import { useChartTooltipPosition } from '~/composables/useChartTooltipPosition' +import { useColors } from '~/composables/useColors' import('vue-data-ui/style.css') @@ -198,24 +199,7 @@ onMounted(async () => { resolvedMode.value = colorMode.value === 'dark' ? 'dark' : 'light' }) -const { colors } = useCssVariables( - [ - '--bg', - '--fg', - '--bg-subtle', - '--bg-elevated', - '--fg-subtle', - '--fg-muted', - '--border', - '--border-subtle', - '--accent', - ], - { - element: rootEl, - watchHtmlAttributes: true, - watchResize: false, - }, -) +const { colors } = useColors(rootEl) watch( () => colorMode.value, diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index 6499064a5a..6b9e94e535 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -2,7 +2,7 @@ import type { Theme as VueDataUiTheme } from 'vue-data-ui' import { VueUiXy, type VueUiXyConfig, type VueUiXyDatasetItem } from 'vue-data-ui/vue-ui-xy' import { useDebounceFn, useElementSize, useTimeoutFn } from '@vueuse/core' -import { useCssVariables } from '~/composables/useColors' +import { useColors } from '~/composables/useColors' import { OKLCH_NEUTRAL_FALLBACK, transparentizeOklch, lightenOklch } from '~/utils/colors' import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' import { drawNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' @@ -92,23 +92,7 @@ onMounted(async () => { loadMetric(selectedMetric.value) }) -const { colors } = useCssVariables( - [ - '--bg', - '--fg', - '--bg-subtle', - '--bg-elevated', - '--fg-subtle', - '--fg-muted', - '--border', - '--border-subtle', - ], - { - element: rootEl, - watchHtmlAttributes: true, - watchResize: false, - }, -) +const { colors } = useColors(rootEl) watch( () => colorMode.value, diff --git a/app/components/Package/VersionDistribution.vue b/app/components/Package/VersionDistribution.vue index 225bc3193f..950dd38f29 100644 --- a/app/components/Package/VersionDistribution.vue +++ b/app/components/Package/VersionDistribution.vue @@ -1,7 +1,7 @@