Compare commits

...

2 Commits

Author SHA1 Message Date
Leon.Schmidt 36b859cabb 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 <bycraky@gmail.com>
2026-08-20 18:59:51 +02:00
DerEchteAlec d0317a35c0 ENH - refine phone setup (#15)
* ENH - refine phone setup experience

* FIX - refine settings header and search icon

* FIX - refine compact weather layout

* ENH - fade empty music widget content

* ENH - align gallery filter navigation

* FIX - align memo player content

* ENH - refine notes header and compose controls

* ENH - refine phone liquid glass controls

* ENH - apply liquid glass across app controls

* FIX - correct messages GIF picker layout

---------

Co-authored-by: smx.pusha <139338836+smxpusha@users.noreply.github.com>
2026-08-20 18:26:03 +02:00
77 changed files with 2718 additions and 844 deletions
+73 -6
View File
@@ -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<string | null>(null)
const openHomeRequested = ref(false)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const activitySuspended = ref(false)
const dynamicIslandExpanded = ref(false)
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
const simPicker = ref<SimPickerPayload | null>(null)
const setupRequired = computed(
() =>
@@ -432,6 +448,12 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
}))
const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value,
'--phone-live-activity-peek-height':
dynamicIslandActivity.value === 'music'
? '190px'
: dynamicIslandActivity.value === 'recording'
? '132px'
: '112px',
visibility: activitySuspended.value ? 'hidden' : 'visible',
}))
const phoneDisplayStyle = computed<CSSProperties>(() => ({
@@ -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<AppMessage>): 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"
/>
<div
v-if="phone.isOpen || notifications.current || calls.activeCall"
v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity
"
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
>
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
@@ -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"
/>
<PhoneDynamicIsland v-if="!setupRequired" />
<SpringboardView
v-if="!isDevelopmentRoute && !setupRequired"
@edit-mode-change="springboardEditing = $event"
@@ -1918,6 +1980,11 @@ onBeforeUnmount(() => {
aria-hidden="true"
draggable="false"
/>
<PhoneDynamicIsland
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
@expanded-change="dynamicIslandExpanded = $event"
@live-activity-change="dynamicIslandActivity = $event"
/>
</section>
</div>
</div>
@@ -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,
)
+78 -85
View File
@@ -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;
@@ -4178,8 +4123,8 @@ button {
display: flex;
align-items: center;
flex-direction: column;
min-height: 218px;
padding: 1px 0 16px;
min-height: 206px;
padding: 1px 0 10px;
text-align: center;
}
.weather-location {
@@ -4231,7 +4176,7 @@ button {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-2);
margin-bottom: var(--sky-space-4);
margin-bottom: var(--sky-space-3);
}
.weather-detail-card,
.weather-panel {
@@ -4242,18 +4187,21 @@ button {
0 8px 22px rgba(5, 16, 32, 0.14);
color: #fff;
}
.weather-detail-card {
.weather-details > .weather-detail-card {
display: grid;
grid-template-columns: 21px 1fr;
gap: 4px 7px;
min-height: 94px;
grid-template-rows: auto auto;
align-content: center;
gap: 7px;
min-height: 76px;
margin: 0;
padding: 14px;
padding: 11px 12px;
border-radius: calc(var(--sky-radius-card) - 6px);
}
.weather-detail-card svg {
grid-row: span 2;
margin-top: 2px;
align-self: center;
margin-top: 0;
color: #b7d9ec;
}
.weather-detail-card:nth-child(1) svg {
@@ -4273,7 +4221,7 @@ button {
text-transform: uppercase;
}
.weather-detail-card strong {
align-self: end;
align-self: auto;
font-size: 18px;
font-weight: 680;
line-height: 1.1;
@@ -4281,7 +4229,7 @@ button {
.weather-detail-card:nth-child(4) strong {
color: var(--weather-accent-cyan);
}
.weather-panel {
.weather-scroll > .weather-panel {
margin: 0;
overflow: hidden;
border-radius: var(--sky-radius-card);
@@ -4315,10 +4263,11 @@ button {
min-height: 108px;
padding: 7px 2px 4px;
border-left: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--sky-radius-control);
border-radius: 0;
}
.weather-hour:first-child {
border-left: 0;
border-radius: var(--sky-radius-control);
background: rgba(255, 255, 255, 0.07);
}
.weather-hour span {
@@ -6611,9 +6560,47 @@ button {
display: block;
object-fit: contain;
}
.messages-media-picker__gifs--masonry {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.messages-gif-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px;
}
.messages-gif-column {
min-width: 0;
display: grid;
align-content: start;
gap: 8px;
}
.messages-media-picker__gifs--masonry .messages-gif-result {
width: 100%;
min-height: 0;
display: block;
overflow: hidden;
border: 1px solid rgb(60 60 67 / 10%);
border-radius: 12px;
background: var(--sky-surface-muted);
box-shadow: none;
}
.messages-media-picker__gifs--masonry .messages-gif-result img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.messages-media-picker__gifs .messages-gif-more {
width: 100%;
min-height: 36px;
grid-column: 1 / -1;
flex: 0 0 auto;
border: 0;
background: transparent;
box-shadow: none;
color: var(--ios-blue);
font-size: 12px;
font-weight: 650;
@@ -6935,6 +6922,12 @@ button {
.phone-app.dark .messages-media-picker__gifs button {
background: #2c2c2e;
}
.phone-app.dark
.messages-media-picker__gifs--masonry
.messages-gif-result {
border-color: rgb(255 255 255 / 10%);
background: var(--sky-surface-muted);
}
.phone-app.dark .messages-media-picker__gifs .messages-gif-more,
.phone-app.dark .messages-media-picker__gifs .messages-gif-error button {
background: rgb(10 132 255 / 20%);
@@ -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)')
})
})
+4 -1
View File
@@ -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)
@@ -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('<PhoneDynamicIsland v-if="!setupRequired" />')
expect(appSource).toContain(
'phone.isOpen || notifications.current || calls.activeCall',
"'phone-device--island-expanded': dynamicIslandExpanded",
)
expect(appSource).toMatch(
/class="phone-device__frame"[\s\S]*?<PhoneDynamicIsland[\s\S]*?@expanded-change="dynamicIslandExpanded = \$event"[\s\S]*?@live-activity-change="dynamicIslandActivity = \$event"/,
)
expect(appSource).toContain('dynamicIslandActivity ||')
expect(appSource).not.toContain(
"window.setTimeout(() => 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('<X aria-hidden="true" />')
expect(source).toMatch(
/\.phone-dynamic-island--timer\.phone-dynamic-island__copy|\.phone-dynamic-island--timer \.phone-dynamic-island__copy/,
)
expect(source).toContain(
'.phone-dynamic-island--music .phone-dynamic-island__actions--media',
)
expect(source).toContain('justify-content: center')
expect(source).toContain('gap: 34px')
})
it('keeps recorder state available across app changes and phone closes', () => {
expect(recorderSource).toContain(
"message.type === 'memo:recordStateRequest'",
)
@@ -65,6 +138,15 @@ describe('Phone Dynamic Island contract', () => {
expect(memosSource).not.toContain(
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
)
expect(recorderSource).toContain('() => phone.device?.imei ?? null')
expect(recorderSource).not.toContain(
'if (!isOpen || deviceSessionChanged) cancelRecording()',
)
expect(recorderSource).toContain('deviceImei: finalDeviceImei')
expect(serverMemosSource).toContain('device_owner(src, data.deviceImei)')
expect(serverMemosSource).toContain(
'SkyPhone.FindDeviceSlots(source, imei)[1]',
)
})
it('opens both clock live activities on the correct clock tab', () => {
@@ -75,13 +157,68 @@ describe('Phone Dynamic Island contract', () => {
)
})
it('opens activities by tapping the island without a separate expand icon', () => {
expect(source).toContain('@click.stop="toggleExpanded"')
expect(source).toContain('@click.stop="openActivity"')
expect(source).not.toContain('Maximize2')
expect(source).not.toContain('phone-dynamic-island__open-icon')
})
it('collapses expanded activities on taps, swipes, and scrolling outside', () => {
expect(source).toContain('ref="islandElement"')
expect(source).toContain(
"document.addEventListener('pointerdown', onOutsidePointerDown, true)",
)
expect(source).toContain(
"document.addEventListener('scroll', collapseExpanded, true)",
)
expect(source).toContain('islandElement.value?.contains(event.target)')
expect(source).toContain('expanded.value = false')
expect(source).toContain(
"document.removeEventListener('pointerdown', onOutsidePointerDown, true)",
)
expect(source).toContain(
"document.removeEventListener('scroll', collapseExpanded, true)",
)
})
it('animates state changes and moves popup notifications below expanded UI', () => {
expect(source).toContain('<Transition name="phone-dynamic-island">')
expect(source).toContain(
'<Transition name="phone-dynamic-island-content" mode="out-in">',
)
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
expect(source).toContain('~ .phone-notification-provider')
expect(source).toContain("emit('expanded-change', false)")
expect(mainCss).toContain(
'.phone-device--island-expanded .phone-notification',
)
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
})
it('renders below the top edge and above the physical camera frame', () => {
expect(source).toMatch(
/\.phone-dynamic-island\s*\{[^}]*z-index:\s*102;[^}]*top:\s*30px;/s,
)
expect(mainCss).not.toMatch(/\.phone-dynamic-island\s*\{/)
})
it('keeps compact and expanded islands close to the physical camera proportions', () => {
expect(source).toMatch(
/\.phone-dynamic-island\s*\{[^}]*width:\s*126px;[^}]*height:\s*38px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island\[data-expanded='true'\]\s*\{[^}]*width:\s*318px;[^}]*height:\s*74px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--incoming-call\[data-expanded='true'\]\s*\{[^}]*height:\s*68px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--music\[data-expanded='true'\]\s*\{[^}]*width:\s*316px;[^}]*height:\s*150px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--stopwatch\[data-expanded='true'\]\s*\{[^}]*height:\s*70px;/s,
)
expect(source).toContain('box-sizing: border-box')
expect(source).toContain('padding: 8px 16px 8px 10px')
})
})
File diff suppressed because it is too large Load Diff
+10 -13
View File
@@ -54,6 +54,7 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
let currentState: MemoRecorderStateName = 'idle'
let currentElapsedMs = 0
let currentCorrelationId = ''
let recordingDeviceImei = ''
let removeRecorderErrorListener: (() => void) | null = null
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
@@ -153,6 +154,7 @@ function resetRecordingData(): void {
pausedStartedAt = 0
totalPausedMs = 0
currentElapsedMs = 0
recordingDeviceImei = ''
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
}
@@ -189,7 +191,8 @@ function failRecording(error: string, generation = recordingGeneration): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (!phone.isOpen) {
const deviceImei = phone.device?.imei
if (!phone.isOpen || !deviceImei) {
console.error('[Memos] Cannot start a recording while the phone is closed.')
return
}
@@ -217,6 +220,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
metadata = { note: '', pinned: false, title: '' }
updateMetadata(data)
resetRecordingData()
recordingDeviceImei = deviceImei
postRecorderState('starting')
try {
const acquiredStream = await navigator.mediaDevices.getUserMedia({
@@ -340,6 +344,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
if (generation !== recordingGeneration) return
const waveform = compressedWaveform()
const finalMetadata = { ...metadata }
const finalDeviceImei = recordingDeviceImei
const exceededSizeLimit = recordingTooLarge
cleanupRecorder(false)
resetRecordingData()
@@ -355,6 +360,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
const uploadData = {
correlationId,
deviceImei: finalDeviceImei,
durationMs,
mimeType,
note: finalMetadata.note,
@@ -569,18 +575,9 @@ function onMessage(event: MessageEvent): void {
onMounted(() => window.addEventListener('message', onMessage))
watch(
() =>
[
phone.isOpen,
phone.device?.imei ?? null,
phone.deviceSessionToken,
] as const,
([isOpen, imei, sessionToken], previous) => {
const deviceSessionChanged =
previous !== undefined &&
previous[0] &&
(previous[1] !== imei || previous[2] !== sessionToken)
if (!isOpen || deviceSessionChanged) cancelRecording()
() => phone.device?.imei ?? null,
(imei, previousImei) => {
if (previousImei && imei !== previousImei) cancelRecording()
},
)
@@ -34,6 +34,15 @@ describe('SpringboardWidget UI contract', () => {
expect(source).toContain('music.progress.value')
})
it('fades empty music artwork downward while keeping its message above the fade', () => {
expect(source).toContain('.home-widget--music-empty::after')
expect(source).toContain(
'.home-widget--music-empty .widget-music-placeholder',
)
expect(source).toContain('-webkit-mask-image: linear-gradient(')
expect(source).toMatch(/\.widget-music-empty\s*{[\s\S]*?z-index:\s*2;/)
})
it.each([
'sunny',
'clear',
+36 -3
View File
@@ -1076,6 +1076,39 @@ onBeforeUnmount(() => {
background: rgb(39 39 42 / 95%);
}
.home-widget--music-empty::after {
position: absolute;
z-index: 1;
inset: 0;
content: '';
border-radius: inherit;
background: linear-gradient(
to bottom,
transparent 34%,
rgb(20 20 22 / 18%) 62%,
rgb(16 16 18 / 82%) 100%
);
pointer-events: none;
}
.home-widget--music-empty .widget-album,
.home-widget--music-empty .widget-music-placeholder {
-webkit-mask-image: linear-gradient(
to bottom,
#000 0%,
#000 42%,
rgb(0 0 0 / 42%) 72%,
transparent 100%
);
mask-image: linear-gradient(
to bottom,
#000 0%,
#000 42%,
rgb(0 0 0 / 42%) 72%,
transparent 100%
);
}
.home-widget-shell--large .home-widget--music {
align-content: start;
grid-template-columns: 1fr;
@@ -1168,12 +1201,12 @@ onBeforeUnmount(() => {
.widget-music-empty {
position: absolute;
right: 15px;
bottom: 11px;
bottom: 14px;
left: 15px;
z-index: 0;
z-index: 2;
margin: 0;
color: rgb(255 255 255 / 72%);
font-size: 13px;
font-size: 14px;
font-weight: 650;
line-height: 17px;
text-align: center;
+6 -1
View File
@@ -1,3 +1,5 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
type Tone = {
@@ -46,7 +48,10 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
oscillator.type = tone.type
oscillator.frequency.setValueAtTime(tone.frequency, start)
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
start + 0.012,
)
gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain)
gain.connect(audioContext.destination)
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
@@ -28,7 +30,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,3 +1,5 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type NeonDropSound =
| 'clear'
| 'drop'
@@ -48,7 +50,10 @@ function playSequence(sound: NeonDropSound): void {
oscillator.type = type
oscillator.frequency.setValueAtTime(frequency, start + delay)
gain.gain.setValueAtTime(0.0001, start + delay)
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
start + delay + 0.008,
)
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
oscillator.connect(gain)
gain.connect(context.destination)
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
@@ -18,7 +20,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
@@ -11,7 +13,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
let players = pools.get(sound)
if (!players) {
players = Array.from({ length: 3 }, () => {
const player = new Audio(urls[sound])
const player = registerPhoneMediaElement(new Audio(urls[sound]))
player.preload = 'auto'
player.volume = 0.84
return player
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
@@ -18,7 +20,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.84
return player
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const appSource = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const audioSource = readFileSync(
new URL('./utils/phoneAudio.ts', import.meta.url),
'utf8',
)
const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
)
const musicSource = readFileSync(
new URL('./stores/music.ts', import.meta.url),
'utf8',
)
describe('phone audio output contract', () => {
it('applies the hardware volume to app media and live YouTube playback', () => {
expect(appSource).toContain('installPhoneAudioController()')
expect(appSource).toContain('setPhoneOutputVolume(volume / 100)')
expect(appSource).toContain(
'if (radio.data.connected) void radio.setVolume(volume)',
)
expect(audioSource).toContain(
"document.addEventListener('play', onMediaPlay, true)",
)
expect(audioSource).toContain('localVolume * outputVolume')
expect(musicSource).toContain('registerPhoneMediaElement(new Audio())')
expect(musicSource).toContain('store.volume * getPhoneOutputVolume() * 100')
})
it('renders the hardware speaker symbol in grey', () => {
expect(mainCss).toMatch(/\.phone-volume-hud\s*\{[\s\S]*?color:\s*#a9abb2;/)
})
})
+17 -1
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const readFrontendFile = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
@@ -232,6 +232,22 @@ describe('phone inventory contracts', () => {
)
})
it('opens a running live activity with Space without affecting normal gameplay', () => {
const phoneClient = readResourceFile('source/client/main.lua')
expect(phoneClient).toContain(
'RegisterCommand("sky_phone_live_activity_open"',
)
expect(phoneClient).toContain(
'if not live_activity_active or is_open or open_requested then',
)
expect(phoneClient).toContain(
'RegisterKeyMapping(\n "sky_phone_live_activity_open"',
)
expect(phoneClient).toContain('"SPACE"')
expect(phoneClient).toContain('RegisterNUICallback("ui:live-activity"')
})
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
+6
View File
@@ -15,6 +15,12 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
name: 'development-sky-ui',
path: '/development/sky-ui/:demo?',
},
{
component: () =>
import('@/views/development/PhoneDynamicIslandGallery.vue'),
name: 'development-dynamic-islands',
path: '/development/dynamic-islands',
},
]
: []
+41 -6
View File
@@ -64,6 +64,41 @@ describe('app store', () => {
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).toBe(true)
expect(apps.isInstalled('citywarn')).toBe(true)
expect(apps.homeLayout.dock).toEqual([
'phone',
'messages',
'camera',
'clock',
])
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
expect(apps.homeLayout.grid).not.toContain(dockAppId)
}
})
it('migrates current layouts so dock apps are not repeated in the grid', () => {
const apps = useAppStoreStore()
apps.hydrate({
homeLayout: {
dock: ['phone', 'messages', 'camera', 'clock'],
grid: ['phone', 'messages', 'calculator', 'camera', 'clock'],
hidden: [],
pageCount: 1,
version: HOME_LAYOUT_VERSION,
},
})
expect(apps.homeLayout.dock).toEqual([
'phone',
'messages',
'camera',
'clock',
])
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
expect(apps.homeLayout.grid).not.toContain(dockAppId)
}
expect(apps.homeLayout.grid).toContain('calculator')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('removes old automatic apps unless the player installed them', () => {
@@ -320,7 +355,7 @@ describe('app store', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect(
@@ -329,7 +364,7 @@ describe('app store', () => {
HOME_GRID_PAGE_SIZE,
]),
).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
@@ -386,18 +421,18 @@ describe('app store', () => {
mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const clockIndex = apps.homeLayout.grid.indexOf('clock')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
clockIndex,
settingsIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
'clock',
'settings',
'notes',
])
const mailIndex = apps.homeLayout.grid.indexOf('mail')
@@ -405,7 +440,7 @@ describe('app store', () => {
apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work')
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
apps: ['mail', 'notes', 'clock'],
apps: ['mail', 'notes', 'settings'],
name: 'Work',
})
+10 -7
View File
@@ -25,6 +25,7 @@ import {
parseHomeLayout,
reflowHomeGridForWidgetChange,
renameHomeFolder,
removeDockGridDuplicates,
removeHomeApp,
restoreHomeApp,
type HomeArea,
@@ -45,7 +46,7 @@ const pendingInstallations = new WeakMap<
>()
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return [...PHONE_APPS]
return PHONE_APPS.filter((app) => app.dockOrder === null)
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
}
@@ -263,12 +264,16 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultGridIds(),
getDefaultDockIds(),
)
this.homeLayout = parseHomeLayout(
const parsedHomeLayout = parseHomeLayout(
data?.homeLayout,
defaults,
installedIds,
false,
)
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp)
for (const appId of protectedHiddenAppIds) {
@@ -292,6 +297,7 @@ export const useAppStoreStore = defineStore('app-store', {
this.hydrated = true
if (
protectedHiddenAppIds.length ||
removedDockGridDuplicates ||
removedLegacyDefaults ||
layoutVersion === 2 ||
layoutVersion === 3 ||
@@ -320,11 +326,8 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultDockIds(),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = parseHomeLayout(
this.homeLayout,
defaults,
installedIds,
false,
this.homeLayout = removeDockGridDuplicates(
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
)
for (const appId of [...this.homeLayout.hidden]) {
+16 -4
View File
@@ -7,6 +7,11 @@ import type {
MusicTrack,
} from '@/types/music'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
registerPhoneMediaElement,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
export type YouTubePlayer = {
destroy: () => void
@@ -48,7 +53,7 @@ declare global {
}
}
const audio = new Audio()
const audio = registerPhoneMediaElement(new Audio())
audio.preload = 'auto'
const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false
@@ -59,6 +64,11 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
let youtubePlayer: YouTubePlayer | null = null
let youtubeProgressTimer: number | null = null
subscribePhoneOutputVolume((volume) => {
const localVolume = useMusicStore().volume
youtubePlayer?.setVolume(localVolume * volume * 100)
})
export function musicTrackKey(
track: Pick<MusicTrack, 'id' | 'source'>,
): string {
@@ -276,7 +286,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
const api = await loadYouTubeApi()
const store = useMusicStore()
if (youtubePlayer) {
youtubePlayer.setVolume(store.volume * 100)
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
youtubePlayer.loadVideoById(videoId)
youtubePlayer.playVideo()
startYoutubeProgress()
@@ -319,7 +329,9 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
},
onReady: (event) => {
youtubePlayer = event.target
event.target.setVolume(useMusicStore().volume * 100)
event.target.setVolume(
useMusicStore().volume * getPhoneOutputVolume() * 100,
)
event.target.playVideo()
startYoutubeProgress()
resolve()
@@ -554,7 +566,7 @@ export const useMusicStore = defineStore('music', {
setVolume(value: number): void {
this.volume = Math.max(0, Math.min(1, value))
audio.volume = this.volume
youtubePlayer?.setVolume(this.volume * 100)
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
},
stop(): void {
stopActiveMedia()
+7
View File
@@ -0,0 +1,7 @@
export type DynamicIslandActivity =
| 'call'
| 'incoming-call'
| 'music'
| 'recording'
| 'stopwatch'
| 'timer'
+31
View File
@@ -111,6 +111,18 @@
color: var(--sky-text, #111827);
}
.sky-glass.sky-button--glass {
border-color: var(--sky-hairline, rgba(0, 0, 0, 0.2));
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
color: var(--sky-text, #000000);
box-shadow: var(--sky-shadow-glass);
}
.sky-glass.sky-button--glass:active:not(:disabled) {
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
filter: brightness(0.94);
}
.sky-button--danger {
background: var(--sky-danger, #dc2626);
}
@@ -2733,6 +2745,9 @@ label.sky-list-item__row {
.sky-fab--icon-only {
width: var(--sky-touch-target, 44px);
height: var(--sky-touch-target, 44px);
flex: none;
align-self: center;
padding: 0;
}
@@ -2741,6 +2756,16 @@ label.sky-list-item__row {
color: var(--sky-text, #000000);
}
.sky-glass.sky-fab--glass {
border: 1px solid var(--sky-hairline, rgba(0, 0, 0, 0.2));
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
color: var(--sky-text, #000000);
box-shadow: var(--sky-shadow-glass);
}
.sky-fab--glass .sky-fab__accent-layer,
.sky-fab--glass .sky-fab__dark-accent-layer,
.sky-fab--glass .sky-fab__surface-layer,
.sky-fab--neutral .sky-fab__accent-layer,
.sky-fab--neutral .sky-fab__dark-accent-layer,
.sky-fab--neutral .sky-fab__surface-layer {
@@ -2755,6 +2780,10 @@ label.sky-list-item__row {
background: var(--sky-glass-solid, rgba(247, 247, 248, 0.96));
}
.sky-glass.sky-fab--glass:active:not(:disabled) {
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
}
.sky-fab--disabled {
cursor: default;
opacity: 0.42;
@@ -2825,6 +2854,8 @@ label.sky-list-item__row {
.sky-glass--interactive {
min-width: var(--sky-touch-target, 44px);
min-height: var(--sky-touch-target, 44px);
-webkit-backdrop-filter: blur(18px) saturate(145%);
backdrop-filter: blur(18px) saturate(145%);
cursor: pointer;
}
@@ -28,6 +28,30 @@ describe('SkyButton', () => {
expect(html).toContain('Continue')
})
it('renders interactive liquid glass buttons through the shared glass surface', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(SkyButton, { glass: true, rounded: true }, () => 'Edit'),
}),
)
expect(html).toContain('sky-button--glass')
expect(html).toContain('sky-glass')
expect(html).toContain('sky-glass--interactive')
const controls = readFileSync(
fileURLToPath(new URL('../controls.css', import.meta.url)),
'utf8',
)
expect(controls).toMatch(
/\.sky-glass\.sky-button--glass\s*\{[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
)
expect(controls).toMatch(
/\.sky-glass--interactive\s*\{[^}]*-webkit-backdrop-filter:\s*blur\(18px\) saturate\(145%\);[^}]*backdrop-filter:\s*blur\(18px\) saturate\(145%\);/s,
)
})
it('keeps focus and pressed feedback on the contextual accent', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
+36 -15
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import SkyGlass from './SkyGlass.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
@@ -9,6 +11,7 @@ const props = withDefaults(
clear?: boolean
component?: 'a' | 'button'
disabled?: boolean
glass?: boolean
href?: string
iconOnly?: boolean
inline?: boolean
@@ -26,6 +29,7 @@ const props = withDefaults(
clear: false,
component: 'button',
disabled: false,
glass: false,
href: undefined,
iconOnly: false,
inline: false,
@@ -44,6 +48,23 @@ const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const buttonClasses = computed(() => [
`sky-button--${props.variant}`,
{
'sky-button--block': props.block,
'sky-button--clear': props.clear,
'sky-button--glass': props.glass,
'sky-button--icon-only': props.iconOnly,
'sky-button--inline': props.inline,
'sky-button--large': props.large,
'sky-button--outline': props.outline,
'sky-button--raised': props.raised,
'sky-button--rounded': props.rounded,
'sky-button--small': props.small && !props.large,
'sky-button--tonal': props.tonal,
},
])
const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === 'a') {
return {
@@ -71,25 +92,25 @@ function handleClick(event: MouseEvent): void {
</script>
<template>
<SkyGlass
v-if="glass"
:component="component"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-button"
:class="buttonClasses"
:disabled="disabled"
:href="href"
:type="type"
@click="handleClick"
>
<slot />
</SkyGlass>
<component
v-else
:is="component"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-button"
:class="[
`sky-button--${variant}`,
{
'sky-button--block': block,
'sky-button--clear': clear,
'sky-button--icon-only': iconOnly,
'sky-button--inline': inline,
'sky-button--large': large,
'sky-button--outline': outline,
'sky-button--raised': raised,
'sky-button--rounded': rounded,
'sky-button--small': small && !large,
'sky-button--tonal': tonal,
},
]"
:class="buttonClasses"
@click="handleClick"
>
<slot />
+29
View File
@@ -73,4 +73,33 @@ describe('SkyFab', () => {
/\.sky-glass\.sky-fab--neutral\s*\{[^}]*background:\s*var\(--sky-glass-solid/s,
)
})
it('offers a translucent glass variant for adjacent floating controls', async () => {
const html = await renderToString(
createSSRApp({
render: () => h(SkyFab, { ariaLabel: 'Create', variant: 'glass' }),
}),
)
expect(html).toContain('sky-fab--glass')
const controls = readFileSync(
fileURLToPath(new URL('../controls.css', import.meta.url)),
'utf8',
)
expect(controls).toMatch(
/\.sky-glass\.sky-fab--glass\s*\{[^}]*border:\s*1px solid var\(--sky-hairline[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
)
})
it('keeps icon-only fabs perfectly square inside stretching toolbars', () => {
const controls = readFileSync(
fileURLToPath(new URL('../controls.css', import.meta.url)),
'utf8',
)
expect(controls).toMatch(
/\.sky-fab--icon-only\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*height:\s*var\(--sky-touch-target, 44px\);[^}]*flex:\s*none;[^}]*align-self:\s*center;/s,
)
})
})
+2 -1
View File
@@ -14,7 +14,7 @@ const props = withDefaults(
text?: string
textPosition?: 'after' | 'before'
type?: 'button' | 'reset' | 'submit'
variant?: 'neutral' | 'primary'
variant?: 'glass' | 'neutral' | 'primary'
}>(),
{
ariaLabel: '',
@@ -72,6 +72,7 @@ function handleClick(event: MouseEvent): void {
class="sky-fab"
:class="{
'sky-fab--disabled': disabled,
'sky-fab--glass': variant === 'glass',
'sky-fab--icon-only': !hasText,
'sky-fab--neutral': variant === 'neutral',
'sky-fab--with-text': hasText,
+35
View File
@@ -21,6 +21,7 @@ import {
moveHomeFolderApp,
parseHomeLayout,
reflowHomeGridForWidgetChange,
removeDockGridDuplicates,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
@@ -78,6 +79,40 @@ describe('home layout', () => {
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('removes dock apps from the grid and normalizes affected folders', () => {
const layout: HomeLayout = {
...defaults,
dock: [
'phone',
{
apps: ['messages', 'mail'],
id: 'folder-abcdef',
name: 'Dock',
type: 'folder',
},
null,
null,
],
grid: [
'phone',
{
apps: ['messages', 'notes'],
id: 'folder-ghijkl',
name: 'Grid',
type: 'folder',
},
'mail',
'clock',
...Array.from({ length: HOME_GRID_PAGE_SIZE - 4 }, () => null),
],
}
const normalized = removeDockGridDuplicates(layout)
expect(normalized.grid.slice(0, 3)).toEqual(['notes', 'clock', null])
expect(normalized.dock).toEqual(layout.dock)
})
it('migrates compact persisted arrays and appends newly installed apps', () => {
const layout = parseHomeLayout(
{
+31
View File
@@ -505,6 +505,37 @@ export function createDefaultHomeLayout(
}
}
export function removeDockGridDuplicates(layout: HomeLayout): HomeLayout {
const dockAppIds = new Set<LaunchablePhoneAppId>()
for (const item of layout.dock) {
if (typeof item === 'string') dockAppIds.add(item)
if (isHomeFolder(item)) {
for (const appId of item.apps) dockAppIds.add(appId)
}
}
if (!dockAppIds.size) return layout
let changed = false
const grid = layout.grid.map((item): HomeSlot => {
if (typeof item === 'string') {
if (!dockAppIds.has(item)) return item
changed = true
return null
}
if (!isHomeFolder(item)) return null
const apps = item.apps.filter((appId) => !dockAppIds.has(appId))
if (apps.length === item.apps.length) return cloneItem(item)
changed = true
return normalizeFolder({ ...item, apps })
})
if (!changed) return layout
const next = cloneLayout(layout)
next.grid = compactGridPages(grid)
return next
}
export function parseHomeLayout(
value: unknown,
defaults: HomeLayout,
+96
View File
@@ -0,0 +1,96 @@
type PhoneAudioVolumeListener = (volume: number) => void
const mediaElements = new Map<HTMLMediaElement, boolean>()
const mediaLocalVolumes = new WeakMap<HTMLMediaElement, number>()
const volumeListeners = new Set<PhoneAudioVolumeListener>()
let documentListenerReferences = 0
let outputVolume = 1
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0))
}
function applyMediaVolume(element: HTMLMediaElement): void {
const localVolume = mediaLocalVolumes.get(element) ?? element.volume
const nextVolume = clampVolume(localVolume * outputVolume)
if (Math.abs(element.volume - nextVolume) < 0.001) return
element.volume = nextVolume
}
function onMediaVolumeChange(event: Event): void {
const element = event.currentTarget as HTMLMediaElement
const currentLocalVolume = mediaLocalVolumes.get(element) ?? element.volume
const expectedVolume = clampVolume(currentLocalVolume * outputVolume)
if (Math.abs(element.volume - expectedVolume) < 0.001) return
mediaLocalVolumes.set(element, clampVolume(element.volume))
applyMediaVolume(element)
}
function onMediaPlay(event: Event): void {
if (event.target instanceof HTMLMediaElement) {
trackPhoneMediaElement(event.target, false)
}
}
function trackPhoneMediaElement<T extends HTMLMediaElement>(
element: T,
persistent: boolean,
): T {
if (mediaElements.has(element)) {
if (persistent) mediaElements.set(element, true)
return element
}
mediaElements.set(element, persistent)
mediaLocalVolumes.set(element, clampVolume(element.volume))
element.addEventListener('volumechange', onMediaVolumeChange)
applyMediaVolume(element)
return element
}
export function getPhoneOutputVolume(): number {
return outputVolume
}
export function installPhoneAudioController(): () => void {
documentListenerReferences += 1
if (documentListenerReferences === 1) {
document.addEventListener('play', onMediaPlay, true)
document
.querySelectorAll<HTMLMediaElement>('audio, video')
.forEach((element) => trackPhoneMediaElement(element, false))
}
return () => {
documentListenerReferences = Math.max(0, documentListenerReferences - 1)
if (documentListenerReferences === 0) {
document.removeEventListener('play', onMediaPlay, true)
}
}
}
export function registerPhoneMediaElement<T extends HTMLMediaElement>(
element: T,
): T {
return trackPhoneMediaElement(element, true)
}
export function setPhoneOutputVolume(volume: number): void {
outputVolume = clampVolume(volume)
for (const [element, persistent] of mediaElements) {
if (!persistent && !element.isConnected && element.paused) {
element.removeEventListener('volumechange', onMediaVolumeChange)
mediaElements.delete(element)
continue
}
applyMediaVolume(element)
}
for (const listener of volumeListeners) listener(outputVolume)
}
export function subscribePhoneOutputVolume(
listener: PhoneAudioVolumeListener,
): () => void {
volumeListeners.add(listener)
return () => volumeListeners.delete(listener)
}
+2 -1
View File
@@ -32,7 +32,7 @@ describe('phone tones', () => {
}> = []
vi.stubGlobal(
'Audio',
class {
class extends EventTarget {
currentTime = 7
loop = false
pause = pause
@@ -42,6 +42,7 @@ describe('phone tones', () => {
volume = 0
constructor(src: string) {
super()
this.src = src
players.push(this)
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences'
@@ -495,8 +496,8 @@ export function playPhoneVibration(
kind: PhoneVibrationKind,
loop: boolean,
): () => void {
const player = new Audio(
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
const player = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
)
let stopped = false
player.loop = loop
@@ -48,13 +48,11 @@ describe('AppStoreDetail contract', () => {
expect(previewSource).toContain('v-if="previewImage && screen < 3"')
expect(previewSource).toContain(':src="previewImage"')
expect(previewSource).toContain('store-detail-preview__screenshot')
expect(previewSource).not.toContain("visual.scene ===")
expect(previewSource).not.toContain('visual.scene ===')
expect(source).toContain('getAppStorePreviewVisual')
expect(source).toContain('Apps.appStore.previews.${props.app.id}')
expect(previewSource).toContain(':src="iconImage"')
expect(source).toMatch(
/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/,
)
expect(source).toMatch(/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/)
expect(previewSource).toContain('screen === 3')
expect(previewSource).toContain('screen === 4')
expect(previewSource).toContain('store-detail-preview__details')
@@ -71,6 +69,16 @@ describe('AppStoreDetail contract', () => {
expect(source).toContain('scrollToPreview(activePreviewIndex + 1)')
expect(source).toContain('details.previousPreview')
expect(source).toContain('details.nextPreview')
expect(source).toContain('.store-detail__toolbar button:hover')
expect(source).toContain('.store-detail__toolbar-button:hover')
})
it('uses shared liquid glass for toolbar and preview controls', () => {
expect(source).toContain("import { SkyButton } from '@/ui'")
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(4)
expect(source).toContain('class="store-detail__toolbar-button"')
expect(source).toContain('class="store-detail__preview-control"')
expect(source).toMatch(
/\.store-detail__preview-control\s*\{[^}]*width:\s*var\(--sky-touch-target\);[^}]*height:\s*var\(--sky-touch-target\);[^}]*border-radius:\s*50%;/s,
)
})
})
+37 -27
View File
@@ -1,15 +1,11 @@
<script setup lang="ts">
import {
ChevronLeft,
ChevronRight,
Share2,
Star,
} from 'lucide-vue-next'
import { ChevronLeft, ChevronRight, Share2, Star } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
import { SkyButton } from '@/ui'
import { getAppStorePreviewImage } from '@/utils/appStorePreviewImages'
import { getAppStorePreviewVisual } from '@/utils/appStorePreviews'
@@ -126,20 +122,28 @@ function updateActivePreview(): void {
<template>
<section class="store-detail" :style="detailStyle">
<header class="store-detail__toolbar">
<button
<SkyButton
glass
icon-only
rounded
class="store-detail__toolbar-button"
type="button"
:aria-label="phone.t('Common.back')"
@click="emit('back')"
>
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="store-detail__toolbar-button"
type="button"
:aria-label="phone.t('Apps.appStore.details.share')"
@click="emit('share')"
>
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section class="store-detail__hero">
@@ -212,22 +216,30 @@ function updateActivePreview(): void {
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
<div class="store-detail__preview-navigation">
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
<button
<SkyButton
glass
icon-only
rounded
class="store-detail__preview-control"
type="button"
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
:disabled="activePreviewIndex === 0"
@click="scrollToPreview(activePreviewIndex - 1)"
>
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="store-detail__preview-control"
type="button"
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
:disabled="activePreviewIndex === previewCount - 1"
@click="scrollToPreview(activePreviewIndex + 1)"
>
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
</button>
</SkyButton>
</div>
</header>
<div
@@ -286,7 +298,7 @@ function updateActivePreview(): void {
justify-content: space-between;
}
.store-detail__toolbar button {
.store-detail__toolbar-button {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
display: grid;
@@ -294,7 +306,6 @@ function updateActivePreview(): void {
border: 1px solid var(--sky-hairline);
border-radius: 50%;
color: var(--sky-text);
background: var(--sky-surface-variant);
transition:
background-color 100ms ease,
border-color 100ms ease,
@@ -302,7 +313,7 @@ function updateActivePreview(): void {
transform 100ms ease;
}
.store-detail__toolbar button:active {
.store-detail__toolbar-button:active {
transform: scale(0.94);
}
@@ -481,27 +492,28 @@ function updateActivePreview(): void {
text-align: center;
}
.store-detail__preview-navigation button {
width: 30px;
height: 30px;
.store-detail__preview-control {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
min-width: var(--sky-touch-target);
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 1px solid var(--sky-hairline);
border-radius: 50%;
padding: 0;
color: var(--sky-text);
background: var(--sky-surface-variant);
transition:
background-color 100ms ease,
color 100ms ease,
transform 100ms ease;
}
.store-detail__preview-navigation button:disabled {
.store-detail__preview-control:disabled {
opacity: 0.34;
}
.store-detail__preview-navigation button:active:not(:disabled) {
.store-detail__preview-control:active:not(:disabled) {
transform: scale(0.92);
}
@@ -520,16 +532,14 @@ function updateActivePreview(): void {
}
@media (hover: hover) {
.store-detail__toolbar button:hover {
.store-detail__toolbar-button:hover {
border-color: rgba(255, 255, 255, 0.16);
background: var(--sky-surface-tint);
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
transform: translateY(-1px);
}
.store-detail__preview-navigation button:hover:not(:disabled) {
.store-detail__preview-control:hover:not(:disabled) {
color: var(--sky-app-accent);
background: var(--sky-surface-tint);
transform: translateY(-1px);
}
}
@@ -52,7 +52,8 @@ describe('CalculatorApp layout contract', () => {
)
expect(source).toContain('calculator-history__nav-button--edit')
expect(source).toContain('calculator-history__nav-button--close')
expect(source).toContain('background: rgb(255 255 255 / 9%);')
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(2)
expect(source).toContain('--sky-glass: rgb(44 44 46 / 62%);')
expect(source).toContain('-webkit-backdrop-filter: none;')
expect(source).toContain('backdrop-filter: none;')
expect(source).toContain('min-width: 106px;')
@@ -62,7 +63,7 @@ describe('CalculatorApp layout contract', () => {
expect(source).toContain('place-items: center;')
expect(source).toContain('padding: 0;')
expect(source).toMatch(
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*background: rgb\(255 255 255 \/ 15%\);[^}]*transform: none;[^}]*filter: none;/s,
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*transform: none;[^}]*filter: brightness\(1\.08\);/s,
)
expect(source).not.toContain('class="calculator-history__edit"')
expect(source).not.toContain('class="calculator-history__close"')
+6 -6
View File
@@ -378,6 +378,7 @@ watch([() => calculator.display, expression], async () => {
>
<template #left>
<SkyButton
glass
class="calculator-history__nav-button calculator-history__nav-button--edit"
inline
rounded
@@ -397,6 +398,7 @@ watch([() => calculator.display, expression], async () => {
</template>
<template #right>
<SkyButton
glass
class="calculator-history__nav-button calculator-history__nav-button--close"
icon-only
rounded
@@ -721,16 +723,15 @@ watch([() => calculator.display, expression], async () => {
}
.calculator-history__navbar :deep(.calculator-history__nav-button) {
--sky-glass: rgb(44 44 46 / 62%);
--sky-hairline: rgb(255 255 255 / 14%);
height: 44px;
min-height: 44px;
background: rgb(255 255 255 / 9%);
color: #fff;
box-shadow: none;
}
.calculator-history__navbar :deep(.calculator-history__nav-button:active) {
background: rgb(255 255 255 / 14%);
filter: none;
filter: brightness(0.94);
}
.calculator-history__navbar :deep(.calculator-history__nav-button--edit) {
@@ -757,9 +758,8 @@ watch([() => calculator.display, expression], async () => {
@media (hover: hover) {
.calculator-history__navbar
:deep(.calculator-history__nav-button:hover:not(:disabled)) {
background: rgb(255 255 255 / 15%);
transform: none;
filter: none;
filter: brightness(1.08);
}
}
@@ -31,6 +31,14 @@ const cameraAnimations = readFileSync(
)
describe('Camera app controls', () => {
it('uses shared liquid glass for camera interaction buttons', () => {
expect(cameraView.match(/variant="glass"/g)).toHaveLength(5)
expect(cameraView).toMatch(
/<sky-glass\s+component="button"\s+class="camera-latest"/,
)
expect(cameraView).not.toContain('variant="neutral"')
})
it('uses the Sky UI moving segment for photo and video modes', () => {
expect(cameraView).toContain('SkySegmented')
expect(cameraView).toContain(':active-index="mode === \'photo\' ? 0 : 1"')
+42 -51
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { SkyFab, SkyAppPage } from '@/ui'
import { SkyAppPage, SkyButton, SkyFab, SkyGlass } from '@/ui'
import {
ArrowLeft,
Images,
@@ -421,13 +421,11 @@ onMounted(() => {
window.addEventListener('keyup', onKeyup)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
void nuiCall<MediaConfig>('media:config').then(
(response) => {
if (response.success && response.data?.videoBitrateKbps) {
videoBitrateKbps.value = response.data.videoBitrateKbps
}
},
)
void nuiCall<MediaConfig>('media:config').then((response) => {
if (response.success && response.data?.videoBitrateKbps) {
videoBitrateKbps.value = response.data.videoBitrateKbps
}
})
void loadLatest()
startGameView()
})
@@ -486,22 +484,24 @@ onBeforeUnmount(() => {
<header class="camera-topbar">
<div class="camera-topbar-actions">
<button
<sky-fab
v-if="requestedMessageMedia"
class="camera-picker-back"
component="button"
class="camera-control camera-picker-back"
type="button"
variant="glass"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<template #icon><ArrowLeft :size="20" /></template>
</sky-fab>
<sky-fab
v-else
component="button"
type="button"
class="camera-control"
:class="{ 'camera-control--flash-active': flashEnabled }"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.camera.flash')"
@click="toggleFlash"
>
@@ -516,7 +516,7 @@ onBeforeUnmount(() => {
type="button"
class="camera-control"
:class="{ 'camera-control--danger': !microphoneEnabled }"
variant="neutral"
variant="glass"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
@@ -543,8 +543,10 @@ onBeforeUnmount(() => {
<span v-else-if="pendingCount" class="camera-upload-pill">
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
</span>
<button
<SkyButton
v-else
glass
rounded
class="camera-focus-pill camera-lock-control"
:class="{ 'camera-lock-control--active': cameraLocked }"
type="button"
@@ -561,12 +563,12 @@ onBeforeUnmount(() => {
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
</button>
</SkyButton>
<sky-fab
component="button"
type="button"
class="camera-control"
variant="neutral"
variant="glass"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
@@ -588,9 +590,11 @@ onBeforeUnmount(() => {
<div class="camera-zoom-control">
<div class="camera-zoom-row">
<button
<SkyButton
v-for="zoom in zoomLevels"
:key="zoom"
glass
rounded
class="camera-zoom-pill"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
type="button"
@@ -599,13 +603,14 @@ onBeforeUnmount(() => {
@click="setZoom(zoom)"
>
{{ zoom }}x
</button>
</SkyButton>
</div>
</div>
<footer class="camera-controls">
<div class="camera-capture-row">
<button
<sky-glass
component="button"
class="camera-latest"
type="button"
:aria-label="phone.t('Apps.camera.openGallery')"
@@ -625,7 +630,7 @@ onBeforeUnmount(() => {
/>
<Video v-else-if="latestMedia" :size="22" />
<Images v-else :size="22" />
</button>
</sky-glass>
<button
class="camera-shutter"
@@ -650,7 +655,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="camera-control camera-selfie"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.camera.flip')"
@click="toggleFacing"
>
@@ -799,8 +804,9 @@ onBeforeUnmount(() => {
height: 44px;
}
.camera-control {
--sky-glass-solid: rgb(28 28 30 / 80%);
color: rgb(255 255 255 / 86%);
--sky-glass: rgb(28 28 30 / 58%);
--sky-hairline: rgb(255 255 255 / 16%);
color: rgb(255 255 255 / 92%) !important;
}
.camera-control--flash-active {
color: #ffd60a !important;
@@ -811,13 +817,6 @@ onBeforeUnmount(() => {
.camera-picker-back {
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: #1c1c1ecc;
color: #fff;
backdrop-filter: blur(16px);
}
.camera-control svg {
width: 21px;
@@ -831,7 +830,7 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg {
transform: rotate(90deg);
}
.camera-focus-pill,
.camera-focus-pill:not(.sky-button--glass),
.camera-upload-pill {
min-width: 0;
padding: 7px 10px;
@@ -853,7 +852,7 @@ onBeforeUnmount(() => {
}
.camera-lock-control {
min-height: 44px;
border: 0;
padding: 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -902,27 +901,20 @@ onBeforeUnmount(() => {
z-index: 4;
bottom: 196px;
left: 50%;
width: 140px;
padding: 4px 6px;
border-radius: 999px;
background: rgb(18 18 20 / 72%);
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
width: auto;
transform: translateX(-50%);
}
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 6px;
gap: 8px;
}
.camera-zoom-pill {
width: 30px;
height: 26px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: #fff;
font-size: 10px;
text-align: center;
@@ -933,9 +925,6 @@ onBeforeUnmount(() => {
box-shadow 0.2s ease;
}
.camera-zoom-pill.active {
border-color: transparent;
background: rgb(44 44 46 / 88%);
box-shadow: 0 8px 16px rgb(0 0 0 / 30%);
color: #ffd60a;
}
.camera-controls {
@@ -956,15 +945,17 @@ onBeforeUnmount(() => {
padding: 0 24px 32px;
}
.camera-latest {
--sky-glass: rgb(28 28 30 / 58%);
--sky-hairline: rgb(255 255 255 / 16%);
width: 44px;
height: 44px;
overflow: hidden;
border: 0;
border-radius: 50%;
background: #111b;
background: var(--sky-glass);
color: #fff;
display: grid;
place-items: center;
box-shadow: var(--sky-shadow-glass);
}
.camera-selfie {
justify-self: end;
@@ -26,6 +26,12 @@ describe('FeatherApp Sky UI contract', () => {
expect(source).toContain('with-tabbar')
})
it('uses shared liquid glass for the floating compose action', () => {
expect(source).toMatch(
/<SkyFab[\s\S]*?class="feather-compose-fab"[\s\S]*?variant="glass"/,
)
})
it('gives likes and bookmarks a reduced-motion-safe pulse animation', () => {
expect(postCard).toContain(
"const reactionPulse = ref<'like' | 'bookmark' | null>(null)",
@@ -143,7 +149,9 @@ describe('FeatherApp Sky UI contract', () => {
})
it('keeps profile suggestion content styles off the follow button', () => {
expect(source).toContain('class="feather-profile-suggestion__profile"')
expect(source).toContain(
'class="feather-profile-suggestion__profile"',
)
expect(source).toMatch(
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
)
@@ -193,9 +201,7 @@ describe('FeatherApp Sky UI contract', () => {
expect(source).toContain('<SkyScrollRail')
expect(source).toContain('class="feather-profile-suggestions__rail"')
expect(source).toContain(':label="t(\'people\')"')
expect(source).toContain(
'class="feather-profile-suggestion__profile"',
)
expect(source).toContain('class="feather-profile-suggestion__profile"')
expect(source).toMatch(
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
)
+10 -25
View File
@@ -2174,6 +2174,7 @@ onMounted(async () => {
component="button"
type="button"
class="feather-compose-fab"
variant="glass"
:aria-label="t('newPost')"
@click="openComposer()"
>
@@ -2331,7 +2332,8 @@ onMounted(async () => {
/>
<template v-if="mediaPreview.items.length > 1">
<SkyButton
clear
glass
icon-only
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--left"
:aria-label="t('previousImage')"
@@ -2340,7 +2342,8 @@ onMounted(async () => {
<ChevronLeft :size="20" :stroke-width="2.8" />
</SkyButton>
<SkyButton
clear
glass
icon-only
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--right"
:aria-label="t('nextImage')"
@@ -2389,22 +2392,15 @@ onMounted(async () => {
cursor: default;
}
.feather-media-preview__arrow {
--sky-app-accent: rgb(10 14 20 / 88%);
--sky-button-text: #fff;
position: absolute;
top: 50%;
width: 34px !important;
min-width: 34px;
height: 34px;
min-height: 34px;
border: 1.5px solid rgb(255 255 255 / 58%);
width: 44px !important;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
color: #fff !important;
background: rgb(10 14 20 / 88%) !important;
box-shadow:
0 5px 16px rgb(0 0 0 / 58%),
inset 0 0 0 1px rgb(255 255 255 / 8%);
backdrop-filter: blur(10px);
transform: translateY(-50%);
}
.feather-media-preview__arrow--left {
@@ -4486,7 +4482,6 @@ onMounted(async () => {
place-items: center;
}
.feather-compose-fab {
--sky-app-accent: #58a6ff;
position: absolute;
z-index: 12;
right: 14px;
@@ -4494,23 +4489,14 @@ onMounted(async () => {
width: 46px;
height: 46px;
min-width: 46px;
border: 1px solid color-mix(in srgb, var(--feather-blue) 55%, #fff);
color: #fff;
box-shadow:
0 9px 24px rgb(29 155 240 / 38%),
0 3px 8px rgb(0 0 0 / 22%),
inset 0 1px 0 rgb(255 255 255 / 32%);
color: var(--feather-blue);
transition:
transform 150ms ease,
box-shadow 150ms ease,
filter 150ms ease;
}
.feather-compose-fab:active {
filter: brightness(0.94);
transform: scale(0.94);
box-shadow:
0 4px 12px rgb(29 155 240 / 28%),
inset 0 1px 0 rgb(255 255 255 / 22%);
}
.feather-navigation__badge-anchor b {
position: absolute;
@@ -5415,7 +5401,6 @@ onMounted(async () => {
.feather-edit__photo-actions :deep(.sky-button) {
border-color: var(--feather-blue);
}
.feather-compose-fab,
.feather-edit__avatar {
border-color: #70c5fa;
}
+9 -7
View File
@@ -2037,14 +2037,17 @@ onBeforeUnmount(() => {
role="dialog"
aria-modal="true"
>
<button
<SkyButton
glass
icon-only
rounded
type="button"
class="flare-match-reveal__close"
:aria-label="phone.t('Common.close')"
@click="matchReveal = null"
>
<X />
</button>
</SkyButton>
<Flame class="flare-match-reveal__flame" fill="currentColor" />
<h2>{{ phone.t('Apps.flare.itsAMatch') }}</h2>
<p>
@@ -3419,14 +3422,13 @@ onBeforeUnmount(() => {
position: absolute;
top: 24px;
right: 18px;
width: 42px;
height: 42px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(0 0 0 / 18%);
}
.flare-match-reveal__flame {
width: 58px;
+27 -12
View File
@@ -77,6 +77,10 @@ import {
SkyToggle,
} from '@/ui'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
type AuthMode = 'login' | 'register'
@@ -194,6 +198,8 @@ let flipTokYoutubePlayer: YouTubePlayer | null = null
let flipTokYoutubeApi: YouTubeApi | null = null
let flipTokYoutubeOwner = ''
let flipTokYoutubeVideoId = ''
let flipTokYoutubeVolume = 0
let removePhoneOutputVolumeListener: (() => void) | undefined
let observer: IntersectionObserver | null = null
let videoClickTimer: number | null = null
let likePulseTimer: number | null = null
@@ -786,6 +792,7 @@ async function playFlipTokYoutube(
seconds = 0,
): Promise<void> {
flipTokYoutubeOwner = owner
flipTokYoutubeVolume = volume
try {
const api = await loadYouTubeApi()
flipTokYoutubeApi = api
@@ -794,7 +801,7 @@ async function playFlipTokYoutube(
flipTokYoutubeVideoId = videoId
flipTokYoutubePlayer.loadVideoById(videoId)
}
flipTokYoutubePlayer.setVolume(volume)
flipTokYoutubePlayer.setVolume(volume * getPhoneOutputVolume())
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
flipTokYoutubePlayer.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -835,7 +842,7 @@ async function playFlipTokYoutube(
},
onReady: (event) => {
flipTokYoutubePlayer = event.target
event.target.setVolume(volume)
event.target.setVolume(volume * getPhoneOutputVolume())
event.target.seekTo(Math.max(0, seconds), true)
event.target.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -1627,7 +1634,10 @@ watch(originalVolume, (value) => {
watch(musicVolume, (value) => {
if (composerMusic.value) composerMusic.value.volume = value / 100
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value)
if (flipTokYoutubeOwner === 'composer') {
flipTokYoutubeVolume = value
flipTokYoutubePlayer?.setVolume(value * getPhoneOutputVolume())
}
})
watch(
@@ -1655,6 +1665,9 @@ watch(
)
onMounted(async () => {
removePhoneOutputVolumeListener = subscribePhoneOutputVolume((volume) => {
flipTokYoutubePlayer?.setVolume(flipTokYoutubeVolume * volume)
})
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
'fliptok:profile-avatar',
)
@@ -1731,6 +1744,7 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
removePhoneOutputVolumeListener?.()
observer?.disconnect()
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
@@ -2408,7 +2422,10 @@ onBeforeUnmount(() => {
alt=""
/>
<template v-if="selectedMediaItems.length > 1">
<button
<SkyButton
glass
icon-only
rounded
type="button"
class="compose-photo-preview__arrow compose-photo-preview__arrow--previous"
:disabled="composerPhotoIndex === 0"
@@ -2416,8 +2433,11 @@ onBeforeUnmount(() => {
@click="moveComposerPhoto(-1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
type="button"
class="compose-photo-preview__arrow compose-photo-preview__arrow--next"
:disabled="composerPhotoIndex === selectedMediaItems.length - 1"
@@ -2425,7 +2445,7 @@ onBeforeUnmount(() => {
@click="moveComposerPhoto(1)"
>
<ChevronRight />
</button>
</SkyButton>
<span class="compose-photo-preview__count">
{{ composerPhotoIndex + 1 }} / {{ selectedMediaItems.length }}
</span>
@@ -5872,12 +5892,7 @@ onBeforeUnmount(() => {
padding: 0;
display: grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 22%);
border-radius: 50%;
background: rgb(14 14 16 / 58%);
color: #fff;
box-shadow: 0 4px 14px rgb(0 0 0 / 24%);
backdrop-filter: blur(10px);
pointer-events: auto;
}
@@ -57,6 +57,21 @@ describe('GalleryApp import action', () => {
)
})
it('uses the shared app tab bar for gallery filters', () => {
expect(source).toContain('<SkyTabBar')
expect(source).toContain('class="gallery-filter-tabbar"')
expect(source).toContain('icons')
expect(source).toContain('<Images :size="21" />')
expect(source).toContain('<Image :size="21" />')
expect(source).toContain('<Video :size="21" />')
expect(source).toContain('<SkyTabButton')
expect(source).toContain(':active="filter === \'all\'"')
expect(source).toContain(':active="filter === \'photo\'"')
expect(source).toContain(':active="filter === \'video\'"')
expect(source).not.toContain('gallery-filter-navbar')
expect(source).not.toContain('<sky-segmented')
})
it('opens an accessible sort menu from the large header', () => {
expect(headerActions).toContain('<ListFilter')
expect(headerActions).toContain('aria-haspopup="menu"')
+32 -35
View File
@@ -9,8 +9,6 @@ import {
SkyNavbarBackLink,
SkyAppPage,
SkySpinner,
SkySegmented,
SkySegmentedButton,
SkyNotification,
} from '@/ui'
import {
@@ -19,11 +17,14 @@ import {
Download,
Globe2,
Heart,
Image,
Images,
Link2,
ListFilter,
Play,
Share2,
Trash2,
Video,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -35,6 +36,8 @@ import {
SkyButton,
SkyDropdown,
SkyNavbar,
SkyTabBar,
SkyTabButton,
SkyToolbar,
SkyToolbarPane,
} from '@/ui'
@@ -68,11 +71,6 @@ const developmentParameters = isDevelopment
const developmentApiEnabled = Boolean(developmentParameters?.has('apiPort'))
const developmentGalleryState =
developmentParameters?.get('galleryMock') ?? null
const filterItems = [
{ id: 'all', label: 'all' },
{ id: 'photo', label: 'photos' },
{ id: 'video', label: 'videos' },
] as const
const phone = usePhoneStore()
const easyShare = useEasyShareStore()
const messageMedia = useMessageMediaStore()
@@ -1378,28 +1376,35 @@ onBeforeUnmount(() => {
</div>
</div>
<sky-navbar
<SkyTabBar
v-if="!requestedMessageMedia && !selectionMode"
component="nav"
class="gallery-filter-navbar"
:aria-label="phone.t('Apps.photos.name')"
icons
labels
class="gallery-filter-tabbar"
:label="phone.t('Apps.photos.name')"
>
<template #subnavbar>
<sky-segmented strong rounded :data-active-filter="filter">
<sky-segmented-button
v-for="item in filterItems"
:key="item.id"
large
:active="filter === item.id"
:class="filter === item.id ? 'text-white' : 'text-[#8e8e93]'"
:aria-pressed="filter === item.id"
@click="filter = item.id"
>
{{ phone.t(`Apps.photos.filters.${item.label}`) }}
</sky-segmented-button>
</sky-segmented>
</template>
</sky-navbar>
<SkyTabButton
:active="filter === 'all'"
:label="phone.t('Apps.photos.filters.all')"
@click="filter = 'all'"
>
<template #icon><Images :size="21" /></template>
</SkyTabButton>
<SkyTabButton
:active="filter === 'photo'"
:label="phone.t('Apps.photos.filters.photos')"
@click="filter = 'photo'"
>
<template #icon><Image :size="21" /></template>
</SkyTabButton>
<SkyTabButton
:active="filter === 'video'"
:label="phone.t('Apps.photos.filters.videos')"
@click="filter = 'video'"
>
<template #icon><Video :size="21" /></template>
</SkyTabButton>
</SkyTabBar>
<SkyToolbar
v-if="selectionMode"
@@ -1694,14 +1699,6 @@ onBeforeUnmount(() => {
flex-direction: column;
overflow-y: auto;
}
.gallery-filter-navbar {
position: absolute !important;
top: auto !important;
bottom: 24px;
}
.gallery-filter-navbar :deep(> div:nth-child(-n + 2)) {
display: none;
}
.gallery-grid {
position: relative;
display: grid;
@@ -112,7 +112,7 @@ describe('MailApp Sky UI contract', () => {
expect(toolbar).toContain('{{ activeFilterSummary }}')
expect(toolbar).toContain('<sky-searchbar')
expect(toolbar).toContain('@update:model-value="updateSearch"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('variant="glass"')
expect(toolbar).toContain('@click="beginCompose()"')
expect(source).not.toContain('class="mail-search"')
expect(source).not.toContain('class="mail-compose-fab"')
@@ -121,6 +121,11 @@ describe('MailApp Sky UI contract', () => {
)
})
it('uses liquid glass for mailbox, filter and compose floating actions', () => {
expect(source.match(/variant="glass"/g)).toHaveLength(5)
expect(source).not.toContain('variant="neutral"')
})
it('offers multiple filter criteria in one scrolling Sky UI modal', () => {
const filterStart = source.indexOf('class="mail-modal mail-filter-modal"')
const filterEnd = source.indexOf('</sky-sheet>', filterStart)
+5 -5
View File
@@ -1246,7 +1246,7 @@ onBeforeUnmount(() => {
v-if="editingMailboxes"
component="button"
type="button"
variant="neutral"
variant="glass"
:text="phone.t('Apps.mail.newMailbox')"
:aria-label="phone.t('Apps.mail.newMailbox')"
@click="beginMailboxCreate"
@@ -1257,7 +1257,7 @@ onBeforeUnmount(() => {
v-else
component="button"
type="button"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.mail.compose')"
@click="beginCompose()"
>
@@ -1426,7 +1426,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="mail-filter-fab mail-filter-fab--active"
variant="neutral"
variant="glass"
:disabled="!canFilterFolder"
:aria-label="filterButtonLabel"
:aria-pressed="true"
@@ -1448,7 +1448,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="mail-filter-fab"
variant="neutral"
variant="glass"
:disabled="!canFilterFolder"
:aria-label="filterButtonLabel"
:aria-pressed="false"
@@ -1467,7 +1467,7 @@ onBeforeUnmount(() => {
<sky-fab
component="button"
type="button"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.mail.compose')"
@click="beginCompose()"
>
@@ -15,6 +15,17 @@ describe('MapApp interaction contract', () => {
expect(source).not.toContain('<EasyShareSheet')
})
it('uses translucent liquid glass for every floating map control', () => {
const controlsStart = source.indexOf('class="map-controls"')
const controlsEnd = source.indexOf('</nav>', controlsStart)
const controls = source.slice(controlsStart, controlsEnd)
expect(controls.match(/variant="glass"/g)).toHaveLength(4)
expect(controls).not.toContain('variant="neutral"')
expect(controls).not.toContain('variant="primary"')
expect(source).toContain('--sky-glass: rgb(247 247 248 / 72%);')
})
it('keeps zoom and panning inside scale-aware map bounds', () => {
expect(source).toContain('minimumCoverZoom(metrics, baseMinZoom)')
expect(source).toContain('clampMapPan(nextPan, nextZoom, metrics)')
+9 -7
View File
@@ -667,7 +667,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--share"
variant="primary"
variant="glass"
:aria-label="phone.t('Apps.easyShare.name')"
@click="shareCurrentLocation"
>
@@ -679,7 +679,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control"
variant="neutral"
variant="glass"
:aria-label="`${phone.t('Apps.map.switchStyle')}: ${phone.t(`Apps.map.styles.${mapStyle}`)}`"
@click="cycleMapStyle"
>
@@ -691,7 +691,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--marker"
variant="neutral"
variant="glass"
:disabled="placingMarker"
:aria-label="phone.t('Apps.map.addMarker')"
@click="startMarkerPlacement"
@@ -704,7 +704,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--location"
variant="neutral"
variant="glass"
:disabled="locating"
:aria-label="phone.t('Apps.map.currentLocation')"
@click="loadCurrentLocation(true)"
@@ -1015,14 +1015,16 @@ onBeforeUnmount(() => {
}
.map-control {
--sky-glass-solid: rgb(247 247 248 / 92%);
--sky-glass: rgb(247 247 248 / 72%);
--sky-hairline: rgb(0 0 0 / 16%);
color: #151515;
}
.map-control--share {
color: #fff;
color: #007aff;
}
.sky-app-page--dark .map-control {
--sky-glass-solid: rgb(44 44 46 / 88%);
--sky-glass: rgb(44 44 46 / 62%);
--sky-hairline: rgb(255 255 255 / 16%);
color: #fff;
}
+11 -9
View File
@@ -24,6 +24,7 @@ import type {
MemoryDifficulty,
} from '@/features/games/memory/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const memory = useMemoryStore()
@@ -135,7 +136,7 @@ onBeforeUnmount(() => {
</div>
<div class="memory-header__actions">
<Sparkles :size="23" aria-hidden="true" />
<button
<SkyButton glass icon-only rounded
type="button"
:aria-label="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
:title="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
@@ -143,7 +144,7 @@ onBeforeUnmount(() => {
>
<Volume2 v-if="memory.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</div>
</header>
@@ -182,7 +183,7 @@ onBeforeUnmount(() => {
<section v-else class="memory-game">
<div class="memory-stats">
<button
<SkyButton glass icon-only rounded
type="button"
class="memory-menu-button"
:aria-label="phone.t('Apps.memory.backToMenu')"
@@ -190,7 +191,7 @@ onBeforeUnmount(() => {
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.6" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.memory.time') }}</span>
<strong>{{ formatTime(memory.elapsedMs) }}</strong>
@@ -316,7 +317,7 @@ onBeforeUnmount(() => {
}
.memory-header__actions > svg,
.memory-header__actions button {
.memory-header__actions button:not(.sky-button--glass) {
box-sizing: content-box;
padding: 9px;
border: 0;
@@ -325,6 +326,8 @@ onBeforeUnmount(() => {
background: rgb(255 255 255 / 54%);
}
.memory-header__actions .sky-button--glass { color: #7658c7; }
.memory-header__actions button {
display: grid;
place-items: center;
@@ -441,17 +444,16 @@ onBeforeUnmount(() => {
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats button:not(.sky-button--glass) { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats .memory-menu-button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
justify-self: start;
padding: 0;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 48%);
color: #7052bf;
cursor: pointer;
}
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./MemosApp.vue', import.meta.url), 'utf8')
describe('MemosApp layout', () => {
it('uses the shared horizontal page gutter in list and detail views', () => {
expect(source).toContain(
'<SkyScrollArea padded class="memos-page__content">',
)
expect(source).toContain(
'<SkyScrollArea padded class="memo-detail-scroll">',
)
})
it('optically centers playback speed labels without moving the controls', () => {
expect(source).toContain('<span class="memo-speed-label">')
expect(source).toContain('.memo-speed-label {')
expect(source).toContain('transform: translateY(1px)')
})
it('centers the skip duration inside a dedicated icon frame', () => {
expect(source).toContain('<span class="memo-skip-icon" aria-hidden="true">')
expect(source).toContain('<RotateCcw :size="24" />')
expect(source).toContain('<RotateCw :size="24" />')
expect(source).toContain('.memo-skip-icon small {')
expect(source).toContain('place-items: center')
})
})
+32 -9
View File
@@ -457,7 +457,7 @@ onBeforeUnmount(() => {
:title="phone.t('Apps.memos.name')"
/>
<SkyScrollArea class="memos-page__content">
<SkyScrollArea padded class="memos-page__content">
<div v-if="memos.loading" class="memos-loading">
<SkySpinner :label="phone.t('Common.loading')" :size="24" />
</div>
@@ -581,7 +581,7 @@ onBeforeUnmount(() => {
</template>
</SkyNavbar>
<SkyScrollArea class="memo-detail-scroll">
<SkyScrollArea padded class="memo-detail-scroll">
<SkyGlass class="memo-fields-glass" :highlight="false">
<SkyList nested :dividers="false">
<SkyField
@@ -648,8 +648,10 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.memos.skipBack')"
@click="skipPlayback(-15)"
>
<RotateCcw :size="22" aria-hidden="true" />
<small>15</small>
<span class="memo-skip-icon" aria-hidden="true">
<RotateCcw :size="24" />
<small>15</small>
</span>
</SkyButton>
<SkyButton
rounded
@@ -678,8 +680,10 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.memos.skipForward')"
@click="skipPlayback(15)"
>
<RotateCw :size="22" aria-hidden="true" />
<small>15</small>
<span class="memo-skip-icon" aria-hidden="true">
<RotateCw :size="24" />
<small>15</small>
</span>
</SkyButton>
</SkyGlass>
@@ -699,7 +703,7 @@ onBeforeUnmount(() => {
:active="playbackRate === rate"
@click="setPlaybackRate(rate)"
>
{{ rate }}×
<span class="memo-speed-label">{{ rate }}×</span>
</SkySegmentedButton>
</SkySegmented>
</SkyGlass>
@@ -1062,10 +1066,25 @@ onBeforeUnmount(() => {
padding: 0;
}
.memo-player-control small {
.memo-skip-icon {
position: relative;
width: 24px;
height: 24px;
display: grid;
flex: none;
place-items: center;
}
.memo-skip-icon small {
position: absolute;
font-size: 8px;
inset: 0;
display: grid;
padding-top: 1px;
font-size: 7px;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
place-items: center;
}
.memo-player-control--main {
@@ -1090,6 +1109,10 @@ onBeforeUnmount(() => {
background: transparent;
}
.memo-speed-label {
transform: translateY(1px);
}
.memo-delete-action {
min-height: 48px;
gap: 8px;
@@ -90,7 +90,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(searchbarStart).toBeGreaterThan(-1)
expect(fabStart).toBeGreaterThan(searchbarStart)
expect(toolbar).toContain('v-model="search"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('variant="glass"')
expect(toolbar).toContain('@click="beginCompose"')
expect(toolbar).toContain('<SquarePen :size="21" />')
expect(inbox).not.toContain('messages-sky-compose-navigation')
@@ -186,6 +186,13 @@ describe('MessagesApp Sky UI contract', () => {
expect(source).toContain(
'aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`',
)
expect(source).toContain('const gifColumns = computed')
expect(source).toContain('class="messages-gif-grid"')
expect(source).toContain('class="messages-gif-column"')
expect(source).toContain('class="messages-gif-result"')
expect(styles).toMatch(
/\.messages-media-picker__gifs--masonry \.messages-gif-result img\s*\{[^}]*object-fit:\s*cover/s,
)
})
it('opens contact sharing in a draggable Sky UI bottom sheet', () => {
+39 -14
View File
@@ -126,6 +126,19 @@ const gifLoading = ref(false)
const gifError = ref<string | null>(null)
const gifHasMore = ref(true)
const gifNextOffset = ref(0)
const gifColumns = computed<[GifSearchResult[], GifSearchResult[]]>(() => {
const columns: [GifSearchResult[], GifSearchResult[]] = [[], []]
const columnHeights = [0, 0]
for (const gif of gifResults.value) {
const columnIndex = columnHeights[0] <= columnHeights[1] ? 0 : 1
columns[columnIndex].push(gif)
columnHeights[columnIndex] +=
Math.max(1, gif.height) / Math.max(1, gif.width)
}
return columns
})
const recording = ref(false)
const recordingStarting = ref(false)
const recordingElapsedMs = ref(0)
@@ -1235,7 +1248,7 @@ onBeforeUnmount(() => {
:clear-label="phone.t('Common.clear')"
/>
<SkyFab
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.messages.compose')"
@click="beginCompose"
>
@@ -1714,7 +1727,10 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.messages.noContactsToShare') }}
</p>
</SkyList>
<div v-else class="messages-media-picker__gifs">
<div
v-else
class="messages-media-picker__gifs messages-media-picker__gifs--masonry"
>
<SkySearchbar
v-model="gifQuery"
class="messages-gif-search"
@@ -1724,18 +1740,27 @@ onBeforeUnmount(() => {
@input="queueGifSearch"
@clear="queueGifSearch"
/>
<button
v-for="gif in gifResults"
:key="gif.id"
type="button"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@click="sendAttachment('gif', gif.url)"
>
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
</button>
<div v-if="gifResults.length" class="messages-gif-grid">
<div
v-for="(column, columnIndex) in gifColumns"
:key="columnIndex"
class="messages-gif-column"
>
<button
v-for="gif in column"
:key="gif.id"
type="button"
class="messages-gif-result"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@click="sendAttachment('gif', gif.url)"
>
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
</button>
</div>
</div>
<button
v-if="gifResults.length && gifHasMore && !gifLoading"
type="button"
+12 -8
View File
@@ -23,6 +23,7 @@ import type {
MinesweeperDifficulty,
} from '@/features/games/minesweeper/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const minesweeper = useMinesweeperStore()
@@ -208,7 +209,7 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
:aria-label="
phone.t(
@@ -221,7 +222,7 @@ onBeforeUnmount(() => {
>
<Volume2 v-if="minesweeper.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section v-if="minesweeper.menuOpen" class="minesweeper-menu">
@@ -275,7 +276,7 @@ onBeforeUnmount(() => {
}"
>
<div class="minesweeper-toolbar">
<button
<SkyButton glass icon-only rounded
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.backToMenu')"
@@ -283,7 +284,7 @@ onBeforeUnmount(() => {
@click.stop="minesweeper.showMenu()"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.minesweeper.mines') }}</span>
<strong>{{ minesRemaining }}</strong>
@@ -292,14 +293,14 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.minesweeper.time') }}</span>
<strong>{{ formatTime(minesweeper.elapsedMs) }}</strong>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.restart')"
@click="restart"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</button>
</SkyButton>
</div>
<div
@@ -438,8 +439,8 @@ onBeforeUnmount(() => {
.minesweeper-header span { display: block; color: #59878a; font-size: 9px; font-weight: 800; letter-spacing: 1.1px; text-transform: uppercase; }
.minesweeper-header h1 { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.7px; }
.minesweeper-header button,
.minesweeper-toolbar__icon {
.minesweeper-header button:not(.sky-button--glass),
.minesweeper-toolbar__icon:not(.sky-button--glass) {
width: 36px;
height: 36px;
display: grid;
@@ -452,6 +453,9 @@ onBeforeUnmount(() => {
box-shadow: 0 4px 10px rgb(23 73 75 / 8%);
}
.minesweeper-header .sky-button--glass { color: #246871; }
.minesweeper-toolbar .sky-button--glass { --sky-touch-target: 32px; width: 32px; height: 32px; color: #246871; }
.minesweeper-menu {
height: calc(100% - 55px);
display: flex;
+23 -11
View File
@@ -25,6 +25,7 @@ import type {
NeonDropPieceKind,
} from '@/features/games/neon-drop/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
type RenderCell = {
active: boolean
@@ -206,7 +207,10 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
</div>
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="
phone.t(
@@ -219,7 +223,7 @@ onBeforeUnmount(() => {
v-else
:size="18"
/>
</button>
</SkyButton>
</header>
<section v-if="neon.menuOpen" class="neon-menu">
@@ -274,13 +278,16 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="neon-game">
<div class="neon-toolbar">
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.neonDrop.backToMenu')"
@click="neon.showMenu()"
>
<ChevronLeft :size="19" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.neonDrop.score') }}</span
><strong>{{ game.score }}</strong>
@@ -289,7 +296,10 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.lines') }}</span
><strong>{{ game.lines }}</strong>
</div>
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.neonDrop.pause')"
@click="togglePause"
@@ -299,7 +309,7 @@ onBeforeUnmount(() => {
:size="16"
fill="currentColor"
/><Play v-else :size="16" fill="currentColor" />
</button>
</SkyButton>
</div>
<div class="neon-play-area">
@@ -403,8 +413,8 @@ onBeforeUnmount(() => {
font-size: 32px;
line-height: 1;
}
.neon-header button,
.neon-toolbar button {
.neon-header button:not(.sky-button--glass),
.neon-toolbar button:not(.sky-button--glass) {
width: 35px;
height: 35px;
display: grid;
@@ -415,6 +425,10 @@ onBeforeUnmount(() => {
color: #fff;
background: #ffffff0d;
}
.neon-header .sky-button--glass,
.neon-toolbar .sky-button--glass {
color: #fff;
}
.neon-menu {
height: calc(100% - 54px);
display: flex;
@@ -614,11 +628,9 @@ onBeforeUnmount(() => {
line-height: 20px;
}
.neon-toolbar button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.neon-play-area {
position: absolute;
@@ -10,10 +10,11 @@ const menuSource = source.slice(
source.indexOf('<SkyActionSheet'),
source.indexOf('</SkyActionSheet>') + '</SkyActionSheet>'.length,
)
const listStart = source.search(
/<sky-app-page\r?\n\s+v-if="!editorOpened"/,
const listStart = source.search(/<sky-app-page\r?\n\s+v-if="!editorOpened"/)
const listSource = source.slice(
listStart,
source.indexOf('<sky-app-page v-else'),
)
const listSource = source.slice(listStart, source.indexOf('<sky-app-page v-else'))
describe('NotesApp list controls', () => {
it('places the Sky searchbar and create action together at the bottom', () => {
@@ -27,7 +28,7 @@ describe('NotesApp list controls', () => {
expect(composerSource).toContain('<SkySearchbar')
expect(composerSource).toContain('v-model="searchQuery"')
expect(composerSource).toContain('<SkyFab')
expect(composerSource).toContain('variant="neutral"')
expect(composerSource).toContain('variant="glass"')
expect(composerSource).toContain('@click="createNote"')
expect(composerSource).not.toContain('notes-search')
expect(composerSource).not.toContain('notes-create-fab')
@@ -37,6 +38,20 @@ describe('NotesApp list controls', () => {
})
})
describe('NotesApp headers', () => {
it('keeps list and editor headers at the shared app height', () => {
expect(source).toContain('class="notes-list-navbar"')
expect(source).toContain(
'.notes-list-navbar.sky-navbar--large.sky-navbar--no-navigation',
)
expect(source).toContain(
'padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3))',
)
expect(source).toContain('class="notes-editor-page !pb-0"')
expect(source).not.toContain('notes-editor-page !pt-[44px]')
})
})
describe('NotesApp more menu', () => {
it('uses the shared Feather-style action sheet', () => {
expect(menuSource).toContain(
+13 -2
View File
@@ -206,6 +206,7 @@ function shareNote(): void {
:aria-label="phone.t('Apps.notes.name')"
>
<sky-navbar
class="notes-list-navbar"
variant="large"
transparent
:title="phone.t('Apps.notes.name')"
@@ -273,7 +274,7 @@ function shareNote(): void {
/>
<SkyFab
:aria-label="phone.t('Apps.notes.newNote')"
variant="neutral"
variant="glass"
@click="createNote"
>
<template #icon>
@@ -305,7 +306,7 @@ function shareNote(): void {
</SkyDialog>
</sky-app-page>
<sky-app-page v-else class="notes-editor-page !pt-[44px] !pb-0">
<sky-app-page v-else class="notes-editor-page !pb-0">
<sky-navbar :title="phone.t('Apps.notes.note')">
<template #left>
<sky-navbar-back-link
@@ -386,6 +387,16 @@ function shareNote(): void {
overflow: hidden;
}
:deep(.notes-list-navbar.sky-navbar--large) {
min-height: calc(
var(--sky-navbar-safe-area-top) + var(--sky-navbar-large-title-height)
);
}
:deep(.notes-list-navbar.sky-navbar--large.sky-navbar--no-navigation) {
padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3));
}
.notes-delete-confirm {
color: var(--sky-danger);
background: var(--sky-danger-soft);
+12 -8
View File
@@ -14,6 +14,7 @@ import type {
NumberMergeTile,
} from '@/features/games/number-merge/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const numberMerge = useNumberMergeStore()
@@ -135,7 +136,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
:aria-label="
phone.t(
@@ -155,7 +156,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
>
<Volume2 v-if="numberMerge.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section v-if="numberMerge.menuOpen" class="number-merge-menu">
@@ -210,7 +211,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<section v-else-if="game" class="number-merge-game">
<div class="number-merge-toolbar">
<button
<SkyButton glass icon-only rounded
type="button"
class="number-merge-toolbar__icon"
:aria-label="phone.t('Apps.numberMerge.backToMenu')"
@@ -218,7 +219,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
@click="numberMerge.showMenu"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.numberMerge.score') }}</span>
<strong>{{ formatScore(game.score) }}</strong>
@@ -227,7 +228,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<span>{{ phone.t('Apps.numberMerge.best') }}</span>
<strong>{{ formatScore(numberMerge.bestScore) }}</strong>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
class="number-merge-toolbar__icon number-merge-toolbar__restart"
:aria-label="phone.t('Apps.numberMerge.newGame')"
@@ -235,7 +236,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
@click="requestNewGame"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</button>
</SkyButton>
</div>
<div
@@ -373,8 +374,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
overflow-wrap: anywhere;
}
.number-merge-header button,
.number-merge-toolbar__icon {
.number-merge-header button:not(.sky-button--glass),
.number-merge-toolbar__icon:not(.sky-button--glass) {
width: 36px;
height: 36px;
display: grid;
@@ -388,6 +389,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
cursor: pointer;
}
.number-merge-header .sky-button--glass { color: #784c3c; }
.number-merge-toolbar .sky-button--glass { --sky-touch-target: 32px; width: 32px; height: 32px; color: #784c3c; }
.number-merge-menu {
height: calc(100% - 55px);
min-height: 0;
@@ -16,7 +16,45 @@ describe('PhoneApp EasyShare contract', () => {
it('uses the shared full-width Sky tab bar for phone sections', () => {
expect(source).toContain('<sky-tab-bar')
expect(source).toContain('<sky-tab-button')
expect(source).not.toContain('<sky-segmented')
})
it('uses shared interactive liquid glass surfaces for phone controls', () => {
expect(source).toMatch(
/<sky-button\s+glass\s+rounded\s+class="phone-detail-header-button phone-detail-back"/,
)
expect(source).toMatch(
/<sky-button\s+glass\s+v-for="action in contactProfileActions"[\s\S]*?icon-only[\s\S]*?class="phone-profile-action"/,
)
expect(source).toContain(
'width: var(--phone-profile-action-size) !important;',
)
expect(source).toContain(
'height: var(--phone-profile-action-size) !important;',
)
expect(source).toMatch(
/<sky-glass\s+v-for="key in keypadKeys"[\s\S]*?component="button"[\s\S]*?class="phone-keypad-key"/,
)
expect(source).toContain('class="phone-contacts-add"')
expect(source).toContain('class="phone-recents-search"')
const recentsFilter = source.slice(
source.indexOf('<sky-segmented'),
source.indexOf('</sky-segmented>') + '</sky-segmented>'.length,
)
expect(recentsFilter).toContain('class="phone-recents-filter"')
expect(recentsFilter).toContain('navigation')
expect(recentsFilter).toContain('strong')
expect(recentsFilter.match(/<sky-segmented-button/g)).toHaveLength(2)
expect(source).toContain(
'background: var(--sky-tabbar-highlight-background);',
)
expect(source).toContain(
'grid-template-columns: 52px minmax(0, 1fr) auto 44px;',
)
expect(source).not.toMatch(
/#(?:007aff|0a84ff|195287|22527d|25458e|2a468f|2f4a98|4b92d1|55aaff|5b91c2|64a8ff|68adff)/i,
)
expect(source).not.toContain('rgba(10, 132, 255')
})
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
File diff suppressed because it is too large Load Diff
+38 -22
View File
@@ -1062,14 +1062,20 @@ onBeforeUnmount(() => {
selectedPost.media.length
}}</span
>
<button
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--left"
:disabled="(carouselIndexes[selectedPost.id] ?? 0) === 0"
@click="moveCarousel(selectedPost, -1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--right"
:disabled="
(carouselIndexes[selectedPost.id] ?? 0) ===
@@ -1078,7 +1084,7 @@ onBeforeUnmount(() => {
@click="moveCarousel(selectedPost, 1)"
>
<ChevronRight />
</button>
</SkyButton>
<div class="ps-dots">
<span
v-for="(_, index) in selectedPost.media"
@@ -1271,14 +1277,20 @@ onBeforeUnmount(() => {
post.media.length
}}</span
>
<button
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--left"
:disabled="(carouselIndexes[post.id] ?? 0) === 0"
@click="moveCarousel(post, -1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--right"
:disabled="
(carouselIndexes[post.id] ?? 0) === post.media.length - 1
@@ -1286,7 +1298,7 @@ onBeforeUnmount(() => {
@click="moveCarousel(post, 1)"
>
<ChevronRight />
</button>
</SkyButton>
<div class="ps-dots">
<span
v-for="(_, index) in post.media"
@@ -1487,22 +1499,28 @@ onBeforeUnmount(() => {
</button>
</div>
<template v-if="selectedMedia.length > 1">
<button
<SkyButton
glass
icon-only
rounded
class="ps-selection-arrow ps-selection-arrow--left"
:aria-label="t('previousPhoto')"
:disabled="composePreviewIndex === 0"
@click="moveComposePreview(-1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-selection-arrow ps-selection-arrow--right"
:aria-label="t('nextPhoto')"
:disabled="composePreviewIndex === selectedMedia.length - 1"
@click="moveComposePreview(1)"
>
<ChevronRight />
</button>
</SkyButton>
<span class="ps-selection-counter">
{{ composePreviewIndex + 1 }}/{{ selectedMedia.length }}
</span>
@@ -2858,12 +2876,11 @@ button {
top: 50%;
display: grid;
place-items: center;
width: 34px;
height: 34px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
background: rgba(0, 0, 0, 0.58);
color: white;
transform: translateY(-50%);
}
@@ -3191,12 +3208,11 @@ button {
top: 50%;
display: grid;
place-items: center;
width: 34px;
height: 34px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.62);
color: white;
transform: translateY(-50%);
}
+7 -6
View File
@@ -9,6 +9,7 @@ import SkyFlappyBird from '@/features/games/sky-flappy/SkyFlappyBird.vue'
import { useSkyFlappyStore } from '@/features/games/sky-flappy/store'
import type { SkyFlappyDesign, SkyFlappyObstacle } from '@/features/games/sky-flappy/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const flappy = useSkyFlappyStore()
@@ -116,9 +117,9 @@ onBeforeUnmount(() => {
>
<header v-if="flappy.menuOpen" class="flappy-header">
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
</button>
</SkyButton>
</header>
<section v-if="flappy.menuOpen" class="flappy-menu">
@@ -141,10 +142,10 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="flappy-game">
<div class="flappy-toolbar">
<button type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></button>
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></SkyButton>
<div><span>{{ phone.t('Apps.skyFlappy.score') }}</span><strong>{{ game.score }}</strong></div>
<div><span>{{ phone.t('Apps.skyFlappy.best') }}</span><strong>{{ flappy.highScore }}</strong></div>
<button type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></button>
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></SkyButton>
</div>
<button type="button" class="flappy-stage" :class="{ 'flappy-stage--crashed': game.status === 'over' && !gameOverVisible }" :aria-label="phone.t('Apps.skyFlappy.flap')" @pointerdown.stop.prevent="flap">
@@ -169,7 +170,7 @@ onBeforeUnmount(() => {
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
.flappy-app--playing { padding:0; }
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 28px #223c6d35,0 18px 32px #101b3b66}.flappy-menu__hero::before{position:absolute;z-index:0;top:22px;left:25px;width:39px;height:39px;border-radius:50%;background:#ffe8a7cc;box-shadow:0 0 18px #fff1b46b;content:""}.flappy-menu__hero::after{position:absolute;z-index:1;bottom:30px;left:-12px;width:68px;height:18px;border-radius:999px;background:#ffffff3d;box-shadow:27px -9px 0 3px #ffffff36,57px 1px 0 -2px #ffffff2c;content:""}.flappy-menu__tower{position:absolute;z-index:3;right:10px;width:42px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 30%),var(--tower));box-shadow:inset 7px 0 0 #ffffff22,inset -6px 0 9px #0003}.flappy-menu__tower::after{position:absolute;left:-8px;width:58px;height:22px;border:3px solid color-mix(in srgb,var(--tower),black 22%);border-radius:7px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 36%),var(--tower));box-shadow:inset 0 4px 0 #ffffff26;content:""}.flappy-menu__tower--top{top:0;height:61px;border-radius:0 0 5px 5px}.flappy-menu__tower--top::after{bottom:-11px}.flappy-menu__tower--bottom{bottom:0;height:67px;border-radius:5px 5px 0 0}.flappy-menu__tower--bottom::after{top:-11px}.flappy-menu__trail{position:absolute;z-index:2;top:102px;left:11px;width:46px;height:4px;border-radius:99px;background:#edfbffb8;box-shadow:13px 12px 0 -1px #e9faff8c,6px -12px 0 -1px #e9faff73;animation:flappy-trail 1s ease-in-out infinite}.sky-bird{position:absolute;z-index:4;width:66px;height:46px;overflow:visible;filter:drop-shadow(0 4px 5px #071d3e66)}.sky-bird :deep(path){stroke-linecap:round;stroke-linejoin:round}.sky-bird :deep(.sky-flappy-bird__far-wing){fill:#2d7188;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__far-wing path:last-child){fill:none;stroke:#8ec6ce;stroke-width:1.4;opacity:.65}.sky-bird :deep(.sky-flappy-bird__tail){fill:#285e78;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__body){fill:url(#sky-bird-body);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__belly){fill:#b7e5df;opacity:.84}.sky-bird :deep(.sky-flappy-bird__neck){fill:#377f98}.sky-bird :deep(.sky-flappy-bird__wing){transform-box:view-box;transform-origin:52px 38px}.sky-bird :deep(.sky-flappy-bird__wing-shape){fill:url(#sky-bird-wing);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__feather){fill:none;stroke:#d4f0ed;stroke-width:1.5;opacity:.78}.sky-bird :deep(.sky-flappy-bird__face){fill:#d8f0e9}.sky-bird :deep(.sky-flappy-bird__eye-ring){fill:#f5fbf7}.sky-bird :deep(.sky-flappy-bird__eye){fill:#102d3d}.sky-bird :deep(.sky-flappy-bird__beak){fill:#f2a84b;stroke:#6f4727;stroke-width:1.5}.sky-bird--hero{top:72px;left:42px;width:100px;height:69px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.sky-bird--hero :deep(.sky-flappy-bird__wing){animation:flappy-bird-glide 1.1s ease-in-out infinite}.sky-bird--hero :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-glide 1.1s ease-in-out infinite}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-secondary{width:100%;min-height:43px;display:flex;align-items:center;justify-content:center;gap:7px;border-radius:14px;font-size:11px;font-weight:850}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
.flappy-game{position:absolute;inset:0}.flappy-toolbar{position:absolute;z-index:10;top:48px;right:14px;left:14px;height:42px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px;padding:4px 6px;border:1px solid #ffffff2b;border-radius:22px;background:#263c6da8;box-shadow:0 8px 24px #10193455;backdrop-filter:blur(14px)}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-toolbar button{width:34px;height:34px;border:0;border-radius:50%;box-shadow:none}.flappy-stage{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:hidden;padding:0;border:0;border-radius:0;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{position:absolute;z-index:6;right:45px;bottom:27px;left:45px;margin:0;padding:7px 10px;border-radius:999px;background:#263c6d91;backdrop-filter:blur(10px);text-align:center;pointer-events:none}.flappy-overlay{position:absolute;z-index:12;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
.flappy-menu__tower{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff38,inset -6px 0 9px #0004,0 0 18px var(--tower-glow)}
@@ -190,7 +191,7 @@ onBeforeUnmount(() => {
.flappy-toolbar strong{display:block;font-size:19px;line-height:20px}
.flappy-toolbar{top:66px;right:18px;left:18px;height:42px;grid-template-columns:32px 1fr 1fr 32px;gap:4px;padding:4px;border-radius:21px;box-sizing:border-box}
.flappy-toolbar div{height:32px;display:grid;grid-template-rows:10px 20px;align-content:center;justify-items:center}
.flappy-toolbar button{width:32px;height:32px}
.flappy-toolbar button{--sky-touch-target:32px;width:32px;height:32px}
.flappy-clouds{top:110px}
.flappy-stage{border:0;box-shadow:inset 0 0 35px #15244c38}
.flappy-obstacle span{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff42,inset -6px 0 8px #0004,0 0 18px var(--tower-glow)}
@@ -90,4 +90,31 @@ describe('phone apps use Sky UI', () => {
expect(source, file).not.toMatch(/(?:citymarkt|pages)__toast/)
}
})
it('uses shared liquid glass for remaining compact interaction controls', () => {
const minimumGlassButtons: Record<string, number> = {
'CameraApp.vue': 2,
'FeatherApp.vue': 2,
'FlareApp.vue': 1,
'FlipTokApp.vue': 2,
'MemoryApp.vue': 2,
'MinesweeperApp.vue': 3,
'NeonDropApp.vue': 3,
'NumberMergeApp.vue': 3,
'PicstagramApp.vue': 6,
'SkyFlappyApp.vue': 3,
'SnakeApp.vue': 2,
'TowerStackApp.vue': 3,
'WeazelNewsApp.vue': 2,
}
for (const [file, minimum] of Object.entries(minimumGlassButtons)) {
const source = appSources.find((app) => app.file === file)?.source ?? ''
const glassButtons = source.match(
/<(?:SkyButton|sky-button)(?=[^>]*\bglass\b)[^>]*>/g,
)
expect(glassButtons?.length ?? 0, file).toBeGreaterThanOrEqual(minimum)
}
})
})
+9 -7
View File
@@ -17,6 +17,7 @@ import type {
SnakeSpeed,
} from '@/features/games/snake/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const snake = useSnakeStore()
@@ -205,7 +206,7 @@ onBeforeUnmount(() => {
<section v-else class="snake-game">
<div class="snake-game__meta">
<button
<SkyButton glass icon-only rounded
type="button"
class="snake-game__back"
:aria-label="phone.t('Apps.snake.backToMenu')"
@@ -213,13 +214,16 @@ onBeforeUnmount(() => {
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.snake.score') }}</span>
<strong>{{ game.score }}</strong>
</div>
<button
<SkyButton
v-if="game.status !== 'game-over'"
glass
icon-only
rounded
type="button"
class="snake-game__pause"
:aria-label="
@@ -233,7 +237,7 @@ onBeforeUnmount(() => {
>
<Play v-if="game.status === 'paused'" :size="18" fill="currentColor" />
<Pause v-else :size="18" fill="currentColor" />
</button>
</SkyButton>
</div>
<div
@@ -517,14 +521,12 @@ onBeforeUnmount(() => {
}
.snake-game__meta button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
color: #dff6d9;
background: rgb(255 255 255 / 8%);
}
.snake-game__meta .snake-game__pause { justify-self: end; }
+16 -8
View File
@@ -23,6 +23,7 @@ import type {
TowerBlock,
} from '@/features/games/tower-stack/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const tower = useTowerStackStore()
@@ -185,14 +186,17 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
</div>
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t(tower.soundEnabled ? 'Apps.towerStack.mute' : 'Apps.towerStack.unmute')"
@click="toggleSound"
>
<Volume2 v-if="tower.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section v-if="tower.menuOpen" class="tower-menu">
@@ -232,18 +236,21 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="tower-game">
<div class="tower-toolbar">
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.towerStack.backToMenu')"
@pointerdown.stop="tower.showMenu()"
@click.stop="tower.showMenu()"
><ChevronLeft :size="19" /></button>
><ChevronLeft :size="19" /></SkyButton>
<div><span>{{ phone.t('Apps.towerStack.height') }}</span><strong>{{ game.blocks.length - 1 }}</strong></div>
<div><span>{{ phone.t('Apps.towerStack.score') }}</span><strong>{{ game.score }}</strong></div>
<button type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
<Pause v-if="game.status === 'playing'" :size="17" fill="currentColor" />
<Play v-else :size="17" fill="currentColor" />
</button>
</SkyButton>
</div>
<button
@@ -319,7 +326,8 @@ onBeforeUnmount(() => {
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
.tower-header h1 { margin: 1px 0 0; font-size: 27px; line-height: 1; letter-spacing: -0.8px; }
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
.tower-header button:not(.sky-button--glass), .tower-toolbar button:not(.sky-button--glass) { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
.tower-header .sky-button--glass, .tower-toolbar .sky-button--glass { color: #f2edff; }
.tower-menu { height: calc(100% - 50px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; text-align: center; }
.tower-menu__preview { position: relative; flex: 0 0 124px; width: 158px; height: 124px; }
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc((var(--preview-index) - 1) * 15px); left: 23px; height: 21px; border-radius: 6px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 3px 0 #ffffff35, 0 5px 11px #08091c5c; transform: perspective(200px) rotateX(5deg); }
@@ -341,7 +349,7 @@ onBeforeUnmount(() => {
.tower-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.tower-toolbar span { color: #c9c2e9; font-size: 11px; font-weight: 850; line-height: 10px; letter-spacing: .35px; text-transform: uppercase; }
.tower-toolbar strong { display: block; font-size: 19px; line-height: 20px; }
.tower-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 50%; box-shadow: none; }
.tower-toolbar button { --sky-touch-target: 32px; width: 32px; height: 32px; }
.tower-stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: hidden; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80; touch-action: manipulation; }
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
@@ -6,6 +6,10 @@ const source = readFileSync(
new URL('./WeatherApp.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
describe('WeatherApp layout contract', () => {
it('preserves its exact custom forecast gutter instead of generic page padding', () => {
@@ -19,4 +23,22 @@ describe('WeatherApp layout contract', () => {
/<SkyScrollArea[\s\S]*?class="weather-scroll"[\s\S]*?\spadded(?:\s|=)[\s\S]*?>/,
)
})
it('removes generic card margins from the compact forecast layout', () => {
expect(styles).toMatch(
/\.weather-details > \.weather-detail-card\s*{[\s\S]*?margin:\s*0;/,
)
expect(styles).toMatch(
/\.weather-scroll > \.weather-panel\s*{\s*margin:\s*0;/,
)
})
it('keeps hourly separators straight outside the highlighted current hour', () => {
expect(styles).toMatch(
/\.weather-hour\s*{[\s\S]*?border-left:[\s\S]*?border-radius:\s*0;/,
)
expect(styles).toMatch(
/\.weather-hour:first-child\s*{[\s\S]*?border-radius:\s*var\(--sky-radius-control\);/,
)
})
})
+2 -3
View File
@@ -1193,7 +1193,7 @@ onBeforeUnmount(() => {
<sky-button
icon-only
rounded
tonal
glass
class="weazel-detail-gallery-control is-previous"
:aria-label="t('accessibility.previousPhoto')"
@click="showPreviousDetailImage"
@@ -1203,7 +1203,7 @@ onBeforeUnmount(() => {
<sky-button
icon-only
rounded
tonal
glass
class="weazel-detail-gallery-control is-next"
:aria-label="t('accessibility.nextPhoto')"
@click="showNextDetailImage"
@@ -2112,7 +2112,6 @@ onBeforeUnmount(() => {
min-width: var(--sky-touch-target) !important;
min-height: var(--sky-touch-target) !important;
transform: translateY(-50%);
background: rgb(0 0 0 / 58%) !important;
color: #fff !important;
}
@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const gallerySource = readFileSync(
new URL('./PhoneDynamicIslandGallery.vue', import.meta.url),
'utf8',
)
const islandSource = readFileSync(
new URL('../../components/PhoneDynamicIsland.vue', import.meta.url),
'utf8',
)
const appSource = readFileSync(
new URL('../../App.vue', import.meta.url),
'utf8',
)
const routerSource = readFileSync(
new URL('../../router/index.ts', import.meta.url),
'utf8',
)
describe('Dynamic Island development gallery contract', () => {
it('shows every runtime activity and both supported presentation sizes', () => {
for (const activity of [
'incoming-call',
'call',
'music',
'recording',
'timer',
'stopwatch',
]) {
expect(gallerySource).toContain(`activity: '${activity}'`)
}
expect(gallerySource).toContain("label: 'Compact'")
expect(gallerySource).toContain("label: 'Expanded'")
expect(
gallerySource.match(
/activity: '(?:incoming-call|call|music|recording|timer|stopwatch)'/g,
),
).toHaveLength(11)
})
it('uses the production component with inert preview data', () => {
expect(gallerySource).toContain('<PhoneDynamicIsland')
expect(gallerySource).toContain(':preview-activity="variant.activity"')
expect(islandSource).toContain('previewActivity?: DynamicIslandActivity')
expect(islandSource).toContain(
':data-preview="props.preview ? \'true\' : undefined"',
)
expect(islandSource).toContain(".phone-dynamic-island[data-preview='true']")
expect(gallerySource).toMatch(
/\.dynamic-island-gallery__stage\s*\{[^}]*width:\s*100%;/s,
)
expect(gallerySource).toMatch(
/dynamic-island-gallery__preview\.phone-dynamic-island[\s\S]*?max-width:\s*calc\(100% - 12px\);/,
)
})
it('keeps the gallery development-only and hides the live island above it', () => {
expect(routerSource).toContain("name: 'development-dynamic-islands'")
expect(routerSource).toContain("path: '/development/dynamic-islands'")
expect(routerSource).toMatch(
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV/,
)
expect(appSource).toContain("route.name === 'development-dynamic-islands'")
expect(appSource).toContain(
'v-if="!setupRequired && !isDynamicIslandGalleryRoute"',
)
})
})
@@ -0,0 +1,303 @@
<script setup lang="ts">
import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
type GalleryVariant = {
activity: DynamicIslandActivity
expanded: boolean
eyebrow: string
label: string
progress?: number
subtitle: string
title: string
value: string
}
type GalleryGroup = {
label: string
variants: GalleryVariant[]
}
const galleryGroups: GalleryGroup[] = [
{
label: 'Incoming call',
variants: [
{
activity: 'incoming-call',
expanded: true,
eyebrow: 'Incoming call',
label: 'Expanded',
subtitle: 'mobile',
title: 'Tania Castillo',
value: 'Incoming',
},
],
},
{
label: 'Active call',
variants: [
{
activity: 'call',
expanded: false,
eyebrow: 'Connected',
label: 'Compact',
subtitle: '00:42',
title: 'Alex Rivera',
value: '00:42',
},
{
activity: 'call',
expanded: true,
eyebrow: 'Connected',
label: 'Expanded',
subtitle: '00:42',
title: 'Alex Rivera',
value: '00:42',
},
],
},
{
label: 'Music',
variants: [
{
activity: 'music',
expanded: false,
eyebrow: 'Now Playing',
label: 'Compact',
progress: 0.64,
subtitle: 'Los Santos Radio',
title: 'Night Drive',
value: 'Night Drive',
},
{
activity: 'music',
expanded: true,
eyebrow: 'Now Playing',
label: 'Expanded',
progress: 0.64,
subtitle: 'Los Santos Radio',
title: 'Night Drive',
value: 'Night Drive',
},
],
},
{
label: 'Voice recording',
variants: [
{
activity: 'recording',
expanded: false,
eyebrow: 'Recording',
label: 'Compact',
subtitle: 'Voice memo',
title: '00:37',
value: '00:37',
},
{
activity: 'recording',
expanded: true,
eyebrow: 'Recording',
label: 'Expanded',
subtitle: 'Voice memo',
title: '00:37',
value: '00:37',
},
],
},
{
label: 'Timer',
variants: [
{
activity: 'timer',
expanded: false,
eyebrow: 'Timer',
label: 'Compact',
progress: 0.47,
subtitle: '',
title: '0:21',
value: '0:21',
},
{
activity: 'timer',
expanded: true,
eyebrow: 'Timer',
label: 'Expanded',
progress: 0.47,
subtitle: '',
title: '0:21',
value: '0:21',
},
],
},
{
label: 'Stopwatch',
variants: [
{
activity: 'stopwatch',
expanded: false,
eyebrow: 'Stopwatch',
label: 'Compact',
subtitle: '',
title: '00:11,48',
value: '00:11,48',
},
{
activity: 'stopwatch',
expanded: true,
eyebrow: 'Stopwatch',
label: 'Expanded',
subtitle: '',
title: '00:11,48',
value: '00:11,48',
},
],
},
]
</script>
<template>
<main class="dynamic-island-gallery">
<header class="dynamic-island-gallery__header">
<span>Development preview</span>
<h1>Dynamic Islands</h1>
<p>Every runtime activity in its supported compact and expanded state.</p>
</header>
<section
v-for="group in galleryGroups"
:key="group.label"
class="dynamic-island-gallery__group"
>
<h2>{{ group.label }}</h2>
<article
v-for="variant in group.variants"
:key="`${variant.activity}-${variant.label}`"
class="dynamic-island-gallery__variant"
>
<span>{{ variant.label }}</span>
<div
class="dynamic-island-gallery__stage"
:class="{
'dynamic-island-gallery__stage--expanded': variant.expanded,
}"
>
<PhoneDynamicIsland
class="dynamic-island-gallery__preview"
preview
:preview-activity="variant.activity"
:preview-expanded="variant.expanded"
:preview-eyebrow="variant.eyebrow"
:preview-progress="variant.progress"
:preview-subtitle="variant.subtitle"
:preview-title="variant.title"
:preview-value="variant.value"
/>
</div>
</article>
</section>
</main>
</template>
<style scoped>
.dynamic-island-gallery {
position: absolute;
z-index: 2;
inset: 0;
overflow-y: auto;
padding: 58px 6px 32px;
color: #f7f7fb;
background:
radial-gradient(circle at 50% 0%, rgb(96 75 180 / 22%), transparent 30%),
linear-gradient(180deg, #111116 0%, #070709 100%);
scrollbar-width: none;
}
.dynamic-island-gallery::-webkit-scrollbar {
display: none;
}
.dynamic-island-gallery__header,
.dynamic-island-gallery__group {
width: 100%;
max-width: 356px;
margin-inline: auto;
}
.dynamic-island-gallery__header {
padding: 4px 8px 18px;
}
.dynamic-island-gallery__header span,
.dynamic-island-gallery__variant > span {
color: #a7a7b3;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.55px;
text-transform: uppercase;
}
.dynamic-island-gallery__header h1 {
margin: 4px 0 5px;
font-size: 28px;
letter-spacing: -0.8px;
}
.dynamic-island-gallery__header p {
margin: 0;
color: #b6b6c2;
font-size: 12px;
line-height: 17px;
}
.dynamic-island-gallery__group {
padding: 14px 0 18px;
border-top: 1px solid rgb(255 255 255 / 9%);
}
.dynamic-island-gallery__group h2 {
margin: 0 8px 10px;
font-size: 15px;
letter-spacing: -0.2px;
}
.dynamic-island-gallery__variant + .dynamic-island-gallery__variant {
margin-top: 10px;
}
.dynamic-island-gallery__variant > span {
display: block;
margin: 0 10px 5px;
font-size: 9px;
}
.dynamic-island-gallery__stage {
display: grid;
width: 100%;
min-height: 72px;
overflow: hidden;
place-items: center;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 30px;
background:
radial-gradient(circle at 70% 15%, rgb(93 89 255 / 24%), transparent 34%),
#1a1a22;
}
.dynamic-island-gallery__stage--expanded {
min-height: 142px;
}
:deep(.dynamic-island-gallery__preview.phone-dynamic-island) {
position: relative;
top: auto;
left: auto;
max-width: calc(100% - 12px);
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.dynamic-island-gallery {
scroll-behavior: auto;
}
}
</style>
@@ -229,9 +229,7 @@ describe('development Sky UI Kitchen Sink contract', () => {
/const developmentRoutes[^=]*=\s*import\.meta\.env\.DEV[\s\S]*?import\('@\/views\/development\/SkyUiKitchenSinkView\.vue'\)/,
)
expect(routerSource).toContain("name: 'development-sky-ui'")
expect(appSource).toContain(
"isDevelopment && route.name === 'development-sky-ui'",
)
expect(appSource).toContain("route.name === 'development-sky-ui'")
expect(appSource).toContain(
'isDevelopmentRoute ? String(route.name) : route.path',
)
+10 -9
View File
@@ -23,6 +23,7 @@ const lifecycleEndpoints = new Set([
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:live-activity',
'ui:opened',
'ui:ready',
])
@@ -2206,12 +2207,12 @@ const attachmentAssets = {
video: new Set(['city-loop', 'ocean-loop', 'sunset-loop']),
}
const gifMocks = [
['ICOgUNjpvO0PC', 'Cat reaction'],
['MDJ9IbxxvDUQM', 'Happy dog'],
['l0HlPystfePnAI3G8', 'Celebrate'],
['26ufdipQqU2lhNA4g', 'Wow'],
['3o7abKhOpu0NwenH3O', 'Perfect'],
['xT0xeJpnrWC4XWblEk', 'Party'],
['JIX9t2j0ZTN9S', 'Cat reaction', 200, 200],
['MDJ9IbxxvDUQM', 'Happy dog', 200, 112],
['l0HlPystfePnAI3G8', 'Celebrate', 200, 200],
['26ufdipQqU2lhNA4g', 'Wow', 200, 200],
['3o7abKhOpu0NwenH3O', 'Perfect', 200, 112],
['xT0xeJpnrWC4XWblEk', 'Party', 200, 132],
['111ebonMs90YLu', 'Thumbs up'],
['5GoVLqeAOo6PK', 'Excited'],
['TdfyKrN7HGTIY', 'Happy dance'],
@@ -9648,13 +9649,13 @@ app.post('/api/:endpoint', (request, response) => {
const pageSize = 6
const results = gifMocks
.slice(offset, offset + pageSize)
.map(([id, title]) => ({
height: 200,
.map(([id, title, width, height]) => ({
height: height ?? 200,
id,
previewUrl: `https://media.giphy.com/media/${id}/200w.gif`,
title,
url: `https://media.giphy.com/media/${id}/giphy.gif`,
width: 200,
width: width ?? 200,
}))
response.json({
success: true,
+1
View File
@@ -1157,6 +1157,7 @@ async function main() {
'notification:focus',
'sim:picker-close',
'ui:input-focus',
'ui:live-activity',
'ui:opened',
'ui:ready',
]
+34
View File
@@ -6,6 +6,8 @@ local open_without_focus = false
local device_payload = nil
local equipped_phone_number = nil
local nui_generation = 0
local live_activity_active = false
local open_home_requested = false
local function get_equipped_phone_number()
if not device_payload or not device_payload.device.sim then
@@ -75,7 +77,9 @@ local function send_open_message()
SendNUIMessage({
type = "app:open",
data = payload,
openHome = open_home_requested,
})
open_home_requested = false
end
local function open_phone()
@@ -169,6 +173,25 @@ RegisterCommand("sky_phone_toggle", function()
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
end, false)
RegisterCommand("sky_phone_live_activity_open", function()
if not live_activity_active or is_open or open_requested then
return
end
open_home_requested = true
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
if not result or not result.success then
open_home_requested = false
end
end, false)
RegisterKeyMapping(
"sky_phone_live_activity_open",
locale.Controls.OpenPhone,
"keyboard",
"SPACE"
)
if Config.Phone.Keybind then
if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then
error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.")
@@ -255,6 +278,15 @@ RegisterNUICallback("ui:input-focus", function(data, cb)
cb({ success = true })
end)
RegisterNUICallback("ui:live-activity", function(data, cb)
if type(data) ~= "table" or type(data.active) ~= "boolean" then
cb({ success = false, error = "invalid_request" })
return
end
live_activity_active = data.active
cb({ success = true })
end)
RegisterNUICallback("close", function(data, cb)
if type(data) ~= "table" then
cb({ success = false, error = "invalid_request" })
@@ -302,6 +334,8 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
close_phone(false)
device_payload = nil
update_equipped_phone_number(nil)
live_activity_active = false
open_home_requested = false
end)
RegisterNetEvent("sky_phone:device:error", function(error_code)
+10 -12
View File
@@ -679,18 +679,16 @@ function SkyPhoneCompanies.GetCallTargets(company_id)
end
for source, readiness in pairs(call_availability) do
local member = online[source] and call_member(source) or nil
local device = member and current_device(source, true) or nil
if not member or not device or member.company_id ~= readiness.company_id
or readiness.company_id ~= company_id or readiness.imei ~= device.imei
or readiness.sim_id ~= device.sim_id
then
if not member or not device or member.company_id ~= readiness.company_id
or readiness.imei ~= (device and device.imei)
or readiness.sim_id ~= (device and device.sim_id)
then
call_availability[source] = nil
end
else
local device = member and SkyPhone.LoadDevice(readiness.imei) or nil
local device_slots = device and SkyPhone.FindDeviceSlots(source, readiness.imei) or {}
local readiness_valid = member and device and device_slots[1]
and member.company_id == readiness.company_id
and device.sim_id == readiness.sim_id
and device.sim_type == "registered"
and device.registered_at ~= nil
if not readiness_valid then
call_availability[source] = nil
elseif readiness.company_id == company_id then
targets[#targets + 1] = {
source = source,
simId = device.sim_id,
+19 -2
View File
@@ -51,6 +51,23 @@ local function session_owner(source)
}
end
local function device_owner(source, imei)
if not SkyPhoneImei.IsValid(imei) then
return nil, { success = false, error = "invalid_request" }
end
if not SkyPhone.FindDeviceSlots(source, imei)[1] then
return nil, { success = false, error = "owner_changed" }
end
local device = SkyPhone.LoadDevice(imei)
if not device then
return nil, { success = false, error = "device_not_found" }
end
return {
account_id = device.account_id and tonumber(device.account_id) or nil,
imei = imei,
}
end
local function owner_condition(owner, alias)
local prefix = alias and ("`%s`."):format(alias) or ""
if owner.account_id then
@@ -317,7 +334,7 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
upload_result(src, type(data) == "table" and data.correlationId or nil, false, "invalid_memo")
return
end
local owner, error_response = session_owner(src)
local owner, error_response = device_owner(src, data.deviceImei)
if not owner then
upload_result(src, memo.correlation_id, false, error_response.error)
return
@@ -392,7 +409,7 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
return
end
state.completing = true
local owner, error_response = session_owner(src)
local owner, error_response = device_owner(src, state.owner.imei)
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
pending_uploads[request_id] = nil
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
-1
View File
@@ -958,7 +958,6 @@ Bridge.Debug(
)
Bridge.Callbacks.Register("sky_phone:device:close", function(source)
SkyPhoneCompanies.ClearCallAvailability(source)
sessions[source] = nil
return { success = true }
end)