From f4cf9fc77e02c9d16ef2e6386ed765042eff839d Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 07:48:02 +0300 Subject: [PATCH] perf: enforce journey budgets and reference traces --- docs/performance/lab-runbook.md | 51 +++ package.json | 4 +- playwright.config.ts | 8 +- scripts/aggregate-drive-traces.mjs | 11 + scripts/check-bundle-budgets.mjs | 80 ++++ scripts/lib/bundle-budgets.d.mts | 5 + scripts/lib/bundle-budgets.mjs | 51 +++ scripts/lib/performance-aggregation.d.mts | 7 + scripts/lib/performance-aggregation.mjs | 37 ++ scripts/verify-artifacts.mjs | 2 + src/components/AppShell.vue | 55 ++- src/components/DriveControls.vue | 6 +- src/components/SiteNavigation.vue | 15 +- src/entry-client.ts | 8 + src/journey/navigation-controller.ts | 9 + src/styles/layout.css | 41 +- src/testing/performance-instrumentation.ts | 394 +++++++++++++++++++ tests/helpers/test-build-auth.ts | 10 + tests/performance/aggregation.test.ts | 30 ++ tests/performance/budgets.test.ts | 41 ++ tests/performance/drive-trace.spec.ts | 209 ++++++++++ tests/performance/metrics.test.ts | 111 ++++++ tests/performance/physical-drive.protocol.ts | 167 ++++++++ tests/performance/physical-drive.test.ts | 101 +++++ vitest.config.ts | 2 +- 25 files changed, 1432 insertions(+), 23 deletions(-) create mode 100644 docs/performance/lab-runbook.md create mode 100644 scripts/aggregate-drive-traces.mjs create mode 100644 scripts/check-bundle-budgets.mjs create mode 100644 scripts/lib/bundle-budgets.d.mts create mode 100644 scripts/lib/bundle-budgets.mjs create mode 100644 scripts/lib/performance-aggregation.d.mts create mode 100644 scripts/lib/performance-aggregation.mjs create mode 100644 src/testing/performance-instrumentation.ts create mode 100644 tests/performance/aggregation.test.ts create mode 100644 tests/performance/budgets.test.ts create mode 100644 tests/performance/drive-trace.spec.ts create mode 100644 tests/performance/metrics.test.ts create mode 100644 tests/performance/physical-drive.protocol.ts create mode 100644 tests/performance/physical-drive.test.ts diff --git a/docs/performance/lab-runbook.md b/docs/performance/lab-runbook.md new file mode 100644 index 0000000..73e6323 --- /dev/null +++ b/docs/performance/lab-runbook.md @@ -0,0 +1,51 @@ +# Portfolio performance lab runbook + +This runbook separates executable developer evidence from the two physical release gates. A passing laptop/headless run never substitutes for the named hardware. + +## Automated build and transfer gate + +Run `npm run build:preview`. The build ends with `npm run check:budgets`, which classifies every shipped file exactly once, measures Brotli transfer bytes (or raw bytes for already-compressed formats), verifies that the live Three.js scene remains behind a dynamic import, and applies the exact section 14 thresholds. Unknown files fail closed. + +The current empty-media preview measures approximately 7.8 KB for HTML plus critical CSS, 61.8 KB for initial application JavaScript, and 114.2 KB for the deferred first live scene. These values are build observations, not permanent allowances. + +## Current-Chromium developer trace + +Run: + +```bash +npx playwright test tests/performance/drive-trace.spec.ts \ + --project=performance-desktop --project=performance-mobile --workers=1 +``` + +The two 20-second traces use current Chromium, trusted CDP mouse/key/wheel input, the specified desktop/mobile network profiles, exact action offsets, fixed High/Low rendering tiers, the deterministic Toolkit cancellation barrier, and one fresh browser context per run. Each test writes `drive-trace.v1.json` under its ignored Playwright result directory. + +Developer reports deliberately contain: + +- `evidenceKind: current-chromium-developer-machine-non-reference` +- `gatingReferenceResult: false` +- individual budget booleans and measured values +- `pendingPhysicalReferenceRuns: 5` + +They prove that the harness and interactions execute. They do not make a release claim, even if every number happens to pass. Instrumentation exists only in an authenticated test build, stays inside the page/test process, records no GPU or audio-device identity, and sends no telemetry. + +## Five-run physical reference gate + +Use only the Pixel 6 8 GB / Chromium Mobile Low and M1 MacBook Air 8 GB / Chromium Desktop High reference classes, or hardware with the same CPU/GPU and RAM. Fix both displays at 60 Hz; require battery above 50%, power saving off, a cool start, no foreground competition, page zoom 100%, full motion, Save-Data off, and the exact network shaping from section 14. Record OS/browser/display versions in the private lab evidence, not in shipped artifacts. + +For each profile, run five fresh temporary profiles from `about:blank`. Clear service workers, Cache Storage, HTTP cache, cookies, local/session storage, and IndexedDB before every run. Set `PORTFOLIO_REFERENCE_LAB=1` so cadence and all per-run budgets become assertions. A warm-up median outside 16.667 ±1 ms invalidates the run. + +Aggregate exactly five reviewed JSON paths: + +```bash +npm run aggregate:performance -- run-1.json run-2.json run-3.json run-4.json run-5.json +``` + +Aggregation fails unless all five are marked reference evidence, every per-run gate passes, worst/median LCP are at most 3.0/2.5 seconds, and worst/median TBT are at most 300/200 ms. Do not commit result files until their metadata has passed privacy review. + +## Physical ten-minute protocol + +`tests/performance/physical-drive.protocol.ts` is the versioned `physical-drive.v1` action generator. A platform runner must consume it without changing timing or order. It uses only `driveTouch` touch and `driveKeys` key sources, round-half-up safe-viewport coordinates, 15×40 ms swipe moves plus a 400 ms pause, and the direction rule sampled at every cycle start. + +The generator includes rotations, 30-second background periods, audio interruption, context loss/restore, the 389/390-second cycle exclusions, the 390-second Infrastructure barrier and 391-second production-epoch cancellation, live versus terminal-Static pause/resume branches, and Contact settlement at ten minutes. Mouse/pen fallback, scripted click/focus, altered offsets, or emulation in place of the physical devices invalidates evidence. + +Physical iPhone/Pixel execution remains pending until those devices and platform automation drivers are available. Never mark those rows passed from desktop emulation. diff --git a/package.json b/package.json index 0c9b70f..efd6e8f 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "verify:artifacts": "node scripts/verify-artifacts.mjs", "prepare:assets": "node scripts/prepare-assets.mjs", "verify:assets": "node scripts/prepare-assets.mjs --verify", + "check:budgets": "node scripts/check-bundle-budgets.mjs dist", + "aggregate:performance": "node scripts/aggregate-drive-traces.mjs", "prepare:preview-assets": "node scripts/materialize-empty-asset-set.mjs", - "build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json" + "build:preview": "npm run prepare:preview-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets" }, "keywords": [], "author": "", diff --git a/playwright.config.ts b/playwright.config.ts index 3876f12..751990f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,14 +1,16 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: './tests/browser', + testDir: './tests', globalSetup: './tests/helpers/test-build-auth.ts', workers: 4, use: { serviceWorkers: 'block', }, projects: [ - { name: 'desktop-chromium', use: { ...devices['Desktop Chrome'] } }, - { name: 'mobile-chromium', use: { ...devices['Pixel 5'] } }, + { name: 'desktop-chromium', testMatch: /browser\/.*\.spec\.ts/u, use: { ...devices['Desktop Chrome'] } }, + { name: 'mobile-chromium', testMatch: /browser\/.*\.spec\.ts/u, use: { ...devices['Pixel 5'] } }, + { name: 'performance-desktop', testMatch: /performance\/drive-trace\.spec\.ts/u, use: { ...devices['Desktop Chrome'] } }, + { name: 'performance-mobile', testMatch: /performance\/drive-trace\.spec\.ts/u, use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } }, ], }); diff --git a/scripts/aggregate-drive-traces.mjs b/scripts/aggregate-drive-traces.mjs new file mode 100644 index 0000000..a672033 --- /dev/null +++ b/scripts/aggregate-drive-traces.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises'; +import { aggregateFiveDriveTraces } from './lib/performance-aggregation.mjs'; + +const paths = process.argv.slice(2); +if (paths.length !== 5) throw new Error('Pass exactly five explicit drive-trace.v1 JSON paths.'); +const runs = []; +for (const path of paths) runs.push(JSON.parse(await readFile(path, 'utf8'))); +const aggregate = aggregateFiveDriveTraces(runs); +process.stdout.write(`${JSON.stringify(aggregate, null, 2)}\n`); +if (!aggregate.passes) process.exitCode = 1; diff --git a/scripts/check-bundle-budgets.mjs b/scripts/check-bundle-budgets.mjs new file mode 100644 index 0000000..d0dd7fc --- /dev/null +++ b/scripts/check-bundle-budgets.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'; +import { readFile, readdir, stat } from 'node:fs/promises'; +import { relative, resolve, sep } from 'node:path'; +import { classifyArtifact, evaluateBudgetTotals, PERFORMANCE_BUDGETS } from './lib/bundle-budgets.mjs'; + +const root = resolve(process.argv[2] ?? 'dist'); +if (!(await stat(root)).isDirectory()) throw new Error('Bundle budget target must be a directory.'); + +const listFiles = async (directory) => { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isSymbolicLink()) throw new Error('Bundle budget input cannot contain symbolic links.'); + if (entry.isDirectory()) files.push(...await listFiles(path)); + else if (entry.isFile()) files.push(path); + else throw new Error('Bundle budget input contains an unsupported entry.'); + } + return files; +}; + +const compressedSize = (path, bytes) => (/\.(?:avif|br|gif|jpe?g|mp4|png|webm|webp|woff2?)$/iu.test(path) + ? bytes.byteLength + : brotliCompressSync(bytes, { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, + }).byteLength); + +const records = []; +for (const absolute of await listFiles(root)) { + const path = relative(root, absolute).split(sep).join('/'); + const bytes = await readFile(absolute); + const source = /\.(?:css|html|js|json|txt|xml)$/u.test(path) ? bytes.toString('utf8') : ''; + const bucket = classifyArtifact(path, source); + records.push({ path, bucket, bytes: bytes.byteLength, transferBytes: compressedSize(path, bytes) }); +} + +const sum = (bucket) => records.filter((record) => record.bucket === bucket).reduce((total, record) => total + record.transferBytes, 0); +const totals = { + initialHtmlCriticalCss: sum('initial-html') + sum('critical-css'), + initialApplicationJs: sum('initial-application-js'), + heroPoster: sum('hero-poster'), + initialFonts: sum('initial-fonts'), + firstLiveScene: sum('first-live-scene'), + deferredProjectVisuals: sum('project-still') + sum('chart'), + deferredNonVideo: sum('first-live-scene') + sum('project-still') + sum('chart'), +}; + +for (const record of records.filter((item) => item.bucket === 'project-video')) { + if (record.transferBytes > PERFORMANCE_BUDGETS.projectVideo) { + throw new Error(`Project video exceeds its transfer budget: ${record.path}`); + } +} +for (const record of records.filter((item) => item.bucket === 'project-still')) { + if (record.transferBytes > PERFORMANCE_BUDGETS.projectStill) { + throw new Error(`Project still exceeds its transfer budget: ${record.path}`); + } +} +for (const record of records.filter((item) => item.bucket === 'chart')) { + if (record.transferBytes > PERFORMANCE_BUDGETS.chart) { + throw new Error(`Chart exceeds its transfer budget: ${record.path}`); + } +} + +const entry = records.find((record) => record.bucket === 'initial-application-js'); +const scene = records.find((record) => record.bucket === 'first-live-scene'); +if (entry === undefined || scene === undefined) throw new Error('Both initial application and deferred live-scene chunks are required.'); +const entrySource = await readFile(resolve(root, entry.path), 'utf8'); +if (!/import\([^)]*environment-/u.test(entrySource)) throw new Error('The live scene is not separated behind a dynamic import.'); +if (/WebGLRenderer|WebGLProgram|ShaderMaterial/u.test(entrySource)) throw new Error('Three.js/live-scene code leaked into the reduced-motion initial chunk.'); + +const result = evaluateBudgetTotals(totals); +const report = { + schema: 'portfolio-bundle-budget/v1', + compression: 'brotli-quality-11-or-raw-already-compressed', + totals, + records: records.sort((left, right) => left.path.localeCompare(right.path)), + violations: result.violations, +}; +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +if (result.violations.length > 0) process.exitCode = 1; diff --git a/scripts/lib/bundle-budgets.d.mts b/scripts/lib/bundle-budgets.d.mts new file mode 100644 index 0000000..db31b35 --- /dev/null +++ b/scripts/lib/bundle-budgets.d.mts @@ -0,0 +1,5 @@ +export const PERFORMANCE_BUDGETS: Readonly>; +export function classifyArtifact(path: string, source?: string): string; +export function evaluateBudgetTotals(totals: Readonly>): { + violations: Array<{ budget: string; actual: number; threshold: number }>; +}; diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs new file mode 100644 index 0000000..4a207d7 --- /dev/null +++ b/scripts/lib/bundle-budgets.mjs @@ -0,0 +1,51 @@ +export const PERFORMANCE_BUDGETS = Object.freeze({ + initialHtmlCriticalCss: 100 * 1024, + initialApplicationJs: 120 * 1024, + heroPoster: 220 * 1024, + initialFonts: 120 * 1024, + firstLiveScene: 650 * 1024, + projectVideo: 2.5 * 1024 * 1024, + projectVideoDurationSeconds: 10, + projectVideoWidth: 1280, + projectVideoHeight: 720, + projectStill: 400 * 1024, + chart: 100 * 1024, + deferredProjectVisuals: 2.5 * 1024 * 1024, + highTierExtras: 750 * 1024, + deferredNonVideo: 3.5 * 1024 * 1024, + imageSceneConcurrency: 2, + videoConcurrency: 1, + mobileTextureDimension: 2048, + lowTierGpuEstimate: 96 * 1024 * 1024, + highTierGpuEstimate: 192 * 1024 * 1024, +}); + +export const classifyArtifact = (path, source = '') => { + if (path === 'index.html') return 'initial-html'; + if (/^assets\/index-[^/]+\.css$/u.test(path)) return 'critical-css'; + if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js'; + if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene'; + if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts'; + if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster'; + if (/^assets\/project-[^/]+\.(?:webm|mp4)$/u.test(path)) return 'project-video'; + if (/^assets\/project-[^/]*chart[^/]*\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'chart'; + if (/^assets\/project-[^/]+\.(?:avif|webp|png|jpe?g|svg)$/u.test(path)) return 'project-still'; + if (/^(?:robots\.txt|sitemap\.xml|_headers\.example|_redirects\.example)$/u.test(path)) return 'release-metadata'; + throw new Error(`Artifact is unclassified: ${path}`); +}; + +const thresholdByTotal = Object.freeze({ + initialHtmlCriticalCss: PERFORMANCE_BUDGETS.initialHtmlCriticalCss, + initialApplicationJs: PERFORMANCE_BUDGETS.initialApplicationJs, + heroPoster: PERFORMANCE_BUDGETS.heroPoster, + initialFonts: PERFORMANCE_BUDGETS.initialFonts, + firstLiveScene: PERFORMANCE_BUDGETS.firstLiveScene, + deferredProjectVisuals: PERFORMANCE_BUDGETS.deferredProjectVisuals, + deferredNonVideo: PERFORMANCE_BUDGETS.deferredNonVideo, +}); + +export const evaluateBudgetTotals = (totals) => ({ + violations: Object.entries(thresholdByTotal) + .filter(([budget, threshold]) => (totals[budget] ?? 0) > threshold) + .map(([budget, threshold]) => ({ budget, actual: totals[budget], threshold })), +}); diff --git a/scripts/lib/performance-aggregation.d.mts b/scripts/lib/performance-aggregation.d.mts new file mode 100644 index 0000000..fb0ad34 --- /dev/null +++ b/scripts/lib/performance-aggregation.d.mts @@ -0,0 +1,7 @@ +export function aggregateFiveDriveTraces(runs: readonly unknown[]): { + schema: 'drive-trace-aggregation/v1'; + profile: string; + runCount: 5; + summary: Record; + passes: boolean; +}; diff --git a/scripts/lib/performance-aggregation.mjs b/scripts/lib/performance-aggregation.mjs new file mode 100644 index 0000000..ebf185a --- /dev/null +++ b/scripts/lib/performance-aggregation.mjs @@ -0,0 +1,37 @@ +const median = (values) => { + if (values.length === 0) throw new Error('At least one value is required.'); + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)]; +}; + +export const aggregateFiveDriveTraces = (runs) => { + if (!Array.isArray(runs) || runs.length !== 5) throw new Error('Exactly five drive-trace.v1 runs are required.'); + const profile = runs[0]?.profile; + if (typeof profile !== 'string' || runs.some((run) => run?.schema !== 'drive-trace.v1' || run.profile !== profile)) { + throw new Error('All runs must use one drive-trace.v1 profile.'); + } + const lcp = runs.map((run) => run.metrics.lcp); + const tbt = runs.map((run) => run.metrics.tbt); + const everyPerRunBudgetPass = runs.every((run) => Object.values(run.budgetResults).every(Boolean)); + const referenceEvidence = runs.every((run) => run.gatingReferenceResult === true && run.validReferenceCadence === true); + const summary = { + lcpWorst: Math.max(...lcp), + lcpMedian: median(lcp), + tbtWorst: Math.max(...tbt), + tbtMedian: median(tbt), + everyPerRunBudgetPass, + referenceEvidence, + }; + return { + schema: 'drive-trace-aggregation/v1', + profile, + runCount: 5, + summary, + passes: referenceEvidence + && everyPerRunBudgetPass + && summary.lcpWorst <= 3_000 + && summary.lcpMedian <= 2_500 + && summary.tbtWorst <= 300 + && summary.tbtMedian <= 200, + }; +}; diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs index 92a22ec..2a360e0 100644 --- a/scripts/verify-artifacts.mjs +++ b/scripts/verify-artifacts.mjs @@ -16,6 +16,8 @@ const canonicalSectionIds = [ const prohibitedSurfaces = [ /__PORTFOLIO_INPUTS__/iu, /__PORTFOLIO_TEST_HOOKS__/iu, + /__PORTFOLIO_PERFORMANCE__/iu, + /__portfolio_performance/iu, /PORTFOLIO_TEST_TOKEN/iu, /__PORTFOLIO_(?:TEST|INSTRUMENTATION)__/iu, /__portfolio(?:Journey)?Instrumentation/iu, diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index dbdc21e..9b4ee6b 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -20,6 +20,9 @@ import type { PortfolioContent, SectionId } from '@/content/types'; import type { SceneHostState } from '@/composables/useSceneController'; import type { MotionPolicy, QualityTier } from '@/journey/motion-policy'; +declare const __PORTFOLIO_TEST_BUILD__: boolean; +const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; + const props = defineProps<{ content: Readonly }>(); const canonicalSectionIds = [ @@ -57,7 +60,13 @@ const adaptive = useAdaptiveQuality({ sceneState, pageLifecycle, }); -const actualQuality = computed(() => adaptive.snapshot.value.actual); +const requestedPerformanceTier = TEST_BUILD + ? new URL(window.location.href).searchParams.get('__portfolio_tier') + : null; +const pinnedPerformanceTier = requestedPerformanceTier === 'high' || requestedPerformanceTier === 'low' + ? requestedPerformanceTier + : null; +const actualQuality = computed(() => pinnedPerformanceTier ?? adaptive.snapshot.value.actual); const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId)); const serverDefaultPolicy: MotionPolicy = { allowSceneRequest: false, @@ -70,6 +79,16 @@ const displayedQuality = computed(() => controlsReady.value ? actua const displayedSceneState = computed(() => controlsReady.value ? sceneState.value : 'running'); let removePageListeners = (): void => {}; +const sampleSceneFrame = (start: number, end: number): void => { + if (pinnedPerformanceTier === null) adaptive.sampleFrame(start, end); + if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneFrame(start, end); +}; + +const updateSceneState = (state: SceneHostState): void => { + sceneState.value = state; + if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordSceneState(state); +}; + watch( () => journey.value.activeSectionId, (id) => navigation.notePassiveSection(id), @@ -100,6 +119,28 @@ const enableMotion = (): void => { const resumeMotion = (): void => { motion.setPaused(false); adaptive.setMaximum(motion.policy.value.maximumTier, 'manual'); + if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('resume'); +}; + +const pauseMotion = (): void => { + motion.setPaused(true); + if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('pause'); +}; + +const startDrive = (): void => { + audio.startFromGesture(); + if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('start'); +}; + +const toggleAudioMuted = (): void => { + const action = audio.muted.value ? 'unmute' : 'mute'; + audio.toggleMuted(); + if (TEST_BUILD) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation(action); +}; + +const updateAudioVolume = (value: number): void => { + audio.setVolume(value); + if (TEST_BUILD && value === 0.35) window.__PORTFOLIO_PERFORMANCE__?.recordActionMutation('volume'); }; onMounted(() => { @@ -126,8 +167,8 @@ onUnmounted(() => removePageListeners()); :policy="motion.policy.value" :quality="actualQuality" :scene-description="content.sceneDescription" - :sample-frame="adaptive.sampleFrame" - @state-change="sceneState = $event" + :sample-frame="sampleSceneFrame" + @state-change="updateSceneState" /> removePageListeners()); :audio-state="audio.state.value" :audio-muted="audio.muted.value" :audio-volume="controlsReady ? motion.volume.value : 0.4" - @pause="motion.setPaused(true)" + @pause="pauseMotion" @resume="resumeMotion" @enable="enableMotion" @quality-change="setQuality" - @audio-start="audio.startFromGesture" + @audio-start="startDrive" @audio-resume="audio.resumeFromGesture" - @audio-mute-toggle="audio.toggleMuted" - @audio-volume-change="audio.setVolume" + @audio-mute-toggle="toggleAudioMuted" + @audio-volume-change="updateAudioVolume" /> diff --git a/src/components/DriveControls.vue b/src/components/DriveControls.vue index f575e0a..8b3a820 100644 --- a/src/components/DriveControls.vue +++ b/src/components/DriveControls.vue @@ -86,9 +86,6 @@ const changeVolume = (event: Event): void => { type="button" @click="emit('audioResume')" >Resume sound - +