feat(stage-ui,stage-web): new colorsFromElement lib extracted

This commit is contained in:
Neko Ayaka
2025-12-20 23:13:05 +08:00
parent 4f914d854c
commit b8b22b3d84
5 changed files with 237 additions and 107 deletions
@@ -1,17 +1,11 @@
<script setup lang="ts">
import type { Color } from 'culori'
import html2canvas from 'html2canvas'
import { colorFromElement } from '@proj-airi/stage-ui/libs'
import { BasicInputFile } from '@proj-airi/ui'
import { average } from 'culori'
import { Vibrant } from 'node-vibrant/browser'
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import defaultBackgroundImage from '../../assets/backgrounds/fairy-forest.e17cbc2774.ko-fi.com.avif'
import { useThemeColor } from '../../composables/theme-color'
// Reactive state
const isCapturing = ref(false)
const extractedColors = ref<string[]>([])
@@ -53,48 +47,56 @@ const topBar = computed(() => {
return ''
})
// Color extraction from image
async function extractColorsFromImage() {
if (images.value.length === 0) {
async function refreshColors() {
if (!imageRef.value || images.value.length === 0) {
return
}
try {
isCapturing.value = true
// Load the image
const img = new window.Image()
img.crossOrigin = 'anonymous'
img.src = images.value[0]
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
// Create a canvas and draw only the top portion
const cropHeight = Math.floor(img.naturalHeight * 0.2) // top 20% of the image
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = cropHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, img.naturalWidth, cropHeight, 0, 0, img.naturalWidth, cropHeight)
if (imageRef.value instanceof HTMLImageElement && !imageRef.value.complete) {
await new Promise<void>((resolve, reject) => {
imageRef.value?.addEventListener('load', () => resolve(), { once: true })
imageRef.value?.addEventListener('error', () => reject(new Error('Image failed to load')), { once: true })
})
}
// Convert canvas to data URL and use Vibrant on the cropped region
const dataUrl = canvas.toDataURL()
const vibrant = new Vibrant(dataUrl)
const palette = await vibrant.getPalette()
const result = await colorFromElement(imageRef.value, {
mode: 'both',
vibrant: {
imageSource: images.value[0],
sampleTopRatio: 0.2,
},
html2canvas: {
region: {
x: 0,
y: 0,
width: imageRef.value.offsetWidth,
height: 100,
},
sampleHeight: 20,
sampleStride: 10,
scale: 0.5,
backgroundColor: null,
allowTaint: true,
useCORS: true,
},
})
const colors = Object.values(palette)
.map(color => color?.hex)
.filter((color): color is string => typeof color === 'string')
extractedColors.value = result.vibrant?.palette ?? []
dominantColor.value = result.vibrant?.dominant ?? ''
topEdgeColors.value = result.html2canvas?.average ?? ''
extractedColors.value = colors
dominantColor.value = palette.Vibrant?.hex || palette.DarkVibrant?.hex || colors[0]
if (canvasRef.value && result.html2canvas?.canvas) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) {
canvasRef.value.width = result.html2canvas.canvas.width
canvasRef.value.height = result.html2canvas.canvas.height
ctx.drawImage(result.html2canvas.canvas, 0, 0)
}
}
// Update PWA theme color
await updateThemeColor()
}
catch (error) {
@@ -105,78 +107,15 @@ async function extractColorsFromImage() {
}
}
// Capture top edge colors using html2canvas
async function captureTopEdgeColors() {
if (!imageRef.value)
return
try {
isCapturing.value = true
// Capture the background element
const canvas = await html2canvas(imageRef.value, {
allowTaint: true,
useCORS: true,
backgroundColor: null,
scale: 0.5, // Reduce quality for performance
height: 100, // Only capture top portion
width: imageRef.value.offsetWidth,
logging: false,
})
// Display captured canvas for debugging
if (canvasRef.value) {
const ctx = canvasRef.value.getContext('2d')
if (ctx) {
canvasRef.value.width = canvas.width
canvasRef.value.height = canvas.height
ctx.drawImage(canvas, 0, 0)
}
}
// Sample colors from top edge
const ctx = canvas.getContext('2d')
if (ctx) {
const imageData = ctx.getImageData(0, 0, canvas.width, 20) // Top 20px
const colors: Color[] = []
// Sample every 10th pixel to get representative colors
for (let i = 0; i < imageData.data.length; i += 40) { // RGBA = 4 bytes, sample every 10 pixels
const r = imageData.data[i]
const g = imageData.data[i + 1]
const b = imageData.data[i + 2]
const a = imageData.data[i + 3]
if (a > 0) { // Skip transparent pixels
colors.push({ mode: 'rgb', r, g, b })
}
}
if (colors.length > 0) {
const c = average(colors as [Color, ...Color[]])
topEdgeColors.value = `rgb(${c.r}, ${c.g}, ${c.b})`
}
}
}
catch (error) {
console.error('Canvas capture failed:', error)
}
finally {
isCapturing.value = false
}
}
// Auto-extract colors on mount
onMounted(async () => {
await nextTick()
await extractColorsFromImage()
await captureTopEdgeColors()
await refreshColors()
})
watch(images, async () => {
await nextTick()
await extractColorsFromImage()
await captureTopEdgeColors()
await refreshColors()
})
onUnmounted(() => {
+2
View File
@@ -107,10 +107,12 @@
"dompurify": "^3.3.1",
"es-toolkit": "catalog:",
"gpuu": "^1.0.6",
"html2canvas": "^1.4.1",
"jszip": "^3.10.1",
"localforage": "^1.10.0",
"mediabunny": "^1.26.0",
"nanoid": "^5.1.6",
"node-vibrant": "^4.0.3",
"ofetch": "^1.5.1",
"pinia": "^3.0.4",
"pixi-filters": "4",
@@ -0,0 +1,182 @@
import type { Color } from 'culori'
import html2canvas from 'html2canvas'
import { average } from 'culori'
import { Vibrant } from 'node-vibrant/browser'
export type ColorFromElementMode = 'vibrant' | 'html2canvas' | 'both'
export interface ColorFromElementOptions {
/**
* Which extraction pipeline to run. Use `'both'` to mirror the devtools view.
* Defaults to `'both'`.
*/
mode?: ColorFromElementMode
/**
* Options for the Vibrant-based palette extraction.
*/
vibrant?: {
/**
* Optional override for the image source; falls back to the `img` element's `currentSrc/src`.
*/
imageSource?: string
/**
* Ratio (0-1) of the image height to sample from the top edge. Defaults to 0.2.
*/
sampleTopRatio?: number
}
/**
* Options for html2canvas-based sampling of the rendered element.
*/
html2canvas?: {
/**
* Region forwarded to html2canvas. Provide only what you need; defaults to the live element box.
*/
region?: {
x?: number
y?: number
width?: number
height?: number
}
/**
* How many pixels (height) to read from the captured canvas. Defaults to 20.
*/
sampleHeight?: number
/**
* Pixel stride when sampling the captured row. Defaults to 10 (i.e., every 10th pixel).
*/
sampleStride?: number
/**
* Canvas scale used by html2canvas. Defaults to 0.5.
*/
scale?: number
allowTaint?: boolean
useCORS?: boolean
backgroundColor?: string | null
logging?: boolean
}
}
export interface ColorFromElementResult {
vibrant?: {
palette: string[]
dominant?: string
}
html2canvas?: {
average?: string
canvas?: HTMLCanvasElement
}
}
export async function colorFromElement(element: HTMLElement, options: ColorFromElementOptions = {}): Promise<ColorFromElementResult> {
const mode = options.mode ?? 'both'
const result: ColorFromElementResult = {}
const shouldRunVibrant = mode === 'vibrant' || mode === 'both'
const shouldRunHtml2Canvas = mode === 'html2canvas' || mode === 'both'
if (shouldRunVibrant) {
const vibrantResult = await extractWithVibrant(element, options.vibrant)
result.vibrant = vibrantResult
}
if (shouldRunHtml2Canvas) {
const html2CanvasResult = await extractWithHtml2Canvas(element, options.html2canvas)
result.html2canvas = html2CanvasResult
}
return result
}
async function extractWithVibrant(element: HTMLElement, options: ColorFromElementOptions['vibrant']): Promise<ColorFromElementResult['vibrant']> {
const sampleTopRatio = options?.sampleTopRatio ?? 0.2
const imageSource = options?.imageSource ?? (element instanceof HTMLImageElement ? (element.currentSrc || element.src) : undefined)
if (!imageSource) {
return { palette: [], dominant: undefined }
}
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = imageSource
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = () => reject(new Error('Failed to load image for Vibrant extraction'))
})
const cropHeight = Math.max(1, Math.floor(img.naturalHeight * sampleTopRatio))
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = cropHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, img.naturalWidth, cropHeight, 0, 0, img.naturalWidth, cropHeight)
}
const dataUrl = canvas.toDataURL()
const vibrant = new Vibrant(dataUrl)
const palette = await vibrant.getPalette()
const colors = Object.values(palette)
.map(color => color?.hex)
.filter((color): color is string => typeof color === 'string')
return {
palette: colors,
dominant: palette.Vibrant?.hex || palette.DarkVibrant?.hex || colors[0],
}
}
async function extractWithHtml2Canvas(element: HTMLElement, options: ColorFromElementOptions['html2canvas']): Promise<ColorFromElementResult['html2canvas']> {
const region = options?.region ?? {}
// NOTICE: Region defaults to the live element box; override when the rendered size differs (e.g., scaled canvases).
const captureWidth = region.width ?? element.offsetWidth
const captureHeight = region.height ?? element.offsetHeight
const canvas = await html2canvas(element, {
allowTaint: options?.allowTaint ?? true,
useCORS: options?.useCORS ?? true,
backgroundColor: options?.backgroundColor ?? null,
scale: options?.scale ?? 0.5,
logging: options?.logging ?? false,
width: captureWidth,
height: captureHeight,
x: region.x,
y: region.y,
})
const ctx = canvas.getContext('2d')
if (!ctx) {
return { canvas, average: undefined }
}
const sampleHeight = Math.max(1, Math.min(options?.sampleHeight ?? 20, canvas.height))
const sampleStride = Math.max(1, options?.sampleStride ?? 10)
const imageData = ctx.getImageData(0, 0, canvas.width, sampleHeight)
const colors: Color[] = []
for (let i = 0; i < imageData.data.length; i += 4 * sampleStride) {
const r = imageData.data[i]
const g = imageData.data[i + 1]
const b = imageData.data[i + 2]
const a = imageData.data[i + 3]
if (a > 0) {
colors.push({ mode: 'rgb', r, g, b })
}
}
if (colors.length === 0) {
return { canvas, average: undefined }
}
const averaged = average(colors as [Color, ...Color[]])
const averageColor = averaged ? `rgb(${averaged.r}, ${averaged.g}, ${averaged.b})` : undefined
return {
canvas,
average: averageColor,
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './audio/manager'
export * from './color-from-element'
export * from './workers/types'
export * from './workers/worker'
+10 -4
View File
@@ -1595,6 +1595,9 @@ importers:
gpuu:
specifier: ^1.0.6
version: 1.0.6
html2canvas:
specifier: ^1.4.1
version: 1.4.1
jszip:
specifier: ^3.10.1
version: 3.10.1
@@ -1607,6 +1610,9 @@ importers:
nanoid:
specifier: ^5.1.6
version: 5.1.6
node-vibrant:
specifier: ^4.0.3
version: 4.0.3(encoding@0.1.13)
ofetch:
specifier: ^1.5.1
version: 1.5.1
@@ -1672,7 +1678,7 @@ importers:
version: 4.6.4(vue@3.5.25(typescript@5.9.3))
vue-sonner:
specifier: ^2.0.9
version: 2.0.9(@nuxt/kit@4.0.3(magicast@0.5.1))(@nuxt/schema@4.0.3)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))
version: 2.0.9(@nuxt/kit@4.0.3(magicast@0.5.1))(@nuxt/schema@4.0.3)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))
vue-tsc:
specifier: ^3.1.8
version: 3.1.8(typescript@5.9.3)
@@ -29137,7 +29143,7 @@ snapshots:
- yaml
optional: true
nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2):
nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2):
dependencies:
'@nuxt/cli': 3.31.2(cac@6.7.14)(magicast@0.5.1)
'@nuxt/devalue': 2.0.2
@@ -33395,11 +33401,11 @@ snapshots:
'@nuxt/schema': 4.0.3
nuxt: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(rolldown@1.0.0-beta.53)(rollup@2.79.2)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2)
vue-sonner@2.0.9(@nuxt/kit@4.0.3(magicast@0.5.1))(@nuxt/schema@4.0.3)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2)):
vue-sonner@2.0.9(@nuxt/kit@4.0.3(magicast@0.5.1))(@nuxt/schema@4.0.3)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2)):
optionalDependencies:
'@nuxt/kit': 4.0.3(magicast@0.5.1)
'@nuxt/schema': 4.0.3
nuxt: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2)
nuxt: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(encoding@0.1.13)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2)
vue-sonner@2.0.9(@nuxt/kit@4.0.3(magicast@0.5.1))(@nuxt/schema@4.0.3)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.0)(@types/node@24.10.4)(@vue/compiler-sfc@3.5.25)(bufferutil@4.0.9)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(gel@2.0.0)(pg@8.16.3)(postgres@3.4.7))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.8.2)(less@4.4.2)(lightningcss@1.30.2)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-beta.53)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(vite@7.2.7(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.1.8(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2)):
optionalDependencies: