FIX - Inline App Store bundle; CEF-compatible CSS

Make the App Store part of the phone shell (remove preload helper/loadAppStoreComponent and App.vue preload) by importing AppStoreApp in PhoneAppWindow and selecting it when app.id === 'app-store'. Update the apps registry to use an inline import for the async component. Replace unsupported CSS features (:has, color-mix) with CEF-safe alternatives using semantic tokens (--sky-app-accent-soft, --sky-danger-soft) and add explicit .store-action-button/.store-action-button--icon and .store-detail__action classes. Add contract tests to assert the App Store is bundled with the shell and that styles remain compatible with the FiveM CEF target.
This commit is contained in:
Leon.Schmidt
2026-08-14 21:04:10 +02:00
parent 6cdbe753ad
commit a9dd46f98f
11 changed files with 90 additions and 57 deletions
+1 -4
View File
@@ -47,7 +47,7 @@ import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppCatalogStore } from '@/stores/app-catalog'
import { useAppStoreStore } from '@/stores/app-store'
import { useWidgetsStore } from '@/stores/widgets'
import { isPhoneAppId, loadAppStoreComponent } from '@/config/apps'
import { isPhoneAppId } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useMemosStore } from '@/stores/memos'
import { useWeatherStore } from '@/stores/weather'
@@ -1223,9 +1223,6 @@ onMounted(() => {
window.addEventListener('resize', updateViewportScale)
systemColorScheme.addEventListener('change', onSystemColorSchemeChange)
phone.setSystemDarkMode(systemColorScheme.matches)
void loadAppStoreComponent().catch((error: unknown) => {
console.error('[App Store] Could not preload the phone app.', error)
})
void nuiCall('ui:ready', { protocolVersion: 1 })
clockTicker = setInterval(() => {
const now = Date.now()
+1 -9
View File
@@ -1,16 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Newspaper } from 'lucide-vue-next'
import { isPhoneAppId, loadAppStoreComponent, PHONE_APPS } from './apps'
import { isPhoneAppId, PHONE_APPS } from './apps'
describe('app registry', () => {
it('reuses the App Store module loaded by the phone shell', async () => {
const firstLoad = loadAppStoreComponent()
const secondLoad = loadAppStoreComponent()
expect(secondLoad).toBe(firstLoad)
expect((await firstLoad).default).toBeTruthy()
})
it('has unique ids and routes with the reference dock order', () => {
expect(new Set(PHONE_APPS.map((app) => app.id)).size).toBe(
PHONE_APPS.length,
+3 -14
View File
@@ -86,19 +86,6 @@ import type {
PhoneAppDefinition,
} from '@/types/apps'
let appStoreComponentPromise:
| ReturnType<typeof importAppStoreComponent>
| null = null
function importAppStoreComponent() {
return import('@/views/apps/AppStoreApp.vue')
}
export function loadAppStoreComponent() {
appStoreComponentPromise ??= importAppStoreComponent()
return appStoreComponentPromise
}
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
{
category: 'social',
@@ -494,7 +481,9 @@ export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
},
{
category: 'utilities',
component: markRaw(defineAsyncComponent(loadAppStoreComponent)),
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/AppStoreApp.vue')),
),
dockOrder: null,
gridOrder: 9,
icon: markRaw(ShoppingBag),
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./PhoneAppWindow.vue', import.meta.url),
'utf8',
)
describe('PhoneAppWindow bundle contract', () => {
it('loads the App Store from the phone shell instead of a delayed chunk', () => {
expect(source).toContain(
"import AppStoreApp from '@/views/apps/AppStoreApp.vue'",
)
expect(source).toContain("app.value?.id === 'app-store'")
expect(source).toContain('? AppStoreApp : app.value?.component')
expect(source).toContain('<component :is="builtinAppComponent" />')
})
})
+5 -1
View File
@@ -6,10 +6,14 @@ import CustomAppFrame from '@/components/CustomAppFrame.vue'
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import { getCustomAppFrameKey } from '@/utils/customAppLifecycle'
import AppStoreApp from '@/views/apps/AppStoreApp.vue'
const route = useRoute()
const phone = usePhoneStore()
const app = computed(() => getPhoneApp(route.params.appId))
const builtinAppComponent = computed(() =>
app.value?.id === 'app-store' ? AppStoreApp : app.value?.component,
)
const launchStyle = computed(() => {
const origin = phone.launchOrigin
return {
@@ -30,7 +34,7 @@ const launchStyle = computed(() => {
:app="app"
/>
<Suspense v-else>
<component :is="app.component" />
<component :is="builtinAppComponent" />
<template #fallback>
<div class="app-loading">{{ phone.t('Common.loading') }}</div>
</template>
@@ -25,5 +25,6 @@ describe('AppStoreAction contract', () => {
expect(source).toMatch(
/\.app-store-action__progress i\s*\{[^}]*width:\s*7px/s,
)
expect(source).not.toContain('color-mix(')
})
})
+2 -4
View File
@@ -50,9 +50,7 @@ const phone = usePhoneStore()
}
.app-store-action__download {
filter: drop-shadow(
0 2px 5px color-mix(in srgb, var(--sky-app-accent) 30%, transparent)
);
filter: drop-shadow(0 2px 5px var(--sky-app-accent-soft));
}
.app-store-action__progress {
@@ -74,7 +72,7 @@ const phone = usePhoneStore()
}
.app-store-action__track {
stroke: color-mix(in srgb, var(--sky-app-accent) 24%, transparent);
stroke: var(--sky-app-accent-soft);
}
.app-store-action__value {
@@ -139,9 +139,7 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toMatch(
/button:not\(\.store-ranking__detail-link\):not\(:disabled\):hover\)\s*\{[^}]*brightness\(1\.08\)[^}]*translateY\(-1px\)/s,
)
expect(source).toContain(
'.app-store-page :deep(button:has(.app-store-action--icon):hover)',
)
expect(source).toContain('.app-store-page .store-action-button--icon:hover')
expect(source).toContain(
'.store-account__primary-action:not(:disabled):hover',
)
@@ -175,9 +173,7 @@ describe('AppStoreApp Sky navigation contract', () => {
expect(source).toContain('@click="openAppDetail(finalHighlight)"')
expect(source).toContain('@click.stop="handleApp(dailyHighlights[0])"')
expect(source).toContain('@click.stop="handleApp(finalHighlight)"')
expect(source).toContain(
'.store-highlight:has(.store-highlight__detail-link:hover)',
)
expect(source).toContain('.store-highlight:hover')
})
it('opens Top Today apps while keeping their direct app actions separate', () => {
@@ -193,6 +189,12 @@ describe('AppStoreApp Sky navigation contract', () => {
)
})
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(':has(')
expect(source).not.toContain('color-mix(')
})
it('builds clean Apps and Games pages with rotating features', () => {
expect(source).not.toContain('class="store-browse__filters"')
expect(source).not.toContain('browseFilter')
+45 -18
View File
@@ -484,6 +484,11 @@ watch(
</div>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon':
appAction(dailyHighlights[0]) !== 'open',
}"
:disabled="appStore.installingApps[dailyHighlights[0].id]"
:aria-label="`${getPhoneAppLabel(dailyHighlights[0], phone.t)} ${phone.t(
`Apps.appStore.${appAction(dailyHighlights[0])}`,
@@ -548,6 +553,10 @@ watch(
</div>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) !== 'open',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
@@ -590,6 +599,10 @@ watch(
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) !== 'open',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
@@ -626,6 +639,10 @@ watch(
<img :src="finalHighlight.iconImage" alt="" draggable="false" />
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(finalHighlight) !== 'open',
}"
:disabled="appStore.installingApps[finalHighlight.id]"
:aria-label="`${getPhoneAppLabel(finalHighlight, phone.t)} ${phone.t(
`Apps.appStore.${appAction(finalHighlight)}`,
@@ -681,6 +698,11 @@ watch(
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon':
appAction(featuredApp) !== 'open',
}"
:disabled="appStore.installingApps[featuredApp.id]"
:aria-label="`${getPhoneAppLabel(featuredApp, phone.t)} ${phone.t(
`Apps.appStore.${appAction(featuredApp)}`,
@@ -746,6 +768,10 @@ watch(
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) !== 'open',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
@@ -792,6 +818,10 @@ watch(
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) !== 'open',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
@@ -857,6 +887,10 @@ watch(
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) !== 'open',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
@@ -1985,8 +2019,8 @@ watch(
}
@media (hover: hover) and (pointer: fine) {
.store-highlight:has(.store-highlight__detail-link:hover),
.store-final-pick:has(.store-highlight__detail-link:hover) {
.store-highlight:hover,
.store-final-pick:hover {
box-shadow: 0 17px 32px rgba(0, 0, 0, 0.24);
transform: translateY(-1px);
}
@@ -2599,16 +2633,12 @@ watch(
background: var(--sky-surface-muted);
}
.store-highlight__footer :deep(button:has(.app-store-action--icon)),
.store-ranking li > :deep(button:has(.app-store-action--icon)),
.store-final-pick > :deep(button:has(.app-store-action--icon)),
.store-browse-feature footer > :deep(button:has(.app-store-action--icon)),
.store-search__recommendations
article
> :deep(button:has(.app-store-action--icon)),
.store-list
article
> :deep(button:not(.store-list__detail-link):has(.app-store-action--icon)) {
.store-highlight__footer .store-action-button--icon,
.store-ranking li > .store-action-button--icon,
.store-final-pick > .store-action-button--icon,
.store-browse-feature footer > .store-action-button--icon,
.store-search__recommendations article > .store-action-button--icon,
.store-list article > .store-action-button--icon {
width: var(--sky-touch-target);
min-width: var(--sky-touch-target);
padding: 0;
@@ -2643,11 +2673,8 @@ watch(
transform: scale(0.97);
}
.app-store-page :deep(button:has(.app-store-action--icon):hover) {
filter: brightness(1.16)
drop-shadow(
0 3px 7px color-mix(in srgb, var(--sky-app-accent) 28%, transparent)
);
.app-store-page .store-action-button--icon:hover {
filter: brightness(1.16) drop-shadow(0 3px 7px var(--sky-app-accent-soft));
}
.store-account__primary-action:not(:disabled):hover {
@@ -2655,7 +2682,7 @@ watch(
}
.store-account__remove:not(:disabled):hover {
background: color-mix(in srgb, var(--sky-danger) 22%, transparent);
background: var(--sky-danger-soft);
}
}
@@ -18,6 +18,8 @@ describe('AppStoreDetail contract', () => {
expect(source).toContain("emit('back')")
expect(source).toContain("emit('share')")
expect(source).toContain('<AppStoreAction :action="action" />')
expect(source).toContain("'store-detail__action--icon': action !== 'open'")
expect(source).not.toContain(':has(')
expect(source).toMatch(
/\.store-detail\s*\{[^}]*padding:\s*var\(--sky-space-3\) 0 var\(--sky-space-6\)/s,
)
+3 -1
View File
@@ -126,6 +126,8 @@ function updateActivePreview(): void {
<p>{{ developer }}</p>
<button
type="button"
class="store-detail__action"
:class="{ 'store-detail__action--icon': action !== 'open' }"
:disabled="action === 'installing'"
@click="emit('action')"
>
@@ -368,7 +370,7 @@ function updateActivePreview(): void {
font-weight: 850;
}
.store-detail__hero button:has(.app-store-action--icon) {
.store-detail__hero .store-detail__action--icon {
width: var(--sky-touch-target);
min-width: var(--sky-touch-target);
padding: 0;