ENH - refine App Store experience

This commit is contained in:
smx.pusha
2026-08-16 19:22:50 +02:00
parent 77dd50a900
commit 42e8180ebe
11 changed files with 652 additions and 424 deletions
+3 -2
View File
@@ -715,8 +715,9 @@ export function getPhoneAppLabel(
export function isPhoneAppRemovable(app: PhoneAppDefinition): boolean {
return app.kind === 'external'
? app.removable
: !NON_REMOVABLE_PHONE_APP_IDS.has(app.id)
? app.removable && !app.defaultInstalled
: !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
!NON_REMOVABLE_PHONE_APP_IDS.has(app.id)
}
export function isLaunchablePhoneApp(
+31 -65
View File
@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DEFAULT_INSTALLED_PHONE_APP_IDS,
NON_REMOVABLE_PHONE_APP_IDS,
} from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import {
@@ -184,26 +183,26 @@ describe('app store', () => {
it('fully uninstalls removable apps and allows downloading them again', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate(null)
apps.hydrate({ claimedApps: ['snake'] })
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.uninstallApp('calculator')).toBe(true)
expect(apps.isInstalled('calculator')).toBe(false)
expect(apps.uninstalledApps).toEqual(['calculator'])
expect(apps.homeLayout.hidden).toContain('calculator')
expect(apps.uninstallApp('snake')).toBe(true)
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.uninstalledApps).toEqual(['snake'])
expect(apps.homeLayout.hidden).toContain('snake')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
claimedApps: [],
homeLayout: apps.homeLayout,
launchCounts: {},
uninstalledApps: ['calculator'],
uninstalledApps: ['snake'],
})
apps.installApp('calculator')
apps.installApp('snake')
vi.advanceTimersByTime(3000)
expect(apps.isInstalled('calculator')).toBe(true)
expect(apps.isInstalled('snake')).toBe(true)
expect(apps.uninstalledApps).toEqual([])
expect(apps.homeLayout.hidden).not.toContain('calculator')
expect(apps.homeLayout.hidden).not.toContain('snake')
})
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
@@ -211,49 +210,25 @@ describe('app store', () => {
apps.hydrate({
uninstalledApps: ['calculator', 'phone', 'not-an-app'],
updatedAppReleases: {
calculator: '2026-08-14',
phone: '2026-08-14',
snake: 'x'.repeat(64),
},
})
expect(apps.uninstalledApps).toEqual(['calculator'])
expect(apps.isInstalled('calculator')).toBe(false)
expect(apps.uninstalledApps).toEqual([])
expect(apps.isInstalled('calculator')).toBe(true)
expect(apps.isInstalled('phone')).toBe(true)
expect(apps.updatedAppReleases).toEqual({ phone: '2026-08-14' })
})
it('protects system apps from full uninstallation', () => {
it('protects every default app from full uninstallation', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
expect(apps.uninstallApp('phone')).toBe(false)
expect(apps.isInstalled('phone')).toBe(true)
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
expect(apps.uninstallApp(appId)).toBe(false)
expect(apps.isInstalled(appId)).toBe(true)
}
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
})
it('updates installed apps and persists the current release', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
apps.hydrate(null)
apps.updateApp('phone', '2026-08-14')
expect(apps.updatingApps.phone).toBe(true)
vi.advanceTimersByTime(1800)
expect(apps.updatingApps.phone).toBeUndefined()
expect(apps.updatedAppReleases.phone).toBe('2026-08-14')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
claimedApps: [],
homeLayout: apps.homeLayout,
launchCounts: {},
updatedAppReleases: { phone: '2026-08-14' },
})
})
it('ignores duplicate installation requests and invalid persisted ids', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
@@ -267,7 +242,7 @@ describe('app store', () => {
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('reinstalls core and claimed apps removed from the Home Screen', () => {
it('reinstalls claimed apps removed from the Home Screen', () => {
vi.useFakeTimers()
const apps = useAppStoreStore()
@@ -279,8 +254,8 @@ describe('app store', () => {
apps.installApp('notes')
apps.installApp('memory')
expect(apps.installingApps).toEqual({ notes: true, memory: true })
expect(apps.homeLayout.hidden).toEqual(['notes', 'memory'])
expect(apps.installingApps).toEqual({ memory: true })
expect(apps.homeLayout.hidden).toEqual(['memory'])
vi.advanceTimersByTime(3000)
@@ -289,25 +264,15 @@ describe('app store', () => {
expect(apps.homeLayout.grid).toContain('notes')
expect(apps.homeLayout.grid).toContain('memory')
expect(apps.claimedApps).toEqual(['memory'])
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(2)
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('prevents protected apps from being removed from the Home Screen', () => {
it('prevents every default app from being removed from the Home Screen', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
expect([...NON_REMOVABLE_PHONE_APP_IDS]).toEqual([
'app-store',
'settings',
'camera',
'photos',
'phone',
'messages',
'mail',
'health',
])
for (const appId of NON_REMOVABLE_PHONE_APP_IDS) {
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
apps.removeHomeApp(appId)
expect(apps.homeLayout.hidden).not.toContain(appId)
}
@@ -332,17 +297,18 @@ describe('app store', () => {
it('persists home reordering and removal independently from installation', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
apps.hydrate({ claimedApps: ['memory'] })
mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes')
apps.moveHomeApp('grid', notesIndex, 'grid', 0)
expect(apps.homeLayout.grid[0]).toBe('notes')
const memoryIndex = apps.homeLayout.grid.indexOf('memory')
apps.moveHomeApp('grid', memoryIndex, 'grid', 0)
expect(apps.homeLayout.grid[0]).toBe('memory')
apps.removeHomeApp('notes')
expect(apps.homeLayout.grid).not.toContain('notes')
expect(apps.homeLayout.hidden).toContain('notes')
apps.removeHomeApp('memory')
expect(apps.homeLayout.grid).not.toContain('memory')
expect(apps.homeLayout.hidden).toContain('memory')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
claimedApps: [],
claimedApps: ['memory'],
homeLayout: apps.homeLayout,
launchCounts: {},
})
-85
View File
@@ -31,7 +31,6 @@ import {
import { nuiCall } from '@/utils/nui'
const INSTALL_DURATION_MS = 3000
const UPDATE_DURATION_MS = 1800
type PendingInstallation = {
deviceImei: string
@@ -39,19 +38,10 @@ type PendingInstallation = {
token: symbol
}
type PendingUpdate = {
deviceImei: string
timer: ReturnType<typeof globalThis.setTimeout>
}
const pendingInstallations = new WeakMap<
object,
Map<LaunchablePhoneAppId, PendingInstallation>
>()
const pendingUpdates = new WeakMap<
object,
Map<LaunchablePhoneAppId, PendingUpdate>
>()
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return [...PHONE_APPS]
@@ -112,7 +102,6 @@ export const useAppStoreStore = defineStore('app-store', {
state: () => ({
claimedApps: [] as LaunchablePhoneAppId[],
uninstalledApps: [] as LaunchablePhoneAppId[],
updatedAppReleases: {} as Partial<Record<LaunchablePhoneAppId, string>>,
homeLayout: createDefaultHomeLayout(
getDefaultInstalledIds(),
getDefaultGridIds(),
@@ -120,7 +109,6 @@ export const useAppStoreStore = defineStore('app-store', {
),
hydrated: false,
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
updatingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
}),
actions: {
@@ -157,14 +145,6 @@ export const useAppStoreStore = defineStore('app-store', {
pendingInstallations.delete(this)
}
this.installingApps = {}
const updates = pendingUpdates.get(this)
if (updates) {
for (const update of updates.values()) {
globalThis.clearTimeout(update.timer)
}
pendingUpdates.delete(this)
}
this.updatingApps = {}
},
installApp(id: LaunchablePhoneAppId): void {
const installed = this.isInstalled(id)
@@ -244,7 +224,6 @@ export const useAppStoreStore = defineStore('app-store', {
homeLayout?: unknown
launchCounts?: unknown
uninstalledApps?: unknown
updatedAppReleases?: unknown
} | null
const layoutVersion =
data?.homeLayout && typeof data.homeLayout === 'object'
@@ -268,29 +247,9 @@ export const useAppStoreStore = defineStore('app-store', {
return !!app && isPhoneAppRemovable(app)
})
: []
this.updatedAppReleases = {}
if (
data?.updatedAppReleases &&
typeof data.updatedAppReleases === 'object'
) {
for (const [id, release] of Object.entries(data.updatedAppReleases)) {
if (
isPhoneAppId(id) &&
typeof release === 'string' &&
release.length <= 32
) {
this.updatedAppReleases[id] = release
}
}
}
const installedIds = [
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
].filter((id) => !this.uninstalledApps.includes(id))
for (const appId of Object.keys(this.updatedAppReleases)) {
if (!isPhoneAppId(appId) || !installedIds.includes(appId)) {
delete this.updatedAppReleases[appId as LaunchablePhoneAppId]
}
}
const removedLegacyDefaults = hasUninstalledBuiltinApp(
data?.homeLayout,
installedIds,
@@ -512,52 +471,11 @@ export const useAppStoreStore = defineStore('app-store', {
if (!this.uninstalledApps.includes(appId)) {
this.uninstalledApps.push(appId)
}
delete this.updatedAppReleases[appId]
this.homeLayout = removeHomeApp(this.homeLayout, appId)
this.persist()
return true
},
updateApp(appId: LaunchablePhoneAppId, release: string): void {
if (
!this.isInstalled(appId) ||
this.updatingApps[appId] ||
!release.trim()
) {
return
}
const phone = usePhoneStore()
const deviceImei = phone.device?.imei
if (!phone.isOpen || !deviceImei) {
console.error(
`[App store] Update cancelled because no phone device is open for ${appId}.`,
)
return
}
const updates =
pendingUpdates.get(this) ??
new Map<LaunchablePhoneAppId, PendingUpdate>()
this.updatingApps[appId] = true
const timer = globalThis.setTimeout(() => {
updates.delete(appId)
if (!updates.size) pendingUpdates.delete(this)
delete this.updatingApps[appId]
const activePhone = usePhoneStore()
if (!activePhone.isOpen || activePhone.device?.imei !== deviceImei) {
console.error(
`[App store] Update cancelled because the active phone changed for ${appId}.`,
)
return
}
if (!this.isInstalled(appId)) return
this.updatedAppReleases[appId] = release
this.persist()
}, UPDATE_DURATION_MS)
updates.set(appId, { deviceImei, timer })
pendingUpdates.set(this, updates)
},
persist(): void {
usePhoneStore().saveDeviceNamespace('apps', {
claimedApps: this.claimedApps,
@@ -566,9 +484,6 @@ export const useAppStoreStore = defineStore('app-store', {
...(this.uninstalledApps.length
? { uninstalledApps: this.uninstalledApps }
: {}),
...(Object.keys(this.updatedAppReleases).length
? { updatedAppReleases: this.updatedAppReleases }
: {}),
})
},
},
+43 -4
View File
@@ -1861,8 +1861,6 @@ const defaultLocales: LocaleTree = {
search: 'Search',
},
today: {
freshDaily: 'A fresh selection every day',
dailyEdition: 'Daily Edition',
featured: 'Featured today',
gameHighlight: 'Game highlight',
appHighlight: 'App highlight',
@@ -1896,11 +1894,52 @@ const defaultLocales: LocaleTree = {
essentialApps: 'Essential Apps',
essentialGames: 'Essential Games',
},
taglines: {
health: 'Health, activity and medical information',
'weazel-news': 'Local news and city stories',
companies: 'Businesses, jobs and services',
music: 'Songs, playlists and audio',
picstagram: 'Photo sharing and social feed',
feather: 'Short posts and city conversations',
fliptok: 'Short videos and trends',
flare: 'Social posts and live moments',
calendar: 'Events, schedules and reminders',
radio: 'Live radio and team communication',
'local-pages': 'Local businesses and community pages',
crewlink: 'Crews, members and coordination',
phone: 'Calls, contacts and voicemail',
messages: 'Private texts and media',
darkchat: 'Private messaging and secure groups',
garage: 'Vehicles, parking and valet',
house: 'Homes, access and management',
map: 'Navigation and nearby places',
skyride: 'City rides and driver booking',
banking: 'Accounts, transfers and payments',
billing: 'Invoices and payment requests',
mail: 'Email and attachments',
notes: 'Notes, lists and ideas',
memos: 'Voice recordings and memos',
calculator: 'Everyday and scientific calculations',
camera: 'Photos and video capture',
clock: 'Time, alarms and timers',
weather: 'Forecasts and current conditions',
photos: 'Photo and video library',
settings: 'Phone preferences and security',
snake: 'Classic arcade snake',
memory: 'Card matching challenge',
'number-merge': 'Number puzzle and strategy',
minesweeper: 'Classic logic puzzle',
'tower-stack': 'Precision tower-building game',
'sky-flappy': 'Fast arcade flying challenge',
citymarkt: 'Local marketplace and listings',
'neon-drop': 'Neon block-dropping puzzle',
},
search: {
recommended: 'Recommended',
discover: 'Discover',
results: 'Search Results',
noResults: 'No matching apps or games found.',
noResults: 'No Results',
noResultsBody: 'Try searching for a different app or game.',
ad: 'Ad',
topApps: 'Top Apps',
topGames: 'Top Games',
@@ -1917,7 +1956,7 @@ const defaultLocales: LocaleTree = {
games: 'Games',
library: 'Your Library',
myApps: 'My Apps',
update: 'UPDATE',
downloadedOn: 'Downloaded {date}',
uninstall: 'Uninstall',
uninstallApp: 'Uninstall {app}',
uninstallTitle: 'Uninstall this app?',
@@ -9,10 +9,13 @@ const source = readFileSync(
)
describe('AppStoreAction contract', () => {
it('uses an Apple-style cloud download icon instead of GET text', () => {
expect(source).toContain("import { CloudDownload } from 'lucide-vue-next'")
it('uses localized text for the download button instead of an icon', () => {
expect(source).toContain('v-if="action === \'get\'"')
expect(source).not.toContain("phone.t('Apps.appStore.get')")
expect(source).toContain("phone.t('Apps.appStore.get')")
expect(source).not.toContain('CloudDownload')
expect(source).toContain(
":class=\"{ 'app-store-action--icon': action === 'installing' }\"",
)
})
it('shows a timed circular installation progress with a center stop mark', () => {
+4 -13
View File
@@ -1,6 +1,4 @@
<script setup lang="ts">
import { CloudDownload } from 'lucide-vue-next'
import { usePhoneStore } from '@/stores/phone'
defineProps<{
@@ -13,15 +11,12 @@ const phone = usePhoneStore()
<template>
<span
class="app-store-action"
:class="{ 'app-store-action--icon': action !== 'open' }"
:class="{ 'app-store-action--icon': action === 'installing' }"
aria-hidden="true"
>
<CloudDownload
v-if="action === 'get'"
class="app-store-action__download"
:size="24"
:stroke-width="1.9"
/>
<template v-if="action === 'get'">
{{ phone.t('Apps.appStore.get') }}
</template>
<span
v-else-if="action === 'installing'"
class="app-store-action__progress"
@@ -49,10 +44,6 @@ const phone = usePhoneStore()
color: var(--sky-app-accent);
}
.app-store-action__download {
filter: drop-shadow(0 2px 5px var(--sky-app-accent-soft));
}
.app-store-action__progress {
width: 28px;
height: 28px;
@@ -19,7 +19,6 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain('<SkyNavbar')
expect(source).toContain('<SkyPillNavigation')
expect(source).toContain('<SkySearchbar')
expect(source).toContain('<SkySpinner')
expect(source.match(/<SkyScrollArea/g)).toHaveLength(1)
})
@@ -46,7 +45,19 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain('const searchRecommendations = computed')
expect(source).toContain('const searchDiscoverCards = computed')
expect(source).toContain('v-if="!hasSearchQuery"')
expect(source).toContain(
":class=\"{ 'app-store-navbar--search': tab === 'search' }\"",
)
expect(source).toMatch(
/\.app-store-navbar--search\s*\{[^}]*margin-bottom:\s*-24px;/s,
)
expect(source).toMatch(
/\.app-store-navbar--search :deep\(\.sky-navbar__subnavbar\)\s*\{[^}]*- 24px/s,
)
expect(source).toContain('class="store-search__recommendations"')
expect(source).toMatch(
/\.store-search__recommendations article\.store-search__recommendation--promoted\s*\{[^}]*margin:\s*0 calc\(0px - var\(--sky-space-2\)\);[^}]*border-radius:\s*var\(--sky-radius-control\);[^}]*padding:\s*var\(--sky-space-3\) var\(--sky-space-2\);[^}]*rgba\(10, 132, 255, 0\.18\)/s,
)
expect(source).toContain('class="store-search__discover-grid"')
expect(source).toContain(
'class="store-list store-list--browse store-list--search-results"',
@@ -58,16 +69,34 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toMatch(
/\.store-search__discover-grid > button\s*\{[^}]*min-height:\s*124px/s,
)
expect(source).toContain('<SkyEmptyState')
expect(source).toContain("phone.t('Apps.appStore.search.noResultsBody')")
expect(source).toContain('<Search :size="38"')
expect(source).toMatch(
/\.store-search__empty\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;/s,
)
})
it('opens profile app management with update and uninstall actions', () => {
it('opens profile app management with open and uninstall actions', () => {
expect(source).toContain('@click="profileOpened = true"')
expect(source).toContain('const installedApps = computed')
expect(source).toContain('return !appStore.isInstalled(app.id)')
expect(source).toContain(
'class="store-account__apps phone-effect--expensive-shadow"',
)
expect(source).toContain('appStore.updateApp(app.id, currentRelease)')
expect(source).toContain('@click="handleManagedApp(app)"')
expect(source).toContain("phone.t('Apps.appStore.open')")
expect(source).toContain("phone.t('Apps.appStore.account.downloadedOn'")
expect(source).toContain('<small>{{ downloadDateDescription }}</small>')
expect(source).toMatch(
/\.store-account__primary-action\s*\{[^}]*min-width:\s*60px;[^}]*height:\s*30px;/s,
)
expect(source).toMatch(
/\.store-account__summary > div\s*\{[^}]*border:\s*1px solid var\(--sky-hairline\);[^}]*border-radius:\s*var\(--sky-radius-card\);[^}]*background:\s*var\(--sky-surface-muted\);/s,
)
expect(source).not.toContain('appStore.updateApp(')
expect(source).not.toContain('appStore.updatedAppReleases')
expect(source).not.toContain('appStore.updatingApps')
expect(source).toContain(
'appStore.uninstallApp(uninstallCandidate.value.id)',
)
@@ -136,10 +165,16 @@ describe('AppStoreApp Sky navigation contract', () => {
)
})
it('gives interactive download, update and remove buttons subtle pointer feedback', () => {
it('gives App Store actions calm pointer feedback without moving app rows', () => {
expect(source).toContain('@media (hover: hover) and (pointer: fine)')
expect(source).toMatch(
/button:not\(\.store-ranking__detail-link\):not\(:disabled\):hover\)\s*\{[^}]*brightness\(1\.08\)[^}]*translateY\(-1px\)/s,
/button:not\(\.store-ranking__detail-link\):not\([\s\S]*?\.store-list__detail-link[\s\S]*?:hover[\s\S]*?brightness\(1\.08\)[\s\S]*?translateY\(-1px\)/,
)
expect(source).toContain(
'.store-list--browse article:hover',
)
expect(source).toMatch(
/button\.store-action-button--get:not\(\.store-list__detail-link\):hover\s*\{[^}]*background:\s*var\(--sky-app-accent-soft\);[^}]*filter:\s*none;[^}]*transform:\s*none;/s,
)
expect(source).toContain('.app-store-page .store-action-button--icon:hover')
expect(source).toContain(
@@ -162,7 +197,10 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain(
'class="store-highlight store-highlight--compact phone-effect--expensive-shadow"',
)
expect(source).toContain('class="store-today__edition"')
expect(source).not.toContain('store-today__edition')
expect(source).not.toContain('todayDate')
expect(source).not.toContain("phone.t('Apps.appStore.today.freshDaily')")
expect(source).not.toContain('store-highlight__story-number')
expect(source).toContain(
'class="store-ranking phone-effect--expensive-shadow"',
)
@@ -184,38 +222,95 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain('@click.stop="handleApp(dailyHighlights[0])"')
expect(source).toContain('@click.stop="handleApp(finalHighlight)"')
expect(source).toContain('.store-highlight:hover')
expect(source).toMatch(
/\.store-highlight__orbit\s*\{[^}]*border:\s*0;[^}]*radial-gradient[^}]*box-shadow:\s*none;[^}]*filter:\s*blur\(14px\) saturate\(125%\);/s,
)
expect(source).toMatch(
/\.store-highlight__orbit::before\s*\{[^}]*radial-gradient[^}]*filter:\s*blur\(12px\);/s,
)
expect(source).toMatch(
/\.store-browse-feature__art > span\s*\{[^}]*radial-gradient[^}]*box-shadow:\s*none;[^}]*filter:\s*blur\(11px\) saturate\(125%\);/s,
)
expect(source).not.toContain('0 0 0 28px')
expect(source).not.toContain('0 0 0 22px')
expect(source).toMatch(
/\.store-highlight__footer\s*\{[^}]*border-radius:\s*0 0 var\(--sky-radius-card\) var\(--sky-radius-card\);[^}]*linear-gradient/s,
)
expect(source).toMatch(
/\.store-highlight__footer::before\s*\{[^}]*top:\s*-28px;[^}]*linear-gradient/s,
)
})
it('opens Top Today apps while keeping their direct app actions separate', () => {
expect(source).toContain('class="store-ranking__detail-link"')
expect(source).toContain('v-for="app in topToday"')
expect(source).not.toContain('store-ranking__position')
expect(source).toContain('@click="openAppDetail(app)"')
expect(source).toContain('@click.stop="handleApp(app)"')
expect(source).toContain(
'.store-ranking li > button:not(.store-ranking__detail-link)',
)
expect(source).toMatch(
/\.store-ranking li > button:not\(\.store-ranking__detail-link\),[\s\S]*?min-width:\s*68px;/,
)
expect(source).not.toContain('.store-ranking__detail-link:hover')
expect(source).toContain(
'button:not(.store-ranking__detail-link):not(:disabled):hover',
'button:not(.store-ranking__detail-link):not(',
)
})
it('keeps App Store actions compatible with the FiveM CEF target', () => {
expect(source.match(/class="store-action-button"/g)).toHaveLength(8)
expect(source).not.toContain("appAction(app) !== 'open'")
expect(source).toContain("appAction(app) === 'installing'")
expect(source).toContain("'store-action-button--get': appAction(app) === 'get'")
expect(source).toContain(
'> button.store-action-button--get:not(.store-browse-feature__details),',
)
expect(source).toMatch(
/\.store-ranking li > button\.store-action-button--get:not\(\.store-ranking__detail-link\),[\s\S]*?\.store-list article > button\.store-action-button--get:not\(\.store-list__detail-link\)\s*\{[^}]*width:\s*56px;[^}]*min-width:\s*56px;[^}]*min-height:\s*32px;[^}]*height:\s*32px;[^}]*font-size:\s*11px;[^}]*font-weight:\s*850;/s,
)
expect(source).not.toContain(':has(')
expect(source).not.toContain('color-mix(')
})
it('builds clean Apps and Games pages with rotating features', () => {
it('builds shorter horizontally snapping Apps and Games features', () => {
expect(source).not.toContain('class="store-browse__filters"')
expect(source).not.toContain('browseFilter')
expect(source).toContain(
'class="store-browse-feature phone-effect--expensive-shadow"',
)
expect(source).toContain('class="store-list store-list--browse"')
expect(source).not.toContain('class="store-list__tags"')
expect(source).toContain('class="store-list__tagline"')
expect(source).toContain('<small>{{ appStoreTagline(app) }}</small>')
expect(source).toContain('function appStoreTagline(')
expect(source).toContain('app.description.trim() || app.developer')
expect(source).toMatch(
/\.store-list__tagline\s*\{[^}]*color:\s*var\(--sky-muted\);[^}]*font-size:\s*11px;[^}]*font-weight:\s*500;/s,
)
expect(source).toContain('featuredCandidates')
expect(source).toContain('globalThis.setInterval')
expect(source).toContain('}, 6500)')
expect(source).toContain('globalThis.clearInterval')
expect(source).toContain('ref="featuredScroller"')
expect(source).toContain('@scroll.passive="updateFeaturedSlide"')
expect(source).toContain('@wheel="handleFeaturedWheel"')
expect(source).toContain('function handleFeaturedWheel(event: WheelEvent)')
expect(source).toContain('event.preventDefault()')
expect(source).toContain('v-for="(app, index) in featuredCandidates"')
expect(source).toContain(":class=\"{ 'is-active': featuredSlide === index }\"")
expect(source).toContain('@click="scrollToFeatured(index)"')
expect(source).not.toContain('globalThis.setInterval')
expect(source).toMatch(
/\.store-browse__featured-scroll\s*\{[^}]*overflow-x:\s*auto;[^}]*scroll-behavior:\s*smooth;[^}]*scroll-snap-type:\s*x mandatory;/s,
)
expect(source).toMatch(
/\.store-browse-feature\s*\{[^}]*min-height:\s*246px;[^}]*opacity:\s*0\.72;[^}]*scroll-snap-align:\s*start;[^}]*scroll-snap-stop:\s*always;[^}]*transform:\s*scale\(0\.97\);[^}]*transition:/s,
)
expect(source).toMatch(
/\.store-browse-feature\.is-active\s*\{[^}]*opacity:\s*1;[^}]*transform:\s*scale\(1\);/s,
)
expect(source).toMatch(
/\.store-browse-feature footer::before\s*\{[^}]*top:\s*-28px;[^}]*linear-gradient\(180deg, transparent, rgba\(5, 8, 16, 0\.3\)\);[^}]*backdrop-filter:\s*blur\(7px\) saturate\(110%\);/s,
)
expect(source).toMatch(
/\.store-browse__pages button\s*\{[^}]*width:\s*var\(--sky-touch-target\)[^}]*height:\s*var\(--sky-touch-target\)/s,
)
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,11 @@ describe('AppStoreDetail contract', () => {
expect(source).toContain("emit('share')")
expect(source).toContain('<AppStoreAction :action="action" />')
expect(source).toContain(
":class=\"{ 'store-detail__action--icon': action !== 'open' }\"",
"'store-detail__action--icon': action === 'installing'",
)
expect(source).toContain("'store-detail__action--get': action === 'get'")
expect(source).toMatch(
/\.store-detail__hero \.store-detail__action--get\s*\{[^}]*min-width:\s*54px;[^}]*min-height:\s*30px;[^}]*height:\s*30px;/s,
)
expect(source).not.toContain(':has(')
expect(source).toMatch(
+12 -1
View File
@@ -132,7 +132,10 @@ function updateActivePreview(): void {
<button
type="button"
class="store-detail__action"
:class="{ 'store-detail__action--icon': action !== 'open' }"
:class="{
'store-detail__action--icon': action === 'installing',
'store-detail__action--get': action === 'get',
}"
:disabled="action === 'installing'"
@click="emit('action')"
>
@@ -389,6 +392,14 @@ function updateActivePreview(): void {
background: transparent;
}
.store-detail__hero .store-detail__action--get {
min-width: 54px;
min-height: 30px;
height: 30px;
padding: 0 10px;
font-size: 10px;
}
.store-detail__facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
+24 -3
View File
@@ -1783,7 +1783,6 @@ Locales["en"] = {
selected = "Apps available for your Sky Phone",
tabs = { today = "Today", apps = "Apps", games = "Games", search = "Search" },
today = {
freshDaily = "A fresh selection every day", dailyEdition = "Daily Edition",
featured = "Featured today",
gameHighlight = "Game highlight", appHighlight = "App highlight",
curatedForYou = "Curated for you", editorsChoice = "Editor's Choice",
@@ -1804,16 +1803,38 @@ Locales["en"] = {
handPicked = "Hand-picked for you", essentialApps = "Essential Apps",
essentialGames = "Essential Games",
},
taglines = {
health = "Health, activity and medical information",
["weazel-news"] = "Local news and city stories",
companies = "Businesses, jobs and services", music = "Songs, playlists and audio",
picstagram = "Photo sharing and social feed", feather = "Short posts and city conversations",
fliptok = "Short videos and trends", flare = "Social posts and live moments",
calendar = "Events, schedules and reminders", radio = "Live radio and team communication",
["local-pages"] = "Local businesses and community pages", crewlink = "Crews, members and coordination",
phone = "Calls, contacts and voicemail", messages = "Private texts and media",
darkchat = "Private messaging and secure groups", garage = "Vehicles, parking and valet",
house = "Homes, access and management", map = "Navigation and nearby places",
skyride = "City rides and driver booking", banking = "Accounts, transfers and payments",
billing = "Invoices and payment requests", mail = "Email and attachments",
notes = "Notes, lists and ideas", memos = "Voice recordings and memos",
calculator = "Everyday and scientific calculations", camera = "Photos and video capture",
clock = "Time, alarms and timers", weather = "Forecasts and current conditions",
photos = "Photo and video library", settings = "Phone preferences and security",
snake = "Classic arcade snake", memory = "Card matching challenge",
["number-merge"] = "Number puzzle and strategy", minesweeper = "Classic logic puzzle",
["tower-stack"] = "Precision tower-building game", ["sky-flappy"] = "Fast arcade flying challenge",
citymarkt = "Local marketplace and listings", ["neon-drop"] = "Neon block-dropping puzzle",
},
search = {
recommended = "Recommended", discover = "Discover", results = "Search Results",
noResults = "No matching apps or games found.", ad = "Ad",
noResults = "No Results", noResultsBody = "Try searching for a different app or game.", ad = "Ad",
topApps = "Top Apps", topGames = "Top Games", bestApps = "Bestselling Apps",
bestGames = "Bestselling Games", productivity = "Productivity", photoVideo = "Photo & Video",
},
account = {
account = "Account", title = "App Management", skyAccount = "Sky Phone Account",
apps = "Installed Apps", games = "Games", library = "Your Library", myApps = "My Apps",
update = "UPDATE", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
downloadedOn = "Downloaded {date}", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
uninstallTitle = "Uninstall this app?",
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
},