ENH - merge latest dev into weather app

Integrates the current dev branch while preserving the weather-app Dynamic Island alongside the new hardware volume HUD and homescreen editing state. Makes the new Companies contract tests line-ending agnostic so the merged suite passes on Windows checkouts.
This commit is contained in:
Type
2026-08-17 11:38:13 +02:00
152 changed files with 15805 additions and 4315 deletions
+117 -99
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import {
SkyBlock as kBlock,
SkyBlockTitle as kBlockTitle,
SkyButton as kButton,
SkyField as kListInput,
@@ -8,6 +7,8 @@ import {
SkyList as kList,
SkyListItem as kListItem,
SkyNavbar as kNavbar,
SkyScrollArea as kScrollArea,
SkySheet as kSheet,
} from '@/ui'
import { ChevronRight, Check, X } from 'lucide-vue-next'
import { reactive, ref } from 'vue'
@@ -61,106 +62,123 @@ function toggleWeekday(weekday: number): void {
</script>
<template>
<AlarmSoundMenu
v-if="soundMenuOpen"
:back-label="phone.t('Apps.clock.tabs.alarm')"
:selected-sound="draft.sound"
@close="soundMenuOpen = false"
@select="draft.sound = $event"
/>
<k-navbar v-show="!soundMenuOpen" :title="phone.t('Apps.clock.tabs.alarm')">
<template #left>
<k-link
component="button"
icon-only
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.cancel')"
@click="emit('cancel')"
>
<X aria-hidden="true" />
</k-link>
</template>
<template #right>
<k-link
component="button"
icon-only
class="clock-alarm-save"
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.save')"
@click="save"
>
<Check aria-hidden="true" />
</k-link>
</template>
</k-navbar>
<k-block
v-show="!soundMenuOpen"
component="section"
class="clock-alarm-editor"
<k-sheet
opened
class="clock-alarm-sheet"
:aria-label="phone.t('Apps.clock.tabs.alarm')"
:show-grabber="false"
swipe-to-close
@backdropclick="emit('cancel')"
@escape="emit('cancel')"
@swipeclose="emit('cancel')"
>
<TimeWheelPicker
v-model="draft.time"
:hours-label="phone.t('Apps.clock.alarm.hours')"
:label="phone.t('Apps.clock.alarm.time')"
:minutes-label="phone.t('Apps.clock.minutes')"
/>
<k-block-title>{{ phone.t('Apps.clock.alarm.repeat') }}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="weekday in WEEKDAY_IDS"
:key="weekday"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.days.${weekdayKeys[weekday]}`)"
@click="toggleWeekday(weekday)"
>
<template #after>
<Check
v-if="draft.weekdays.includes(weekday)"
class="w-5 h-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<k-list strong inset>
<k-list-input
:label="phone.t('Apps.clock.alarm.note')"
:placeholder="phone.t('Apps.clock.alarm.notePlaceholder')"
:value="draft.note"
maxlength="80"
@input="setNote"
<section class="clock-alarm-sheet-surface">
<AlarmSoundMenu
v-if="soundMenuOpen"
:back-label="phone.t('Apps.clock.tabs.alarm')"
:selected-sound="draft.sound"
@close="soundMenuOpen = false"
@select="draft.sound = $event"
/>
</k-list>
<k-list strong inset>
<k-list-item
link
:chevron="false"
:title="phone.t('Apps.clock.alarm.sound')"
@click="soundMenuOpen = true"
>
<template #after>
<span class="clock-sound-selection">
{{ phone.t(`Apps.clock.alarm.sounds.${draft.sound}`) }}
<ChevronRight aria-hidden="true" />
</span>
</template>
</k-list-item>
</k-list>
<template v-else>
<k-navbar
class="clock-alarm-sheet-navbar"
data-sky-sheet-drag-handle
:title="phone.t('Apps.clock.tabs.alarm')"
>
<template #left>
<k-link
component="button"
icon-only
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.cancel')"
@click="emit('cancel')"
>
<X aria-hidden="true" />
</k-link>
</template>
<template #right>
<k-link
component="button"
icon-only
class="clock-alarm-save"
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.save')"
@click="save"
>
<Check aria-hidden="true" />
</k-link>
</template>
</k-navbar>
<k-button
v-if="alarm"
large
rounded
variant="danger"
class="clock-alarm-delete"
@click="emit('delete')"
>
{{ phone.t('Apps.clock.alarm.delete') }}
</k-button>
</k-block>
<k-scroll-area padded class="clock-alarm-editor">
<TimeWheelPicker
v-model="draft.time"
:hours-label="phone.t('Apps.clock.alarm.hours')"
:label="phone.t('Apps.clock.alarm.time')"
:minutes-label="phone.t('Apps.clock.minutes')"
/>
<k-block-title>{{
phone.t('Apps.clock.alarm.repeat')
}}</k-block-title>
<k-list strong inset>
<k-list-item
v-for="weekday in WEEKDAY_IDS"
:key="weekday"
link
:chevron="false"
:title="phone.t(`Apps.clock.alarm.days.${weekdayKeys[weekday]}`)"
@click="toggleWeekday(weekday)"
>
<template #after>
<Check
v-if="draft.weekdays.includes(weekday)"
class="w-5 h-5 text-primary"
/>
</template>
</k-list-item>
</k-list>
<k-list strong inset>
<k-list-input
:label="phone.t('Apps.clock.alarm.note')"
:placeholder="phone.t('Apps.clock.alarm.notePlaceholder')"
:value="draft.note"
maxlength="80"
@input="setNote"
/>
</k-list>
<k-list strong inset>
<k-list-item
link
:chevron="false"
:title="phone.t('Apps.clock.alarm.sound')"
@click="soundMenuOpen = true"
>
<template #after>
<span class="clock-sound-selection">
{{ phone.t(`Apps.clock.alarm.sounds.${draft.sound}`) }}
<ChevronRight aria-hidden="true" />
</span>
</template>
</k-list-item>
</k-list>
<k-button
v-if="alarm"
large
rounded
variant="danger"
class="clock-alarm-delete"
@click="emit('delete')"
>
{{ phone.t('Apps.clock.alarm.delete') }}
</k-button>
</k-scroll-area>
</template>
</section>
</k-sheet>
</template>
+59 -20
View File
@@ -17,22 +17,24 @@ import {
type ReorderDirection,
} from '@/utils/keyboard'
import {
readSpringboardDragMetrics,
springboardPageDragCompensation,
springboardSwipeIntent,
springboardViewportToLocal,
type SpringboardDragMetrics,
} from '@/utils/springboardDrag'
import { bindPointerDragSession } from '@/utils/pointerDragSession'
const props = withDefaults(
defineProps<{
app: PhoneAppDefinition
compact?: boolean
editMode?: boolean
externalDragVisual?: boolean
showLabel?: boolean
}>(),
{
compact: false,
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -59,15 +61,16 @@ const calendarToday = ref(new Date())
const dragOffset = ref({ x: 0, y: 0 })
let dragStartPage = 0
let dragPageWidth = 0
let dragPageMetrics: SpringboardDragMetrics | null = null
const dragStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -84,6 +87,7 @@ let calendarTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let pointerTarget: HTMLElement | null = null
let pointerId: number | null = null
let stopPointerSession: (() => void) | null = null
watch(
() => props.app.iconImage,
@@ -149,6 +153,12 @@ function onPointerDown(event: PointerEvent): void {
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
stopPointerSession?.()
stopPointerSession = bindPointerDragSession(window, pointerId, {
cancel: cancelPointerDrag,
move: onPointerMove,
up: onPointerUp,
})
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
@@ -165,9 +175,36 @@ function onPointerDown(event: PointerEvent): void {
function onPointerMove(event: PointerEvent): void {
if (isDragging.value) {
dragOffset.value = {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
if (dragPageMetrics) {
const pointer = springboardViewportToLocal(
event.clientX,
event.clientY,
dragPageMetrics.viewportLeft,
dragPageMetrics.viewportTop,
dragPageMetrics.viewportWidth,
dragPageMetrics.viewportHeight,
dragPageMetrics.layoutWidth,
dragPageMetrics.layoutHeight,
)
const start = springboardViewportToLocal(
pointerStart.x,
pointerStart.y,
dragPageMetrics.viewportLeft,
dragPageMetrics.viewportTop,
dragPageMetrics.viewportWidth,
dragPageMetrics.viewportHeight,
dragPageMetrics.layoutWidth,
dragPageMetrics.layoutHeight,
)
dragOffset.value = {
x: pointer.x - start.x,
y: pointer.y - start.y,
}
} else {
dragOffset.value = {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
}
}
emit('dragmove', event)
return
@@ -185,10 +222,15 @@ function onPointerMove(event: PointerEvent): void {
function beginPointerDrag(event: PointerEvent): void {
dragStartPage = phone.currentPage
dragPageWidth =
(event.target as HTMLElement)
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
const element =
pointerTarget ??
(event.currentTarget instanceof Element
? event.currentTarget
: event.target instanceof Element
? event.target
: null)
dragPageMetrics = readSpringboardDragMetrics(element)
dragPageWidth = dragPageMetrics?.layoutWidth ?? 0
isDragging.value = true
emit('dragstart', event)
}
@@ -216,6 +258,8 @@ function cancelPointerDrag(): void {
}
function releasePointerCapture(): void {
stopPointerSession?.()
stopPointerSession = null
if (
pointerTarget &&
pointerId !== null &&
@@ -225,6 +269,7 @@ function releasePointerCapture(): void {
}
pointerTarget = null
pointerId = null
dragPageMetrics = null
}
function onKeydown(event: KeyboardEvent): void {
@@ -255,7 +300,6 @@ 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,
}"
@@ -274,12 +318,7 @@ onBeforeUnmount(() => {
@click="launch"
@contextmenu.prevent
@keydown="onKeydown"
@pointercancel="cancelPointerDrag"
@pointerdown="onPointerDown"
@lostpointercapture="cancelPointerDrag"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<span class="app-icon-anchor" aria-hidden="true">
<span
@@ -334,9 +373,9 @@ onBeforeUnmount(() => {
@click.stop="emit('remove')"
@pointerdown.stop
>
<k-badge class="app-icon-remove__badge" tone="neutral">
<Minus :size="15" :stroke-width="3" aria-hidden="true" />
</k-badge>
<span class="app-icon-remove__badge" aria-hidden="true">
<Minus :size="14" :stroke-width="4" />
</span>
</button>
</div>
</template>
+68 -23
View File
@@ -9,22 +9,24 @@ import {
type ReorderDirection,
} from '@/utils/keyboard'
import {
readSpringboardDragMetrics,
springboardPageDragCompensation,
springboardSwipeIntent,
springboardViewportToLocal,
type SpringboardDragMetrics,
} from '@/utils/springboardDrag'
import { bindPointerDragSession } from '@/utils/pointerDragSession'
const props = withDefaults(
defineProps<{
apps: PhoneAppDefinition[]
defaultName: string
editMode?: boolean
externalDragVisual?: boolean
folder: HomeFolder
showLabel?: boolean
}>(),
{
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -44,21 +46,23 @@ const dragOffset = ref({ x: 0, y: 0 })
const suppressClick = ref(false)
let dragStartPage = 0
let dragPageWidth = 0
let dragMetrics: SpringboardDragMetrics | null = null
let holdTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let pointerTarget: HTMLElement | null = null
let pointerId: number | null = null
let stopPointerSession: (() => void) | null = null
const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value && !props.externalDragVisual
isDragging.value
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -71,6 +75,8 @@ function clearHold(): void {
}
function releasePointerCapture(): void {
stopPointerSession?.()
stopPointerSession = null
if (
pointerTarget &&
pointerId !== null &&
@@ -80,30 +86,73 @@ function releasePointerCapture(): void {
}
pointerTarget = null
pointerId = null
dragMetrics = null
}
function beginPointerDrag(event: PointerEvent): void {
dragStartPage = phone.currentPage
dragPageWidth =
(event.target as HTMLElement)
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
const element =
pointerTarget ??
(event.currentTarget instanceof Element
? event.currentTarget
: event.target instanceof Element
? event.target
: null)
dragMetrics = readSpringboardDragMetrics(element)
dragPageWidth = dragMetrics?.layoutWidth ?? 0
isDragging.value = true
emit('dragstart', event)
}
function updateDragOffset(event: PointerEvent): void {
if (dragMetrics) {
const pointer = springboardViewportToLocal(
event.clientX,
event.clientY,
dragMetrics.viewportLeft,
dragMetrics.viewportTop,
dragMetrics.viewportWidth,
dragMetrics.viewportHeight,
dragMetrics.layoutWidth,
dragMetrics.layoutHeight,
)
const start = springboardViewportToLocal(
pointerStart.x,
pointerStart.y,
dragMetrics.viewportLeft,
dragMetrics.viewportTop,
dragMetrics.viewportWidth,
dragMetrics.viewportHeight,
dragMetrics.layoutWidth,
dragMetrics.layoutHeight,
)
dragOffset.value = {
x: pointer.x - start.x,
y: pointer.y - start.y,
}
return
}
dragOffset.value = {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
}
}
function onPointerDown(event: PointerEvent): void {
if (event.button !== 0) return
suppressClick.value = false
pointerTarget = event.currentTarget as HTMLElement
pointerId = event.pointerId
pointerTarget.setPointerCapture(pointerId)
stopPointerSession?.()
stopPointerSession = bindPointerDragSession(window, pointerId, {
cancel: cancelPointerDrag,
move: onPointerMove,
up: onPointerUp,
})
pointerStart = { x: event.clientX, y: event.clientY }
clearHold()
if (props.editMode) {
beginPointerDrag(event)
return
}
if (props.editMode) return
holdTimer = window.setTimeout(() => {
suppressClick.value = true
emit('edit')
@@ -114,10 +163,7 @@ function onPointerDown(event: PointerEvent): void {
function onPointerMove(event: PointerEvent): void {
if (isDragging.value) {
dragOffset.value = {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
}
updateDragOffset(event)
emit('dragmove', event)
return
}
@@ -129,6 +175,11 @@ function onPointerMove(event: PointerEvent): void {
) {
suppressClick.value = true
clearHold()
if (props.editMode) {
beginPointerDrag(event)
updateDragOffset(event)
emit('dragmove', event)
}
}
}
@@ -155,7 +206,7 @@ function cancelPointerDrag(): void {
}
function openFolder(): void {
if (props.editMode || suppressClick.value) {
if (suppressClick.value) {
suppressClick.value = false
return
}
@@ -181,7 +232,6 @@ 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,
@@ -199,12 +249,7 @@ onBeforeUnmount(() => {
@click="openFolder"
@contextmenu.prevent
@keydown="onKeydown"
@lostpointercapture="cancelPointerDrag"
@pointercancel="cancelPointerDrag"
@pointerdown="onPointerDown"
@pointerleave="isDragging || clearHold()"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
>
<span class="home-folder-preview" aria-hidden="true">
<span
@@ -0,0 +1,104 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const iconSource = readFileSync(
new URL('./HomeFolderIcon.vue', import.meta.url),
'utf8',
)
const overlaySource = readFileSync(
new URL('./HomeFolderOverlay.vue', import.meta.url),
'utf8',
)
const dragSource = readFileSync(
new URL('../utils/springboardDrag.ts', import.meta.url),
'utf8',
)
const springboardSource = readFileSync(
new URL('../views/SpringboardView.vue', import.meta.url),
'utf8',
)
const appIconSource = readFileSync(
new URL('./AppIcon.vue', import.meta.url),
'utf8',
)
const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
describe('Home folder interaction contract', () => {
it('opens folders from edit mode and starts dragging only after movement', () => {
expect(iconSource).toContain('if (props.editMode) return')
expect(iconSource).toMatch(
/if \(props\.editMode\) \{\s+beginPointerDrag\(event\)/,
)
expect(iconSource).not.toContain('props.editMode || suppressClick.value')
})
it('keeps folder app dragging aligned at every phone scale', () => {
expect(dragSource).toContain("'.springboard-page, .home-folder-panel'")
expect(dragSource).toContain('viewportWidth: bounds.width')
expect(dragSource).not.toContain("getPropertyValue('zoom')")
expect(iconSource).toContain('springboardViewportToLocal')
expect(iconSource).not.toContain('@pointerleave')
expect(springboardSource).not.toContain(
'(event.currentTarget as HTMLElement | null)?.closest',
)
})
it('edits the folder name inline with Sky UI controls', () => {
expect(overlaySource).toContain('<SkyField')
expect(overlaySource).toContain('home-folder-heading--editing')
expect(overlaySource).toContain('<Check')
expect(overlaySource).toContain('<X')
expect(overlaySource).not.toContain('<SkyDialog')
expect(overlaySource).not.toContain('<SkySheet')
})
it('visually closes the folder while an app leaves and closes after extraction', () => {
expect(overlaySource).toContain('@dragmove="moveFolderAppDrag"')
expect(overlaySource).toContain("emit('drag-outside-change', outside)")
expect(overlaySource).toContain('home-folder-layer--dragging-out')
expect(springboardSource).toContain(
'@drag-outside-change="folderDraggingOutside = $event"',
)
expect(overlaySource).toContain(
'if (draggingIndex.value === null || draggingOutside.value) return',
)
expect(overlaySource).toContain(
'draggingOutside.value || !isPointerInsidePanel(event)',
)
expect(springboardSource).toContain(
"'springboard--folder-open': folderOverlayVisible",
)
expect(springboardSource).toContain(
'editMode && isEditablePage && !folderOverlayVisible',
)
expect(springboardSource).toMatch(
/extractHomeFolderApp\([\s\S]*?closeFolder\(\)/,
)
})
it('keeps the folder title left-aligned and gives the rename field more height', () => {
expect(overlaySource).toMatch(
/\.home-folder-heading\s*\{[^}]*justify-content:\s*flex-start;/s,
)
expect(overlaySource).toMatch(
/\.home-folder-title\s*\{[^}]*text-align:\s*left;/s,
)
expect(overlaySource).toMatch(
/\.home-folder-rename-field :deep\(\.sky-field__input\)\s*\{[^}]*height:\s*46px;[^}]*min-height:\s*46px;/s,
)
})
it('uses one native iOS-style removal badge inside and outside folders', () => {
expect(appIconSource).toContain('<span class="app-icon-remove__badge"')
expect(appIconSource).not.toContain(
'<k-badge class="app-icon-remove__badge"',
)
expect(mainCss).toMatch(
/\.app-icon-remove__badge\s*\{[^}]*background:\s*rgb\(174 174 178 \/ 88%\);[^}]*color:\s*#111;/s,
)
})
})
+184 -129
View File
@@ -1,16 +1,11 @@
<script setup lang="ts">
import { Pencil } from 'lucide-vue-next'
import { Check, Pencil, X } from 'lucide-vue-next'
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
import {
SkyButton,
SkyField,
SkyLink,
SkySheet,
} from '@/ui'
import { SkyField, SkyLink } from '@/ui'
import {
HOME_FOLDER_NAME_MAX_LENGTH,
HOME_FOLDER_PAGE_SIZE,
@@ -30,6 +25,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
close: []
'drag-outside-change': [outside: boolean]
edit: []
extract: [index: number, event: PointerEvent]
move: [sourceIndex: number, targetIndex: number]
@@ -41,6 +37,8 @@ const phone = usePhoneStore()
const currentPage = ref(0)
const pageTransitionDirection = ref<'backward' | 'forward'>('forward')
const draggingIndex = ref<number | null>(null)
const draggingOutside = ref(false)
const panelElement = ref<HTMLElement | null>(null)
const renameOpened = ref(false)
const renameDraft = ref('')
let pagePointerStart = 0
@@ -87,7 +85,9 @@ watch(
emit('rename-opened')
await nextTick()
document
.querySelector<HTMLInputElement>('.home-folder-rename .sky-field__input')
.querySelector<HTMLInputElement>(
'.home-folder-rename-field .sky-field__input',
)
?.select()
},
{ immediate: true },
@@ -98,36 +98,62 @@ function openRename(): void {
renameOpened.value = true
void nextTick(() => {
document
.querySelector<HTMLInputElement>('.home-folder-rename .sky-field__input')
.querySelector<HTMLInputElement>(
'.home-folder-rename-field .sky-field__input',
)
?.select()
})
}
function saveRename(): void {
emit('rename', renameDraft.value.trim() || phone.t('Home.folders.defaultName'))
emit(
'rename',
renameDraft.value.trim() || phone.t('Home.folders.defaultName'),
)
renameOpened.value = false
}
function startFolderAppDrag(index: number): void {
draggingIndex.value = index
setDraggingOutside(false)
}
function finishFolderAppDrag(event: PointerEvent): void {
const sourceIndex = draggingIndex.value
draggingIndex.value = null
if (sourceIndex === null) return
const panel = document.querySelector<HTMLElement>('.home-folder-panel')
if (!panel) return
const panelBounds = panel.getBoundingClientRect()
const insidePanel =
function isPointerInsidePanel(event: PointerEvent): boolean {
const panelBounds = panelElement.value?.getBoundingClientRect()
if (!panelBounds) return false
return (
event.clientX >= panelBounds.left &&
event.clientX <= panelBounds.right &&
event.clientY >= panelBounds.top &&
event.clientY <= panelBounds.bottom
if (!insidePanel) {
emit('extract', sourceIndex, event)
)
}
function moveFolderAppDrag(event: PointerEvent): void {
if (draggingIndex.value === null || draggingOutside.value) return
if (!isPointerInsidePanel(event)) setDraggingOutside(true)
}
function setDraggingOutside(outside: boolean): void {
if (draggingOutside.value === outside) return
draggingOutside.value = outside
emit('drag-outside-change', outside)
}
function finishFolderAppDrag(event: PointerEvent): void {
const sourceIndex = draggingIndex.value
const shouldExtract = draggingOutside.value || !isPointerInsidePanel(event)
draggingIndex.value = null
if (sourceIndex === null) {
setDraggingOutside(false)
return
}
if (shouldExtract) {
emit('extract', sourceIndex, event)
setDraggingOutside(false)
return
}
setDraggingOutside(false)
const target = document
.elementsFromPoint(event.clientX, event.clientY)
@@ -143,6 +169,7 @@ function finishFolderAppDrag(event: PointerEvent): void {
function stopFolderAppDrag(): void {
draggingIndex.value = null
setDraggingOutside(false)
}
function goToPage(page: number): void {
@@ -171,7 +198,10 @@ function finishPageSwipe(event: PointerEvent): void {
</script>
<template>
<div class="home-folder-layer">
<div
class="home-folder-layer"
:class="{ 'home-folder-layer--dragging-out': draggingOutside }"
>
<button
class="home-folder-backdrop"
type="button"
@@ -185,7 +215,7 @@ function finishPageSwipe(event: PointerEvent): void {
:aria-label="folderName"
@pointerdown.stop
>
<div class="home-folder-heading">
<div v-if="!renameOpened" class="home-folder-heading">
<button
class="home-folder-title"
type="button"
@@ -205,7 +235,46 @@ function finishPageSwipe(event: PointerEvent): void {
<Pencil :size="18" :stroke-width="2.2" />
</SkyLink>
</div>
<form
v-else
class="home-folder-heading home-folder-heading--editing"
@keydown.esc.prevent="renameOpened = false"
@submit.prevent="saveRename"
>
<SkyLink
class="home-folder-rename-action"
component="button"
icon-only
:aria-label="phone.t('Common.cancel')"
type="button"
@click="renameOpened = false"
>
<X :size="19" :stroke-width="2.3" />
</SkyLink>
<SkyField
v-model="renameDraft"
class="home-folder-rename-field"
:aria-label="phone.t('Home.folders.name')"
autocomplete="off"
autofocus
clear-button
:clear-label="phone.t('Common.clear')"
component="div"
:maxlength="HOME_FOLDER_NAME_MAX_LENGTH"
:placeholder="phone.t('Home.folders.defaultName')"
/>
<SkyLink
class="home-folder-rename-action"
component="button"
icon-only
:aria-label="phone.t('Common.save')"
type="submit"
>
<Check :size="20" :stroke-width="2.5" />
</SkyLink>
</form>
<div
ref="panelElement"
class="home-folder-panel"
:class="{ 'home-folder-panel--dragging': draggingIndex !== null }"
@pointerdown="startPageSwipe"
@@ -223,6 +292,7 @@ function finishPageSwipe(event: PointerEvent): void {
:edit-mode="editMode"
@dragcancel="stopFolderAppDrag"
@dragend="finishFolderAppDrag"
@dragmove="moveFolderAppDrag"
@dragstart="startFolderAppDrag(entry.index)"
@edit="emit('edit')"
/>
@@ -245,51 +315,6 @@ function finishPageSwipe(event: PointerEvent): void {
</nav>
</div>
</section>
<SkySheet
class="home-folder-rename"
:opened="renameOpened"
:aria-label="phone.t('Home.folders.rename')"
@backdropclick="renameOpened = false"
@escape="renameOpened = false"
>
<form class="home-folder-rename__content" @submit.prevent="saveRename">
<span class="home-folder-rename__handle" aria-hidden="true"></span>
<header>
<div>
<small>{{ phone.t('Home.folders.name') }}</small>
<h2>{{ phone.t('Home.folders.rename') }}</h2>
</div>
</header>
<ul class="home-folder-rename__fields">
<SkyField
v-model="renameDraft"
:aria-label="phone.t('Home.folders.name')"
autocomplete="off"
clear-button
:clear-label="phone.t('Common.clear')"
:maxlength="HOME_FOLDER_NAME_MAX_LENGTH"
outline
:placeholder="phone.t('Home.folders.defaultName')"
/>
</ul>
<div class="home-folder-rename__actions">
<SkyButton
block
large
rounded
type="button"
variant="secondary"
@click="renameOpened = false"
>
{{ phone.t('Common.cancel') }}
</SkyButton>
<SkyButton block large rounded type="submit">
{{ phone.t('Common.save') }}
</SkyButton>
</div>
</form>
</SkySheet>
</div>
</template>
@@ -322,30 +347,32 @@ function finishPageSwipe(event: PointerEvent): void {
.home-folder-dialog {
position: absolute;
top: 108px;
right: 18px;
left: 18px;
top: 118px;
right: 30px;
left: 30px;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
gap: 14px;
}
.home-folder-heading {
width: 100%;
max-width: 100%;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 5px;
}
.home-folder-title {
min-width: 0;
max-width: 100%;
max-width: calc(100% - 49px);
min-height: 44px;
flex: 1 1 auto;
margin: 0;
padding: 2px 12px;
padding: 2px 8px;
overflow: hidden;
border: 0;
border-radius: 12px;
@@ -356,7 +383,7 @@ function finishPageSwipe(event: PointerEvent): void {
font-weight: 650;
letter-spacing: -0.8px;
line-height: 36px;
text-align: center;
text-align: left;
text-overflow: ellipsis;
text-shadow: 0 2px 8px rgb(0 0 0 / 38%);
white-space: nowrap;
@@ -388,11 +415,11 @@ function finishPageSwipe(event: PointerEvent): void {
.home-folder-panel {
box-sizing: border-box;
width: 100%;
min-height: 342px;
padding: 25px 22px 16px;
min-height: 324px;
padding: 22px 16px 14px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 25%);
border-radius: 28px;
border-radius: 26px;
background: rgb(115 135 176 / 72%);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 18%),
@@ -406,8 +433,8 @@ function finishPageSwipe(event: PointerEvent): void {
.home-folder-page-viewport {
position: relative;
min-height: 285px;
margin-top: -8px;
min-height: 270px;
margin-top: -6px;
overflow: hidden;
}
@@ -418,11 +445,11 @@ function finishPageSwipe(event: PointerEvent): void {
.home-folder-page-grid {
position: absolute;
inset: 0;
top: 8px;
top: 6px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(3, 79px);
gap: 13px 20px;
gap: 10px 14px;
align-items: start;
}
@@ -465,6 +492,36 @@ function finishPageSwipe(event: PointerEvent): void {
z-index: 80;
}
.home-folder-backdrop,
.home-folder-heading,
.home-folder-panel,
.home-folder-page-grid :deep(.app-icon-item),
.home-folder-pages {
transition:
opacity 160ms ease,
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.home-folder-layer--dragging-out .home-folder-backdrop {
background: transparent;
}
.home-folder-layer--dragging-out .home-folder-heading,
.home-folder-layer--dragging-out .home-folder-pages,
.home-folder-layer--dragging-out
.home-folder-page-grid
:deep(.app-icon-item:not(.app-icon-item--dragging)) {
opacity: 0;
}
.home-folder-layer--dragging-out .home-folder-panel {
border-color: transparent;
background: transparent;
box-shadow: none;
}
.home-folder-pages {
min-height: 20px;
margin-top: 5px;
@@ -491,59 +548,57 @@ function finishPageSwipe(event: PointerEvent): void {
transform: scale(1.18);
}
.home-folder-rename {
--sky-overlay-layer: 90;
.home-folder-heading--editing {
min-height: 52px;
gap: 8px;
}
.home-folder-rename :deep(.sky-sheet__panel) {
.home-folder-rename-field {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
border-radius: 26px 26px 0 0;
background: rgb(28 28 30 / 98%);
}
.home-folder-rename__content {
padding: 9px 16px calc(20px + var(--sky-safe-area-bottom));
}
.home-folder-rename__handle {
display: block;
width: 38px;
height: 5px;
margin: 0 auto 16px;
border-radius: 999px;
background: rgb(255 255 255 / 28%);
}
.home-folder-rename__content header {
margin: 0 2px 14px;
}
.home-folder-rename__content header small {
display: block;
margin-bottom: 2px;
color: var(--sky-muted);
font-size: 12px;
}
.home-folder-rename__content h2 {
margin: 0;
color: var(--sky-text);
font-size: 21px;
line-height: 27px;
}
.home-folder-rename__fields {
margin: 0;
padding: 0;
list-style: none;
text-align: left;
border: 1px solid rgb(255 255 255 / 26%);
border-radius: 14px;
background: rgb(28 28 30 / 78%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 12%);
}
.home-folder-rename__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
.home-folder-rename-field :deep(.sky-field__inner) {
padding: 2px 12px;
}
.home-folder-rename-field :deep(.sky-field__input) {
height: 46px;
min-height: 46px;
color: #fff;
font-size: 18px;
font-weight: 600;
text-align: center;
}
.home-folder-rename-field :deep(.sky-field__input::placeholder) {
color: rgb(255 255 255 / 48%);
}
.home-folder-rename-field :deep(.sky-field__clear) {
color: #fff;
}
.home-folder-rename-action {
width: 44px;
height: 44px;
flex: 0 0 44px;
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(255 255 255 / 13%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 12%);
}
.home-folder-rename-action:active {
background: rgb(255 255 255 / 22%);
transform: scale(0.94);
}
@media (prefers-reduced-motion: reduce) {
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const component = readFileSync(
new URL('./MessageAttachmentBubble.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
describe('MessageAttachmentBubble contract', () => {
it('renders an optional caption below media', () => {
expect(component).toContain('message.body?.trim()')
expect(component).toContain('messages-attachment-caption')
})
it('gives image-only messages an accessible name', () => {
expect(component).toContain('role="img"')
expect(component).toContain('Apps.photos.photoAlt')
})
it('keeps remote GIFs proportional instead of cover-cropping them', () => {
expect(component).toContain('messages-attachment--remote')
expect(styles).toMatch(
/\.messages-attachment--gif\.messages-attachment--remote img\s*\{[^}]*width:\s*auto;[^}]*height:\s*auto;[^}]*object-fit:\s*contain;/s,
)
})
})
@@ -6,6 +6,7 @@ import { usePhoneStore } from '@/stores/phone'
import type { SmsMessageType } from '@/types/messages'
type MessageAttachment = {
body?: string
media_asset_id: string | null
media_duration_ms: number | null
message_type: SmsMessageType
@@ -82,6 +83,8 @@ function durationLabel(milliseconds: number | null): string {
<div
v-if="message.message_type === 'image'"
class="messages-attachment messages-attachment--image"
role="img"
:aria-label="phone.t('Apps.photos.photoAlt')"
:style="{ background }"
>
<img
@@ -121,18 +124,18 @@ function durationLabel(milliseconds: number | null): string {
><TriangleAlert v-if="playbackFailed" :size="22" /><Pause
v-else-if="playing"
:size="22"
fill="currentColor"
/><Play
v-else
:size="22"
fill="currentColor"
fill="currentColor" /><Play v-else :size="22" fill="currentColor"
/></span>
<small v-if="playbackFailed">{{
phone.t('Apps.photos.errors.unsupported')
}}</small>
<small v-else>{{ durationLabel(message.media_duration_ms) }}</small>
</button>
<div v-else class="messages-attachment messages-attachment--gif">
<div
v-else
class="messages-attachment messages-attachment--gif"
:class="{ 'messages-attachment--remote': mediaUrl }"
>
<img
v-if="mediaUrl"
:src="mediaUrl"
@@ -145,4 +148,16 @@ function durationLabel(milliseconds: number | null): string {
<strong>{{ gif.label }}</strong>
</template>
</div>
<p v-if="message.body?.trim()" class="messages-attachment-caption">
{{ message.body }}
</p>
</template>
<style scoped>
.messages-attachment-caption {
max-width: 205px;
margin: 7px 1px 1px;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
</style>
@@ -32,7 +32,9 @@ const frameImage = computed(
)
const wrapperStyle = computed<CSSProperties>(() => ({
...getHairlinePixelStyle(props.zoom, props.devicePixelRatio),
zoom: props.zoom,
'--phone-rendered-height': `${844 * props.zoom}px`,
'--phone-rendered-width': `${390 * props.zoom}px`,
'--phone-zoom': props.zoom,
}))
</script>
@@ -41,52 +43,54 @@ const wrapperStyle = computed<CSSProperties>(() => ({
class="phone-resolution-wrapper phone-resolution-wrapper--notification"
:style="wrapperStyle"
>
<section
class="phone-device phone-device--notification"
:class="{
'phone-app--light': !isDarkMode,
[`phone-app--${preferences.settings.graphicsMode}`]: true,
}"
:aria-label="device.name"
>
<div
class="phone-screen"
<div class="phone-resolution-canvas">
<section
class="phone-device phone-device--notification"
:class="{
'phone-app--light': !isDarkMode,
[`phone-app--${preferences.settings.graphicsMode}`]: true,
}"
:aria-label="device.name"
>
<k-app
theme="ios"
:dark="isDarkMode"
safe-areas
class="phone-app"
<div
class="phone-screen"
:class="{
dark: isDarkMode,
'phone-app--light': !isDarkMode,
[`phone-app--${preferences.settings.graphicsMode}`]: true,
}"
>
<PhoneLockScreen
:notifications="[]"
:preferences="preferences"
preview
/>
<PhoneNotifications
<k-app
theme="ios"
:dark="isDarkMode"
:notification="notification"
@close="emit('close')"
@open="emit('open', $event)"
/>
</k-app>
</div>
<img
class="phone-device__frame"
:src="frameImage"
alt=""
aria-hidden="true"
draggable="false"
/>
</section>
safe-areas
class="phone-app"
:class="{
dark: isDarkMode,
'phone-app--light': !isDarkMode,
[`phone-app--${preferences.settings.graphicsMode}`]: true,
}"
>
<PhoneLockScreen
:notifications="[]"
:preferences="preferences"
preview
/>
<PhoneNotifications
:dark="isDarkMode"
:notification="notification"
@close="emit('close')"
@open="emit('open', $event)"
/>
</k-app>
</div>
<img
class="phone-device__frame"
:src="frameImage"
alt=""
aria-hidden="true"
draggable="false"
/>
</section>
</div>
</div>
</template>
+25 -8
View File
@@ -31,8 +31,11 @@ import {
type ReorderDirection,
} from '@/utils/keyboard'
import {
readSpringboardDragMetrics,
springboardPageDragCompensation,
springboardSwipeIntent,
springboardViewportDeltaToLocal,
type SpringboardDragMetrics,
} from '@/utils/springboardDrag'
import { WIDGET_SPANS } from '@/utils/widgetLayout'
@@ -78,6 +81,7 @@ let holdTimer: number | undefined
let pointerStart = { x: 0, y: 0 }
let dragStartPage = 0
let dragPageWidth = 0
let dragMetrics: SpringboardDragMetrics | null = null
let pointerTarget: HTMLElement | null = null
let pointerId: number | null = null
@@ -207,10 +211,18 @@ function onPointerDown(event: PointerEvent): void {
function onPointerMove(event: PointerEvent): void {
if (isDragging.value) {
dragOffset.value = {
x: event.clientX - pointerStart.x,
y: event.clientY - pointerStart.y,
}
const deltaX = event.clientX - pointerStart.x
const deltaY = event.clientY - pointerStart.y
dragOffset.value = dragMetrics
? springboardViewportDeltaToLocal(
deltaX,
deltaY,
dragMetrics.viewportWidth,
dragMetrics.viewportHeight,
dragMetrics.layoutWidth,
dragMetrics.layoutHeight,
)
: { x: deltaX, y: deltaY }
emit('dragmove', event)
return
}
@@ -227,10 +239,14 @@ function onPointerMove(event: PointerEvent): void {
function beginDrag(event: PointerEvent): void {
dragStartPage = phone.currentPage
dragPageWidth =
(event.currentTarget as HTMLElement)
.closest<HTMLElement>('.springboard-page')
?.getBoundingClientRect().width ?? 0
const element =
event.currentTarget instanceof Element
? event.currentTarget
: event.target instanceof Element
? event.target
: null
dragMetrics = readSpringboardDragMetrics(element)
dragPageWidth = dragMetrics?.layoutWidth ?? 0
isDragging.value = true
emit('dragstart', event)
}
@@ -265,6 +281,7 @@ function releasePointerCapture(): void {
}
pointerTarget = null
pointerId = null
dragMetrics = null
}
function onKeydown(event: KeyboardEvent): void {
@@ -0,0 +1,30 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./VoiceMessageBubble.vue', import.meta.url),
'utf8',
)
describe('VoiceMessageBubble contract', () => {
it('uses the central accessible range to seek protected audio', () => {
expect(source).toContain("import { SkyRange } from '@/ui'")
expect(source).toContain('<SkyRange')
expect(source).toContain("phone.t('Apps.messages.seekAudio')")
expect(source).toContain('@input="seekPlayback"')
expect(source).toContain('audio.value.currentTime = nextTime')
expect(source).toContain('await ensureSource()')
expect(source).toContain(
'let sourceLoadPromise: Promise<boolean> | undefined',
)
expect(source).toContain('return sourceLoadPromise')
expect(source).toContain(':aria-value-text="seekValueText"')
})
it('fits every stored sample into the available waveform width', () => {
expect(source).toMatch(
/\.voice-message__waveform i\s*\{[^}]*min-width:\s*1px;[^}]*flex:\s*1 1 1px;/s,
)
})
})
+118 -20
View File
@@ -5,6 +5,7 @@ import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { useMessagesStore } from '@/stores/messages'
import { usePhoneStore } from '@/stores/phone'
import type { SmsMessage } from '@/types/messages'
import { SkyRange } from '@/ui'
const props = defineProps<{ message: SmsMessage }>()
const phone = usePhoneStore()
@@ -14,13 +15,20 @@ const currentTime = ref(0)
const playing = ref(false)
const loading = ref(false)
const failed = ref(false)
const duration = computed(() => (props.message.media_duration_ms ?? 0) / 1000)
const metadataDuration = ref(0)
let sourceLoadPromise: Promise<boolean> | undefined
const duration = computed(
() => metadataDuration.value || (props.message.media_duration_ms ?? 0) / 1000,
)
const progress = computed(() =>
duration.value > 0 ? Math.min(1, currentTime.value / duration.value) : 0,
)
const displayTime = computed(() =>
formatDuration(playing.value || currentTime.value > 0 ? currentTime.value : duration.value),
formatDuration(
playing.value || currentTime.value > 0 ? currentTime.value : duration.value,
),
)
const seekValueText = computed(() => formatDuration(currentTime.value))
const source = computed(() => messages.mediaSources[props.message.id] ?? '')
function formatDuration(seconds: number): string {
@@ -28,6 +36,26 @@ function formatDuration(seconds: number): string {
return `${Math.floor(rounded / 60)}:${String(rounded % 60).padStart(2, '0')}`
}
async function ensureSource(): Promise<boolean> {
if (source.value) return Boolean(audio.value)
if (!sourceLoadPromise) {
loading.value = true
sourceLoadPromise = (async () => {
const loaded = await messages.loadMedia(props.message.id)
if (!loaded) {
failed.value = true
return false
}
await nextTick()
return Boolean(audio.value && source.value)
})().finally(() => {
loading.value = false
sourceLoadPromise = undefined
})
}
return sourceLoadPromise
}
async function togglePlayback(): Promise<void> {
if (loading.value) return
failed.value = false
@@ -35,24 +63,27 @@ async function togglePlayback(): Promise<void> {
audio.value?.pause()
return
}
if (!source.value) {
loading.value = true
const loaded = await messages.loadMedia(props.message.id)
loading.value = false
if (!loaded) {
failed.value = true
return
}
await nextTick()
}
if (!(await ensureSource())) return
try {
await audio.value?.play()
} catch (error) {
failed.value = true
console.error(`[Messages] Could not play audio message ${props.message.id}:`, error)
console.error(
`[Messages] Could not play audio message ${props.message.id}:`,
error,
)
}
}
async function seekPlayback(event: Event): Promise<void> {
const value = Number((event.target as HTMLInputElement).value)
failed.value = false
if (!Number.isFinite(value) || !(await ensureSource()) || !audio.value) return
const nextTime = Math.min(duration.value, Math.max(0, value))
audio.value.currentTime = nextTime
currentTime.value = nextTime
}
function updateProgress(): void {
currentTime.value = audio.value?.currentTime ?? 0
}
@@ -62,6 +93,11 @@ function finishPlayback(): void {
currentTime.value = 0
}
function updateDuration(): void {
const value = audio.value?.duration ?? 0
if (Number.isFinite(value) && value > 0) metadataDuration.value = value
}
onBeforeUnmount(() => audio.value?.pause())
</script>
@@ -71,7 +107,11 @@ onBeforeUnmount(() => audio.value?.pause())
type="button"
class="voice-message__play"
:disabled="loading"
:aria-label="phone.t(playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio')"
:aria-label="
phone.t(
playing ? 'Apps.messages.pauseAudio' : 'Apps.messages.playAudio',
)
"
@click="togglePlayback"
>
<span v-if="loading" class="voice-message__loader" />
@@ -79,12 +119,29 @@ onBeforeUnmount(() => audio.value?.pause())
<Play v-else :size="16" fill="currentColor" />
</button>
<div class="voice-message__content">
<div class="voice-message__waveform" aria-hidden="true">
<i
v-for="(sample, index) in message.media_waveform ?? []"
:key="index"
:class="{ active: index / Math.max(1, (message.media_waveform?.length ?? 1) - 1) <= progress }"
:style="{ height: `${Math.max(4, sample * 22)}px` }"
<div class="voice-message__timeline">
<div class="voice-message__waveform" aria-hidden="true">
<i
v-for="(sample, index) in message.media_waveform ?? []"
:key="index"
:class="{
active:
index /
Math.max(1, (message.media_waveform?.length ?? 1) - 1) <=
progress,
}"
:style="{ height: `${Math.max(4, sample * 22)}px` }"
/>
</div>
<SkyRange
class="voice-message__range"
:model-value="currentTime"
:min="0"
:max="Math.max(0.1, duration)"
:step="0.1"
:aria-label="phone.t('Apps.messages.seekAudio')"
:aria-value-text="seekValueText"
@input="seekPlayback"
/>
</div>
<span>{{ displayTime }}</span>
@@ -93,6 +150,7 @@ onBeforeUnmount(() => audio.value?.pause())
ref="audio"
:src="source"
preload="metadata"
@loadedmetadata="updateDuration"
@play="playing = true"
@pause="playing = false"
@timeupdate="updateProgress"
@@ -101,3 +159,43 @@ onBeforeUnmount(() => audio.value?.pause())
/>
</div>
</template>
<style scoped>
.voice-message__timeline {
position: relative;
min-width: 0;
height: 24px;
}
.voice-message__waveform {
min-width: 0;
overflow: hidden;
gap: 1px;
pointer-events: none;
}
.voice-message__waveform i {
width: auto;
min-width: 1px;
flex: 1 1 1px;
}
.voice-message__range {
position: absolute;
inset: 0;
cursor: pointer;
}
.voice-message__range :deep(.sky-range__input) {
width: 100%;
height: 24px;
opacity: 0;
cursor: pointer;
}
.voice-message__timeline:focus-within {
border-radius: var(--sky-radius-pill);
outline: 2px solid currentColor;
outline-offset: 2px;
}
</style>
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const configSource = readFileSync(
new URL('./WidgetConfigSheet.vue', import.meta.url),
'utf8',
)
const pickerSource = readFileSync(
new URL('./WidgetPickerSheet.vue', import.meta.url),
'utf8',
)
const widgetSource = readFileSync(
new URL('./SpringboardWidget.vue', import.meta.url),
'utf8',
)
describe('WidgetConfigSheet Sky UI contract', () => {
it('uses first-party Sky UI controls for the configuration surface', () => {
expect(configSource).not.toContain("from 'konsta/vue'")
expect(configSource).not.toMatch(/<\/?k-[a-z]/)
expect(configSource).toContain('<SkyProvider')
expect(configSource).toContain('<SkySheet')
expect(configSource).toContain('<SkyScrollArea')
expect(configSource).toContain('<SkySegmented')
expect(configSource).toContain('<SkySettingsGroup')
expect(configSource).toContain('<SkySettingsRow')
})
it('keeps the sheet inside the phone-owned springboard geometry', () => {
expect(configSource).not.toContain('<Teleport')
expect(configSource).toMatch(
/\.widget-config-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
)
expect(configSource).toMatch(
/\.widget-config-sheet\s+:deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*max-height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*overflow:\s*hidden;/s,
)
})
it('has one scroll owner and preserves all configurable values', () => {
expect(configSource.match(/<SkyScrollArea/g)).toHaveLength(1)
expect(configSource).toContain('balanceSource: balanceSource.value')
expect(configSource).toContain('contactIds: contactIds.value')
expect(configSource).toContain('showDate: showDate.value')
expect(configSource).toContain('supportedSizes')
})
it('lets the widget component own preview geometry for every size', () => {
expect(configSource).not.toContain(':deep(.home-widget-shell--preview)')
expect(pickerSource).not.toContain(':deep(.home-widget-shell--preview)')
expect(widgetSource).toMatch(
/\.home-widget-shell--preview\.home-widget-shell--small\s*\{[^}]*max-width:\s*150px;[^}]*aspect-ratio:\s*1;/s,
)
})
})
+224 -149
View File
@@ -1,16 +1,4 @@
<script setup lang="ts">
import { Check } from 'lucide-vue-next'
import {
kLink,
kList,
kListItem,
kNavbar,
kPage,
kSegmented,
kSegmentedButton,
kSheet,
kToggle,
} from 'konsta/vue'
import { computed, ref, watch } from 'vue'
import SpringboardWidget from '@/components/SpringboardWidget.vue'
@@ -22,6 +10,16 @@ import type {
WidgetSettings,
WidgetSize,
} from '@/types/widgets'
import {
SkyButton,
SkyProvider,
SkyScrollArea,
SkySegmented,
SkySegmentedButton,
SkySettingsGroup,
SkySettingsRow,
SkySheet,
} from '@/ui'
const props = defineProps<{
instance: WidgetInstance | null
@@ -39,9 +37,15 @@ const size = ref<WidgetSize>('small')
const showDate = ref(true)
const balanceSource = ref<'bank' | 'cash'>('bank')
const contactIds = ref<string[]>([])
const sheetColors = {
bgIos: 'bg-[#f2f2f7] dark:bg-black',
}
const supportedSizes = computed(
() =>
(props.instance &&
WIDGET_REGISTRY_BY_KIND.get(props.instance.kind)?.supportedSizes) ||
[],
)
const activeSizeIndex = computed(() =>
Math.max(0, supportedSizes.value.indexOf(size.value)),
)
function toggleContact(id: string): void {
const index = contactIds.value.indexOf(id)
@@ -71,191 +75,260 @@ watch(
</script>
<template>
<div class="widget-config-sheet">
<k-sheet
<SkyProvider
class="widget-config-provider"
:dark="phone.isDarkMode"
safe-areas
>
<SkySheet
class="widget-config-sheet"
:opened="opened"
:colors="sheetColors"
:aria-label="phone.t('Home.widgetSystem.configure')"
grabber-clickable
:grabber-label="phone.t('Common.cancel')"
swipe-to-close
@backdropclick="emit('close')"
@escape="emit('close')"
@grabberclick="emit('close')"
@swipeclose="emit('close')"
>
<k-page
v-if="instance"
class="widget-config-page"
:class="{ 'widget-config-page--dark': phone.isDarkMode }"
>
<k-navbar :title="phone.t('Home.widgetSystem.configure')">
<template #left>
<k-link component="button" type="button" @click="emit('close')">
<section v-if="instance" class="widget-config-surface">
<header class="widget-config-navbar">
<SkyButton
class="widget-config-nav-button"
inline
rounded
type="button"
variant="secondary"
@click="emit('close')"
>
{{ phone.t('Common.cancel') }}
</k-link>
</template>
<template #right>
<k-link component="button" type="button" @click="save">
</SkyButton>
<h2>{{ phone.t('Home.widgetSystem.configure') }}</h2>
<SkyButton
class="widget-config-nav-button"
inline
rounded
type="button"
variant="secondary"
@click="save"
>
{{ phone.t('Common.done') }}
</k-link>
</template>
</k-navbar>
</SkyButton>
</header>
<div class="widget-config-scroll">
<div class="widget-config-preview">
<SpringboardWidget
:instance="{ ...instance, size }"
preview
:interactive="false"
/>
</div>
<SkyScrollArea class="widget-config-scroll">
<div class="widget-config-preview">
<SpringboardWidget
:instance="{ ...instance, size }"
preview
:interactive="false"
/>
</div>
<section class="widget-config-size">
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
<k-segmented raised>
<k-segmented-button
v-for="supportedSize in WIDGET_REGISTRY_BY_KIND.get(instance.kind)
?.supportedSizes"
:key="supportedSize"
:active="size === supportedSize"
@click="size = supportedSize"
<section class="widget-config-size">
<h3>{{ phone.t('Home.widgetSystem.size') }}</h3>
<SkySegmented
:active-index="activeSizeIndex"
:aria-label="phone.t('Home.widgetSystem.size')"
:item-count="supportedSizes.length"
rounded
strong
>
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
</k-segmented-button>
</k-segmented>
</section>
<SkySegmentedButton
v-for="supportedSize in supportedSizes"
:key="supportedSize"
:active="size === supportedSize"
@click="size = supportedSize"
>
{{ phone.t(`Home.widgetSystem.sizes.${supportedSize}`) }}
</SkySegmentedButton>
</SkySegmented>
</section>
<k-list
v-if="instance.kind === 'clock'"
inset
strong
class="widget-config-list"
>
<k-list-item :title="phone.t('Home.widgetSystem.clock.showDate')">
<template #after>
<k-toggle :checked="showDate" @change="showDate = !showDate" />
</template>
</k-list-item>
</k-list>
<SkySettingsGroup
v-if="instance.kind === 'clock'"
class="widget-config-group"
:aria-label="phone.t('Home.widgetSystem.clock.name')"
>
<SkySettingsRow
v-model="showDate"
kind="toggle"
:title="phone.t('Home.widgetSystem.clock.showDate')"
/>
</SkySettingsGroup>
<k-list
v-if="instance.kind === 'wallet'"
inset
strong
class="widget-config-list"
>
<k-list-item :title="phone.t('Home.widgetSystem.wallet.balance')">
<template #after>
<k-segmented class="widget-config-balance">
<k-segmented-button
:active="balanceSource === 'bank'"
@click="balanceSource = 'bank'"
<SkySettingsGroup
v-if="instance.kind === 'wallet'"
class="widget-config-group"
:aria-label="phone.t('Home.widgetSystem.wallet.balance')"
>
<SkySettingsRow
kind="custom"
:title="phone.t('Home.widgetSystem.wallet.balance')"
>
<template #trailing>
<SkySegmented
:active-index="balanceSource === 'bank' ? 0 : 1"
:aria-label="phone.t('Home.widgetSystem.wallet.balance')"
class="widget-config-balance"
:item-count="2"
rounded
strong
>
{{ phone.t('Home.widgetSystem.wallet.bank') }}
</k-segmented-button>
<k-segmented-button
:active="balanceSource === 'cash'"
@click="balanceSource = 'cash'"
>
{{ phone.t('Home.widgetSystem.wallet.cash') }}
</k-segmented-button>
</k-segmented>
</template>
</k-list-item>
</k-list>
<SkySegmentedButton
:active="balanceSource === 'bank'"
@click="balanceSource = 'bank'"
>
{{ phone.t('Home.widgetSystem.wallet.bank') }}
</SkySegmentedButton>
<SkySegmentedButton
:active="balanceSource === 'cash'"
@click="balanceSource = 'cash'"
>
{{ phone.t('Home.widgetSystem.wallet.cash') }}
</SkySegmentedButton>
</SkySegmented>
</template>
</SkySettingsRow>
</SkySettingsGroup>
<section v-if="instance.kind === 'contacts'">
<h3>{{ phone.t('Home.widgetSystem.contacts.choose') }}</h3>
<k-list inset strong class="widget-config-list">
<k-list-item
<SkySettingsGroup
v-if="instance.kind === 'contacts'"
class="widget-config-group"
:title="phone.t('Home.widgetSystem.contacts.choose')"
>
<SkySettingsRow
v-for="contact in contactsService.contacts.value"
:key="contact.id"
link
link-component="button"
content-class="w-full"
:chevron="false"
kind="choice"
:description="contact.phone_number"
:selected="contactIds.includes(contact.id)"
:title="contact.name"
:subtitle="contact.phone_number"
@click="toggleContact(contact.id)"
@activate="toggleContact(contact.id)"
>
<template #media>
<template #leading>
<span class="widget-config-avatar">{{
contact.name.charAt(0).toUpperCase()
}}</span>
</template>
<template v-if="contactIds.includes(contact.id)" #after>
<Check :size="20" class="widget-config-check" />
</template>
</k-list-item>
</k-list>
</section>
</div>
</k-page>
</k-sheet>
</div>
</SkySettingsRow>
</SkySettingsGroup>
</SkyScrollArea>
</section>
</SkySheet>
</SkyProvider>
</template>
<style scoped>
.widget-config-sheet :deep(.k-sheet) {
.widget-config-provider {
position: absolute;
z-index: 115;
height: calc(100% - 22px);
inset: 0;
pointer-events: none;
}
.widget-config-sheet {
--sky-overlay-layer: 115;
}
.widget-config-sheet :deep(.sky-overlay-backdrop) {
background: rgb(0 0 0 / 58%);
}
.widget-config-sheet :deep(.sky-sheet__panel) {
height: calc(100% - var(--sky-space-3));
max-height: calc(100% - var(--sky-space-3));
overflow: hidden;
border-radius: 28px 28px 0 0;
background: var(--sky-bg);
}
.widget-config-page {
height: 100%;
color: #111;
background: #f2f2f7;
.widget-config-surface {
height: calc(100% - 32px);
min-height: 0;
display: flex;
flex-direction: column;
background: var(--sky-bg);
color: var(--sky-text);
}
.widget-config-page--dark {
.widget-config-navbar {
min-height: 56px;
display: grid;
grid-template-columns: minmax(72px, 1fr) minmax(0, 2fr) minmax(72px, 1fr);
align-items: center;
gap: var(--sky-space-2);
padding: 0 var(--sky-space-3);
border-bottom: 1px solid var(--sky-hairline);
}
.widget-config-navbar h2 {
overflow: hidden;
margin: 0;
color: var(--sky-text);
font-size: var(--sky-font-title);
font-weight: 650;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-config-navbar > :first-child {
justify-self: start;
}
.widget-config-navbar > :last-child {
justify-self: end;
}
.widget-config-navbar :deep(.widget-config-nav-button.sky-button) {
min-height: var(--sky-touch-target);
padding: 0 var(--sky-space-4);
border: 1px solid rgb(255 255 255 / 12%);
background: var(--sky-surface-variant);
color: #fff;
background: #000;
font-size: 15px;
font-weight: 600;
}
.widget-config-scroll {
height: calc(100% - 54px);
padding: 8px 12px 42px;
min-height: 0;
flex: 1;
overflow-y: auto;
scrollbar-width: none;
}
.widget-config-scroll::-webkit-scrollbar {
display: none;
padding: var(--sky-space-3) var(--sky-page-gutter)
calc(var(--sky-space-6) + var(--sky-safe-area-bottom));
}
.widget-config-preview {
display: flex;
min-height: 215px;
padding: 20px 12px;
min-height: 250px;
padding: var(--sky-space-4) 0;
align-items: center;
justify-content: center;
}
.widget-config-preview :deep(.home-widget-shell) {
max-height: 188px;
}
.widget-config-size {
display: grid;
margin: 0 4px 16px;
grid-template-columns: auto 1fr;
align-items: center;
gap: 12px;
margin-bottom: var(--sky-space-5);
}
.widget-config-size > span,
.widget-config-scroll h3 {
color: #6e6e73;
font-size: 13px;
.widget-config-size h3 {
margin: 0 0 var(--sky-space-2) var(--sky-space-3);
color: var(--sky-muted);
font-size: var(--sky-font-caption);
font-weight: 600;
text-transform: uppercase;
}
.widget-config-scroll h3 {
margin: 18px 14px 7px;
.widget-config-size :deep(.sky-segmented) {
width: 100%;
}
.widget-config-list :deep([class*='title']) {
color: inherit;
.widget-config-group {
margin: 0 0 var(--sky-space-5);
}
.widget-config-balance {
width: 138px;
width: 146px;
}
.widget-config-avatar {
@@ -269,7 +342,9 @@ watch(
font-weight: 650;
}
.widget-config-check {
color: #0a84ff;
@media (max-height: 700px) {
.widget-config-preview {
min-height: 205px;
}
}
</style>
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./WidgetPickerSheet.vue', import.meta.url),
'utf8',
)
describe('WidgetPickerSheet Sky UI contract', () => {
it('uses only first-party Sky UI primitives for the picker surface', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).not.toMatch(/<\/?k-[a-z]/)
expect(source).toContain('<SkyProvider')
expect(source).toContain('<SkySheet')
expect(source).toContain('<SkySearchbar')
expect(source).toContain('<SkyList')
expect(source).toContain('<SkyListItem')
expect(source).toContain('<SkyEmptyState')
expect(source).toContain('<SkyButton')
})
it('keeps the modal inside the phone-owned springboard geometry', () => {
expect(source).not.toContain('<Teleport')
expect(source).toMatch(
/\.widget-picker-provider\s*\{[^}]*position:\s*absolute;[^}]*inset:\s*0;[^}]*pointer-events:\s*none;/s,
)
expect(source).toMatch(
/\.widget-picker-sheet\s+:deep\(\.sky-sheet__panel\)\s*\{[^}]*height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*max-height:\s*calc\(100%\s*-\s*var\(--sky-space-3\)\);[^}]*overflow:\s*hidden;/s,
)
})
it('uses the iOS gallery, preview, size, and add sequence', () => {
expect(source).toContain("ref<'gallery' | 'preview'>('gallery')")
expect(source).toContain("pickerView.value = 'preview'")
expect(source).toContain("pickerView.value = 'gallery'")
expect(source).toContain('selectedDefinition.supportedSizes')
expect(source).toContain('startPreviewSwipe')
expect(source).toContain('widget-picker-size-dots')
expect(source).toContain("phone.t('Home.widgetSystem.addWidget')")
})
it('has one explicit vertical scroll owner', () => {
expect(source.match(/<SkyScrollArea/g)).toHaveLength(1)
expect(source).toMatch(
/\.widget-picker-scroll\s*\{[^}]*overflow-y:\s*auto;/s,
)
})
})
+426 -163
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import {
CalendarDays,
Check,
ChevronLeft,
Clock3,
CloudSun,
Music,
@@ -9,18 +9,6 @@ import {
Users,
WalletCards,
} from 'lucide-vue-next'
import {
kButton,
kLink,
kList,
kListItem,
kNavbar,
kPage,
kSearchbar,
kSegmented,
kSegmentedButton,
kSheet,
} from 'konsta/vue'
import { computed, ref, watch, type Component } from 'vue'
import SpringboardWidget from '@/components/SpringboardWidget.vue'
@@ -32,6 +20,16 @@ import type {
WidgetKind,
WidgetSize,
} from '@/types/widgets'
import {
SkyButton,
SkyEmptyState,
SkyList,
SkyListItem,
SkyProvider,
SkyScrollArea,
SkySearchbar,
SkySheet,
} from '@/ui'
const props = defineProps<{ opened: boolean }>()
const emit = defineEmits<{
@@ -40,8 +38,10 @@ const emit = defineEmits<{
}>()
const phone = usePhoneStore()
const query = ref('')
const pickerView = ref<'gallery' | 'preview'>('gallery')
const selectedKind = ref<WidgetKind>('clock')
const selectedSize = ref<WidgetSize>('small')
let previewPointerStartX: number | null = null
const icons: Record<WidgetKind, Component> = {
clock: Clock3,
@@ -90,214 +90,447 @@ const previewInstance = computed<WidgetInstance>(() => ({
},
size: selectedSize.value,
}))
const sheetColors = {
bgIos: 'bg-[#f2f2f7] dark:bg-black',
}
function inputValue(event: Event): string {
return (event.target as HTMLInputElement).value
}
const selectedSizeIndex = computed(() =>
selectedDefinition.value.supportedSizes.indexOf(selectedSize.value),
)
function selectWidget(definition: WidgetDefinition): void {
selectedKind.value = definition.kind
if (!definition.supportedSizes.includes(selectedSize.value))
selectedSize.value = definition.defaultSize
selectedSize.value = definition.defaultSize
pickerView.value = 'preview'
}
function addWidget(): void {
emit('add', selectedKind.value, selectedSize.value)
}
function closePicker(): void {
emit('close')
}
function showGallery(): void {
pickerView.value = 'gallery'
}
function changeSize(offset: number): void {
const sizes = selectedDefinition.value.supportedSizes
const currentIndex = Math.max(0, sizes.indexOf(selectedSize.value))
const nextIndex = Math.max(
0,
Math.min(sizes.length - 1, currentIndex + offset),
)
selectedSize.value = sizes[nextIndex] ?? selectedSize.value
}
function startPreviewSwipe(event: PointerEvent): void {
if (
!event.isPrimary ||
(event.pointerType === 'mouse' && event.button !== 0)
) {
return
}
previewPointerStartX = event.clientX
const target = event.currentTarget as HTMLElement
target.setPointerCapture(event.pointerId)
}
function finishPreviewSwipe(event: PointerEvent): void {
if (previewPointerStartX === null) return
const deltaX = event.clientX - previewPointerStartX
previewPointerStartX = null
const target = event.currentTarget as HTMLElement
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId)
}
if (Math.abs(deltaX) < 36) return
changeSize(deltaX < 0 ? 1 : -1)
}
function cancelPreviewSwipe(): void {
previewPointerStartX = null
}
watch(
() => props.opened,
(opened) => {
if (!opened) query.value = ''
if (opened) return
query.value = ''
pickerView.value = 'gallery'
previewPointerStartX = null
},
)
</script>
<template>
<div class="widget-picker-sheet">
<k-sheet
<SkyProvider
class="widget-picker-provider"
:dark="phone.isDarkMode"
safe-areas
>
<SkySheet
class="widget-picker-sheet"
:opened="opened"
:colors="sheetColors"
@backdropclick="emit('close')"
:aria-label="phone.t('Home.widgetSystem.galleryTitle')"
grabber-clickable
:grabber-label="phone.t('Common.close')"
swipe-to-close
@backdropclick="closePicker"
@escape="closePicker"
@grabberclick="closePicker"
@swipeclose="closePicker"
>
<k-page
class="widget-picker-page"
:class="{ 'widget-picker-page--dark': phone.isDarkMode }"
>
<k-navbar :title="phone.t('Home.widgetSystem.galleryTitle')">
<template #right>
<k-link component="button" type="button" @click="emit('close')">
<section class="widget-picker-surface">
<header class="widget-picker-navbar">
<SkyButton
v-if="pickerView === 'preview'"
class="widget-picker-nav-button widget-picker-nav-button--icon"
icon-only
inline
rounded
type="button"
variant="secondary"
:aria-label="phone.t('Common.back')"
@click="showGallery"
>
<ChevronLeft :size="21" :stroke-width="2.4" />
</SkyButton>
<span v-else aria-hidden="true"></span>
<h2>
{{
pickerView === 'gallery'
? phone.t('Home.widgetSystem.galleryTitle')
: phone.t(selectedDefinition.labelKey)
}}
</h2>
<SkyButton
class="widget-picker-nav-button widget-picker-done"
inline
rounded
type="button"
variant="secondary"
@click="closePicker"
>
{{ phone.t('Common.done') }}
</k-link>
</template>
</k-navbar>
</SkyButton>
</header>
<div class="widget-picker-scroll">
<k-searchbar
:value="query"
:placeholder="phone.t('Home.widgetSystem.search')"
@input="query = inputValue($event)"
@clear="query = ''"
/>
<SkyScrollArea class="widget-picker-scroll">
<template v-if="pickerView === 'gallery'">
<SkySearchbar
v-model="query"
class="widget-picker-search"
:clear-label="phone.t('Common.clear')"
:label="phone.t('Home.widgetSystem.search')"
:placeholder="phone.t('Home.widgetSystem.search')"
/>
<section class="widget-picker-preview">
<SpringboardWidget
:instance="previewInstance"
preview
:interactive="false"
/>
<h2>{{ phone.t(selectedDefinition.labelKey) }}</h2>
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
</section>
<section class="widget-picker-size">
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
<k-segmented raised>
<k-segmented-button
v-for="size in selectedDefinition.supportedSizes"
:key="size"
:active="selectedSize === size"
@click="selectedSize = size"
<section
v-for="category in categories"
:key="category.key"
class="widget-picker-category"
>
{{ phone.t(`Home.widgetSystem.sizes.${size}`) }}
</k-segmented-button>
</k-segmented>
</section>
<h3>{{ phone.t(category.key) }}</h3>
<SkyList flush strong class="widget-picker-list">
<SkyListItem
v-for="definition in category.widgets"
:key="definition.kind"
chevron
link
link-component="button"
:text="phone.t(definition.descriptionKey)"
:title="phone.t(definition.labelKey)"
@click="selectWidget(definition)"
>
<template #media>
<span
class="widget-picker-icon"
:class="`widget-picker-icon--${definition.kind}`"
>
<component :is="icons[definition.kind]" :size="21" />
</span>
</template>
</SkyListItem>
</SkyList>
</section>
<k-button large rounded class="widget-picker-add" @click="addWidget">
{{ phone.t('Home.widgetSystem.addWidget') }}
</k-button>
<SkyEmptyState
v-if="filteredWidgets.length === 0"
compact
class="widget-picker-empty"
:title="phone.t('Home.widgetSystem.noResults')"
/>
</template>
<section
v-for="category in categories"
:key="category.key"
class="widget-picker-category"
>
<h3>{{ phone.t(category.key) }}</h3>
<k-list inset strong>
<k-list-item
v-for="definition in category.widgets"
:key="definition.kind"
link
link-component="button"
content-class="w-full"
:chevron="false"
:title="phone.t(definition.labelKey)"
:subtitle="phone.t(definition.descriptionKey)"
@click="selectWidget(definition)"
<section v-else class="widget-picker-preview">
<div
class="widget-picker-preview-stage"
role="group"
tabindex="0"
:aria-label="`${phone.t('Home.widgetSystem.size')}: ${phone.t(`Home.widgetSystem.sizes.${selectedSize}`)}`"
@keydown.left.prevent="changeSize(-1)"
@keydown.right.prevent="changeSize(1)"
@lostpointercapture="cancelPreviewSwipe"
@pointercancel="cancelPreviewSwipe"
@pointerdown="startPreviewSwipe"
@pointerup="finishPreviewSwipe"
>
<template #media>
<span class="widget-picker-icon">
<component :is="icons[definition.kind]" :size="21" />
</span>
</template>
<template v-if="selectedKind === definition.kind" #after>
<Check :size="20" class="widget-picker-check" />
</template>
</k-list-item>
</k-list>
</section>
<SpringboardWidget
:instance="previewInstance"
preview
:interactive="false"
/>
</div>
<p v-if="filteredWidgets.length === 0" class="widget-picker-empty">
{{ phone.t('Home.widgetSystem.noResults') }}
</p>
</div>
</k-page>
</k-sheet>
</div>
<div class="widget-picker-size-copy" aria-live="polite">
<strong>{{
phone.t(`Home.widgetSystem.sizes.${selectedSize}`)
}}</strong>
<span>{{ phone.t('Home.widgetSystem.size') }}</span>
</div>
<div
class="widget-picker-size-dots"
:aria-label="phone.t('Home.widgetSystem.size')"
role="group"
>
<button
v-for="(size, index) in selectedDefinition.supportedSizes"
:key="size"
type="button"
:class="{
'widget-picker-size-dot--active': index === selectedSizeIndex,
}"
:aria-label="phone.t(`Home.widgetSystem.sizes.${size}`)"
:aria-pressed="index === selectedSizeIndex"
@click="selectedSize = size"
></button>
</div>
<h3>{{ phone.t(selectedDefinition.labelKey) }}</h3>
<p>{{ phone.t(selectedDefinition.descriptionKey) }}</p>
<SkyButton
block
large
rounded
class="widget-picker-add"
@click="addWidget"
>
{{ phone.t('Home.widgetSystem.addWidget') }}
</SkyButton>
</section>
</SkyScrollArea>
</section>
</SkySheet>
</SkyProvider>
</template>
<style scoped>
.widget-picker-sheet :deep(.k-sheet) {
.widget-picker-provider {
position: absolute;
z-index: 110;
height: calc(100% - 22px);
inset: 0;
pointer-events: none;
}
.widget-picker-sheet {
--sky-overlay-layer: 110;
}
.widget-picker-sheet :deep(.sky-overlay-backdrop) {
background: rgb(0 0 0 / 58%);
}
.widget-picker-sheet :deep(.sky-sheet__panel) {
height: calc(100% - var(--sky-space-3));
max-height: calc(100% - var(--sky-space-3));
overflow: hidden;
border-radius: 28px 28px 0 0;
background: var(--sky-bg);
}
.widget-picker-page {
height: 100%;
color: #111;
background: #f2f2f7;
.widget-picker-surface {
height: calc(100% - 32px);
min-height: 0;
display: flex;
flex-direction: column;
background: var(--sky-bg);
color: var(--sky-text);
}
.widget-picker-page--dark {
.widget-picker-navbar {
min-height: 56px;
display: grid;
grid-template-columns: minmax(72px, 1fr) minmax(0, 2fr) minmax(72px, 1fr);
align-items: center;
gap: var(--sky-space-2);
padding: 0 var(--sky-space-3);
border-bottom: 1px solid var(--sky-hairline);
}
.widget-picker-navbar h2 {
overflow: hidden;
margin: 0;
color: var(--sky-text);
font-size: var(--sky-font-title);
font-weight: 650;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-picker-navbar > :first-child {
justify-self: start;
}
.widget-picker-navbar > :last-child {
justify-self: end;
}
.widget-picker-navbar :deep(.widget-picker-nav-button.sky-button) {
min-height: var(--sky-touch-target);
padding: 0 var(--sky-space-4);
border: 1px solid rgb(255 255 255 / 12%);
background: var(--sky-surface-variant);
color: #fff;
background: #000;
font-size: 15px;
font-weight: 600;
}
.widget-picker-navbar :deep(.widget-picker-nav-button--icon.sky-button) {
width: var(--sky-touch-target);
min-width: var(--sky-touch-target);
padding: 0;
}
.widget-picker-scroll {
height: calc(100% - 54px);
padding: 8px 13px 42px;
min-height: 0;
flex: 1;
overflow-y: auto;
scrollbar-width: none;
padding: var(--sky-space-3) var(--sky-page-gutter)
calc(var(--sky-space-6) + var(--sky-safe-area-bottom));
}
.widget-picker-scroll::-webkit-scrollbar {
display: none;
.widget-picker-search {
margin-bottom: var(--sky-space-5);
}
.widget-picker-preview {
display: flex;
min-height: 235px;
padding: 22px 14px 14px;
min-height: 100%;
padding: var(--sky-space-4) 0 0;
align-items: center;
flex-direction: column;
justify-content: center;
}
.widget-picker-preview :deep(.home-widget-shell) {
max-height: 188px;
}
.widget-picker-preview h2 {
margin: 14px 0 3px;
font-size: 20px;
letter-spacing: -0.4px;
}
.widget-picker-preview p {
max-width: 285px;
margin: 0;
color: #6e6e73;
font-size: 13px;
line-height: 1.35;
text-align: center;
}
.widget-picker-page--dark .widget-picker-preview p {
color: #98989d;
}
.widget-picker-size {
display: grid;
margin: 0 4px 14px;
grid-template-columns: auto 1fr;
.widget-picker-preview-stage {
width: 100%;
min-height: 310px;
display: flex;
align-items: center;
gap: 12px;
justify-content: center;
outline: 0;
touch-action: pan-y;
user-select: none;
}
.widget-picker-size > span {
font-size: 13px;
.widget-picker-preview-stage:focus-visible {
border-radius: var(--sky-radius-card);
box-shadow: inset 0 0 0 2px var(--sky-app-accent);
}
.widget-picker-size-copy {
display: flex;
align-items: baseline;
justify-content: center;
gap: var(--sky-space-2);
color: var(--sky-muted);
font-size: var(--sky-font-caption);
}
.widget-picker-size-copy strong {
color: var(--sky-text);
font-size: 14px;
font-weight: 600;
}
.widget-picker-size-dots {
min-height: var(--sky-touch-target);
display: flex;
align-items: center;
justify-content: center;
gap: var(--sky-space-2);
}
.widget-picker-size-dots button {
position: relative;
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--sky-subtle);
transition: background-color var(--sky-transition-fast) ease;
}
.widget-picker-size-dots button::before {
position: absolute;
inset: -18px -8px;
content: '';
}
.widget-picker-size-dots .widget-picker-size-dot--active {
background: var(--sky-text);
}
.widget-picker-preview h3 {
margin: 0;
color: var(--sky-text);
font-size: 21px;
font-weight: 650;
letter-spacing: -0.35px;
}
.widget-picker-preview p {
max-width: 290px;
margin: var(--sky-space-1) 0 var(--sky-space-5);
color: var(--sky-muted);
font-size: 14px;
line-height: 19px;
}
.widget-picker-add {
margin-bottom: 22px;
margin-top: auto;
}
.widget-picker-category h3 {
margin: 18px 12px 7px;
color: #6e6e73;
font-size: 13px;
font-weight: 500;
margin: var(--sky-space-5) var(--sky-space-3) var(--sky-space-2);
color: var(--sky-muted);
font-size: var(--sky-font-caption);
font-weight: 600;
text-transform: uppercase;
}
.widget-picker-category :deep([class*='title']) {
color: inherit;
.widget-picker-category:first-of-type h3 {
margin-top: 0;
}
.widget-picker-list {
overflow: hidden;
margin: 0;
border-radius: var(--sky-radius-card);
}
.widget-picker-list :deep(.sky-list-item__content) {
min-height: 58px;
}
.widget-picker-list :deep(.sky-list-item__text) {
color: var(--sky-muted);
}
.widget-picker-icon {
@@ -305,18 +538,48 @@ watch(
width: 38px;
height: 38px;
place-items: center;
border-radius: 10px;
border-radius: 11px;
color: #fff;
background: #2c2c2e;
}
.widget-picker-check {
color: #0a84ff;
.widget-picker-icon--date {
background: #ff453a;
}
.widget-picker-icon--weather {
background: #0a84ff;
}
.widget-picker-icon--music {
background: #ff375f;
}
.widget-picker-icon--wallet {
background: #5856d6;
}
.widget-picker-icon--transactions {
background: #30b35a;
}
.widget-picker-icon--contacts {
background: #ff9f0a;
}
.widget-picker-empty {
padding: 40px 20px;
color: #8e8e93;
text-align: center;
min-height: 220px;
}
@media (max-height: 700px) {
.widget-picker-preview-stage {
min-height: 260px;
}
}
@media (prefers-reduced-motion: reduce) {
.widget-picker-size-dots button {
transition: none;
}
}
</style>
@@ -32,17 +32,27 @@ const props = withDefaults(
maxUsernameLength?: number
minUsernameLength?: number
mode: 'login' | 'register'
password?: string
passwordLabel?: string
passwordPlaceholder?: string
pending: boolean
registerLabel: string
requirePassword?: boolean
title: string
username: string
usernameLabel: string
usernamePlaceholder?: string
variant?: 'default' | 'centered'
}>(),
{
maxUsernameLength: 40,
minUsernameLength: 2,
password: '',
passwordLabel: 'Password',
passwordPlaceholder: '',
requirePassword: false,
usernamePlaceholder: '',
variant: 'default',
},
)
const emit = defineEmits<{
@@ -50,21 +60,30 @@ const emit = defineEmits<{
gallery: []
submit: []
'update:mode': [value: 'login' | 'register']
'update:password': [value: string]
'update:username': [value: string]
}>()
const canSubmit = computed(() => {
const length = props.username.trim().length
const validUsername =
length >= props.minUsernameLength && length <= props.maxUsernameLength
return Boolean(
props.email &&
length >= props.minUsernameLength &&
length <= props.maxUsernameLength,
(props.mode === 'login' && props.requirePassword
? true
: validUsername) &&
(!props.requirePassword ||
(props.password.length >= 8 && props.password.length <= 72)),
)
})
</script>
<template>
<section class="app-profile-auth">
<section
class="app-profile-auth"
:class="{ 'app-profile-auth--centered': variant === 'centered' }"
>
<header class="app-profile-auth__hero">
<span class="app-profile-auth__mark"><UserRound :size="23" /></span>
<div>
@@ -75,7 +94,39 @@ const canSubmit = computed(() => {
</header>
<k-glass class="app-profile-auth__card">
<k-segmented raised class="app-profile-auth__mode">
<div
v-if="variant === 'centered'"
class="app-profile-auth__mode app-profile-auth__mode--centered"
role="tablist"
:aria-label="`${loginLabel} / ${registerLabel}`"
>
<button
type="button"
class="app-profile-auth__mode-choice"
:class="{
'app-profile-auth__mode-choice--active': mode === 'login',
}"
role="tab"
:aria-selected="mode === 'login'"
@click="emit('update:mode', 'login')"
>
{{ loginLabel }}
</button>
<button
type="button"
class="app-profile-auth__mode-choice"
:class="{
'app-profile-auth__mode-choice--active': mode === 'register',
}"
role="tab"
:aria-selected="mode === 'register'"
@click="emit('update:mode', 'register')"
>
{{ registerLabel }}
</button>
</div>
<k-segmented v-else raised class="app-profile-auth__mode">
<k-segmented-button
class="app-profile-auth__mode-button app-profile-auth__mode-button--login"
:class="{
@@ -123,7 +174,64 @@ const canSubmit = computed(() => {
<LockKeyhole :size="15" />
</div>
<k-list inset strong class="app-profile-auth__fields">
<label
v-if="
variant === 'centered' && (mode === 'register' || !requirePassword)
"
class="app-profile-auth__username-field"
for="app-profile-auth-username"
>
<span><UserRound :size="17" /></span>
<div>
<small>{{ usernameLabel }}</small>
<input
id="app-profile-auth-username"
:value="username"
:maxlength="maxUsernameLength"
:placeholder="usernamePlaceholder"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
@input="
emit('update:username', ($event.target as HTMLInputElement).value)
"
@keydown.enter="emit('submit')"
/>
</div>
</label>
<label
v-if="variant === 'centered' && requirePassword"
class="app-profile-auth__password-field"
for="app-profile-auth-password"
>
<span><LockKeyhole :size="17" /></span>
<div>
<small>{{ passwordLabel }}</small>
<input
id="app-profile-auth-password"
:value="password"
maxlength="72"
:placeholder="passwordPlaceholder"
:autocomplete="
mode === 'login' ? 'current-password' : 'new-password'
"
type="password"
@input="
emit('update:password', ($event.target as HTMLInputElement).value)
"
@keydown.enter="emit('submit')"
/>
</div>
</label>
<k-list
v-else-if="variant !== 'centered'"
inset
strong
class="app-profile-auth__fields"
>
<k-list-input
input-id="app-profile-auth-username"
:label="usernameLabel"
@@ -380,6 +488,102 @@ const canSubmit = computed(() => {
.app-profile-auth__identity > svg {
color: var(--muted, #9ba4aa);
}
.app-profile-auth__username-field {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 9px;
margin-bottom: 11px;
padding: 9px 11px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 15px;
background: rgba(255, 255, 255, 0.045);
text-align: left;
}
.app-profile-auth__password-field {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 9px;
margin-bottom: 11px;
padding: 9px 11px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 15px;
background: rgba(255, 255, 255, 0.045);
text-align: left;
}
.app-profile-auth__password-field > span {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 11px;
color: var(--auth-accent, #ffd63e);
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
}
.app-profile-auth__password-field div {
min-width: 0;
}
.app-profile-auth__password-field small {
display: block;
margin-bottom: 1px;
color: var(--muted, #9ba4aa);
font-size: 9px;
}
.app-profile-auth__password-field input {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 0;
border: 0;
outline: 0;
color: inherit;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.app-profile-auth__password-field input::placeholder {
color: var(--muted, #9ba4aa);
opacity: 0.72;
}
.app-profile-auth__username-field > span {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 11px;
color: var(--auth-accent, #ffd63e);
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
}
.app-profile-auth__username-field div {
min-width: 0;
}
.app-profile-auth__username-field small {
display: block;
margin-bottom: 1px;
color: var(--muted, #9ba4aa);
font-size: 9px;
}
.app-profile-auth__username-field input {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 0;
border: 0;
outline: 0;
color: inherit;
background: transparent;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.app-profile-auth__username-field input::placeholder {
color: var(--muted, #9ba4aa);
opacity: 0.72;
}
.app-profile-auth__fields {
margin-top: 0;
margin-right: 0;
@@ -427,4 +631,110 @@ const canSubmit = computed(() => {
.app-profile-auth__submit:disabled {
opacity: 0.46;
}
.app-profile-auth--centered {
max-width: 340px;
padding: 0;
}
.app-profile-auth--centered .app-profile-auth__hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
margin: 0 18px 18px;
text-align: center;
}
.app-profile-auth--centered .app-profile-auth__mark {
width: 54px;
height: 54px;
margin-bottom: 3px;
border-radius: 18px;
}
.app-profile-auth--centered .app-profile-auth__hero h2 {
margin: 0;
font-size: 24px;
}
.app-profile-auth--centered .app-profile-auth__hero p {
max-width: 285px;
margin: 0;
font-size: 12px;
line-height: 1.45;
}
.app-profile-auth--centered .app-profile-auth__card {
box-sizing: border-box;
width: 100%;
margin-inline: auto;
padding: 14px;
border-radius: 28px;
}
.app-profile-auth--centered .app-profile-auth__mode {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 4px;
border-radius: 15px;
}
.app-profile-auth__mode-choice {
min-width: 0;
min-height: 40px;
padding: 0 12px;
border: 0;
border-radius: 11px;
display: grid;
place-items: center;
color: #b8c5ce;
background: transparent;
font: inherit;
font-size: 14px;
font-weight: 650;
line-height: 1;
cursor: pointer;
transition:
color 160ms ease,
background 160ms ease,
box-shadow 160ms ease;
}
.app-profile-auth__mode-choice--active {
color: #fff;
background: var(--auth-accent, #ffd63e);
box-shadow: 0 6px 16px
color-mix(in srgb, var(--auth-accent, #ffd63e) 28%, transparent);
font-weight: 750;
}
.app-profile-auth--centered .app-profile-auth__identity,
.app-profile-auth--centered .app-profile-auth__username-field,
.app-profile-auth--centered .app-profile-auth__password-field {
grid-template-columns: 38px minmax(0, 1fr) 18px;
min-height: 58px;
margin-bottom: 10px;
padding: 8px 12px;
border-radius: 17px;
}
.app-profile-auth--centered .app-profile-auth__username-field {
grid-template-columns: 38px minmax(0, 1fr);
}
.app-profile-auth--centered .app-profile-auth__password-field {
grid-template-columns: 38px minmax(0, 1fr);
}
.app-profile-auth--centered .app-profile-auth__identity > span,
.app-profile-auth--centered .app-profile-auth__username-field > span,
.app-profile-auth--centered .app-profile-auth__password-field > span {
width: 38px;
height: 38px;
border-radius: 12px;
}
.app-profile-auth--centered .app-profile-auth__identity small,
.app-profile-auth--centered .app-profile-auth__username-field small,
.app-profile-auth--centered .app-profile-auth__password-field small {
font-size: 10px;
}
.app-profile-auth--centered .app-profile-auth__identity strong,
.app-profile-auth--centered .app-profile-auth__username-field input,
.app-profile-auth--centered .app-profile-auth__password-field input {
font-size: 14px;
line-height: 20px;
}
.app-profile-auth--centered .app-profile-auth__submit {
min-height: 48px;
border-radius: 16px;
}
</style>
@@ -8,18 +8,14 @@ import {
Share2,
UserRound,
} from 'lucide-vue-next'
import {
SkyButton as kButton,
SkyGlass as kGlass,
SkyIcon as kIcon,
} from '@/ui'
import { computed, ref } from 'vue'
import { SkyButton, SkyGlass, SkyIcon } from '@/ui'
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import type { FeatherPost } from '@/types/feather'
const props = defineProps<{ post: FeatherPost }>()
defineEmits<{
const emit = defineEmits<{
follow: [post: FeatherPost]
media: [post: FeatherPost, index: number]
menu: [post: FeatherPost]
@@ -32,6 +28,9 @@ defineEmits<{
const phone = usePhoneStore()
const expanded = ref(false)
const reactionPulse = ref<'like' | 'bookmark' | null>(null)
let reactionPulseFrame: number | null = null
let reactionPulseTimer: number | null = null
const shouldTruncate = computed(() => props.post.body.length > 260)
const visibleBody = computed(() =>
shouldTruncate.value && !expanded.value
@@ -57,10 +56,40 @@ function relativeTime(timestamp: number): string {
month: 'short',
}).format(timestamp)
}
function playReactionPulse(kind: 'like' | 'bookmark'): void {
if (reactionPulseFrame !== null)
window.cancelAnimationFrame(reactionPulseFrame)
if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer)
reactionPulse.value = null
void nextTick(() => {
reactionPulseFrame = window.requestAnimationFrame(() => {
reactionPulse.value = kind
reactionPulseFrame = null
reactionPulseTimer = window.setTimeout(() => {
reactionPulse.value = null
reactionPulseTimer = null
}, 520)
})
})
}
function react(kind: 'like' | 'bookmark'): void {
const isActivating =
kind === 'like' ? !props.post.is_liked : !props.post.is_bookmarked
if (isActivating) playReactionPulse(kind)
emit('react', props.post, kind)
}
onBeforeUnmount(() => {
if (reactionPulseFrame !== null)
window.cancelAnimationFrame(reactionPulseFrame)
if (reactionPulseTimer !== null) window.clearTimeout(reactionPulseTimer)
})
</script>
<template>
<kGlass :highlight="false" class="feather-post-glass">
<SkyGlass :highlight="false" class="feather-post-glass">
<article class="feather-post" @click="$emit('open', post)">
<button
class="feather-avatar"
@@ -91,7 +120,7 @@ function relativeTime(timestamp: number): string {
@{{ post.handle }} · {{ relativeTime(post.created_at) }}
</span>
</button>
<kButton
<SkyButton
v-if="!post.is_owner && !post.is_following"
outline
inline
@@ -101,16 +130,18 @@ function relativeTime(timestamp: number): string {
@click.stop="$emit('follow', post)"
>
{{ phone.t('Apps.feather.follow') }}
</kButton>
<kButton
clear
</SkyButton>
<SkyButton
icon-only
rounded
small
tonal
class="feather-more"
:aria-label="phone.t('Apps.feather.moreActions')"
@click.stop="$emit('menu', post)"
>
<kIcon><MoreHorizontal :size="18" /></kIcon>
</kButton>
<SkyIcon><MoreHorizontal :size="18" /></SkyIcon>
</SkyButton>
</header>
<p v-if="post.body" class="feather-post__text">
@@ -155,16 +186,24 @@ function relativeTime(timestamp: number): string {
</button>
<button
type="button"
:class="{ 'is-liked': post.is_liked }"
@click.stop="$emit('react', post, 'like')"
class="feather-action feather-action--like"
:class="{
'is-liked': post.is_liked,
'is-pulsing': reactionPulse === 'like',
}"
@click.stop="react('like')"
>
<Heart :size="17" :fill="post.is_liked ? 'currentColor' : 'none'" />
<span v-if="post.like_count">{{ post.like_count }}</span>
</button>
<button
type="button"
:class="{ 'is-bookmarked': post.is_bookmarked }"
@click.stop="$emit('react', post, 'bookmark')"
class="feather-action feather-action--bookmark"
:class="{
'is-bookmarked': post.is_bookmarked,
'is-pulsing': reactionPulse === 'bookmark',
}"
@click.stop="react('bookmark')"
>
<Bookmark
:size="17"
@@ -177,7 +216,7 @@ function relativeTime(timestamp: number): string {
</footer>
</div>
</article>
</kGlass>
</SkyGlass>
</template>
<style scoped>
@@ -267,8 +306,12 @@ function relativeTime(timestamp: number): string {
color: #438cf5;
}
.feather-follow {
--sky-app-accent: transparent;
--sky-button-text: var(--feather-blue, #1d9bf0);
--sky-app-accent: var(--feather-blue, #1d9bf0);
--sky-app-accent-soft: color-mix(
in srgb,
var(--feather-blue, #1d9bf0) 14%,
transparent
);
width: auto;
min-width: 58px;
max-width: 68px;
@@ -281,17 +324,26 @@ function relativeTime(timestamp: number): string {
transparent
);
padding-inline: 9px;
color: var(--feather-blue, #1d9bf0);
background: transparent;
box-shadow: none;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.1px;
}
.feather-more {
--sky-app-accent: #71767b;
--sky-app-accent-soft: color-mix(in srgb, currentColor 10%, transparent);
flex: 0 0 auto;
margin-left: 0;
width: 27px;
min-width: 27px;
height: 27px;
min-height: 27px;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
padding: 0;
color: #71767b;
background: color-mix(in srgb, currentColor 7%, transparent);
}
.feather-post__text {
margin: 2px 0 8px;
@@ -352,6 +404,7 @@ function relativeTime(timestamp: number): string {
color: #71767b;
}
.feather-actions button {
position: relative;
display: flex;
align-items: center;
gap: 4px;
@@ -371,6 +424,57 @@ function relativeTime(timestamp: number): string {
.feather-actions .is-bookmarked {
color: #438cf5;
}
.feather-actions .feather-action.is-pulsing svg {
animation: feather-reaction-pop 520ms cubic-bezier(0.22, 1.45, 0.36, 1) both;
}
.feather-actions .feather-action.is-pulsing::after {
position: absolute;
top: 50%;
left: 8px;
width: 24px;
height: 24px;
border: 2px solid currentColor;
border-radius: 50%;
content: '';
pointer-events: none;
animation: feather-reaction-ring 520ms ease-out both;
}
.feather-actions .feather-action--like.is-pulsing {
color: #f04f87;
}
.feather-actions .feather-action--bookmark.is-pulsing {
color: #438cf5;
}
@keyframes feather-reaction-pop {
0% {
transform: scale(0.78) rotate(-8deg);
}
48% {
transform: scale(1.42) rotate(7deg);
}
72% {
transform: scale(0.94) rotate(-2deg);
}
100% {
transform: scale(1) rotate(0);
}
}
@keyframes feather-reaction-ring {
0% {
opacity: 0.5;
transform: translate(-50%, -50%) scale(0.35);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(1.65);
}
}
@media (prefers-reduced-motion: reduce) {
.feather-actions .feather-action.is-pulsing svg,
.feather-actions .feather-action.is-pulsing::after {
animation: none;
}
}
@supports not (color: color-mix(in srgb, white, black)) {
.feather-post:active {
background: rgb(127 127 127 / 4%);