ENH - merge Picstagram Sky UI updates

Merge the Picstagram Sky UI work and its dependent phone, media, voice, gallery, app store, setup, and UI parity updates into weather-app.

Resolve the frontend development port configuration and retain the Sky UI EasyShare implementation. Frontend validation and local resource deployment completed; included configuration, locale, and SQL updates require no separate migration step beyond this merge.
This commit is contained in:
Type
2026-08-14 17:12:31 +02:00
195 changed files with 29814 additions and 6854 deletions
+28 -7
View File
@@ -16,17 +16,23 @@ import {
reorderDirectionFromKeyboard,
type ReorderDirection,
} from '@/utils/keyboard'
import {
springboardPageDragCompensation,
springboardSwipeIntent,
} from '@/utils/springboardDrag'
const props = withDefaults(
defineProps<{
app: PhoneAppDefinition
compact?: boolean
editMode?: boolean
externalDragVisual?: boolean
showLabel?: boolean
}>(),
{
compact: false,
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -54,10 +60,16 @@ const dragOffset = ref({ x: 0, y: 0 })
let dragStartPage = 0
let dragPageWidth = 0
const dragStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`,
translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`,
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
: undefined,
)
@@ -142,6 +154,7 @@ function clearHold(): void {
function onPointerDown(event: PointerEvent): void {
if (props.compact || event.button !== 0) return
suppressClick.value = false
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
@@ -169,9 +182,12 @@ function onPointerMove(event: PointerEvent): void {
return
}
if (
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
8
springboardSwipeIntent(
event.clientX - pointerStart.x,
event.clientY - pointerStart.y,
) !== 'pending'
) {
suppressClick.value = true
clearHold()
}
}
@@ -190,9 +206,11 @@ function onPointerUp(event: PointerEvent): void {
clearHold()
if (isDragging.value) {
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
releasePointerCapture()
emit('dragend', event)
return
}
releasePointerCapture()
}
@@ -237,7 +255,7 @@ onMounted(() => {
onBeforeUnmount(() => {
clearHold()
if (calendarTimer !== undefined) window.clearInterval(calendarTimer)
releasePointerCapture()
cancelPointerDrag()
})
</script>
@@ -246,6 +264,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,
}"
@@ -260,6 +279,7 @@ onBeforeUnmount(() => {
:aria-keyshortcuts="
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
"
:style="dragPointerStyle"
@click="launch"
@contextmenu.prevent
@keydown="onKeydown"
@@ -319,6 +339,7 @@ onBeforeUnmount(() => {
app: getPhoneAppLabel(app, phone.t),
})
"
:style="dragPointerStyle"
@click.stop="emit('remove')"
@pointerdown.stop
>
@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./EasyShareSheet.vue', import.meta.url),
'utf8',
)
describe('EasyShareSheet Sky UI contract', () => {
it('uses the first-party bottom sheet without Konsta markup', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkySheet')
expect(source).toContain('<SkyList')
expect(source).toContain('<SkyGlass')
expect(source).toContain('.easyshare-host :deep(.sky-sheet__panel)')
expect(source).toMatch(/--easyshare-solid-surface:\s*#1c1c1d/)
expect(source).toMatch(
/background:\s*var\(--easyshare-solid-surface\)\s*!important/,
)
expect(source).toMatch(/--easyshare-list-surface:\s*#2c2c2e/)
expect(source).toMatch(
/\.easyshare-history\s*\{[^}]*overflow:\s*hidden[^}]*background:\s*var\(--easyshare-list-surface\)/s,
)
})
})
File diff suppressed because one or more lines are too long
+27 -7
View File
@@ -8,17 +8,23 @@ import {
reorderDirectionFromKeyboard,
type ReorderDirection,
} from '@/utils/keyboard'
import {
springboardPageDragCompensation,
springboardSwipeIntent,
} from '@/utils/springboardDrag'
const props = withDefaults(
defineProps<{
apps: PhoneAppDefinition[]
defaultName: string
editMode?: boolean
externalDragVisual?: boolean
folder: HomeFolder
showLabel?: boolean
}>(),
{
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -45,10 +51,16 @@ let pointerId: number | null = null
const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translateX(${(phone.currentPage - dragStartPage) * dragPageWidth}px)`,
translate: `${dragOffset.value.x}px ${dragOffset.value.y}px`,
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
: undefined,
)
@@ -82,6 +94,7 @@ function beginPointerDrag(event: PointerEvent): void {
function onPointerDown(event: PointerEvent): void {
if (event.button !== 0) return
suppressClick.value = false
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
@@ -109,9 +122,12 @@ function onPointerMove(event: PointerEvent): void {
return
}
if (
Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >
8
springboardSwipeIntent(
event.clientX - pointerStart.x,
event.clientY - pointerStart.y,
) !== 'pending'
) {
suppressClick.value = true
clearHold()
}
}
@@ -120,9 +136,11 @@ function onPointerUp(event: PointerEvent): void {
clearHold()
if (isDragging.value) {
suppressClick.value = true
emit('dragend', event)
isDragging.value = false
dragOffset.value = { x: 0, y: 0 }
releasePointerCapture()
emit('dragend', event)
return
}
releasePointerCapture()
}
@@ -155,7 +173,7 @@ function onKeydown(event: KeyboardEvent): void {
onBeforeUnmount(() => {
clearHold()
releasePointerCapture()
cancelPointerDrag()
})
</script>
@@ -163,6 +181,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,
@@ -176,6 +195,7 @@ onBeforeUnmount(() => {
:aria-keyshortcuts="
editMode ? 'ArrowLeft ArrowRight ArrowUp ArrowDown' : undefined
"
:style="dragPointerStyle"
@click="openFolder"
@contextmenu.prevent
@keydown="onKeydown"
@@ -0,0 +1,25 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./MessageContactBubble.vue', import.meta.url),
'utf8',
)
describe('MessageContactBubble Sky UI contract', () => {
it('uses Sky buttons without direct Konsta markup', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkyButton')
})
it('inherits light and dark colors from Sky theme tokens', () => {
expect(source).toContain('var(--sky-text)')
expect(source).toContain('var(--sky-surface)')
expect(source).toContain('var(--sky-surface-muted)')
expect(source).toContain('var(--sky-muted)')
expect(source).toContain('var(--sky-hairline)')
expect(source).not.toContain(':global(.phone-app.dark)')
})
})
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { kButton } from 'konsta/vue'
import {
Check,
ChevronRight,
@@ -10,6 +9,7 @@ import {
import { computed, ref, watch } from 'vue'
import type { SmsSharedContact } from '@/types/messages'
import { SkyButton } from '@/ui'
const props = defineProps<{
addLabel: string
@@ -61,12 +61,13 @@ const initials = computed(() =>
<ChevronRight :size="20" class="message-contact-card__chevron" />
</div>
<div class="message-contact-card__actions">
<k-button rounded tonal @click.stop="emit('message')">
<SkyButton rounded small tonal @click.stop="emit('message')">
<MessageCircle :size="17" />
{{ messageLabel }}
</k-button>
<k-button
</SkyButton>
<SkyButton
rounded
small
tonal
:disabled="saved"
@click.stop="emit('save')"
@@ -74,7 +75,7 @@ const initials = computed(() =>
<Check v-if="saved" :size="17" />
<UserPlus v-else :size="17" />
{{ saved ? savedLabel : addLabel }}
</k-button>
</SkyButton>
</div>
</div>
</template>
@@ -84,11 +85,15 @@ const initials = computed(() =>
width: min(258px, 100%);
width: min(258px, 72cqw);
overflow: hidden;
border: 1px solid rgb(255 255 255 / 48%);
border: 1px solid var(--sky-hairline);
border-radius: 21px;
color: #151517;
background: linear-gradient(155deg, rgb(255 255 255 / 96%), #edf5ff);
box-shadow: 0 8px 24px rgb(22 63 112 / 14%);
color: var(--sky-text);
background: linear-gradient(
155deg,
var(--sky-surface-tint),
var(--sky-surface)
);
box-shadow: 0 8px 24px rgb(0 0 0 / 16%);
}
.message-contact-card__identity {
@@ -107,9 +112,9 @@ const initials = computed(() =>
border-radius: 50%;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #64d2ff, #0a84ff);
box-shadow: 0 4px 14px rgb(10 132 255 / 24%);
color: #ffffff;
background: linear-gradient(145deg, var(--sky-success), #248a3d);
box-shadow: 0 4px 14px rgb(52 199 89 / 24%);
}
.message-contact-card__avatar img {
@@ -143,23 +148,23 @@ const initials = computed(() =>
.message-contact-card__identity small {
margin-top: 3px;
color: #6e6e73;
color: var(--sky-muted);
font-size: 11.5px;
}
.message-contact-card__chevron {
color: #8e8e93;
color: var(--sky-muted);
}
.message-contact-card__actions {
display: grid;
gap: 1px;
padding: 8px;
border-top: 1px solid rgb(60 60 67 / 12%);
background: rgb(255 255 255 / 58%);
border-top: 1px solid var(--sky-hairline);
background: var(--sky-surface-muted);
}
.message-contact-card__actions :deep(.button) {
.message-contact-card__actions :deep(.sky-button) {
width: 100%;
margin: 0;
min-height: 36px;
@@ -167,20 +172,4 @@ const initials = computed(() =>
padding-inline: 14px;
font-size: 13px;
}
:global(.phone-app.dark) .message-contact-card {
color: #f7f7f7;
border-color: rgb(255 255 255 / 10%);
background: linear-gradient(155deg, #34363b, #242a33);
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
}
:global(.phone-app.dark) .message-contact-card__identity small {
color: #aeaeb2;
}
:global(.phone-app.dark) .message-contact-card__actions {
border-top-color: rgb(255 255 255 / 10%);
background: rgb(0 0 0 / 10%);
}
</style>
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const styles = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
describe('PhoneLockScreen notification theme contract', () => {
it('uses explicit light and dark notification material tokens', () => {
expect(styles).toMatch(
/\.lock-screen\s*\{[^}]*--lock-notification-background:\s*rgb\(38 36 40 \/ 76%\)[^}]*--lock-notification-color:\s*#fff/s,
)
expect(styles).toMatch(
/\.phone-app--light \.lock-screen\s*\{[^}]*--lock-notification-background:\s*rgb\(247 247 248 \/ 82%\)[^}]*--lock-notification-color:\s*#111[^}]*--lock-notification-muted:\s*rgb\(0 0 0 \/ 55%\)/s,
)
})
it('applies the adaptive tokens to cards, metadata, focus and clear all', () => {
expect(styles).toMatch(
/\.lock-screen__notification\s*\{[^}]*color:\s*var\(--lock-notification-color\)[^}]*background-color:\s*var\(--lock-notification-background\)[^}]*text-shadow:\s*var\(--lock-notification-text-shadow\)/s,
)
expect(styles).toMatch(
/\.lock-screen__notifications-clear\s*\{[^}]*color:\s*var\(--lock-notification-color\)[^}]*background:\s*var\(--lock-notification-clear-background\)/s,
)
expect(styles).toContain('color: var(--lock-notification-muted);')
expect(styles).toContain(
'outline: 2px solid var(--lock-notification-focus);',
)
})
it('uses the solid mode material for the performance clear action', () => {
expect(styles).toMatch(
/\.phone-app--performance \.lock-screen\s*\{[^}]*--lock-notification-clear-background:\s*var\(--phone-performance-glass\)/s,
)
})
})
+9 -3
View File
@@ -73,9 +73,15 @@ const time = computed(() =>
.join('')
.trim(),
)
const dragStyle = computed(() => ({
'--lock-drag': `${dragOffset.value}px`,
}))
const dragStyle = computed<Record<string, string>>(() => {
const imageUrl = preferences.value.settings.wallpaperImageUrl
return {
'--lock-drag': `${dragOffset.value}px`,
...(preferences.value.settings.wallpaper === 'custom' && imageUrl
? { '--phone-wallpaper-image': `url(${JSON.stringify(imageUrl)})` }
: {}),
}
})
const flashlightShortcutColors = computed(() =>
flashlightActive.value
? {
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./PhoneSetupAssistant.vue', import.meta.url),
'utf8',
)
describe('PhoneSetupAssistant contract', () => {
it('uses first-party controls and exposes the complete setup journey', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).toContain('PhonePasscode')
expect(source).toContain("step === 1")
expect(source).toContain("step === 8")
expect(source).toContain("['performance', 'ultimate']")
expect(source).toContain('WALLPAPER_IDS')
expect(source).toContain('setAllAppNotifications')
expect(source).toContain('appStore.claimApp')
expect(source).toContain('phone.completeSetup()')
})
it('persists progress and supports resuming or moving backward', () => {
expect(source).toContain('phone.preferences.settings.setupStep')
expect(source).toContain('phone.setSetupStep(step.value)')
expect(source).toContain('@click="moveTo(step - 1)"')
})
})
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -9,11 +9,13 @@ const props = withDefaults(
defineProps<{
activeCallReturn?: boolean
controlCenterOpened?: boolean
interactive?: boolean
lockable?: boolean
}>(),
{
activeCallReturn: false,
controlCenterOpened: false,
interactive: true,
lockable: false,
},
)
@@ -30,6 +32,7 @@ function updateTime(): void {
}
function handleTimeClick(): void {
if (!props.interactive) return
if (props.activeCallReturn) {
emit('activeCall')
return
@@ -76,7 +79,8 @@ onBeforeUnmount(() => {
type="button"
:aria-label="phone.t('ControlCenter.open')"
:aria-expanded="controlCenterOpened"
@click.stop="emit('controlCenter')"
:disabled="!interactive"
@click.stop="interactive && emit('controlCenter')"
>
<PhoneStatusIndicators
:airplane-mode="phone.preferences.settings.airplaneMode"
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./SpringboardWidget.vue', import.meta.url),
'utf8',
)
describe('SpringboardWidget UI contract', () => {
it('uses the Sky widget frame without a Konsta surface', () => {
expect(source).toContain('<SkyWidgetFrame')
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toContain('<k-glass')
expect(source).not.toContain('<k-badge')
})
it('keeps the full widget surface clickable without affecting layout', () => {
expect(source).toContain('class="home-widget-open"')
expect(source).toContain('@click.stop="openWidget"')
expect(source).toContain('position: absolute;')
expect(source).toContain('background: transparent;')
})
it('shows the reactive clock time in the calendar header', () => {
expect(source).toContain('class="widget-date-time"')
expect(source).toContain('{{ clock.time.value }}')
expect(source).toContain('font-variant-numeric: tabular-nums;')
})
it('reuses weather artwork and music metadata from the owning apps', () => {
expect(source).toContain('<WeatherConditionIcon')
expect(source).toContain('music.current.value?.artwork')
expect(source).toContain('music.progress.value')
})
it.each([
'sunny',
'clear',
'partly_cloudy',
'cloudy',
'rain',
'thunder',
'fog',
'snow',
])('provides an adaptive %s weather surface', (condition) => {
expect(source).toContain(`data-weather-condition='${condition}'`)
})
})
File diff suppressed because it is too large Load Diff