mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bbafd0026 | |||
| 300329825f | |||
| 93d2263f14 | |||
| dca0868753 | |||
| 36b859cabb | |||
| d0317a35c0 | |||
| 074117fe33 | |||
| a1025f4520 | |||
| 7b8bab75a8 | |||
| c0492a70cb | |||
| 05169acbfe | |||
| 086b77e464 | |||
| de613e2502 |
@@ -26,6 +26,100 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Inter 4.1
|
||||
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
## Framework7 documentation placeholder media
|
||||
|
||||
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
|
||||
|
||||
+78
-6
@@ -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,14 +1770,25 @@ 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
|
||||
id="phone-home-drag-portal"
|
||||
class="phone-home-drag-portal"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
|
||||
<section
|
||||
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 +1900,6 @@ onBeforeUnmount(() => {
|
||||
@control-center="toggleControlCenter"
|
||||
@lock="lockPhone"
|
||||
/>
|
||||
<PhoneDynamicIsland v-if="!setupRequired" />
|
||||
<SpringboardView
|
||||
v-if="!isDevelopmentRoute && !setupRequired"
|
||||
@edit-mode-change="springboardEditing = $event"
|
||||
@@ -1918,6 +1985,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', () => {
|
||||
@@ -75,6 +92,10 @@ describe('browser development preview contract', () => {
|
||||
|
||||
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
|
||||
expect(source).toContain('phone-resolution-canvas--primary')
|
||||
expect(source).toMatch(
|
||||
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
|
||||
)
|
||||
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
|
||||
expect(source).toContain("'--phone-rendered-height'")
|
||||
expect(source).toContain("'--phone-rendered-width'")
|
||||
expect(mainCss).toMatch(
|
||||
@@ -100,7 +121,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,
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+130
-99
@@ -211,9 +211,11 @@
|
||||
}
|
||||
}
|
||||
:root {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
font-feature-settings:
|
||||
'liga' 1,
|
||||
'calt' 1;
|
||||
font-optical-sizing: auto;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -234,7 +236,9 @@ body,
|
||||
user-select: none;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
@@ -259,6 +263,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;
|
||||
}
|
||||
@@ -281,6 +299,18 @@ button {
|
||||
height: var(--phone-rendered-height, 844px);
|
||||
}
|
||||
|
||||
.phone-home-drag-portal {
|
||||
position: absolute;
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.phone-home-drag-portal * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.phone-resolution-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -384,7 +414,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 +702,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 +828,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;
|
||||
@@ -1341,8 +1302,7 @@ button {
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #f5f5f7;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.darkchat-page button,
|
||||
.darkchat-page input,
|
||||
@@ -2677,8 +2637,30 @@ button {
|
||||
.springboard--widget-dragging .springboard-widget-page-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
.springboard--home-dragging .springboard-page--apps {
|
||||
overflow: visible;
|
||||
.home-drag-layer {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.home-drag-layer * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.home-drag-position {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
.home-drag-ghost {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
}
|
||||
.app-grid {
|
||||
display: grid;
|
||||
@@ -2713,6 +2695,9 @@ button {
|
||||
transition: transform var(--springboard-page-duration)
|
||||
var(--springboard-page-easing);
|
||||
}
|
||||
.app-icon-item--drag-source {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-icon-remove {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
@@ -2838,7 +2823,6 @@ button {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-icon--calculator {
|
||||
background: linear-gradient(145deg, #76767b, #1b1b1d);
|
||||
@@ -4178,8 +4162,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 +4215,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 +4226,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 +4260,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 +4268,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 +4302,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 +6599,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 +6961,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%);
|
||||
@@ -7549,18 +7581,17 @@ button {
|
||||
min-height: var(--springboard-grid-row);
|
||||
}
|
||||
.app-icon-button {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.app-icon-label {
|
||||
min-height: 15px;
|
||||
font-size: 11.5px;
|
||||
min-height: var(--sky-home-label-height);
|
||||
font-size: var(--sky-home-label-font-size);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.15px;
|
||||
line-height: 15px;
|
||||
line-height: var(--sky-home-label-height);
|
||||
text-align: center;
|
||||
}
|
||||
.springboard-edit-add {
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,11 +30,13 @@ const props = withDefaults(
|
||||
app: PhoneAppDefinition
|
||||
compact?: boolean
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -63,14 +65,14 @@ let dragStartPage = 0
|
||||
let dragPageWidth = 0
|
||||
let dragPageMetrics: SpringboardDragMetrics | null = null
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value
|
||||
isDragging.value && !props.externalDragVisual
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value
|
||||
isDragging.value && !props.externalDragVisual
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -300,6 +302,7 @@ onBeforeUnmount(() => {
|
||||
class="app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--compact': compact,
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
}"
|
||||
|
||||
@@ -22,11 +22,13 @@ const props = withDefaults(
|
||||
apps: PhoneAppDefinition[]
|
||||
defaultName: string
|
||||
editMode?: boolean
|
||||
externalDragVisual?: boolean
|
||||
folder: HomeFolder
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
externalDragVisual: false,
|
||||
showLabel: true,
|
||||
},
|
||||
)
|
||||
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
|
||||
|
||||
const folderName = computed(() => props.folder.name || props.defaultName)
|
||||
const dragStyle = computed(() =>
|
||||
isDragging.value
|
||||
isDragging.value && !props.externalDragVisual
|
||||
? {
|
||||
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
const dragPointerStyle = computed(() =>
|
||||
isDragging.value
|
||||
isDragging.value && !props.externalDragVisual
|
||||
? {
|
||||
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
|
||||
}
|
||||
@@ -232,6 +234,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
class="home-folder-item app-icon-item"
|
||||
:class="{
|
||||
'app-icon-item--drag-source': isDragging && externalDragVisual,
|
||||
'app-icon-item--dragging': isDragging,
|
||||
'app-icon-item--editing': editMode,
|
||||
'home-folder-item--dragging': isDragging,
|
||||
|
||||
@@ -331,8 +331,7 @@ function finishPageSwipe(event: PointerEvent): void {
|
||||
z-index: 70;
|
||||
inset: 0;
|
||||
color: #fff;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
|
||||
.home-folder-backdrop {
|
||||
|
||||
@@ -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)
|
||||
@@ -399,7 +402,7 @@ onBeforeUnmount(() => {
|
||||
rgb(30 38 42 / 35%),
|
||||
rgb(0 0 0 / 84%) 72%
|
||||
);
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
|
||||
.radio-hud[data-horizontal='left'] {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
|
||||
backdrop-filter: blur(26px) saturate(125%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(125%);
|
||||
cursor: pointer;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
||||
'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
@@ -1076,6 +1074,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 +1199,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;/)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type DynamicIslandActivity =
|
||||
| 'call'
|
||||
| 'incoming-call'
|
||||
| 'music'
|
||||
| 'recording'
|
||||
| 'stopwatch'
|
||||
| 'timer'
|
||||
@@ -0,0 +1,59 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
|
||||
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
|
||||
const mainCssSource = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
function styleSources(directory: string): Array<{
|
||||
file: string
|
||||
source: string
|
||||
}> {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isDirectory()) return styleSources(path)
|
||||
if (!/\.(?:css|vue)$/.test(entry.name)) return []
|
||||
return [{ file: path, source: readFileSync(path, 'utf8') }]
|
||||
})
|
||||
}
|
||||
|
||||
describe('Inter font contract', () => {
|
||||
it('bundles normal and italic variable fonts for every supported weight', () => {
|
||||
expect(
|
||||
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
|
||||
).toBe(true)
|
||||
expect(
|
||||
existsSync(
|
||||
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
|
||||
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
|
||||
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
|
||||
})
|
||||
|
||||
it('applies the shared font to the document and native form controls', () => {
|
||||
expect(mainCssSource).toMatch(
|
||||
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
|
||||
)
|
||||
expect(mainCssSource).toMatch(
|
||||
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not bypass the shared token with a generic system UI stack', () => {
|
||||
const genericSystemStack =
|
||||
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
|
||||
const violations = styleSources(sourceDirectory)
|
||||
.filter(({ source }) => genericSystemStack.test(source))
|
||||
.map(({ file }) => file.slice(sourceDirectory.length + 1))
|
||||
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+2
-3
@@ -525,9 +525,8 @@
|
||||
height: var(--sky-widget-label-height);
|
||||
overflow: hidden;
|
||||
color: var(--sky-widget-label-color, #fff);
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-family: var(--sky-font-family);
|
||||
font-size: var(--sky-home-label-font-size);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.15px;
|
||||
line-height: var(--sky-widget-label-height);
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--sky-device-pixel-ratio: 1;
|
||||
--sky-hairline-scale: 1;
|
||||
@@ -26,9 +42,7 @@
|
||||
--sky-font-title: 17px;
|
||||
--sky-font-medium-title: 24px;
|
||||
--sky-font-large-title: 34px;
|
||||
--sky-font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
|
||||
'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--sky-font-family: 'Inter', Arial, sans-serif;
|
||||
--sky-transition-fast: 100ms;
|
||||
--sky-transition-normal: 200ms;
|
||||
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
@@ -55,8 +69,10 @@
|
||||
--sky-shadow-thumb:
|
||||
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
|
||||
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
|
||||
--sky-home-label-font-size: 13px;
|
||||
--sky-home-label-height: 16px;
|
||||
--sky-widget-label-gap: 5px;
|
||||
--sky-widget-label-height: 15px;
|
||||
--sky-widget-label-height: var(--sky-home-label-height);
|
||||
--sky-widget-radius-small: 23px;
|
||||
--sky-widget-radius-medium: 25px;
|
||||
--sky-widget-radius-large: 28px;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
import type { ExternalPhoneAppDefinition } from '@/types/apps'
|
||||
import {
|
||||
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
|
||||
expect(() => new Function(runtime)).not.toThrow()
|
||||
})
|
||||
|
||||
it('applies the LB iframe layout contract before revealing vendor apps', () => {
|
||||
const document = createLbPhoneFrameDocument(
|
||||
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
|
||||
{
|
||||
appName: 'radio-app',
|
||||
localStorage: {},
|
||||
resourceName: 'lb-radioapp',
|
||||
settings: createLbPhoneHostSettings({
|
||||
deviceName: 'Main phone',
|
||||
isDarkMode: true,
|
||||
language: 'en',
|
||||
preferences: DEFAULT_PHONE_PREFERENCES,
|
||||
securityEnabled: false,
|
||||
}),
|
||||
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
|
||||
},
|
||||
)
|
||||
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
|
||||
expect(runtime).toBeTruthy()
|
||||
|
||||
const messageListeners: Array<(event: { data: unknown }) => void> = []
|
||||
const readyListeners: Array<() => void> = []
|
||||
const documentElement = { dataset: {}, style: {} }
|
||||
const body = {
|
||||
dataset: {},
|
||||
style: { visibility: 'hidden' },
|
||||
}
|
||||
const sandbox = {
|
||||
addEventListener(
|
||||
eventName: string,
|
||||
listener: (event: { data: unknown }) => void,
|
||||
) {
|
||||
if (eventName === 'message') messageListeners.push(listener)
|
||||
},
|
||||
componentsLoaded: undefined as boolean | undefined,
|
||||
console,
|
||||
document: {
|
||||
addEventListener(eventName: string, listener: () => void) {
|
||||
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
|
||||
},
|
||||
body,
|
||||
documentElement,
|
||||
},
|
||||
parent: { postMessage() {} },
|
||||
}
|
||||
|
||||
runInNewContext(runtime ?? '', sandbox)
|
||||
expect(body.style.visibility).toBe('hidden')
|
||||
|
||||
expect(readyListeners).toHaveLength(1)
|
||||
readyListeners[0]?.()
|
||||
expect(documentElement.style).toMatchObject({
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
width: '100%',
|
||||
})
|
||||
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
|
||||
expect(body.style).toMatchObject({
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
visibility: 'visible',
|
||||
width: '100%',
|
||||
})
|
||||
|
||||
body.style.visibility = 'hidden'
|
||||
expect(messageListeners).toHaveLength(1)
|
||||
messageListeners[0]?.({ data: 'componentsLoaded' })
|
||||
expect(body.style.visibility).toBe('visible')
|
||||
expect(sandbox.componentsLoaded).toBe(true)
|
||||
})
|
||||
|
||||
it('persists isolated LB localStorage snapshots without app changes', () => {
|
||||
const values = new Map<string, string>()
|
||||
const storage = {
|
||||
|
||||
@@ -130,6 +130,25 @@ function applySettings(nextSettings) {
|
||||
if (document.body) document.body.dataset.theme = theme;
|
||||
}
|
||||
|
||||
function prepareDocument() {
|
||||
Object.assign(document.documentElement.style, {
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
width: '100%'
|
||||
});
|
||||
if (!document.body) return;
|
||||
|
||||
document.body.dataset.device = 'phone';
|
||||
Object.assign(document.body.style, {
|
||||
height: '100%',
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
visibility: 'visible',
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.resourceName = config.resourceName;
|
||||
globalThis.appName = config.appName;
|
||||
globalThis.components = globalThis.components ?? {};
|
||||
@@ -189,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
|
||||
|
||||
globalThis.addEventListener('message', (event) => {
|
||||
const message = event.data;
|
||||
if (message === 'componentsLoaded') {
|
||||
globalThis.componentsLoaded = true;
|
||||
prepareDocument();
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') return;
|
||||
|
||||
if (message.type === 'sky-phone:lb-settings') {
|
||||
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
|
||||
});
|
||||
|
||||
applySettings(config.settings);
|
||||
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
prepareDocument();
|
||||
applySettings(globalThis.settings);
|
||||
}, { once: true });
|
||||
`
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
normalizePhoneViewportRect,
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
>
|
||||
|
||||
function measuredRect(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): RectMeasurement {
|
||||
return { height, left, top, width }
|
||||
}
|
||||
|
||||
function expectRectClose(
|
||||
actual: PhoneViewportRect,
|
||||
expected: PhoneViewportRect,
|
||||
): void {
|
||||
for (const key of [
|
||||
'bottom',
|
||||
'height',
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'width',
|
||||
] as const) {
|
||||
expect(actual[key]).toBeCloseTo(expected[key], 6)
|
||||
}
|
||||
}
|
||||
|
||||
function createGeometryFixture(options: {
|
||||
canvasOffsetHeight: number
|
||||
canvasOffsetWidth: number
|
||||
rawCanvasRect: RectMeasurement
|
||||
wrapperRect: RectMeasurement
|
||||
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
|
||||
const wrapper = {
|
||||
getBoundingClientRect: () => options.wrapperRect,
|
||||
} as unknown as HTMLElement
|
||||
const canvas = {
|
||||
closest: (selector: string) =>
|
||||
selector === '.phone-resolution-wrapper' ? wrapper : null,
|
||||
getBoundingClientRect: () => options.rawCanvasRect,
|
||||
offsetHeight: options.canvasOffsetHeight,
|
||||
offsetWidth: options.canvasOffsetWidth,
|
||||
} as unknown as HTMLElement
|
||||
|
||||
return {
|
||||
anchor: {
|
||||
closest: (selector: string) =>
|
||||
selector === '.phone-resolution-canvas' ? canvas : null,
|
||||
} as unknown as Element,
|
||||
element: (rect) =>
|
||||
({ getBoundingClientRect: () => rect }) as unknown as Element,
|
||||
}
|
||||
}
|
||||
|
||||
describe('phone viewport geometry', () => {
|
||||
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
|
||||
|
||||
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
|
||||
bottom: layer.top + layer.height,
|
||||
height: layer.height,
|
||||
left: layer.left,
|
||||
right: layer.left + layer.width,
|
||||
top: layer.top,
|
||||
width: layer.width,
|
||||
})
|
||||
})
|
||||
|
||||
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
|
||||
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
|
||||
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
|
||||
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
|
||||
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
|
||||
|
||||
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
|
||||
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
|
||||
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
|
||||
expect(corrected.left).toBeGreaterThan(wrapper.left)
|
||||
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['80%', 0.6624],
|
||||
['100%', 0.828],
|
||||
['120%', 0.9936],
|
||||
])(
|
||||
'normalizes fractional positions, sizes, and deltas at %s scaling',
|
||||
(_label, zoom) => {
|
||||
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
|
||||
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
|
||||
const rawStart = measuredRect(
|
||||
rawCanvas.left + 20.125,
|
||||
rawCanvas.top + 50.375,
|
||||
349.75,
|
||||
73.125,
|
||||
)
|
||||
const rawEnd = measuredRect(
|
||||
rawStart.left + 73.25,
|
||||
rawStart.top - 41.75,
|
||||
rawStart.width,
|
||||
rawStart.height,
|
||||
)
|
||||
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
|
||||
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
|
||||
|
||||
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
|
||||
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
|
||||
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
|
||||
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
|
||||
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
|
||||
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
|
||||
},
|
||||
)
|
||||
|
||||
it('reads visual scale and normalized element rects from a canvas anchor', () => {
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 844,
|
||||
canvasOffsetWidth: 390,
|
||||
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
|
||||
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
|
||||
})
|
||||
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
|
||||
|
||||
expect(geometry).not.toBeNull()
|
||||
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
|
||||
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
|
||||
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
|
||||
})
|
||||
|
||||
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
|
||||
const rawCanvas = measuredRect(100, 50, 0, 0)
|
||||
const corrected = normalizePhoneViewportRect(
|
||||
measuredRect(112.5, 58.25, 40, 20),
|
||||
rawCanvas,
|
||||
measuredRect(500, 300, 0, 0),
|
||||
)
|
||||
const fixture = createGeometryFixture({
|
||||
canvasOffsetHeight: 0,
|
||||
canvasOffsetWidth: 0,
|
||||
rawCanvasRect: rawCanvas,
|
||||
wrapperRect: measuredRect(500, 300, 0, 0),
|
||||
})
|
||||
const geometry = readPhoneViewportGeometry(fixture.anchor)
|
||||
|
||||
expect(corrected).toEqual({
|
||||
bottom: 328.25,
|
||||
height: 20,
|
||||
left: 512.5,
|
||||
right: 552.5,
|
||||
top: 308.25,
|
||||
width: 40,
|
||||
})
|
||||
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
|
||||
expect(geometry?.scaleX).toBe(1)
|
||||
expect(geometry?.scaleY).toBe(1)
|
||||
expect(readPhoneViewportGeometry(null)).toBeNull()
|
||||
expect(
|
||||
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
export type PhoneViewportRect = {
|
||||
bottom: number
|
||||
height: number
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
width: number
|
||||
}
|
||||
|
||||
type RectMeasurement = Pick<
|
||||
DOMRectReadOnly,
|
||||
'height' | 'left' | 'top' | 'width'
|
||||
>
|
||||
|
||||
export type PhoneViewportGeometry = {
|
||||
readonly scaleX: number
|
||||
readonly scaleY: number
|
||||
rect(element: Element): PhoneViewportRect
|
||||
}
|
||||
|
||||
function positiveRatio(numerator: number, denominator: number): number {
|
||||
return Number.isFinite(numerator) &&
|
||||
Number.isFinite(denominator) &&
|
||||
numerator > 0 &&
|
||||
denominator > 0
|
||||
? numerator / denominator
|
||||
: 1
|
||||
}
|
||||
|
||||
export function normalizePhoneViewportRect(
|
||||
rawRect: RectMeasurement,
|
||||
rawCanvasRect: RectMeasurement,
|
||||
wrapperRect: RectMeasurement,
|
||||
): PhoneViewportRect {
|
||||
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
|
||||
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
|
||||
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
|
||||
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
|
||||
const width = rawRect.width * factorX
|
||||
const height = rawRect.height * factorY
|
||||
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
export function readPhoneViewportGeometry(
|
||||
anchor: Element | null,
|
||||
): PhoneViewportGeometry | null {
|
||||
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
|
||||
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
|
||||
if (!canvas || !wrapper) return null
|
||||
|
||||
const rawCanvasRect = canvas.getBoundingClientRect()
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
|
||||
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
|
||||
rect(element: Element): PhoneViewportRect {
|
||||
return normalizePhoneViewportRect(
|
||||
element.getBoundingClientRect(),
|
||||
rawCanvasRect,
|
||||
wrapperRect,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -140,4 +140,56 @@ describe('springboard widget drag', () => {
|
||||
expect(delta.x).toBeCloseTo(100)
|
||||
expect(delta.y).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['80% preview', 0.6624],
|
||||
['80% production clamp', 2 / 3],
|
||||
['100%', 0.828],
|
||||
['120%', 0.9936],
|
||||
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
|
||||
const layoutWidth = 350
|
||||
const layoutHeight = 808
|
||||
const viewportLeft = 128.25
|
||||
const viewportTop = 64.5
|
||||
const viewportWidth = layoutWidth * zoom
|
||||
const viewportHeight = layoutHeight * zoom
|
||||
const pointerStart = {
|
||||
x: viewportLeft + 48.5 * zoom,
|
||||
y: viewportTop + 132.25 * zoom,
|
||||
}
|
||||
const viewportDelta = { x: 73.25, y: -41.75 }
|
||||
const start = springboardViewportToLocal(
|
||||
pointerStart.x,
|
||||
pointerStart.y,
|
||||
viewportLeft,
|
||||
viewportTop,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
const end = springboardViewportToLocal(
|
||||
pointerStart.x + viewportDelta.x,
|
||||
pointerStart.y + viewportDelta.y,
|
||||
viewportLeft,
|
||||
viewportTop,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
const localDelta = springboardViewportDeltaToLocal(
|
||||
viewportDelta.x,
|
||||
viewportDelta.y,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
layoutWidth,
|
||||
layoutHeight,
|
||||
)
|
||||
|
||||
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
|
||||
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
|
||||
|
||||
export type PageTurnDirection = -1 | 0 | 1
|
||||
|
||||
export type SpringboardEdgeTurn = {
|
||||
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
|
||||
'.springboard-page, .home-folder-panel',
|
||||
)
|
||||
if (!surface) return null
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
const bounds =
|
||||
readPhoneViewportGeometry(surface)?.rect(surface) ??
|
||||
surface.getBoundingClientRect()
|
||||
return {
|
||||
layoutHeight: surface.offsetHeight,
|
||||
layoutWidth: surface.offsetWidth,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,11 +19,31 @@ const mainCss = readFileSync(
|
||||
new URL('../assets/main.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const tokensCss = readFileSync(
|
||||
new URL('../ui/tokens.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const foundationCss = readFileSync(
|
||||
new URL('../ui/foundation.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const builtInWallpaperCss = mainCss.slice(
|
||||
mainCss.indexOf('.wallpaper--midnight'),
|
||||
mainCss.indexOf('.wallpaper--custom'),
|
||||
)
|
||||
|
||||
describe('Springboard page swipe contract', () => {
|
||||
it('keeps app and widget labels on the larger shared home typography', () => {
|
||||
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
|
||||
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
|
||||
expect(mainCss).toMatch(
|
||||
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||
)
|
||||
expect(foundationCss).toMatch(
|
||||
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the built-in wallpapers visually restrained', () => {
|
||||
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
|
||||
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
|
||||
@@ -94,36 +114,99 @@ describe('Springboard page swipe contract', () => {
|
||||
expect(folderIconSource).toContain(':style="dragPointerStyle"')
|
||||
})
|
||||
|
||||
it('uses the same scale-aware in-phone drag path as widgets', () => {
|
||||
expect(viewSource).not.toContain('source.cloneNode(true)')
|
||||
expect(viewSource).not.toContain('<Teleport to="body">')
|
||||
expect(viewSource).not.toContain(':external-drag-visual')
|
||||
it('renders the home drag visual through the unzoomed phone portal', () => {
|
||||
expect(viewSource).toContain('source.cloneNode(true)')
|
||||
expect(viewSource).toContain(
|
||||
"'springboard--home-dragging': draggingHomeApp !== null",
|
||||
'<Teleport defer to="#phone-home-drag-portal">',
|
||||
)
|
||||
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
|
||||
expect(appIconSource).not.toContain('externalDragVisual')
|
||||
expect(folderIconSource).not.toContain('externalDragVisual')
|
||||
expect(mainCss).not.toContain('.home-drag-layer')
|
||||
expect(mainCss).not.toContain('.home-drag-ghost')
|
||||
expect(mainCss).not.toContain('.app-icon-item--drag-source')
|
||||
expect(viewSource).toContain(
|
||||
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
|
||||
)
|
||||
expect(viewSource).toContain('readPhoneViewportGeometry')
|
||||
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
|
||||
expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
|
||||
expect(viewSource).not.toContain('homeDragLocalPoint')
|
||||
expect(viewSource).not.toContain('springboardViewportToLocal')
|
||||
expect(viewSource).toContain("position.className = 'home-drag-position'")
|
||||
expect(viewSource).toMatch(
|
||||
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
|
||||
)
|
||||
expect(
|
||||
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
|
||||
).toHaveLength(4)
|
||||
expect(viewSource).toContain('updateHomeDragGhost(event)')
|
||||
expect(viewSource).toContain('const dropGhost = homeDragGhost')
|
||||
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
|
||||
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
|
||||
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
|
||||
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
|
||||
expect(viewSource).not.toContain('springboard--home-dragging')
|
||||
expect(appIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(folderIconSource).toContain('externalDragVisual?: boolean')
|
||||
expect(appIconSource).toContain('app-icon-item--drag-source')
|
||||
expect(folderIconSource).toContain('app-icon-item--drag-source')
|
||||
expect(mainCss).toMatch(
|
||||
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
|
||||
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
|
||||
)
|
||||
expect(mainCss).toContain('.home-drag-position')
|
||||
expect(mainCss).toContain('.home-drag-ghost')
|
||||
expect(mainCss).toContain('.app-icon-item--drag-source')
|
||||
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
|
||||
expect(mainCss).toMatch(
|
||||
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans up the external drag visual on every terminal path', () => {
|
||||
expect(
|
||||
viewSource.match(
|
||||
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
|
||||
),
|
||||
).toHaveLength(4)
|
||||
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
|
||||
expect(viewSource).toContain(
|
||||
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
expect(viewSource).toMatch(
|
||||
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('targets the active visual page and lets grid or dock drags turn pages', () => {
|
||||
expect(viewSource).toContain(':data-home-page="page.page"')
|
||||
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
|
||||
expect(viewSource).toContain('nearestGridDropTarget')
|
||||
expect(viewSource).toContain('moveHomeAppToGridPage')
|
||||
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
|
||||
expect(viewSource).toContain('event.clientX < pageBounds.left')
|
||||
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
|
||||
expect(viewSource).toContain('event.clientX < springboardBounds.left')
|
||||
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
|
||||
expect(viewSource).toContain('function homeDragViewportRect(')
|
||||
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
|
||||
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
|
||||
expect(viewSource).toMatch(
|
||||
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
|
||||
)
|
||||
expect(viewSource).toContain(
|
||||
'springboardBounds.left + (bounds.left - pageBounds.left)',
|
||||
)
|
||||
expect(viewSource).toContain('nearestSpringboardRectIndex')
|
||||
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
|
||||
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
|
||||
|
||||
@@ -41,6 +41,11 @@ import {
|
||||
type HomeItem,
|
||||
} from '@/utils/homeLayout'
|
||||
import type { ReorderDirection } from '@/utils/keyboard'
|
||||
import {
|
||||
readPhoneViewportGeometry,
|
||||
type PhoneViewportGeometry,
|
||||
type PhoneViewportRect,
|
||||
} from '@/utils/phoneViewportGeometry'
|
||||
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
|
||||
import {
|
||||
maximumRenderedWidgetPage,
|
||||
@@ -106,6 +111,8 @@ const draggingHomeApp = ref<{
|
||||
area: HomeArea
|
||||
index: number
|
||||
} | null>(null)
|
||||
const homeDragLayer = ref<HTMLElement | null>(null)
|
||||
const homeDragVisualActive = ref(false)
|
||||
const temporaryHomePage = ref<number | null>(null)
|
||||
const openedFolderId = ref<string | null>(null)
|
||||
const folderDraggingOutside = ref(false)
|
||||
@@ -125,6 +132,11 @@ let edgePageDirection = 0
|
||||
let edgePageLocked = false
|
||||
let folderHoverTimer: number | undefined
|
||||
let lastHomePointer: { clientX: number; clientY: number } | null = null
|
||||
let homeDragGhost: HTMLElement | null = null
|
||||
let homeDragGrip: { x: number; y: number } | null = null
|
||||
let homeDragGeometry: PhoneViewportGeometry | null = null
|
||||
let homeDragClipBounds: PhoneViewportRect | null = null
|
||||
let homeDragPreviewBounds: PhoneViewportRect | null = null
|
||||
|
||||
const installedApps = computed(() =>
|
||||
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
|
||||
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
|
||||
).find((candidate) => candidate.dataset.widgetId === id)
|
||||
if (!widget) return
|
||||
pageTransitioning.value = false
|
||||
const bounds = widget.getBoundingClientRect()
|
||||
const bounds = homeDragViewportRect(widget)
|
||||
draggingWidgetId.value = id
|
||||
widgetDragGrip.value = {
|
||||
x: event.clientX - bounds.left,
|
||||
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
if (!grid || !pageElement) return
|
||||
const renderedGridBounds = grid.getBoundingClientRect()
|
||||
const renderedGridBounds = homeDragViewportRect(grid)
|
||||
const gridStyle = getComputedStyle(grid)
|
||||
const scaleX =
|
||||
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
|
||||
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
|
||||
function resolveHomeEdgeTurn(event: { clientX: number }) {
|
||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||
if (!springboard) return null
|
||||
const bounds = springboard.getBoundingClientRect()
|
||||
const bounds = homeDragViewportRect(springboard)
|
||||
const renderedLastPage = appPages.value.length
|
||||
return resolveSpringboardHomeEdgeTurn(
|
||||
event.clientX,
|
||||
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
|
||||
): void {
|
||||
const springboard = document.querySelector<HTMLElement>('.springboard')
|
||||
if (!springboard) return
|
||||
const bounds = springboard.getBoundingClientRect()
|
||||
const bounds = homeDragViewportRect(springboard)
|
||||
const turn =
|
||||
dragType === 'app'
|
||||
? resolveHomeEdgeTurn(event)
|
||||
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
|
||||
if (configured) changePage(configured.page)
|
||||
}
|
||||
|
||||
function startHomeDrag(area: HomeArea, index: number): void {
|
||||
function homeDragViewportRect(
|
||||
element: Element,
|
||||
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
|
||||
): PhoneViewportRect {
|
||||
if (geometry) return geometry.rect(element)
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return {
|
||||
bottom: bounds.bottom,
|
||||
height: bounds.height,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
}
|
||||
}
|
||||
|
||||
function viewportRectAt(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): PhoneViewportRect {
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
}
|
||||
}
|
||||
|
||||
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
|
||||
if (expectedGhost && homeDragGhost !== expectedGhost) return
|
||||
homeDragGhost?.remove()
|
||||
homeDragGhost = null
|
||||
homeDragGrip = null
|
||||
homeDragGeometry = null
|
||||
homeDragClipBounds = null
|
||||
homeDragPreviewBounds = null
|
||||
homeDragVisualActive.value = false
|
||||
const layer = homeDragLayer.value
|
||||
layer?.style.removeProperty('left')
|
||||
layer?.style.removeProperty('top')
|
||||
layer?.style.removeProperty('width')
|
||||
layer?.style.removeProperty('height')
|
||||
}
|
||||
|
||||
function updateHomeDragGhost(event: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
}): void {
|
||||
const ghost = homeDragGhost
|
||||
const grip = homeDragGrip
|
||||
const clipBounds = homeDragClipBounds
|
||||
const previewBounds = homeDragPreviewBounds
|
||||
if (!ghost || !grip || !clipBounds || !previewBounds) return
|
||||
|
||||
const left = event.clientX - grip.x
|
||||
const top = event.clientY - grip.y
|
||||
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
|
||||
homeDragPreviewBounds = viewportRectAt(
|
||||
left,
|
||||
top,
|
||||
previewBounds.width,
|
||||
previewBounds.height,
|
||||
)
|
||||
}
|
||||
|
||||
function createHomeDragGhost(event: PointerEvent): void {
|
||||
clearHomeDragGhost()
|
||||
const eventTarget =
|
||||
event.currentTarget instanceof Element
|
||||
? event.currentTarget
|
||||
: event.target instanceof Element
|
||||
? event.target
|
||||
: null
|
||||
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
|
||||
const layer = homeDragLayer.value
|
||||
const springboard = source?.closest<HTMLElement>('.springboard')
|
||||
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
|
||||
const geometry = readPhoneViewportGeometry(source ?? null)
|
||||
if (!source || !layer || !springboard || !portal || !geometry) return
|
||||
|
||||
const portalBounds = portal.getBoundingClientRect()
|
||||
const clipBounds = geometry.rect(springboard)
|
||||
const sourceBounds = geometry.rect(source)
|
||||
if (
|
||||
clipBounds.width <= 0 ||
|
||||
clipBounds.height <= 0 ||
|
||||
sourceBounds.width <= 0 ||
|
||||
sourceBounds.height <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
layer.style.left = `${clipBounds.left - portalBounds.left}px`
|
||||
layer.style.top = `${clipBounds.top - portalBounds.top}px`
|
||||
layer.style.width = `${clipBounds.width}px`
|
||||
layer.style.height = `${clipBounds.height}px`
|
||||
|
||||
const ghost = source.cloneNode(true) as HTMLElement
|
||||
ghost.classList.remove(
|
||||
'app-icon-item--dragging',
|
||||
'app-icon-item--editing',
|
||||
'app-icon-item--drag-source',
|
||||
'app-icon-item--drop-settling',
|
||||
'home-folder-item--dragging',
|
||||
'home-item--folder-hover',
|
||||
)
|
||||
ghost.classList.add('home-drag-ghost')
|
||||
ghost.removeAttribute('style')
|
||||
ghost
|
||||
.querySelector<HTMLElement>('.app-icon-button')
|
||||
?.style.removeProperty('transform')
|
||||
ghost.setAttribute('aria-hidden', 'true')
|
||||
ghost.querySelector('.app-icon-remove')?.remove()
|
||||
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
|
||||
element.removeAttribute('id')
|
||||
for (const attribute of element.getAttributeNames()) {
|
||||
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
|
||||
}
|
||||
}
|
||||
for (const control of ghost.querySelectorAll<HTMLElement>(
|
||||
'a, button, input, select, textarea, [tabindex]',
|
||||
)) {
|
||||
control.tabIndex = -1
|
||||
}
|
||||
|
||||
const position = document.createElement('div')
|
||||
position.className = 'home-drag-position'
|
||||
position.style.width = `${sourceBounds.width}px`
|
||||
position.style.height = `${sourceBounds.height}px`
|
||||
ghost.style.width = `${source.offsetWidth}px`
|
||||
ghost.style.height = `${source.offsetHeight}px`
|
||||
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
|
||||
position.appendChild(ghost)
|
||||
|
||||
homeDragGhost = position
|
||||
homeDragGrip = {
|
||||
x: event.clientX - sourceBounds.left,
|
||||
y: event.clientY - sourceBounds.top,
|
||||
}
|
||||
homeDragGeometry = geometry
|
||||
homeDragClipBounds = clipBounds
|
||||
homeDragPreviewBounds = sourceBounds
|
||||
layer.appendChild(position)
|
||||
homeDragVisualActive.value = true
|
||||
updateHomeDragGhost(event)
|
||||
}
|
||||
|
||||
function startHomeDrag(
|
||||
area: HomeArea,
|
||||
index: number,
|
||||
event: PointerEvent,
|
||||
): void {
|
||||
clearTemporaryHomePage()
|
||||
lastHomePointer = null
|
||||
draggingHomeApp.value = { area, index }
|
||||
createHomeDragGhost(event)
|
||||
}
|
||||
|
||||
function clearFolderHover(): void {
|
||||
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
return
|
||||
}
|
||||
|
||||
const targetElement = document
|
||||
.elementsFromPoint(event.clientX, event.clientY)
|
||||
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
|
||||
.find((element) => element && !element.closest('.app-icon-item--dragging'))
|
||||
const targetElement = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
|
||||
)
|
||||
.filter((element) => {
|
||||
const area = element.dataset.homeArea as HomeArea | undefined
|
||||
const index = Number(element.dataset.homeIndex)
|
||||
if (
|
||||
(area !== 'grid' && area !== 'dock') ||
|
||||
!Number.isInteger(index) ||
|
||||
(area === dragged.area && index === dragged.index)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
area === 'grid' &&
|
||||
Number(
|
||||
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
|
||||
) !== phone.currentPage
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const bounds = homeDragViewportRect(element)
|
||||
return (
|
||||
event.clientX >= bounds.left &&
|
||||
event.clientX <= bounds.right &&
|
||||
event.clientY >= bounds.top &&
|
||||
event.clientY <= bounds.bottom
|
||||
)
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftIsDock = left.dataset.homeArea === 'dock'
|
||||
const rightIsDock = right.dataset.homeArea === 'dock'
|
||||
return Number(rightIsDock) - Number(leftIsDock)
|
||||
})[0]
|
||||
const area = targetElement?.dataset.homeArea as HomeArea | undefined
|
||||
const targetIndex = Number(targetElement?.dataset.homeIndex)
|
||||
if (
|
||||
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
if (folderId) {
|
||||
draggingHomeApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
openedFolderId.value = folderId
|
||||
}
|
||||
@@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void {
|
||||
function moveHomeDrag(event: PointerEvent): void {
|
||||
if (!draggingHomeApp.value) return
|
||||
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
|
||||
updateHomeDragGhost(event)
|
||||
if (resolveHomeEdgeTurn(event)) {
|
||||
clearFolderHover()
|
||||
queueEdgePageTurn(event, 'app')
|
||||
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
|
||||
item: HomeItem,
|
||||
area: HomeArea,
|
||||
index: number,
|
||||
from: DOMRect,
|
||||
from: PhoneViewportRect,
|
||||
): Promise<void> {
|
||||
await nextTick()
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
|
||||
)
|
||||
if (!appElement) return
|
||||
|
||||
const target = appElement.getBoundingClientRect()
|
||||
const target = homeDragViewportRect(
|
||||
appElement,
|
||||
readPhoneViewportGeometry(appElement),
|
||||
)
|
||||
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
|
||||
from.left - target.left,
|
||||
from.top - target.top,
|
||||
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
|
||||
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
|
||||
)
|
||||
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
|
||||
if (!grid || !pageElement) return null
|
||||
const pageBounds = pageElement.getBoundingClientRect()
|
||||
const springboard = grid?.closest<HTMLElement>('.springboard')
|
||||
if (!grid || !pageElement || !springboard) return null
|
||||
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
|
||||
const pageBounds = homeDragViewportRect(pageElement, geometry)
|
||||
const springboardBounds = homeDragViewportRect(springboard, geometry)
|
||||
if (
|
||||
event.clientX < pageBounds.left ||
|
||||
event.clientX > pageBounds.right ||
|
||||
event.clientY < pageBounds.top ||
|
||||
event.clientY > pageBounds.bottom
|
||||
event.clientX < springboardBounds.left ||
|
||||
event.clientX > springboardBounds.right ||
|
||||
event.clientY < springboardBounds.top ||
|
||||
event.clientY > springboardBounds.bottom
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => slot.getBoundingClientRect()),
|
||||
slots.map((slot) => {
|
||||
const bounds = homeDragViewportRect(slot, geometry)
|
||||
return {
|
||||
height: bounds.height,
|
||||
left: springboardBounds.left + (bounds.left - pageBounds.left),
|
||||
top: springboardBounds.top + (bounds.top - pageBounds.top),
|
||||
width: bounds.width,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const closest = closestIndex === null ? null : slots[closestIndex]
|
||||
const targetOffset = Number(closest?.dataset.homeTargetOffset)
|
||||
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
|
||||
}): HTMLElement | null {
|
||||
const dock = document.querySelector<HTMLElement>('.app-dock')
|
||||
if (!dock) return null
|
||||
const dockBounds = dock.getBoundingClientRect()
|
||||
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
|
||||
const dockBounds = homeDragViewportRect(dock, geometry)
|
||||
if (
|
||||
event.clientX < dockBounds.left ||
|
||||
event.clientX > dockBounds.right ||
|
||||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
|
||||
const closestIndex = nearestSpringboardRectIndex(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
slots.map((slot) => slot.getBoundingClientRect()),
|
||||
slots.map((slot) => homeDragViewportRect(slot, geometry)),
|
||||
)
|
||||
return closestIndex === null ? null : slots[closestIndex]
|
||||
}
|
||||
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
clearEdgePageTurn()
|
||||
clearFolderHover()
|
||||
const dragged = draggingHomeApp.value
|
||||
if (!dragged) return
|
||||
if (!dragged) {
|
||||
clearHomeDragGhost()
|
||||
return
|
||||
}
|
||||
updateHomeDragGhost(event)
|
||||
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
|
||||
const draggedElement = document.querySelector<HTMLElement>(
|
||||
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
|
||||
)
|
||||
const dropOrigin = draggedElement?.getBoundingClientRect()
|
||||
const dropGhost = homeDragGhost
|
||||
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
|
||||
const dockTarget = nearestDockDropTarget(event)
|
||||
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
|
||||
let dropArea = dragged.area
|
||||
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
|
||||
lastHomePointer = null
|
||||
clearTemporaryHomePage(moved && gridTarget !== null)
|
||||
if (moved && draggedItem && dropOrigin) {
|
||||
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
|
||||
void animateHomeItemDrop(
|
||||
draggedItem,
|
||||
dropArea,
|
||||
dropIndex,
|
||||
dropOrigin,
|
||||
).finally(() => clearHomeDragGhost(dropGhost))
|
||||
} else {
|
||||
clearHomeDragGhost()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
|
||||
clearFolderHover()
|
||||
draggingHomeApp.value = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
clearTemporaryHomePage()
|
||||
}
|
||||
|
||||
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
|
||||
const appElement = document.querySelector<HTMLElement>(
|
||||
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
|
||||
)
|
||||
const moveOrigin = appElement?.getBoundingClientRect()
|
||||
const moveOrigin = appElement
|
||||
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
|
||||
: null
|
||||
const targetIndex = homeKeyboardTarget(
|
||||
appStore.homeLayout,
|
||||
area,
|
||||
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
|
||||
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
|
||||
)
|
||||
?.closest<HTMLElement>('.app-icon-item')
|
||||
const sourceBounds = sourceElement?.getBoundingClientRect()
|
||||
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
|
||||
const sourceBounds = sourceElement
|
||||
? homeDragViewportRect(sourceElement, geometry)
|
||||
: null
|
||||
const candidates = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
|
||||
).filter((element) => {
|
||||
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
|
||||
})
|
||||
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
|
||||
if (!closest) return candidate
|
||||
const bounds = candidate.getBoundingClientRect()
|
||||
const closestBounds = closest.getBoundingClientRect()
|
||||
const bounds = homeDragViewportRect(candidate, geometry)
|
||||
const closestBounds = homeDragViewportRect(closest, geometry)
|
||||
const distance = Math.hypot(
|
||||
event.clientX - (bounds.left + bounds.width / 2),
|
||||
event.clientY - (bounds.top + bounds.height / 2),
|
||||
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
|
||||
pendingWidgetPointer = null
|
||||
lastWidgetPointer = null
|
||||
lastHomePointer = null
|
||||
clearHomeDragGhost()
|
||||
temporaryHomePage.value = null
|
||||
draggingHomeApp.value = null
|
||||
draggingWidgetId.value = null
|
||||
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
|
||||
'springboard--dragging': dragging,
|
||||
'springboard--editing': editMode,
|
||||
'springboard--folder-open': folderOverlayVisible,
|
||||
'springboard--home-dragging': draggingHomeApp !== null,
|
||||
'springboard--widget-dragging': draggingWidgetId !== null,
|
||||
},
|
||||
]"
|
||||
@@ -1303,6 +1533,7 @@ onBeforeUnmount(() => {
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:data-home-target-offset="cell.targetOffset"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
folderHoverTarget === `grid:${cell.sourceIndex}`,
|
||||
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(cell.app.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
@@ -1325,6 +1556,7 @@ onBeforeUnmount(() => {
|
||||
:data-home-index="cell.sourceIndex"
|
||||
:data-home-target-offset="cell.targetOffset"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="cell.folder"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
|
||||
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(cell.folder.id)"
|
||||
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
|
||||
@@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Teleport defer to="#phone-home-drag-portal">
|
||||
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
|
||||
</Teleport>
|
||||
|
||||
<Transition name="edit-done">
|
||||
<k-glass
|
||||
v-if="editMode && isEditablePage && !folderOverlayVisible"
|
||||
@@ -1530,6 +1766,7 @@ onBeforeUnmount(() => {
|
||||
:data-home-item-key="`app-${slot.app.id}`"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
'home-item--folder-hover':
|
||||
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||
@edit="enterEditMode"
|
||||
@remove="removeHomeApp(slot.app.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
@@ -1552,6 +1789,7 @@ onBeforeUnmount(() => {
|
||||
:data-home-item-key="slot.folder.id"
|
||||
:data-home-index="slot.index"
|
||||
:edit-mode="editMode"
|
||||
:external-drag-visual="homeDragVisualActive"
|
||||
:folder="slot.folder"
|
||||
:show-label="false"
|
||||
:class="{
|
||||
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
|
||||
@dragcancel="stopHomeDrag"
|
||||
@dragend="finishHomeDrag"
|
||||
@dragmove="moveHomeDrag"
|
||||
@dragstart="startHomeDrag('dock', slot.index)"
|
||||
@dragstart="startHomeDrag('dock', slot.index, $event)"
|
||||
@edit="enterEditMode"
|
||||
@open="openFolder(slot.folder.id)"
|
||||
@reorder="reorderHomeApp('dock', slot.index, $event)"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
color: #f6f7f9;
|
||||
background: #07090c;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.billing-app--light {
|
||||
--billing-border: rgb(15 23 42 / 10%);
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1156,13 +1156,7 @@ onMounted(async () => {
|
||||
padding: 47px 0 24px;
|
||||
background: var(--bg);
|
||||
color: var(--label);
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'SF Pro Display',
|
||||
system-ui,
|
||||
sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
|
||||
.calendar--light {
|
||||
|
||||
@@ -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"')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2195,7 +2195,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
background: #151613;
|
||||
color: #f8f8f4;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.citymarkt--light {
|
||||
--ink: #fff;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
@@ -3590,7 +3586,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
background: #12171b !important;
|
||||
color: #f7f8f4;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.feather-app--active.feather-app--light {
|
||||
--feather-panel: #f0f1ec;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -3375,7 +3395,7 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.state,
|
||||
.light-empty,
|
||||
@@ -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"')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1526,7 +1526,7 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
background: #12171b;
|
||||
color: #f7f7f2;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
}
|
||||
.pages--light {
|
||||
--yellow: #8a6500;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -278,7 +279,7 @@ onBeforeUnmount(() => {
|
||||
background:
|
||||
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
|
||||
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -419,7 +420,7 @@ onBeforeUnmount(() => {
|
||||
background:
|
||||
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
|
||||
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
@@ -376,7 +386,7 @@ onBeforeUnmount(() => {
|
||||
padding: 52px 14px 25px;
|
||||
color: #f5fbff;
|
||||
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -334,7 +335,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
|
||||
background:
|
||||
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
|
||||
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -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
@@ -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%);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
@@ -166,10 +167,10 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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 { --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:var(--sky-font-family);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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -302,7 +306,7 @@ onBeforeUnmount(() => {
|
||||
background:
|
||||
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
|
||||
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-family: var(--sky-font-family);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
@@ -314,12 +321,13 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
|
||||
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: var(--sky-font-family); user-select: none; touch-action: manipulation; }
|
||||
.tower-app--playing { padding: 0; }
|
||||
.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\);/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1157,6 +1157,7 @@ async function main() {
|
||||
'notification:focus',
|
||||
'sim:picker-close',
|
||||
'ui:input-focus',
|
||||
'ui:live-activity',
|
||||
'ui:opened',
|
||||
'ui:ready',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
|
||||
|
||||
author 'Sky-Systems'
|
||||
description 'Sky Phone'
|
||||
version '0.1.0'
|
||||
version '0.2.0'
|
||||
|
||||
provide 'lb-phone'
|
||||
provide '17mov_Phone'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user