From 36b859cabbc67ff9b7af0cad718609734d866030 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" <159480018+leonw21342315@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:59:51 +0200 Subject: [PATCH] ENH - add persistent dynamic island activities (#17) * FIX - layer dynamic island above phone camera The phone screen stacking context kept the Dynamic Island below the physical frame and its camera lens. Render the island at device-shell level, position it around the lens, and keep expanded notification spacing in sync. * ADD - preview all dynamic island states Reuse the runtime component for a development-only gallery and size expanded previews to the actual phone display so they cannot be clipped. * FIX - match dynamic island live activity behavior Show each activity only outside its owning app, use reference-sized activity layouts, and remove the separate expand glyph in favor of direct island taps. * FIX - refine dynamic island live controls Keep background activity state separate from foreground visibility so the island remains visible in the compact closed-phone peek. Match the timer, stopwatch, music, collapse, and spacing behavior to the supplied references. * FIX - keep business call availability after closing Stop treating the closed phone UI as the end of business-call readiness. Route background service-line calls only after revalidating the employee, owned device, and registered SIM. * FIX - remove dock apps from home grid The stored Home layout treated dock and grid shortcuts independently, so default dock apps appeared twice. Let the dock own those default placements and migrate saved layouts by removing matching grid shortcuts. * FIX - keep memo island active after closing Memo recording was cancelled as soon as the UI closed, while uploads required an active UI session and reopening a locked phone forced the home route. Keep the recorder bound to the physical device, revalidate device ownership server-side for uploads, preserve the last route behind the lock screen, and render only a compact frame, display, and live Dynamic Island while closed. * ENH - align live activity controls with phone hardware Report compact live-activity state to the client so Space opens Home or the enabled lock screen contextually. Apply the phone hardware volume as a shared output level for app media, games, YouTube playback, radio, and auxiliary sounds, and render the volume speaker icon in grey. * FIX - hide paused music live activity The music island previously depended only on the selected track, which remains set after playback pauses. Require active playback as well so the island closes when music stops while preserving the track for later resume. --------- Co-authored-by: DerEchteAlec --- frontend/src/App.vue | 79 +- .../AppDevelopmentPreview.contract.test.ts | 31 +- frontend/src/assets/main.css | 91 +-- ...companiesServiceLineCalls.contract.test.ts | 36 + frontend/src/components/PayphoneOverlay.vue | 5 +- .../PhoneDynamicIsland.contract.test.ts | 147 +++- .../src/components/PhoneDynamicIsland.vue | 718 ++++++++++++++---- frontend/src/components/PhoneMemoRecorder.vue | 23 +- frontend/src/features/games/memory/audio.ts | 7 +- .../src/features/games/minesweeper/audio.ts | 4 +- .../src/features/games/neon-drop/audio.ts | 7 +- .../src/features/games/number-merge/audio.ts | 4 +- .../src/features/games/sky-flappy/audio.ts | 4 +- .../src/features/games/tower-stack/audio.ts | 4 +- frontend/src/phoneAudio.contract.test.ts | 37 + frontend/src/phoneInventory.contract.test.ts | 18 +- frontend/src/router/index.ts | 6 + frontend/src/stores/app-store.test.ts | 47 +- frontend/src/stores/app-store.ts | 17 +- frontend/src/stores/music.ts | 20 +- frontend/src/types/dynamicIsland.ts | 7 + frontend/src/utils/homeLayout.test.ts | 35 + frontend/src/utils/homeLayout.ts | 31 + frontend/src/utils/phoneAudio.ts | 96 +++ frontend/src/utils/tones.test.ts | 3 +- frontend/src/utils/tones.ts | 5 +- frontend/src/views/apps/FlipTokApp.vue | 20 +- ...PhoneDynamicIslandGallery.contract.test.ts | 70 ++ .../development/PhoneDynamicIslandGallery.vue | 303 ++++++++ .../SkyUiKitchenSinkView.contract.test.ts | 4 +- frontend/testserver/index.cjs | 1 + frontend/testserver/smoke.cjs | 1 + sky_phone/source/client/main.lua | 34 + sky_phone/source/server/companies.lua | 22 +- sky_phone/source/server/memos.lua | 21 +- sky_phone/source/server/phone.lua | 1 - 36 files changed, 1644 insertions(+), 315 deletions(-) create mode 100644 frontend/src/phoneAudio.contract.test.ts create mode 100644 frontend/src/types/dynamicIsland.ts create mode 100644 frontend/src/utils/phoneAudio.ts create mode 100644 frontend/src/views/development/PhoneDynamicIslandGallery.contract.test.ts create mode 100644 frontend/src/views/development/PhoneDynamicIslandGallery.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3ff419b..78ebe16 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -56,6 +56,7 @@ import { useNotesStore } from '@/stores/notes' import { useMemosStore } from '@/stores/memos' import { useWeatherStore } from '@/stores/weather' import { useEasyShareStore } from '@/stores/easyshare' +import { useRadioStore } from '@/stores/radio' import { useNotificationsStore, type PhoneNotification, @@ -70,10 +71,15 @@ import type { CompanyUnreadCounts, } from '@/types/companies' import type { PhoneCall } from '@/types/phone' +import type { DynamicIslandActivity } from '@/types/dynamicIsland' import type { EasyShareEvent } from '@/types/easyshare' import type { CryptoMarketChangedData } from '@/types/crypto' import type { CityWarnEventData } from '@/types/citywarn' import { nuiCall } from '@/utils/nui' +import { + installPhoneAudioController, + setPhoneOutputVolume, +} from '@/utils/phoneAudio' import { formatTimer } from '@/utils/clock' import { parsePhonePreferences } from '@/utils/preferences' import { getHairlinePixelStyle } from '@/utils/rendering' @@ -82,6 +88,7 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages' import SpringboardView from '@/views/SpringboardView.vue' type AppMessage = { + openHome?: boolean type?: string data?: | CalendarReminderData @@ -313,6 +320,7 @@ const notes = useNotesStore() const memos = useMemosStore() const weather = useWeatherStore() const easyShare = useEasyShareStore() +const radio = useRadioStore() const notifications = useNotificationsStore() const route = useRoute() const router = useRouter() @@ -335,8 +343,13 @@ const DARK_STATUS_BAR_APP_IDS = new Set([ 'minesweeper', 'number-merge', ]) +const isDynamicIslandGalleryRoute = computed( + () => isDevelopment && route.name === 'development-dynamic-islands', +) const isDevelopmentRoute = computed( - () => isDevelopment && route.name === 'development-sky-ui', + () => + isDevelopment && + (route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value), ) const appTransitionName = computed(() => route.query.transition === 'app-switch' ? 'app-switch' : 'app-window', @@ -356,9 +369,12 @@ const setupPreviewDismissed = ref(false) const setupDevelopmentSkipped = ref(false) const setupAppearanceSelected = ref(false) const pendingUnlockRoute = ref(null) +const openHomeRequested = ref(false) const unlockedServicesLoaded = ref(false) const controlCenterOpened = ref(false) const activitySuspended = ref(false) +const dynamicIslandExpanded = ref(false) +const dynamicIslandActivity = ref(null) const simPicker = ref(null) const setupRequired = computed( () => @@ -432,6 +448,12 @@ const phoneResolutionStyle = computed(() => ({ })) const phoneStageStyle = computed(() => ({ ...phoneResolutionStyle.value, + '--phone-live-activity-peek-height': + dynamicIslandActivity.value === 'music' + ? '190px' + : dynamicIslandActivity.value === 'recording' + ? '132px' + : '112px', visibility: activitySuspended.value ? 'hidden' : 'visible', })) const phoneDisplayStyle = computed(() => ({ @@ -449,6 +471,7 @@ let unlockTimer: number | undefined let passcodeLockTimer: number | undefined let hardwareVolumeHudTimer: number | undefined let unlockedServicesIdle: number | undefined +let removePhoneAudioController: (() => void) | undefined let phoneClosePending = false let simPickerClosePending = false @@ -758,6 +781,7 @@ function onMessage(event: MessageEvent): void { }) } } else if (event.data?.type === 'app:open') { + openHomeRequested.value = event.data.openHome === true hydratePhone(event.data.data as PhoneOpenPayload) void syncNavigationState().then(() => nuiCall('ui:opened')) } else if (event.data?.type === 'device:updated') { @@ -1473,6 +1497,7 @@ function onFocusOut(event: FocusEvent): void { } onMounted(() => { + removePhoneAudioController = installPhoneAudioController() document.addEventListener('focusin', onFocusIn) document.addEventListener('focusout', onFocusOut) window.addEventListener('message', onMessage) @@ -1587,6 +1612,23 @@ watch( }, ) +watch( + () => Boolean(dynamicIslandActivity.value || calls.activeCall), + (active) => { + void nuiCall('ui:live-activity', { active }) + }, + { immediate: true }, +) + +watch( + hardwareAlertVolume, + (volume) => { + setPhoneOutputVolume(volume / 100) + if (radio.data.connected) void radio.setVolume(volume) + }, + { immediate: true }, +) + watch( () => phone.isOpen, (isOpen) => { @@ -1619,7 +1661,8 @@ watch( } isLocked.value = setupRequired.value ? false - : !isDevelopment || developmentLockScreenPreview + : developmentLockScreenPreview || + (!isDevelopment && phone.security.enabled) passcodeRequired.value = isLocked.value && phone.security.enabled unlockedServicesLoaded.value = false controlCenterOpened.value = false @@ -1637,8 +1680,18 @@ watch( startPasscodeLock(passcodeRetrySeconds.value) } phone.setLaunchOrigin(null) - if (isLocked.value || setupRequired.value) void router.replace('/') - else loadUnlockedPhoneData() + if (setupRequired.value) { + void router.replace('/') + } else if (openHomeRequested.value) { + openHomeRequested.value = false + if (isLocked.value) pendingUnlockRoute.value = '/' + else { + void router.replace('/') + loadUnlockedPhoneData() + } + } else if (!isLocked.value) { + loadUnlockedPhoneData() + } }, ) @@ -1650,6 +1703,7 @@ watch( ) onBeforeUnmount(() => { + removePhoneAudioController?.() updateTextInputFocus(false) cancelUnlockedPhoneDataLoad() weather.stop() @@ -1688,12 +1742,15 @@ onBeforeUnmount(() => { phone.isOpen || notifications.current || calls.activeCall || + dynamicIslandActivity || notifications.devicePreviews.length " class="phone-stage" :class="{ 'phone-stage--browser-preview': isBrowserPreview, 'phone-stage--landscape': phone.cameraLandscape, + 'phone-stage--live-activity': + !phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall), 'phone-stage--peek': notifications.isPeeking, }" :style="phoneStageStyle" @@ -1713,7 +1770,12 @@ onBeforeUnmount(() => { @open="openNotificationPreview" />
@@ -1721,6 +1783,7 @@ onBeforeUnmount(() => { class="phone-device" :class="{ 'phone-app--light': !displayedDarkMode, + 'phone-device--island-expanded': dynamicIslandExpanded, [`phone-app--${phone.preferences.settings.graphicsMode}`]: true, }" :aria-label="phone.t('Common.phone')" @@ -1832,7 +1895,6 @@ onBeforeUnmount(() => { @control-center="toggleControlCenter" @lock="lockPhone" /> - { aria-hidden="true" draggable="false" /> +
diff --git a/frontend/src/AppDevelopmentPreview.contract.test.ts b/frontend/src/AppDevelopmentPreview.contract.test.ts index da85cf8..bd46b82 100644 --- a/frontend/src/AppDevelopmentPreview.contract.test.ts +++ b/frontend/src/AppDevelopmentPreview.contract.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8') +const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n') const mainCss = readFileSync( new URL('./assets/main.css', import.meta.url), 'utf8', @@ -22,15 +22,32 @@ describe('browser development preview contract', () => { it('starts unlocked while preserving an explicit lock screen preview', () => { expect(source).toContain("developmentParameters.has('lockScreenPreview')") - expect(source).toContain(': !isDevelopment || developmentLockScreenPreview') + expect(source).toContain( + 'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)', + ) expect(source).toContain("developmentParameters.has('setupPreview')") }) - it('loads authenticated app data without replacing direct app routes', () => { - expect(source).toContain( + it('restores the active route after the lock screen', () => { + expect(source).toMatch( + /if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/, + ) + expect(source).toMatch( + /else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/, + ) + expect(source).not.toContain( "if (isLocked.value || setupRequired.value) void router.replace('/')", ) - expect(source).toContain('else loadUnlockedPhoneData()') + }) + + it('opens Space-triggered live activities on Home or the enabled lock screen', () => { + expect(source).toContain( + 'openHomeRequested.value = event.data.openHome === true', + ) + expect(source).toContain( + "if (isLocked.value) pendingUnlockRoute.value = '/'", + ) + expect(source).toContain("void router.replace('/')") }) it('requires the passcode again after a full device lock', () => { @@ -100,7 +117,9 @@ describe('browser development preview contract', () => { expect(source).toContain("developmentParameters.has('browserPreview')") expect(source).toContain('import.meta.env.DEV ||') expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview") - expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE') + expect(source).toContain( + 'return (availableScale * 0.94) / PHONE_BASE_SCALE', + ) expect(mainCss).toMatch( /\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s, ) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 41ea588..1eb36f7 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -259,6 +259,20 @@ button { transform: translateY(calc(100% - 190px)); transform-origin: right bottom; } + +.phone-stage--live-activity .phone-resolution-wrapper--primary .phone-device { + pointer-events: none; + transform: translateY( + calc(100% - var(--phone-live-activity-peek-height, 145px)) + ); + transform-origin: right bottom; +} +.phone-stage--live-activity + .phone-device + > :not(.phone-screen):not(.phone-device__frame):not(.phone-dynamic-island), +.phone-stage--live-activity .phone-screen > * { + visibility: hidden; +} .phone-lift-enter-active { transition: opacity 0.52s linear; } @@ -384,7 +398,7 @@ button { overflow: hidden; border: 1px solid rgb(255 255 255 / 16%); border-radius: 15px; - color: #0a84ff; + color: #a9abb2; background: rgb(47 47 51 / 98%); box-shadow: 0 8px 24px rgb(0 0 0 / 28%); pointer-events: none; @@ -672,6 +686,9 @@ button { transition: transform 280ms var(--sky-ease-out, ease-out); will-change: transform; } +.phone-device--island-expanded .phone-notification { + top: 130px !important; +} .phone-notification__icon { width: 38px; height: 38px; @@ -795,78 +812,6 @@ button { cursor: pointer; pointer-events: auto; } -.phone-dynamic-island { - position: absolute; - z-index: 98; - top: 12px; - left: 50%; - display: flex; - align-items: center; - gap: 10px; - width: 300px; - min-height: 72px; - padding: 10px 12px 10px 16px; - border: 1px solid rgb(255 255 255 / 10%); - border-radius: 28px; - color: #fff; - background: #050505; - box-shadow: 0 8px 24px rgb(0 0 0 / 45%); - transform: translateX(-50%); -} -.phone-dynamic-island__caller { - min-width: 0; - flex: 1; -} -.phone-dynamic-island__caller span, -.phone-dynamic-island__caller strong { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.phone-dynamic-island__caller span { - color: rgb(255 255 255 / 58%); - font-size: 11px; - line-height: 14px; -} -.phone-dynamic-island__caller strong { - margin-top: 2px; - font-size: 15px; - line-height: 18px; -} -.phone-dynamic-island__actions { - display: flex; - gap: 8px; -} -.phone-dynamic-island__actions .sky-button--icon-only { - width: 44px; - min-width: 44px; - height: 44px; -} -.phone-dynamic-island__actions svg { - width: 19px; - height: 19px; -} -.phone-dynamic-island__answer { - --sky-app-accent: #34c759; -} -.phone-dynamic-island-enter-active, -.phone-dynamic-island-leave-active { - transition: - opacity 180ms ease, - transform 220ms cubic-bezier(0.22, 1, 0.36, 1); -} -.phone-dynamic-island-enter-from, -.phone-dynamic-island-leave-to { - opacity: 0; - transform: translateX(-50%) scale(0.86); -} -@media (prefers-reduced-motion: reduce) { - .phone-dynamic-island-enter-active, - .phone-dynamic-island-leave-active { - transition: none; - } -} .phone-home-indicator { position: absolute; z-index: 90; diff --git a/frontend/src/companiesServiceLineCalls.contract.test.ts b/frontend/src/companiesServiceLineCalls.contract.test.ts index b604bcc..c110c9f 100644 --- a/frontend/src/companiesServiceLineCalls.contract.test.ts +++ b/frontend/src/companiesServiceLineCalls.contract.test.ts @@ -14,6 +14,10 @@ const callsServer = readFileSync( new URL('../../sky_phone/source/server/calls.lua', import.meta.url), 'utf8', ).replace(/\r\n/g, '\n') +const phoneServer = readFileSync( + new URL('../../sky_phone/source/server/phone.lua', import.meta.url), + 'utf8', +).replace(/\r\n/g, '\n') const companiesStore = readFileSync( new URL('./stores/companies.ts', import.meta.url), 'utf8', @@ -85,3 +89,35 @@ describe('Companies outbound service-line call contract', () => { expect(startCompanyCall).not.toContain('data.callerNumber') }) }) + +describe('Companies background call availability contract', () => { + it('keeps call availability enabled after the phone UI closes', () => { + const closeDevice = sourceBlock( + phoneServer, + `Bridge.Callbacks.Register(${quote}sky_phone:device:close${quote}`, + `Bridge.Callbacks.Register(${quote}sky_phone:device:notification-open${quote}`, + ) + + expect(closeDevice).toContain('sessions[source] = nil') + expect(closeDevice).not.toContain( + 'SkyPhoneCompanies.ClearCallAvailability(source)', + ) + }) + + it('routes background calls only to an owned phone with the same registered SIM', () => { + const getCallTargets = sourceBlock( + companiesServer, + 'function SkyPhoneCompanies.GetCallTargets(', + '\n\nlocal function profile_row(', + ) + + expect(getCallTargets).toContain('SkyPhone.LoadDevice(readiness.imei)') + expect(getCallTargets).toContain( + 'SkyPhone.FindDeviceSlots(source, readiness.imei)', + ) + expect(getCallTargets).toContain('device.sim_id == readiness.sim_id') + expect(getCallTargets).toContain('device.sim_type == "registered"') + expect(getCallTargets).toContain('device.registered_at ~= nil') + expect(getCallTargets).not.toContain('current_device(source, true)') + }) +}) diff --git a/frontend/src/components/PayphoneOverlay.vue b/frontend/src/components/PayphoneOverlay.vue index eb1fbd3..fef7d10 100644 --- a/frontend/src/components/PayphoneOverlay.vue +++ b/frontend/src/components/PayphoneOverlay.vue @@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png' import { nuiCall } from '@/utils/nui' +import { registerPhoneMediaElement } from '@/utils/phoneAudio' import { isTrustedRootMessageSource } from '@/utils/windowMessages' type PayphoneState = @@ -70,7 +71,9 @@ const buttonSounds: HTMLAudioElement[] = [] function prepareButtonSounds(): void { if (buttonSounds.length) return for (let index = 0; index < 4; index += 1) { - const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`) + const sound = registerPhoneMediaElement( + new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`), + ) sound.preload = 'auto' sound.volume = 0.55 buttonSounds.push(sound) diff --git a/frontend/src/components/PhoneDynamicIsland.contract.test.ts b/frontend/src/components/PhoneDynamicIsland.contract.test.ts index 7997f79..627f510 100644 --- a/frontend/src/components/PhoneDynamicIsland.contract.test.ts +++ b/frontend/src/components/PhoneDynamicIsland.contract.test.ts @@ -5,8 +5,15 @@ import { describe, expect, it } from 'vitest' const source = readFileSync( new URL('./PhoneDynamicIsland.vue', import.meta.url), 'utf8', +).replace(/\r\n/g, '\n') +const appSource = readFileSync( + new URL('../App.vue', import.meta.url), + 'utf8', +).replace(/\r\n/g, '\n') +const mainCss = readFileSync( + new URL('../assets/main.css', import.meta.url), + 'utf8', ) -const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8') const recorderSource = readFileSync( new URL('./PhoneMemoRecorder.vue', import.meta.url), 'utf8', @@ -19,16 +26,23 @@ const clockSource = readFileSync( new URL('../views/apps/ClockApp.vue', import.meta.url), 'utf8', ) +const serverMemosSource = readFileSync( + new URL('../../../sky_phone/source/server/memos.lua', import.meta.url), + 'utf8', +) describe('Phone Dynamic Island contract', () => { it('renders once at phone shell level instead of forcing calls into Phone', () => { expect(appSource).toContain( "import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'", ) - expect(appSource).toContain('') expect(appSource).toContain( - 'phone.isOpen || notifications.current || calls.activeCall', + "'phone-device--island-expanded': dynamicIslandExpanded", ) + expect(appSource).toMatch( + /class="phone-device__frame"[\s\S]*? void router.push('/apps/phone'), 0)", ) @@ -45,7 +59,44 @@ describe('Phone Dynamic Island contract', () => { expect(source).toContain('call.answeredAt ?? call.startedAt') }) + it('hides an activity in its owning foreground app but restores it after closing', () => { + expect(source).toContain( + 'if (!currentActivity || !phone.isOpen) return currentActivity', + ) + expect(source).toContain("activeAppId.value === 'phone'") + expect(source).toContain( + "currentActivity === 'recording' && activeAppId.value === 'memos'", + ) + expect(source).toContain("activeAppId.value === 'clock'") + expect(source).toContain( + "currentActivity === 'music' && activeAppId.value === 'music'", + ) + }) + + it('shows only a compact frame and island for background live activities', () => { + expect(source).toContain("'live-activity-change': [activity:") + expect(source).toContain("emit('live-activity-change', nextActivity)") + expect(appSource).toContain( + "'phone-stage--live-activity':\n !phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall)", + ) + expect(mainCss).toMatch( + /\.phone-stage--live-activity[\s\S]*?pointer-events:\s*none;[\s\S]*?--phone-live-activity-peek-height/, + ) + expect(mainCss).toMatch( + /\.phone-stage--live-activity[\s\S]*?> :not\(\.phone-screen\):not\(\.phone-device__frame\):not\(\.phone-dynamic-island\)[\s\S]*?\.phone-screen > \*[\s\S]*?visibility:\s*hidden;/, + ) + expect(appSource).toContain("? '190px'") + expect(appSource).toContain("? '132px'") + expect(appSource).toContain(": '112px'") + expect(source).toContain( + "!phone.isOpen ||\n activity.value === 'incoming-call' ||", + ) + }) + it('connects music, recorder, timer, and stopwatch controls to their stores', () => { + expect(source).toContain( + "if (music.isPlaying && music.currentTrack) return 'music'", + ) expect(source).toContain('@click.stop="music.previous()"') expect(source).toContain('@click.stop="music.toggle()"') expect(source).toContain('@click.stop="music.next()"') @@ -53,9 +104,31 @@ describe('Phone Dynamic Island contract', () => { expect(source).toContain('clock.pauseTimer(Date.now())') expect(source).toContain('clock.pauseStopwatch(Date.now())') expect(source).toContain('clock.addLap(Date.now())') + expect(source).not.toContain('phone-dynamic-island__lap') + expect(source).toContain('phone-dynamic-island__stopwatch-meta') + expect(source).toContain('{{ stopwatchLapLabel }}') + expect(source).toContain('{{ stopwatchLapValue }}') + expect(source).toContain('{{ stopwatchTotalDisplay }}') }) - it('keeps recorder state available across app changes', () => { + it('matches the reference music player and timer control layouts', () => { + expect(source).toContain('phone-dynamic-island__music-equalizer') + expect(source).toContain('phone-dynamic-island__progress-track') + expect(source).toContain('{{ musicElapsedLabel }}') + expect(source).toContain('{{ musicRemainingLabel }}') + expect(source).not.toContain('Airplay') + expect(source).toContain('