Compare commits

...

13 Commits

Author SHA1 Message Date
DerEchteAlec 3314fa5355 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 18:25:45 +02:00
DerEchteAlec 637168c616 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 17:26:47 +02:00
DerEchteAlec 39fe171961 Merge branch 'dev' into feat/refine-phone-setup 2026-08-20 17:17:17 +02:00
smx.pusha c2fe8b5e23 FIX - correct messages GIF picker layout 2026-08-20 12:52:14 +02:00
smx.pusha a24cda989a ENH - apply liquid glass across app controls 2026-08-20 12:38:46 +02:00
smx.pusha b9f8930f00 ENH - refine phone liquid glass controls 2026-08-20 11:50:41 +02:00
smx.pusha 5efd806e43 ENH - refine notes header and compose controls 2026-08-20 11:02:44 +02:00
smx.pusha 65a0846eea FIX - align memo player content 2026-08-20 10:46:09 +02:00
smx.pusha 0a0053c3af ENH - align gallery filter navigation 2026-08-20 10:03:18 +02:00
smx.pusha 2f0faa8206 ENH - fade empty music widget content 2026-08-20 09:48:56 +02:00
smx.pusha 76b0378649 FIX - refine compact weather layout 2026-08-20 09:25:34 +02:00
smx.pusha 07b348a01a FIX - refine settings header and search icon 2026-08-20 08:45:58 +02:00
smx.pusha 9abf53b140 ENH - refine phone setup experience 2026-08-20 08:25:54 +02:00
44 changed files with 1074 additions and 529 deletions
+60 -12
View File
@@ -4178,8 +4178,8 @@ button {
display: flex;
align-items: center;
flex-direction: column;
min-height: 218px;
padding: 1px 0 16px;
min-height: 206px;
padding: 1px 0 10px;
text-align: center;
}
.weather-location {
@@ -4231,7 +4231,7 @@ button {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-2);
margin-bottom: var(--sky-space-4);
margin-bottom: var(--sky-space-3);
}
.weather-detail-card,
.weather-panel {
@@ -4242,18 +4242,21 @@ button {
0 8px 22px rgba(5, 16, 32, 0.14);
color: #fff;
}
.weather-detail-card {
.weather-details > .weather-detail-card {
display: grid;
grid-template-columns: 21px 1fr;
gap: 4px 7px;
min-height: 94px;
grid-template-rows: auto auto;
align-content: center;
gap: 7px;
min-height: 76px;
margin: 0;
padding: 14px;
padding: 11px 12px;
border-radius: calc(var(--sky-radius-card) - 6px);
}
.weather-detail-card svg {
grid-row: span 2;
margin-top: 2px;
align-self: center;
margin-top: 0;
color: #b7d9ec;
}
.weather-detail-card:nth-child(1) svg {
@@ -4273,7 +4276,7 @@ button {
text-transform: uppercase;
}
.weather-detail-card strong {
align-self: end;
align-self: auto;
font-size: 18px;
font-weight: 680;
line-height: 1.1;
@@ -4281,7 +4284,7 @@ button {
.weather-detail-card:nth-child(4) strong {
color: var(--weather-accent-cyan);
}
.weather-panel {
.weather-scroll > .weather-panel {
margin: 0;
overflow: hidden;
border-radius: var(--sky-radius-card);
@@ -4315,10 +4318,11 @@ button {
min-height: 108px;
padding: 7px 2px 4px;
border-left: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--sky-radius-control);
border-radius: 0;
}
.weather-hour:first-child {
border-left: 0;
border-radius: var(--sky-radius-control);
background: rgba(255, 255, 255, 0.07);
}
.weather-hour span {
@@ -6611,9 +6615,47 @@ button {
display: block;
object-fit: contain;
}
.messages-media-picker__gifs--masonry {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.messages-gif-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 8px;
}
.messages-gif-column {
min-width: 0;
display: grid;
align-content: start;
gap: 8px;
}
.messages-media-picker__gifs--masonry .messages-gif-result {
width: 100%;
min-height: 0;
display: block;
overflow: hidden;
border: 1px solid rgb(60 60 67 / 10%);
border-radius: 12px;
background: var(--sky-surface-muted);
box-shadow: none;
}
.messages-media-picker__gifs--masonry .messages-gif-result img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.messages-media-picker__gifs .messages-gif-more {
width: 100%;
min-height: 36px;
grid-column: 1 / -1;
flex: 0 0 auto;
border: 0;
background: transparent;
box-shadow: none;
color: var(--ios-blue);
font-size: 12px;
font-weight: 650;
@@ -6935,6 +6977,12 @@ button {
.phone-app.dark .messages-media-picker__gifs button {
background: #2c2c2e;
}
.phone-app.dark
.messages-media-picker__gifs--masonry
.messages-gif-result {
border-color: rgb(255 255 255 / 10%);
background: var(--sky-surface-muted);
}
.phone-app.dark .messages-media-picker__gifs .messages-gif-more,
.phone-app.dark .messages-media-picker__gifs .messages-gif-error button {
background: rgb(10 132 255 / 20%);
@@ -34,6 +34,15 @@ describe('SpringboardWidget UI contract', () => {
expect(source).toContain('music.progress.value')
})
it('fades empty music artwork downward while keeping its message above the fade', () => {
expect(source).toContain('.home-widget--music-empty::after')
expect(source).toContain(
'.home-widget--music-empty .widget-music-placeholder',
)
expect(source).toContain('-webkit-mask-image: linear-gradient(')
expect(source).toMatch(/\.widget-music-empty\s*{[\s\S]*?z-index:\s*2;/)
})
it.each([
'sunny',
'clear',
+36 -3
View File
@@ -1076,6 +1076,39 @@ onBeforeUnmount(() => {
background: rgb(39 39 42 / 95%);
}
.home-widget--music-empty::after {
position: absolute;
z-index: 1;
inset: 0;
content: '';
border-radius: inherit;
background: linear-gradient(
to bottom,
transparent 34%,
rgb(20 20 22 / 18%) 62%,
rgb(16 16 18 / 82%) 100%
);
pointer-events: none;
}
.home-widget--music-empty .widget-album,
.home-widget--music-empty .widget-music-placeholder {
-webkit-mask-image: linear-gradient(
to bottom,
#000 0%,
#000 42%,
rgb(0 0 0 / 42%) 72%,
transparent 100%
);
mask-image: linear-gradient(
to bottom,
#000 0%,
#000 42%,
rgb(0 0 0 / 42%) 72%,
transparent 100%
);
}
.home-widget-shell--large .home-widget--music {
align-content: start;
grid-template-columns: 1fr;
@@ -1168,12 +1201,12 @@ onBeforeUnmount(() => {
.widget-music-empty {
position: absolute;
right: 15px;
bottom: 11px;
bottom: 14px;
left: 15px;
z-index: 0;
z-index: 2;
margin: 0;
color: rgb(255 255 255 / 72%);
font-size: 13px;
font-size: 14px;
font-weight: 650;
line-height: 17px;
text-align: center;
+31
View File
@@ -111,6 +111,18 @@
color: var(--sky-text, #111827);
}
.sky-glass.sky-button--glass {
border-color: var(--sky-hairline, rgba(0, 0, 0, 0.2));
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
color: var(--sky-text, #000000);
box-shadow: var(--sky-shadow-glass);
}
.sky-glass.sky-button--glass:active:not(:disabled) {
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
filter: brightness(0.94);
}
.sky-button--danger {
background: var(--sky-danger, #dc2626);
}
@@ -2733,6 +2745,9 @@ label.sky-list-item__row {
.sky-fab--icon-only {
width: var(--sky-touch-target, 44px);
height: var(--sky-touch-target, 44px);
flex: none;
align-self: center;
padding: 0;
}
@@ -2741,6 +2756,16 @@ label.sky-list-item__row {
color: var(--sky-text, #000000);
}
.sky-glass.sky-fab--glass {
border: 1px solid var(--sky-hairline, rgba(0, 0, 0, 0.2));
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
color: var(--sky-text, #000000);
box-shadow: var(--sky-shadow-glass);
}
.sky-fab--glass .sky-fab__accent-layer,
.sky-fab--glass .sky-fab__dark-accent-layer,
.sky-fab--glass .sky-fab__surface-layer,
.sky-fab--neutral .sky-fab__accent-layer,
.sky-fab--neutral .sky-fab__dark-accent-layer,
.sky-fab--neutral .sky-fab__surface-layer {
@@ -2755,6 +2780,10 @@ label.sky-list-item__row {
background: var(--sky-glass-solid, rgba(247, 247, 248, 0.96));
}
.sky-glass.sky-fab--glass:active:not(:disabled) {
background: var(--sky-glass, rgba(255, 255, 255, 0.75));
}
.sky-fab--disabled {
cursor: default;
opacity: 0.42;
@@ -2825,6 +2854,8 @@ label.sky-list-item__row {
.sky-glass--interactive {
min-width: var(--sky-touch-target, 44px);
min-height: var(--sky-touch-target, 44px);
-webkit-backdrop-filter: blur(18px) saturate(145%);
backdrop-filter: blur(18px) saturate(145%);
cursor: pointer;
}
@@ -28,6 +28,30 @@ describe('SkyButton', () => {
expect(html).toContain('Continue')
})
it('renders interactive liquid glass buttons through the shared glass surface', async () => {
const html = await renderToString(
createSSRApp({
render: () =>
h(SkyButton, { glass: true, rounded: true }, () => 'Edit'),
}),
)
expect(html).toContain('sky-button--glass')
expect(html).toContain('sky-glass')
expect(html).toContain('sky-glass--interactive')
const controls = readFileSync(
fileURLToPath(new URL('../controls.css', import.meta.url)),
'utf8',
)
expect(controls).toMatch(
/\.sky-glass\.sky-button--glass\s*\{[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
)
expect(controls).toMatch(
/\.sky-glass--interactive\s*\{[^}]*-webkit-backdrop-filter:\s*blur\(18px\) saturate\(145%\);[^}]*backdrop-filter:\s*blur\(18px\) saturate\(145%\);/s,
)
})
it('keeps focus and pressed feedback on the contextual accent', () => {
const uiDirectory = fileURLToPath(new URL('..', import.meta.url))
const controls = readFileSync(`${uiDirectory}/controls.css`, 'utf8')
+36 -15
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import SkyGlass from './SkyGlass.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
@@ -9,6 +11,7 @@ const props = withDefaults(
clear?: boolean
component?: 'a' | 'button'
disabled?: boolean
glass?: boolean
href?: string
iconOnly?: boolean
inline?: boolean
@@ -26,6 +29,7 @@ const props = withDefaults(
clear: false,
component: 'button',
disabled: false,
glass: false,
href: undefined,
iconOnly: false,
inline: false,
@@ -44,6 +48,23 @@ const emit = defineEmits<{
click: [event: MouseEvent]
}>()
const buttonClasses = computed(() => [
`sky-button--${props.variant}`,
{
'sky-button--block': props.block,
'sky-button--clear': props.clear,
'sky-button--glass': props.glass,
'sky-button--icon-only': props.iconOnly,
'sky-button--inline': props.inline,
'sky-button--large': props.large,
'sky-button--outline': props.outline,
'sky-button--raised': props.raised,
'sky-button--rounded': props.rounded,
'sky-button--small': props.small && !props.large,
'sky-button--tonal': props.tonal,
},
])
const elementProps = computed<Record<string, unknown>>(() => {
if (props.component === 'a') {
return {
@@ -71,25 +92,25 @@ function handleClick(event: MouseEvent): void {
</script>
<template>
<SkyGlass
v-if="glass"
:component="component"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-button"
:class="buttonClasses"
:disabled="disabled"
:href="href"
:type="type"
@click="handleClick"
>
<slot />
</SkyGlass>
<component
v-else
:is="component"
v-bind="{ ...$attrs, ...elementProps }"
class="sky-button"
:class="[
`sky-button--${variant}`,
{
'sky-button--block': block,
'sky-button--clear': clear,
'sky-button--icon-only': iconOnly,
'sky-button--inline': inline,
'sky-button--large': large,
'sky-button--outline': outline,
'sky-button--raised': raised,
'sky-button--rounded': rounded,
'sky-button--small': small && !large,
'sky-button--tonal': tonal,
},
]"
:class="buttonClasses"
@click="handleClick"
>
<slot />
+29
View File
@@ -73,4 +73,33 @@ describe('SkyFab', () => {
/\.sky-glass\.sky-fab--neutral\s*\{[^}]*background:\s*var\(--sky-glass-solid/s,
)
})
it('offers a translucent glass variant for adjacent floating controls', async () => {
const html = await renderToString(
createSSRApp({
render: () => h(SkyFab, { ariaLabel: 'Create', variant: 'glass' }),
}),
)
expect(html).toContain('sky-fab--glass')
const controls = readFileSync(
fileURLToPath(new URL('../controls.css', import.meta.url)),
'utf8',
)
expect(controls).toMatch(
/\.sky-glass\.sky-fab--glass\s*\{[^}]*border:\s*1px solid var\(--sky-hairline[^}]*background:\s*var\(--sky-glass[^}]*box-shadow:\s*var\(--sky-shadow-glass\)/s,
)
})
it('keeps icon-only fabs perfectly square inside stretching toolbars', () => {
const controls = readFileSync(
fileURLToPath(new URL('../controls.css', import.meta.url)),
'utf8',
)
expect(controls).toMatch(
/\.sky-fab--icon-only\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*height:\s*var\(--sky-touch-target, 44px\);[^}]*flex:\s*none;[^}]*align-self:\s*center;/s,
)
})
})
+2 -1
View File
@@ -14,7 +14,7 @@ const props = withDefaults(
text?: string
textPosition?: 'after' | 'before'
type?: 'button' | 'reset' | 'submit'
variant?: 'neutral' | 'primary'
variant?: 'glass' | 'neutral' | 'primary'
}>(),
{
ariaLabel: '',
@@ -72,6 +72,7 @@ function handleClick(event: MouseEvent): void {
class="sky-fab"
:class="{
'sky-fab--disabled': disabled,
'sky-fab--glass': variant === 'glass',
'sky-fab--icon-only': !hasText,
'sky-fab--neutral': variant === 'neutral',
'sky-fab--with-text': hasText,
@@ -48,13 +48,11 @@ describe('AppStoreDetail contract', () => {
expect(previewSource).toContain('v-if="previewImage && screen < 3"')
expect(previewSource).toContain(':src="previewImage"')
expect(previewSource).toContain('store-detail-preview__screenshot')
expect(previewSource).not.toContain("visual.scene ===")
expect(previewSource).not.toContain('visual.scene ===')
expect(source).toContain('getAppStorePreviewVisual')
expect(source).toContain('Apps.appStore.previews.${props.app.id}')
expect(previewSource).toContain(':src="iconImage"')
expect(source).toMatch(
/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/,
)
expect(source).toMatch(/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/)
expect(previewSource).toContain('screen === 3')
expect(previewSource).toContain('screen === 4')
expect(previewSource).toContain('store-detail-preview__details')
@@ -71,6 +69,16 @@ describe('AppStoreDetail contract', () => {
expect(source).toContain('scrollToPreview(activePreviewIndex + 1)')
expect(source).toContain('details.previousPreview')
expect(source).toContain('details.nextPreview')
expect(source).toContain('.store-detail__toolbar button:hover')
expect(source).toContain('.store-detail__toolbar-button:hover')
})
it('uses shared liquid glass for toolbar and preview controls', () => {
expect(source).toContain("import { SkyButton } from '@/ui'")
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(4)
expect(source).toContain('class="store-detail__toolbar-button"')
expect(source).toContain('class="store-detail__preview-control"')
expect(source).toMatch(
/\.store-detail__preview-control\s*\{[^}]*width:\s*var\(--sky-touch-target\);[^}]*height:\s*var\(--sky-touch-target\);[^}]*border-radius:\s*50%;/s,
)
})
})
+37 -27
View File
@@ -1,15 +1,11 @@
<script setup lang="ts">
import {
ChevronLeft,
ChevronRight,
Share2,
Star,
} from 'lucide-vue-next'
import { ChevronLeft, ChevronRight, Share2, Star } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
import { SkyButton } from '@/ui'
import { getAppStorePreviewImage } from '@/utils/appStorePreviewImages'
import { getAppStorePreviewVisual } from '@/utils/appStorePreviews'
@@ -126,20 +122,28 @@ function updateActivePreview(): void {
<template>
<section class="store-detail" :style="detailStyle">
<header class="store-detail__toolbar">
<button
<SkyButton
glass
icon-only
rounded
class="store-detail__toolbar-button"
type="button"
:aria-label="phone.t('Common.back')"
@click="emit('back')"
>
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="store-detail__toolbar-button"
type="button"
:aria-label="phone.t('Apps.appStore.details.share')"
@click="emit('share')"
>
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section class="store-detail__hero">
@@ -212,22 +216,30 @@ function updateActivePreview(): void {
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
<div class="store-detail__preview-navigation">
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
<button
<SkyButton
glass
icon-only
rounded
class="store-detail__preview-control"
type="button"
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
:disabled="activePreviewIndex === 0"
@click="scrollToPreview(activePreviewIndex - 1)"
>
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="store-detail__preview-control"
type="button"
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
:disabled="activePreviewIndex === previewCount - 1"
@click="scrollToPreview(activePreviewIndex + 1)"
>
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
</button>
</SkyButton>
</div>
</header>
<div
@@ -286,7 +298,7 @@ function updateActivePreview(): void {
justify-content: space-between;
}
.store-detail__toolbar button {
.store-detail__toolbar-button {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
display: grid;
@@ -294,7 +306,6 @@ function updateActivePreview(): void {
border: 1px solid var(--sky-hairline);
border-radius: 50%;
color: var(--sky-text);
background: var(--sky-surface-variant);
transition:
background-color 100ms ease,
border-color 100ms ease,
@@ -302,7 +313,7 @@ function updateActivePreview(): void {
transform 100ms ease;
}
.store-detail__toolbar button:active {
.store-detail__toolbar-button:active {
transform: scale(0.94);
}
@@ -481,27 +492,28 @@ function updateActivePreview(): void {
text-align: center;
}
.store-detail__preview-navigation button {
width: 30px;
height: 30px;
.store-detail__preview-control {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
min-width: var(--sky-touch-target);
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 1px solid var(--sky-hairline);
border-radius: 50%;
padding: 0;
color: var(--sky-text);
background: var(--sky-surface-variant);
transition:
background-color 100ms ease,
color 100ms ease,
transform 100ms ease;
}
.store-detail__preview-navigation button:disabled {
.store-detail__preview-control:disabled {
opacity: 0.34;
}
.store-detail__preview-navigation button:active:not(:disabled) {
.store-detail__preview-control:active:not(:disabled) {
transform: scale(0.92);
}
@@ -520,16 +532,14 @@ function updateActivePreview(): void {
}
@media (hover: hover) {
.store-detail__toolbar button:hover {
.store-detail__toolbar-button:hover {
border-color: rgba(255, 255, 255, 0.16);
background: var(--sky-surface-tint);
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
transform: translateY(-1px);
}
.store-detail__preview-navigation button:hover:not(:disabled) {
.store-detail__preview-control:hover:not(:disabled) {
color: var(--sky-app-accent);
background: var(--sky-surface-tint);
transform: translateY(-1px);
}
}
@@ -52,7 +52,8 @@ describe('CalculatorApp layout contract', () => {
)
expect(source).toContain('calculator-history__nav-button--edit')
expect(source).toContain('calculator-history__nav-button--close')
expect(source).toContain('background: rgb(255 255 255 / 9%);')
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(2)
expect(source).toContain('--sky-glass: rgb(44 44 46 / 62%);')
expect(source).toContain('-webkit-backdrop-filter: none;')
expect(source).toContain('backdrop-filter: none;')
expect(source).toContain('min-width: 106px;')
@@ -62,7 +63,7 @@ describe('CalculatorApp layout contract', () => {
expect(source).toContain('place-items: center;')
expect(source).toContain('padding: 0;')
expect(source).toMatch(
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*background: rgb\(255 255 255 \/ 15%\);[^}]*transform: none;[^}]*filter: none;/s,
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*transform: none;[^}]*filter: brightness\(1\.08\);/s,
)
expect(source).not.toContain('class="calculator-history__edit"')
expect(source).not.toContain('class="calculator-history__close"')
+6 -6
View File
@@ -378,6 +378,7 @@ watch([() => calculator.display, expression], async () => {
>
<template #left>
<SkyButton
glass
class="calculator-history__nav-button calculator-history__nav-button--edit"
inline
rounded
@@ -397,6 +398,7 @@ watch([() => calculator.display, expression], async () => {
</template>
<template #right>
<SkyButton
glass
class="calculator-history__nav-button calculator-history__nav-button--close"
icon-only
rounded
@@ -721,16 +723,15 @@ watch([() => calculator.display, expression], async () => {
}
.calculator-history__navbar :deep(.calculator-history__nav-button) {
--sky-glass: rgb(44 44 46 / 62%);
--sky-hairline: rgb(255 255 255 / 14%);
height: 44px;
min-height: 44px;
background: rgb(255 255 255 / 9%);
color: #fff;
box-shadow: none;
}
.calculator-history__navbar :deep(.calculator-history__nav-button:active) {
background: rgb(255 255 255 / 14%);
filter: none;
filter: brightness(0.94);
}
.calculator-history__navbar :deep(.calculator-history__nav-button--edit) {
@@ -757,9 +758,8 @@ watch([() => calculator.display, expression], async () => {
@media (hover: hover) {
.calculator-history__navbar
:deep(.calculator-history__nav-button:hover:not(:disabled)) {
background: rgb(255 255 255 / 15%);
transform: none;
filter: none;
filter: brightness(1.08);
}
}
@@ -31,6 +31,14 @@ const cameraAnimations = readFileSync(
)
describe('Camera app controls', () => {
it('uses shared liquid glass for camera interaction buttons', () => {
expect(cameraView.match(/variant="glass"/g)).toHaveLength(5)
expect(cameraView).toMatch(
/<sky-glass\s+component="button"\s+class="camera-latest"/,
)
expect(cameraView).not.toContain('variant="neutral"')
})
it('uses the Sky UI moving segment for photo and video modes', () => {
expect(cameraView).toContain('SkySegmented')
expect(cameraView).toContain(':active-index="mode === \'photo\' ? 0 : 1"')
+42 -51
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { SkyFab, SkyAppPage } from '@/ui'
import { SkyAppPage, SkyButton, SkyFab, SkyGlass } from '@/ui'
import {
ArrowLeft,
Images,
@@ -421,13 +421,11 @@ onMounted(() => {
window.addEventListener('keyup', onKeyup)
window.addEventListener('message', onMessage)
void nuiCall('camera:setActive', { active: true })
void nuiCall<MediaConfig>('media:config').then(
(response) => {
if (response.success && response.data?.videoBitrateKbps) {
videoBitrateKbps.value = response.data.videoBitrateKbps
}
},
)
void nuiCall<MediaConfig>('media:config').then((response) => {
if (response.success && response.data?.videoBitrateKbps) {
videoBitrateKbps.value = response.data.videoBitrateKbps
}
})
void loadLatest()
startGameView()
})
@@ -486,22 +484,24 @@ onBeforeUnmount(() => {
<header class="camera-topbar">
<div class="camera-topbar-actions">
<button
<sky-fab
v-if="requestedMessageMedia"
class="camera-picker-back"
component="button"
class="camera-control camera-picker-back"
type="button"
variant="glass"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<ArrowLeft :size="20" />
</button>
<template #icon><ArrowLeft :size="20" /></template>
</sky-fab>
<sky-fab
v-else
component="button"
type="button"
class="camera-control"
:class="{ 'camera-control--flash-active': flashEnabled }"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.camera.flash')"
@click="toggleFlash"
>
@@ -516,7 +516,7 @@ onBeforeUnmount(() => {
type="button"
class="camera-control"
:class="{ 'camera-control--danger': !microphoneEnabled }"
variant="neutral"
variant="glass"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
@@ -543,8 +543,10 @@ onBeforeUnmount(() => {
<span v-else-if="pendingCount" class="camera-upload-pill">
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
</span>
<button
<SkyButton
v-else
glass
rounded
class="camera-focus-pill camera-lock-control"
:class="{ 'camera-lock-control--active': cameraLocked }"
type="button"
@@ -561,12 +563,12 @@ onBeforeUnmount(() => {
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
</button>
</SkyButton>
<sky-fab
component="button"
type="button"
class="camera-control"
variant="neutral"
variant="glass"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
@@ -588,9 +590,11 @@ onBeforeUnmount(() => {
<div class="camera-zoom-control">
<div class="camera-zoom-row">
<button
<SkyButton
v-for="zoom in zoomLevels"
:key="zoom"
glass
rounded
class="camera-zoom-pill"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
type="button"
@@ -599,13 +603,14 @@ onBeforeUnmount(() => {
@click="setZoom(zoom)"
>
{{ zoom }}x
</button>
</SkyButton>
</div>
</div>
<footer class="camera-controls">
<div class="camera-capture-row">
<button
<sky-glass
component="button"
class="camera-latest"
type="button"
:aria-label="phone.t('Apps.camera.openGallery')"
@@ -625,7 +630,7 @@ onBeforeUnmount(() => {
/>
<Video v-else-if="latestMedia" :size="22" />
<Images v-else :size="22" />
</button>
</sky-glass>
<button
class="camera-shutter"
@@ -650,7 +655,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="camera-control camera-selfie"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.camera.flip')"
@click="toggleFacing"
>
@@ -799,8 +804,9 @@ onBeforeUnmount(() => {
height: 44px;
}
.camera-control {
--sky-glass-solid: rgb(28 28 30 / 80%);
color: rgb(255 255 255 / 86%);
--sky-glass: rgb(28 28 30 / 58%);
--sky-hairline: rgb(255 255 255 / 16%);
color: rgb(255 255 255 / 92%) !important;
}
.camera-control--flash-active {
color: #ffd60a !important;
@@ -811,13 +817,6 @@ onBeforeUnmount(() => {
.camera-picker-back {
width: 44px;
height: 44px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: #1c1c1ecc;
color: #fff;
backdrop-filter: blur(16px);
}
.camera-control svg {
width: 21px;
@@ -831,7 +830,7 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg {
transform: rotate(90deg);
}
.camera-focus-pill,
.camera-focus-pill:not(.sky-button--glass),
.camera-upload-pill {
min-width: 0;
padding: 7px 10px;
@@ -853,7 +852,7 @@ onBeforeUnmount(() => {
}
.camera-lock-control {
min-height: 44px;
border: 0;
padding: 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -902,27 +901,20 @@ onBeforeUnmount(() => {
z-index: 4;
bottom: 196px;
left: 50%;
width: 140px;
padding: 4px 6px;
border-radius: 999px;
background: rgb(18 18 20 / 72%);
box-shadow: 0 8px 24px rgb(0 0 0 / 24%);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
width: auto;
transform: translateX(-50%);
}
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 6px;
gap: 8px;
}
.camera-zoom-pill {
width: 30px;
height: 26px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: #fff;
font-size: 10px;
text-align: center;
@@ -933,9 +925,6 @@ onBeforeUnmount(() => {
box-shadow 0.2s ease;
}
.camera-zoom-pill.active {
border-color: transparent;
background: rgb(44 44 46 / 88%);
box-shadow: 0 8px 16px rgb(0 0 0 / 30%);
color: #ffd60a;
}
.camera-controls {
@@ -956,15 +945,17 @@ onBeforeUnmount(() => {
padding: 0 24px 32px;
}
.camera-latest {
--sky-glass: rgb(28 28 30 / 58%);
--sky-hairline: rgb(255 255 255 / 16%);
width: 44px;
height: 44px;
overflow: hidden;
border: 0;
border-radius: 50%;
background: #111b;
background: var(--sky-glass);
color: #fff;
display: grid;
place-items: center;
box-shadow: var(--sky-shadow-glass);
}
.camera-selfie {
justify-self: end;
@@ -26,6 +26,12 @@ describe('FeatherApp Sky UI contract', () => {
expect(source).toContain('with-tabbar')
})
it('uses shared liquid glass for the floating compose action', () => {
expect(source).toMatch(
/<SkyFab[\s\S]*?class="feather-compose-fab"[\s\S]*?variant="glass"/,
)
})
it('gives likes and bookmarks a reduced-motion-safe pulse animation', () => {
expect(postCard).toContain(
"const reactionPulse = ref<'like' | 'bookmark' | null>(null)",
@@ -143,7 +149,9 @@ describe('FeatherApp Sky UI contract', () => {
})
it('keeps profile suggestion content styles off the follow button', () => {
expect(source).toContain('class="feather-profile-suggestion__profile"')
expect(source).toContain(
'class="feather-profile-suggestion__profile"',
)
expect(source).toMatch(
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
)
@@ -193,9 +201,7 @@ describe('FeatherApp Sky UI contract', () => {
expect(source).toContain('<SkyScrollRail')
expect(source).toContain('class="feather-profile-suggestions__rail"')
expect(source).toContain(':label="t(\'people\')"')
expect(source).toContain(
'class="feather-profile-suggestion__profile"',
)
expect(source).toContain('class="feather-profile-suggestion__profile"')
expect(source).toMatch(
/\.feather-app\.feather-app--active \.feather-profile-suggestion__profile\s*\{[^}]*width:\s*100%/s,
)
+10 -25
View File
@@ -2174,6 +2174,7 @@ onMounted(async () => {
component="button"
type="button"
class="feather-compose-fab"
variant="glass"
:aria-label="t('newPost')"
@click="openComposer()"
>
@@ -2331,7 +2332,8 @@ onMounted(async () => {
/>
<template v-if="mediaPreview.items.length > 1">
<SkyButton
clear
glass
icon-only
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--left"
:aria-label="t('previousImage')"
@@ -2340,7 +2342,8 @@ onMounted(async () => {
<ChevronLeft :size="20" :stroke-width="2.8" />
</SkyButton>
<SkyButton
clear
glass
icon-only
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--right"
:aria-label="t('nextImage')"
@@ -2389,22 +2392,15 @@ onMounted(async () => {
cursor: default;
}
.feather-media-preview__arrow {
--sky-app-accent: rgb(10 14 20 / 88%);
--sky-button-text: #fff;
position: absolute;
top: 50%;
width: 34px !important;
min-width: 34px;
height: 34px;
min-height: 34px;
border: 1.5px solid rgb(255 255 255 / 58%);
width: 44px !important;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
color: #fff !important;
background: rgb(10 14 20 / 88%) !important;
box-shadow:
0 5px 16px rgb(0 0 0 / 58%),
inset 0 0 0 1px rgb(255 255 255 / 8%);
backdrop-filter: blur(10px);
transform: translateY(-50%);
}
.feather-media-preview__arrow--left {
@@ -4486,7 +4482,6 @@ onMounted(async () => {
place-items: center;
}
.feather-compose-fab {
--sky-app-accent: #58a6ff;
position: absolute;
z-index: 12;
right: 14px;
@@ -4494,23 +4489,14 @@ onMounted(async () => {
width: 46px;
height: 46px;
min-width: 46px;
border: 1px solid color-mix(in srgb, var(--feather-blue) 55%, #fff);
color: #fff;
box-shadow:
0 9px 24px rgb(29 155 240 / 38%),
0 3px 8px rgb(0 0 0 / 22%),
inset 0 1px 0 rgb(255 255 255 / 32%);
color: var(--feather-blue);
transition:
transform 150ms ease,
box-shadow 150ms ease,
filter 150ms ease;
}
.feather-compose-fab:active {
filter: brightness(0.94);
transform: scale(0.94);
box-shadow:
0 4px 12px rgb(29 155 240 / 28%),
inset 0 1px 0 rgb(255 255 255 / 22%);
}
.feather-navigation__badge-anchor b {
position: absolute;
@@ -5415,7 +5401,6 @@ onMounted(async () => {
.feather-edit__photo-actions :deep(.sky-button) {
border-color: var(--feather-blue);
}
.feather-compose-fab,
.feather-edit__avatar {
border-color: #70c5fa;
}
+9 -7
View File
@@ -2037,14 +2037,17 @@ onBeforeUnmount(() => {
role="dialog"
aria-modal="true"
>
<button
<SkyButton
glass
icon-only
rounded
type="button"
class="flare-match-reveal__close"
:aria-label="phone.t('Common.close')"
@click="matchReveal = null"
>
<X />
</button>
</SkyButton>
<Flame class="flare-match-reveal__flame" fill="currentColor" />
<h2>{{ phone.t('Apps.flare.itsAMatch') }}</h2>
<p>
@@ -3419,14 +3422,13 @@ onBeforeUnmount(() => {
position: absolute;
top: 24px;
right: 18px;
width: 42px;
height: 42px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(0 0 0 / 18%);
}
.flare-match-reveal__flame {
width: 58px;
+10 -9
View File
@@ -2408,7 +2408,10 @@ onBeforeUnmount(() => {
alt=""
/>
<template v-if="selectedMediaItems.length > 1">
<button
<SkyButton
glass
icon-only
rounded
type="button"
class="compose-photo-preview__arrow compose-photo-preview__arrow--previous"
:disabled="composerPhotoIndex === 0"
@@ -2416,8 +2419,11 @@ onBeforeUnmount(() => {
@click="moveComposerPhoto(-1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
type="button"
class="compose-photo-preview__arrow compose-photo-preview__arrow--next"
:disabled="composerPhotoIndex === selectedMediaItems.length - 1"
@@ -2425,7 +2431,7 @@ onBeforeUnmount(() => {
@click="moveComposerPhoto(1)"
>
<ChevronRight />
</button>
</SkyButton>
<span class="compose-photo-preview__count">
{{ composerPhotoIndex + 1 }} / {{ selectedMediaItems.length }}
</span>
@@ -5872,12 +5878,7 @@ onBeforeUnmount(() => {
padding: 0;
display: grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 22%);
border-radius: 50%;
background: rgb(14 14 16 / 58%);
color: #fff;
box-shadow: 0 4px 14px rgb(0 0 0 / 24%);
backdrop-filter: blur(10px);
pointer-events: auto;
}
@@ -57,6 +57,21 @@ describe('GalleryApp import action', () => {
)
})
it('uses the shared app tab bar for gallery filters', () => {
expect(source).toContain('<SkyTabBar')
expect(source).toContain('class="gallery-filter-tabbar"')
expect(source).toContain('icons')
expect(source).toContain('<Images :size="21" />')
expect(source).toContain('<Image :size="21" />')
expect(source).toContain('<Video :size="21" />')
expect(source).toContain('<SkyTabButton')
expect(source).toContain(':active="filter === \'all\'"')
expect(source).toContain(':active="filter === \'photo\'"')
expect(source).toContain(':active="filter === \'video\'"')
expect(source).not.toContain('gallery-filter-navbar')
expect(source).not.toContain('<sky-segmented')
})
it('opens an accessible sort menu from the large header', () => {
expect(headerActions).toContain('<ListFilter')
expect(headerActions).toContain('aria-haspopup="menu"')
+32 -35
View File
@@ -9,8 +9,6 @@ import {
SkyNavbarBackLink,
SkyAppPage,
SkySpinner,
SkySegmented,
SkySegmentedButton,
SkyNotification,
} from '@/ui'
import {
@@ -19,11 +17,14 @@ import {
Download,
Globe2,
Heart,
Image,
Images,
Link2,
ListFilter,
Play,
Share2,
Trash2,
Video,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -35,6 +36,8 @@ import {
SkyButton,
SkyDropdown,
SkyNavbar,
SkyTabBar,
SkyTabButton,
SkyToolbar,
SkyToolbarPane,
} from '@/ui'
@@ -68,11 +71,6 @@ const developmentParameters = isDevelopment
const developmentApiEnabled = Boolean(developmentParameters?.has('apiPort'))
const developmentGalleryState =
developmentParameters?.get('galleryMock') ?? null
const filterItems = [
{ id: 'all', label: 'all' },
{ id: 'photo', label: 'photos' },
{ id: 'video', label: 'videos' },
] as const
const phone = usePhoneStore()
const easyShare = useEasyShareStore()
const messageMedia = useMessageMediaStore()
@@ -1378,28 +1376,35 @@ onBeforeUnmount(() => {
</div>
</div>
<sky-navbar
<SkyTabBar
v-if="!requestedMessageMedia && !selectionMode"
component="nav"
class="gallery-filter-navbar"
:aria-label="phone.t('Apps.photos.name')"
icons
labels
class="gallery-filter-tabbar"
:label="phone.t('Apps.photos.name')"
>
<template #subnavbar>
<sky-segmented strong rounded :data-active-filter="filter">
<sky-segmented-button
v-for="item in filterItems"
:key="item.id"
large
:active="filter === item.id"
:class="filter === item.id ? 'text-white' : 'text-[#8e8e93]'"
:aria-pressed="filter === item.id"
@click="filter = item.id"
>
{{ phone.t(`Apps.photos.filters.${item.label}`) }}
</sky-segmented-button>
</sky-segmented>
</template>
</sky-navbar>
<SkyTabButton
:active="filter === 'all'"
:label="phone.t('Apps.photos.filters.all')"
@click="filter = 'all'"
>
<template #icon><Images :size="21" /></template>
</SkyTabButton>
<SkyTabButton
:active="filter === 'photo'"
:label="phone.t('Apps.photos.filters.photos')"
@click="filter = 'photo'"
>
<template #icon><Image :size="21" /></template>
</SkyTabButton>
<SkyTabButton
:active="filter === 'video'"
:label="phone.t('Apps.photos.filters.videos')"
@click="filter = 'video'"
>
<template #icon><Video :size="21" /></template>
</SkyTabButton>
</SkyTabBar>
<SkyToolbar
v-if="selectionMode"
@@ -1694,14 +1699,6 @@ onBeforeUnmount(() => {
flex-direction: column;
overflow-y: auto;
}
.gallery-filter-navbar {
position: absolute !important;
top: auto !important;
bottom: 24px;
}
.gallery-filter-navbar :deep(> div:nth-child(-n + 2)) {
display: none;
}
.gallery-grid {
position: relative;
display: grid;
@@ -112,7 +112,7 @@ describe('MailApp Sky UI contract', () => {
expect(toolbar).toContain('{{ activeFilterSummary }}')
expect(toolbar).toContain('<sky-searchbar')
expect(toolbar).toContain('@update:model-value="updateSearch"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('variant="glass"')
expect(toolbar).toContain('@click="beginCompose()"')
expect(source).not.toContain('class="mail-search"')
expect(source).not.toContain('class="mail-compose-fab"')
@@ -121,6 +121,11 @@ describe('MailApp Sky UI contract', () => {
)
})
it('uses liquid glass for mailbox, filter and compose floating actions', () => {
expect(source.match(/variant="glass"/g)).toHaveLength(5)
expect(source).not.toContain('variant="neutral"')
})
it('offers multiple filter criteria in one scrolling Sky UI modal', () => {
const filterStart = source.indexOf('class="mail-modal mail-filter-modal"')
const filterEnd = source.indexOf('</sky-sheet>', filterStart)
+5 -5
View File
@@ -1246,7 +1246,7 @@ onBeforeUnmount(() => {
v-if="editingMailboxes"
component="button"
type="button"
variant="neutral"
variant="glass"
:text="phone.t('Apps.mail.newMailbox')"
:aria-label="phone.t('Apps.mail.newMailbox')"
@click="beginMailboxCreate"
@@ -1257,7 +1257,7 @@ onBeforeUnmount(() => {
v-else
component="button"
type="button"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.mail.compose')"
@click="beginCompose()"
>
@@ -1426,7 +1426,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="mail-filter-fab mail-filter-fab--active"
variant="neutral"
variant="glass"
:disabled="!canFilterFolder"
:aria-label="filterButtonLabel"
:aria-pressed="true"
@@ -1448,7 +1448,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="mail-filter-fab"
variant="neutral"
variant="glass"
:disabled="!canFilterFolder"
:aria-label="filterButtonLabel"
:aria-pressed="false"
@@ -1467,7 +1467,7 @@ onBeforeUnmount(() => {
<sky-fab
component="button"
type="button"
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.mail.compose')"
@click="beginCompose()"
>
@@ -15,6 +15,17 @@ describe('MapApp interaction contract', () => {
expect(source).not.toContain('<EasyShareSheet')
})
it('uses translucent liquid glass for every floating map control', () => {
const controlsStart = source.indexOf('class="map-controls"')
const controlsEnd = source.indexOf('</nav>', controlsStart)
const controls = source.slice(controlsStart, controlsEnd)
expect(controls.match(/variant="glass"/g)).toHaveLength(4)
expect(controls).not.toContain('variant="neutral"')
expect(controls).not.toContain('variant="primary"')
expect(source).toContain('--sky-glass: rgb(247 247 248 / 72%);')
})
it('keeps zoom and panning inside scale-aware map bounds', () => {
expect(source).toContain('minimumCoverZoom(metrics, baseMinZoom)')
expect(source).toContain('clampMapPan(nextPan, nextZoom, metrics)')
+9 -7
View File
@@ -667,7 +667,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--share"
variant="primary"
variant="glass"
:aria-label="phone.t('Apps.easyShare.name')"
@click="shareCurrentLocation"
>
@@ -679,7 +679,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control"
variant="neutral"
variant="glass"
:aria-label="`${phone.t('Apps.map.switchStyle')}: ${phone.t(`Apps.map.styles.${mapStyle}`)}`"
@click="cycleMapStyle"
>
@@ -691,7 +691,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--marker"
variant="neutral"
variant="glass"
:disabled="placingMarker"
:aria-label="phone.t('Apps.map.addMarker')"
@click="startMarkerPlacement"
@@ -704,7 +704,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--location"
variant="neutral"
variant="glass"
:disabled="locating"
:aria-label="phone.t('Apps.map.currentLocation')"
@click="loadCurrentLocation(true)"
@@ -1015,14 +1015,16 @@ onBeforeUnmount(() => {
}
.map-control {
--sky-glass-solid: rgb(247 247 248 / 92%);
--sky-glass: rgb(247 247 248 / 72%);
--sky-hairline: rgb(0 0 0 / 16%);
color: #151515;
}
.map-control--share {
color: #fff;
color: #007aff;
}
.sky-app-page--dark .map-control {
--sky-glass-solid: rgb(44 44 46 / 88%);
--sky-glass: rgb(44 44 46 / 62%);
--sky-hairline: rgb(255 255 255 / 16%);
color: #fff;
}
+11 -9
View File
@@ -24,6 +24,7 @@ import type {
MemoryDifficulty,
} from '@/features/games/memory/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const memory = useMemoryStore()
@@ -135,7 +136,7 @@ onBeforeUnmount(() => {
</div>
<div class="memory-header__actions">
<Sparkles :size="23" aria-hidden="true" />
<button
<SkyButton glass icon-only rounded
type="button"
:aria-label="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
:title="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
@@ -143,7 +144,7 @@ onBeforeUnmount(() => {
>
<Volume2 v-if="memory.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</div>
</header>
@@ -182,7 +183,7 @@ onBeforeUnmount(() => {
<section v-else class="memory-game">
<div class="memory-stats">
<button
<SkyButton glass icon-only rounded
type="button"
class="memory-menu-button"
:aria-label="phone.t('Apps.memory.backToMenu')"
@@ -190,7 +191,7 @@ onBeforeUnmount(() => {
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.6" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.memory.time') }}</span>
<strong>{{ formatTime(memory.elapsedMs) }}</strong>
@@ -316,7 +317,7 @@ onBeforeUnmount(() => {
}
.memory-header__actions > svg,
.memory-header__actions button {
.memory-header__actions button:not(.sky-button--glass) {
box-sizing: content-box;
padding: 9px;
border: 0;
@@ -325,6 +326,8 @@ onBeforeUnmount(() => {
background: rgb(255 255 255 / 54%);
}
.memory-header__actions .sky-button--glass { color: #7658c7; }
.memory-header__actions button {
display: grid;
place-items: center;
@@ -441,17 +444,16 @@ onBeforeUnmount(() => {
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats button:not(.sky-button--glass) { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats .memory-menu-button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
justify-self: start;
padding: 0;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 48%);
color: #7052bf;
cursor: pointer;
}
@@ -0,0 +1,29 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./MemosApp.vue', import.meta.url), 'utf8')
describe('MemosApp layout', () => {
it('uses the shared horizontal page gutter in list and detail views', () => {
expect(source).toContain(
'<SkyScrollArea padded class="memos-page__content">',
)
expect(source).toContain(
'<SkyScrollArea padded class="memo-detail-scroll">',
)
})
it('optically centers playback speed labels without moving the controls', () => {
expect(source).toContain('<span class="memo-speed-label">')
expect(source).toContain('.memo-speed-label {')
expect(source).toContain('transform: translateY(1px)')
})
it('centers the skip duration inside a dedicated icon frame', () => {
expect(source).toContain('<span class="memo-skip-icon" aria-hidden="true">')
expect(source).toContain('<RotateCcw :size="24" />')
expect(source).toContain('<RotateCw :size="24" />')
expect(source).toContain('.memo-skip-icon small {')
expect(source).toContain('place-items: center')
})
})
+32 -9
View File
@@ -457,7 +457,7 @@ onBeforeUnmount(() => {
:title="phone.t('Apps.memos.name')"
/>
<SkyScrollArea class="memos-page__content">
<SkyScrollArea padded class="memos-page__content">
<div v-if="memos.loading" class="memos-loading">
<SkySpinner :label="phone.t('Common.loading')" :size="24" />
</div>
@@ -581,7 +581,7 @@ onBeforeUnmount(() => {
</template>
</SkyNavbar>
<SkyScrollArea class="memo-detail-scroll">
<SkyScrollArea padded class="memo-detail-scroll">
<SkyGlass class="memo-fields-glass" :highlight="false">
<SkyList nested :dividers="false">
<SkyField
@@ -648,8 +648,10 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.memos.skipBack')"
@click="skipPlayback(-15)"
>
<RotateCcw :size="22" aria-hidden="true" />
<small>15</small>
<span class="memo-skip-icon" aria-hidden="true">
<RotateCcw :size="24" />
<small>15</small>
</span>
</SkyButton>
<SkyButton
rounded
@@ -678,8 +680,10 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.memos.skipForward')"
@click="skipPlayback(15)"
>
<RotateCw :size="22" aria-hidden="true" />
<small>15</small>
<span class="memo-skip-icon" aria-hidden="true">
<RotateCw :size="24" />
<small>15</small>
</span>
</SkyButton>
</SkyGlass>
@@ -699,7 +703,7 @@ onBeforeUnmount(() => {
:active="playbackRate === rate"
@click="setPlaybackRate(rate)"
>
{{ rate }}×
<span class="memo-speed-label">{{ rate }}×</span>
</SkySegmentedButton>
</SkySegmented>
</SkyGlass>
@@ -1062,10 +1066,25 @@ onBeforeUnmount(() => {
padding: 0;
}
.memo-player-control small {
.memo-skip-icon {
position: relative;
width: 24px;
height: 24px;
display: grid;
flex: none;
place-items: center;
}
.memo-skip-icon small {
position: absolute;
font-size: 8px;
inset: 0;
display: grid;
padding-top: 1px;
font-size: 7px;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
place-items: center;
}
.memo-player-control--main {
@@ -1090,6 +1109,10 @@ onBeforeUnmount(() => {
background: transparent;
}
.memo-speed-label {
transform: translateY(1px);
}
.memo-delete-action {
min-height: 48px;
gap: 8px;
@@ -90,7 +90,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(searchbarStart).toBeGreaterThan(-1)
expect(fabStart).toBeGreaterThan(searchbarStart)
expect(toolbar).toContain('v-model="search"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('variant="glass"')
expect(toolbar).toContain('@click="beginCompose"')
expect(toolbar).toContain('<SquarePen :size="21" />')
expect(inbox).not.toContain('messages-sky-compose-navigation')
@@ -186,6 +186,13 @@ describe('MessagesApp Sky UI contract', () => {
expect(source).toContain(
'aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`',
)
expect(source).toContain('const gifColumns = computed')
expect(source).toContain('class="messages-gif-grid"')
expect(source).toContain('class="messages-gif-column"')
expect(source).toContain('class="messages-gif-result"')
expect(styles).toMatch(
/\.messages-media-picker__gifs--masonry \.messages-gif-result img\s*\{[^}]*object-fit:\s*cover/s,
)
})
it('opens contact sharing in a draggable Sky UI bottom sheet', () => {
+39 -14
View File
@@ -126,6 +126,19 @@ const gifLoading = ref(false)
const gifError = ref<string | null>(null)
const gifHasMore = ref(true)
const gifNextOffset = ref(0)
const gifColumns = computed<[GifSearchResult[], GifSearchResult[]]>(() => {
const columns: [GifSearchResult[], GifSearchResult[]] = [[], []]
const columnHeights = [0, 0]
for (const gif of gifResults.value) {
const columnIndex = columnHeights[0] <= columnHeights[1] ? 0 : 1
columns[columnIndex].push(gif)
columnHeights[columnIndex] +=
Math.max(1, gif.height) / Math.max(1, gif.width)
}
return columns
})
const recording = ref(false)
const recordingStarting = ref(false)
const recordingElapsedMs = ref(0)
@@ -1235,7 +1248,7 @@ onBeforeUnmount(() => {
:clear-label="phone.t('Common.clear')"
/>
<SkyFab
variant="neutral"
variant="glass"
:aria-label="phone.t('Apps.messages.compose')"
@click="beginCompose"
>
@@ -1714,7 +1727,10 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.messages.noContactsToShare') }}
</p>
</SkyList>
<div v-else class="messages-media-picker__gifs">
<div
v-else
class="messages-media-picker__gifs messages-media-picker__gifs--masonry"
>
<SkySearchbar
v-model="gifQuery"
class="messages-gif-search"
@@ -1724,18 +1740,27 @@ onBeforeUnmount(() => {
@input="queueGifSearch"
@clear="queueGifSearch"
/>
<button
v-for="gif in gifResults"
:key="gif.id"
type="button"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@click="sendAttachment('gif', gif.url)"
>
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
</button>
<div v-if="gifResults.length" class="messages-gif-grid">
<div
v-for="(column, columnIndex) in gifColumns"
:key="columnIndex"
class="messages-gif-column"
>
<button
v-for="gif in column"
:key="gif.id"
type="button"
class="messages-gif-result"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@click="sendAttachment('gif', gif.url)"
>
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
</button>
</div>
</div>
<button
v-if="gifResults.length && gifHasMore && !gifLoading"
type="button"
+12 -8
View File
@@ -23,6 +23,7 @@ import type {
MinesweeperDifficulty,
} from '@/features/games/minesweeper/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const minesweeper = useMinesweeperStore()
@@ -208,7 +209,7 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
:aria-label="
phone.t(
@@ -221,7 +222,7 @@ onBeforeUnmount(() => {
>
<Volume2 v-if="minesweeper.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section v-if="minesweeper.menuOpen" class="minesweeper-menu">
@@ -275,7 +276,7 @@ onBeforeUnmount(() => {
}"
>
<div class="minesweeper-toolbar">
<button
<SkyButton glass icon-only rounded
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.backToMenu')"
@@ -283,7 +284,7 @@ onBeforeUnmount(() => {
@click.stop="minesweeper.showMenu()"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.minesweeper.mines') }}</span>
<strong>{{ minesRemaining }}</strong>
@@ -292,14 +293,14 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.minesweeper.time') }}</span>
<strong>{{ formatTime(minesweeper.elapsedMs) }}</strong>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.restart')"
@click="restart"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</button>
</SkyButton>
</div>
<div
@@ -438,8 +439,8 @@ onBeforeUnmount(() => {
.minesweeper-header span { display: block; color: #59878a; font-size: 9px; font-weight: 800; letter-spacing: 1.1px; text-transform: uppercase; }
.minesweeper-header h1 { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.7px; }
.minesweeper-header button,
.minesweeper-toolbar__icon {
.minesweeper-header button:not(.sky-button--glass),
.minesweeper-toolbar__icon:not(.sky-button--glass) {
width: 36px;
height: 36px;
display: grid;
@@ -452,6 +453,9 @@ onBeforeUnmount(() => {
box-shadow: 0 4px 10px rgb(23 73 75 / 8%);
}
.minesweeper-header .sky-button--glass { color: #246871; }
.minesweeper-toolbar .sky-button--glass { --sky-touch-target: 32px; width: 32px; height: 32px; color: #246871; }
.minesweeper-menu {
height: calc(100% - 55px);
display: flex;
+23 -11
View File
@@ -25,6 +25,7 @@ import type {
NeonDropPieceKind,
} from '@/features/games/neon-drop/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
type RenderCell = {
active: boolean
@@ -206,7 +207,10 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
</div>
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="
phone.t(
@@ -219,7 +223,7 @@ onBeforeUnmount(() => {
v-else
:size="18"
/>
</button>
</SkyButton>
</header>
<section v-if="neon.menuOpen" class="neon-menu">
@@ -274,13 +278,16 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="neon-game">
<div class="neon-toolbar">
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.neonDrop.backToMenu')"
@click="neon.showMenu()"
>
<ChevronLeft :size="19" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.neonDrop.score') }}</span
><strong>{{ game.score }}</strong>
@@ -289,7 +296,10 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.lines') }}</span
><strong>{{ game.lines }}</strong>
</div>
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.neonDrop.pause')"
@click="togglePause"
@@ -299,7 +309,7 @@ onBeforeUnmount(() => {
:size="16"
fill="currentColor"
/><Play v-else :size="16" fill="currentColor" />
</button>
</SkyButton>
</div>
<div class="neon-play-area">
@@ -403,8 +413,8 @@ onBeforeUnmount(() => {
font-size: 32px;
line-height: 1;
}
.neon-header button,
.neon-toolbar button {
.neon-header button:not(.sky-button--glass),
.neon-toolbar button:not(.sky-button--glass) {
width: 35px;
height: 35px;
display: grid;
@@ -415,6 +425,10 @@ onBeforeUnmount(() => {
color: #fff;
background: #ffffff0d;
}
.neon-header .sky-button--glass,
.neon-toolbar .sky-button--glass {
color: #fff;
}
.neon-menu {
height: calc(100% - 54px);
display: flex;
@@ -614,11 +628,9 @@ onBeforeUnmount(() => {
line-height: 20px;
}
.neon-toolbar button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.neon-play-area {
position: absolute;
@@ -10,10 +10,11 @@ const menuSource = source.slice(
source.indexOf('<SkyActionSheet'),
source.indexOf('</SkyActionSheet>') + '</SkyActionSheet>'.length,
)
const listStart = source.search(
/<sky-app-page\r?\n\s+v-if="!editorOpened"/,
const listStart = source.search(/<sky-app-page\r?\n\s+v-if="!editorOpened"/)
const listSource = source.slice(
listStart,
source.indexOf('<sky-app-page v-else'),
)
const listSource = source.slice(listStart, source.indexOf('<sky-app-page v-else'))
describe('NotesApp list controls', () => {
it('places the Sky searchbar and create action together at the bottom', () => {
@@ -27,7 +28,7 @@ describe('NotesApp list controls', () => {
expect(composerSource).toContain('<SkySearchbar')
expect(composerSource).toContain('v-model="searchQuery"')
expect(composerSource).toContain('<SkyFab')
expect(composerSource).toContain('variant="neutral"')
expect(composerSource).toContain('variant="glass"')
expect(composerSource).toContain('@click="createNote"')
expect(composerSource).not.toContain('notes-search')
expect(composerSource).not.toContain('notes-create-fab')
@@ -37,6 +38,20 @@ describe('NotesApp list controls', () => {
})
})
describe('NotesApp headers', () => {
it('keeps list and editor headers at the shared app height', () => {
expect(source).toContain('class="notes-list-navbar"')
expect(source).toContain(
'.notes-list-navbar.sky-navbar--large.sky-navbar--no-navigation',
)
expect(source).toContain(
'padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3))',
)
expect(source).toContain('class="notes-editor-page !pb-0"')
expect(source).not.toContain('notes-editor-page !pt-[44px]')
})
})
describe('NotesApp more menu', () => {
it('uses the shared Feather-style action sheet', () => {
expect(menuSource).toContain(
+13 -2
View File
@@ -206,6 +206,7 @@ function shareNote(): void {
:aria-label="phone.t('Apps.notes.name')"
>
<sky-navbar
class="notes-list-navbar"
variant="large"
transparent
:title="phone.t('Apps.notes.name')"
@@ -273,7 +274,7 @@ function shareNote(): void {
/>
<SkyFab
:aria-label="phone.t('Apps.notes.newNote')"
variant="neutral"
variant="glass"
@click="createNote"
>
<template #icon>
@@ -305,7 +306,7 @@ function shareNote(): void {
</SkyDialog>
</sky-app-page>
<sky-app-page v-else class="notes-editor-page !pt-[44px] !pb-0">
<sky-app-page v-else class="notes-editor-page !pb-0">
<sky-navbar :title="phone.t('Apps.notes.note')">
<template #left>
<sky-navbar-back-link
@@ -386,6 +387,16 @@ function shareNote(): void {
overflow: hidden;
}
:deep(.notes-list-navbar.sky-navbar--large) {
min-height: calc(
var(--sky-navbar-safe-area-top) + var(--sky-navbar-large-title-height)
);
}
:deep(.notes-list-navbar.sky-navbar--large.sky-navbar--no-navigation) {
padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3));
}
.notes-delete-confirm {
color: var(--sky-danger);
background: var(--sky-danger-soft);
+12 -8
View File
@@ -14,6 +14,7 @@ import type {
NumberMergeTile,
} from '@/features/games/number-merge/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const numberMerge = useNumberMergeStore()
@@ -135,7 +136,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
:aria-label="
phone.t(
@@ -155,7 +156,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
>
<Volume2 v-if="numberMerge.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section v-if="numberMerge.menuOpen" class="number-merge-menu">
@@ -210,7 +211,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<section v-else-if="game" class="number-merge-game">
<div class="number-merge-toolbar">
<button
<SkyButton glass icon-only rounded
type="button"
class="number-merge-toolbar__icon"
:aria-label="phone.t('Apps.numberMerge.backToMenu')"
@@ -218,7 +219,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
@click="numberMerge.showMenu"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.numberMerge.score') }}</span>
<strong>{{ formatScore(game.score) }}</strong>
@@ -227,7 +228,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<span>{{ phone.t('Apps.numberMerge.best') }}</span>
<strong>{{ formatScore(numberMerge.bestScore) }}</strong>
</div>
<button
<SkyButton glass icon-only rounded
type="button"
class="number-merge-toolbar__icon number-merge-toolbar__restart"
:aria-label="phone.t('Apps.numberMerge.newGame')"
@@ -235,7 +236,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
@click="requestNewGame"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</button>
</SkyButton>
</div>
<div
@@ -373,8 +374,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
overflow-wrap: anywhere;
}
.number-merge-header button,
.number-merge-toolbar__icon {
.number-merge-header button:not(.sky-button--glass),
.number-merge-toolbar__icon:not(.sky-button--glass) {
width: 36px;
height: 36px;
display: grid;
@@ -388,6 +389,9 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
cursor: pointer;
}
.number-merge-header .sky-button--glass { color: #784c3c; }
.number-merge-toolbar .sky-button--glass { --sky-touch-target: 32px; width: 32px; height: 32px; color: #784c3c; }
.number-merge-menu {
height: calc(100% - 55px);
min-height: 0;
@@ -16,7 +16,45 @@ describe('PhoneApp EasyShare contract', () => {
it('uses the shared full-width Sky tab bar for phone sections', () => {
expect(source).toContain('<sky-tab-bar')
expect(source).toContain('<sky-tab-button')
expect(source).not.toContain('<sky-segmented')
})
it('uses shared interactive liquid glass surfaces for phone controls', () => {
expect(source).toMatch(
/<sky-button\s+glass\s+rounded\s+class="phone-detail-header-button phone-detail-back"/,
)
expect(source).toMatch(
/<sky-button\s+glass\s+v-for="action in contactProfileActions"[\s\S]*?icon-only[\s\S]*?class="phone-profile-action"/,
)
expect(source).toContain(
'width: var(--phone-profile-action-size) !important;',
)
expect(source).toContain(
'height: var(--phone-profile-action-size) !important;',
)
expect(source).toMatch(
/<sky-glass\s+v-for="key in keypadKeys"[\s\S]*?component="button"[\s\S]*?class="phone-keypad-key"/,
)
expect(source).toContain('class="phone-contacts-add"')
expect(source).toContain('class="phone-recents-search"')
const recentsFilter = source.slice(
source.indexOf('<sky-segmented'),
source.indexOf('</sky-segmented>') + '</sky-segmented>'.length,
)
expect(recentsFilter).toContain('class="phone-recents-filter"')
expect(recentsFilter).toContain('navigation')
expect(recentsFilter).toContain('strong')
expect(recentsFilter.match(/<sky-segmented-button/g)).toHaveLength(2)
expect(source).toContain(
'background: var(--sky-tabbar-highlight-background);',
)
expect(source).toContain(
'grid-template-columns: 52px minmax(0, 1fr) auto 44px;',
)
expect(source).not.toMatch(
/#(?:007aff|0a84ff|195287|22527d|25458e|2a468f|2f4a98|4b92d1|55aaff|5b91c2|64a8ff|68adff)/i,
)
expect(source).not.toContain('rgba(10, 132, 255')
})
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
File diff suppressed because it is too large Load Diff
+38 -22
View File
@@ -1062,14 +1062,20 @@ onBeforeUnmount(() => {
selectedPost.media.length
}}</span
>
<button
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--left"
:disabled="(carouselIndexes[selectedPost.id] ?? 0) === 0"
@click="moveCarousel(selectedPost, -1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--right"
:disabled="
(carouselIndexes[selectedPost.id] ?? 0) ===
@@ -1078,7 +1084,7 @@ onBeforeUnmount(() => {
@click="moveCarousel(selectedPost, 1)"
>
<ChevronRight />
</button>
</SkyButton>
<div class="ps-dots">
<span
v-for="(_, index) in selectedPost.media"
@@ -1271,14 +1277,20 @@ onBeforeUnmount(() => {
post.media.length
}}</span
>
<button
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--left"
:disabled="(carouselIndexes[post.id] ?? 0) === 0"
@click="moveCarousel(post, -1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-carousel-button ps-carousel-button--right"
:disabled="
(carouselIndexes[post.id] ?? 0) === post.media.length - 1
@@ -1286,7 +1298,7 @@ onBeforeUnmount(() => {
@click="moveCarousel(post, 1)"
>
<ChevronRight />
</button>
</SkyButton>
<div class="ps-dots">
<span
v-for="(_, index) in post.media"
@@ -1487,22 +1499,28 @@ onBeforeUnmount(() => {
</button>
</div>
<template v-if="selectedMedia.length > 1">
<button
<SkyButton
glass
icon-only
rounded
class="ps-selection-arrow ps-selection-arrow--left"
:aria-label="t('previousPhoto')"
:disabled="composePreviewIndex === 0"
@click="moveComposePreview(-1)"
>
<ChevronLeft />
</button>
<button
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="ps-selection-arrow ps-selection-arrow--right"
:aria-label="t('nextPhoto')"
:disabled="composePreviewIndex === selectedMedia.length - 1"
@click="moveComposePreview(1)"
>
<ChevronRight />
</button>
</SkyButton>
<span class="ps-selection-counter">
{{ composePreviewIndex + 1 }}/{{ selectedMedia.length }}
</span>
@@ -2858,12 +2876,11 @@ button {
top: 50%;
display: grid;
place-items: center;
width: 34px;
height: 34px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
background: rgba(0, 0, 0, 0.58);
color: white;
transform: translateY(-50%);
}
@@ -3191,12 +3208,11 @@ button {
top: 50%;
display: grid;
place-items: center;
width: 34px;
height: 34px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.62);
color: white;
transform: translateY(-50%);
}
+7 -6
View File
@@ -9,6 +9,7 @@ import SkyFlappyBird from '@/features/games/sky-flappy/SkyFlappyBird.vue'
import { useSkyFlappyStore } from '@/features/games/sky-flappy/store'
import type { SkyFlappyDesign, SkyFlappyObstacle } from '@/features/games/sky-flappy/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const flappy = useSkyFlappyStore()
@@ -116,9 +117,9 @@ onBeforeUnmount(() => {
>
<header v-if="flappy.menuOpen" class="flappy-header">
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
</button>
</SkyButton>
</header>
<section v-if="flappy.menuOpen" class="flappy-menu">
@@ -141,10 +142,10 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="flappy-game">
<div class="flappy-toolbar">
<button type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></button>
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></SkyButton>
<div><span>{{ phone.t('Apps.skyFlappy.score') }}</span><strong>{{ game.score }}</strong></div>
<div><span>{{ phone.t('Apps.skyFlappy.best') }}</span><strong>{{ flappy.highScore }}</strong></div>
<button type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></button>
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></SkyButton>
</div>
<button type="button" class="flappy-stage" :class="{ 'flappy-stage--crashed': game.status === 'over' && !gameOverVisible }" :aria-label="phone.t('Apps.skyFlappy.flap')" @pointerdown.stop.prevent="flap">
@@ -169,7 +170,7 @@ onBeforeUnmount(() => {
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
.flappy-app--playing { padding:0; }
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 28px #223c6d35,0 18px 32px #101b3b66}.flappy-menu__hero::before{position:absolute;z-index:0;top:22px;left:25px;width:39px;height:39px;border-radius:50%;background:#ffe8a7cc;box-shadow:0 0 18px #fff1b46b;content:""}.flappy-menu__hero::after{position:absolute;z-index:1;bottom:30px;left:-12px;width:68px;height:18px;border-radius:999px;background:#ffffff3d;box-shadow:27px -9px 0 3px #ffffff36,57px 1px 0 -2px #ffffff2c;content:""}.flappy-menu__tower{position:absolute;z-index:3;right:10px;width:42px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 30%),var(--tower));box-shadow:inset 7px 0 0 #ffffff22,inset -6px 0 9px #0003}.flappy-menu__tower::after{position:absolute;left:-8px;width:58px;height:22px;border:3px solid color-mix(in srgb,var(--tower),black 22%);border-radius:7px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 36%),var(--tower));box-shadow:inset 0 4px 0 #ffffff26;content:""}.flappy-menu__tower--top{top:0;height:61px;border-radius:0 0 5px 5px}.flappy-menu__tower--top::after{bottom:-11px}.flappy-menu__tower--bottom{bottom:0;height:67px;border-radius:5px 5px 0 0}.flappy-menu__tower--bottom::after{top:-11px}.flappy-menu__trail{position:absolute;z-index:2;top:102px;left:11px;width:46px;height:4px;border-radius:99px;background:#edfbffb8;box-shadow:13px 12px 0 -1px #e9faff8c,6px -12px 0 -1px #e9faff73;animation:flappy-trail 1s ease-in-out infinite}.sky-bird{position:absolute;z-index:4;width:66px;height:46px;overflow:visible;filter:drop-shadow(0 4px 5px #071d3e66)}.sky-bird :deep(path){stroke-linecap:round;stroke-linejoin:round}.sky-bird :deep(.sky-flappy-bird__far-wing){fill:#2d7188;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__far-wing path:last-child){fill:none;stroke:#8ec6ce;stroke-width:1.4;opacity:.65}.sky-bird :deep(.sky-flappy-bird__tail){fill:#285e78;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__body){fill:url(#sky-bird-body);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__belly){fill:#b7e5df;opacity:.84}.sky-bird :deep(.sky-flappy-bird__neck){fill:#377f98}.sky-bird :deep(.sky-flappy-bird__wing){transform-box:view-box;transform-origin:52px 38px}.sky-bird :deep(.sky-flappy-bird__wing-shape){fill:url(#sky-bird-wing);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__feather){fill:none;stroke:#d4f0ed;stroke-width:1.5;opacity:.78}.sky-bird :deep(.sky-flappy-bird__face){fill:#d8f0e9}.sky-bird :deep(.sky-flappy-bird__eye-ring){fill:#f5fbf7}.sky-bird :deep(.sky-flappy-bird__eye){fill:#102d3d}.sky-bird :deep(.sky-flappy-bird__beak){fill:#f2a84b;stroke:#6f4727;stroke-width:1.5}.sky-bird--hero{top:72px;left:42px;width:100px;height:69px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.sky-bird--hero :deep(.sky-flappy-bird__wing){animation:flappy-bird-glide 1.1s ease-in-out infinite}.sky-bird--hero :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-glide 1.1s ease-in-out infinite}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-secondary{width:100%;min-height:43px;display:flex;align-items:center;justify-content:center;gap:7px;border-radius:14px;font-size:11px;font-weight:850}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
.flappy-game{position:absolute;inset:0}.flappy-toolbar{position:absolute;z-index:10;top:48px;right:14px;left:14px;height:42px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px;padding:4px 6px;border:1px solid #ffffff2b;border-radius:22px;background:#263c6da8;box-shadow:0 8px 24px #10193455;backdrop-filter:blur(14px)}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-toolbar button{width:34px;height:34px;border:0;border-radius:50%;box-shadow:none}.flappy-stage{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:hidden;padding:0;border:0;border-radius:0;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{position:absolute;z-index:6;right:45px;bottom:27px;left:45px;margin:0;padding:7px 10px;border-radius:999px;background:#263c6d91;backdrop-filter:blur(10px);text-align:center;pointer-events:none}.flappy-overlay{position:absolute;z-index:12;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
.flappy-menu__tower{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff38,inset -6px 0 9px #0004,0 0 18px var(--tower-glow)}
@@ -190,7 +191,7 @@ onBeforeUnmount(() => {
.flappy-toolbar strong{display:block;font-size:19px;line-height:20px}
.flappy-toolbar{top:66px;right:18px;left:18px;height:42px;grid-template-columns:32px 1fr 1fr 32px;gap:4px;padding:4px;border-radius:21px;box-sizing:border-box}
.flappy-toolbar div{height:32px;display:grid;grid-template-rows:10px 20px;align-content:center;justify-items:center}
.flappy-toolbar button{width:32px;height:32px}
.flappy-toolbar button{--sky-touch-target:32px;width:32px;height:32px}
.flappy-clouds{top:110px}
.flappy-stage{border:0;box-shadow:inset 0 0 35px #15244c38}
.flappy-obstacle span{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff42,inset -6px 0 8px #0004,0 0 18px var(--tower-glow)}
@@ -90,4 +90,31 @@ describe('phone apps use Sky UI', () => {
expect(source, file).not.toMatch(/(?:citymarkt|pages)__toast/)
}
})
it('uses shared liquid glass for remaining compact interaction controls', () => {
const minimumGlassButtons: Record<string, number> = {
'CameraApp.vue': 2,
'FeatherApp.vue': 2,
'FlareApp.vue': 1,
'FlipTokApp.vue': 2,
'MemoryApp.vue': 2,
'MinesweeperApp.vue': 3,
'NeonDropApp.vue': 3,
'NumberMergeApp.vue': 3,
'PicstagramApp.vue': 6,
'SkyFlappyApp.vue': 3,
'SnakeApp.vue': 2,
'TowerStackApp.vue': 3,
'WeazelNewsApp.vue': 2,
}
for (const [file, minimum] of Object.entries(minimumGlassButtons)) {
const source = appSources.find((app) => app.file === file)?.source ?? ''
const glassButtons = source.match(
/<(?:SkyButton|sky-button)(?=[^>]*\bglass\b)[^>]*>/g,
)
expect(glassButtons?.length ?? 0, file).toBeGreaterThanOrEqual(minimum)
}
})
})
+9 -7
View File
@@ -17,6 +17,7 @@ import type {
SnakeSpeed,
} from '@/features/games/snake/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const snake = useSnakeStore()
@@ -205,7 +206,7 @@ onBeforeUnmount(() => {
<section v-else class="snake-game">
<div class="snake-game__meta">
<button
<SkyButton glass icon-only rounded
type="button"
class="snake-game__back"
:aria-label="phone.t('Apps.snake.backToMenu')"
@@ -213,13 +214,16 @@ onBeforeUnmount(() => {
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.7" aria-hidden="true" />
</button>
</SkyButton>
<div>
<span>{{ phone.t('Apps.snake.score') }}</span>
<strong>{{ game.score }}</strong>
</div>
<button
<SkyButton
v-if="game.status !== 'game-over'"
glass
icon-only
rounded
type="button"
class="snake-game__pause"
:aria-label="
@@ -233,7 +237,7 @@ onBeforeUnmount(() => {
>
<Play v-if="game.status === 'paused'" :size="18" fill="currentColor" />
<Pause v-else :size="18" fill="currentColor" />
</button>
</SkyButton>
</div>
<div
@@ -517,14 +521,12 @@ onBeforeUnmount(() => {
}
.snake-game__meta button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
color: #dff6d9;
background: rgb(255 255 255 / 8%);
}
.snake-game__meta .snake-game__pause { justify-self: end; }
+16 -8
View File
@@ -23,6 +23,7 @@ import type {
TowerBlock,
} from '@/features/games/tower-stack/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const tower = useTowerStackStore()
@@ -185,14 +186,17 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
</div>
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t(tower.soundEnabled ? 'Apps.towerStack.mute' : 'Apps.towerStack.unmute')"
@click="toggleSound"
>
<Volume2 v-if="tower.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</SkyButton>
</header>
<section v-if="tower.menuOpen" class="tower-menu">
@@ -232,18 +236,21 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="tower-game">
<div class="tower-toolbar">
<button
<SkyButton
glass
icon-only
rounded
type="button"
:aria-label="phone.t('Apps.towerStack.backToMenu')"
@pointerdown.stop="tower.showMenu()"
@click.stop="tower.showMenu()"
><ChevronLeft :size="19" /></button>
><ChevronLeft :size="19" /></SkyButton>
<div><span>{{ phone.t('Apps.towerStack.height') }}</span><strong>{{ game.blocks.length - 1 }}</strong></div>
<div><span>{{ phone.t('Apps.towerStack.score') }}</span><strong>{{ game.score }}</strong></div>
<button type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
<Pause v-if="game.status === 'playing'" :size="17" fill="currentColor" />
<Play v-else :size="17" fill="currentColor" />
</button>
</SkyButton>
</div>
<button
@@ -319,7 +326,8 @@ onBeforeUnmount(() => {
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
.tower-header h1 { margin: 1px 0 0; font-size: 27px; line-height: 1; letter-spacing: -0.8px; }
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
.tower-header button:not(.sky-button--glass), .tower-toolbar button:not(.sky-button--glass) { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
.tower-header .sky-button--glass, .tower-toolbar .sky-button--glass { color: #f2edff; }
.tower-menu { height: calc(100% - 50px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; text-align: center; }
.tower-menu__preview { position: relative; flex: 0 0 124px; width: 158px; height: 124px; }
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc((var(--preview-index) - 1) * 15px); left: 23px; height: 21px; border-radius: 6px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 3px 0 #ffffff35, 0 5px 11px #08091c5c; transform: perspective(200px) rotateX(5deg); }
@@ -341,7 +349,7 @@ onBeforeUnmount(() => {
.tower-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.tower-toolbar span { color: #c9c2e9; font-size: 11px; font-weight: 850; line-height: 10px; letter-spacing: .35px; text-transform: uppercase; }
.tower-toolbar strong { display: block; font-size: 19px; line-height: 20px; }
.tower-toolbar button { width: 32px; height: 32px; border: 0; border-radius: 50%; box-shadow: none; }
.tower-toolbar button { --sky-touch-target: 32px; width: 32px; height: 32px; }
.tower-stage { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: hidden; padding: 0; border: 0; border-radius: 0; background: linear-gradient(#1d1b52, #433078 60%, #8e4c78); box-shadow: inset 0 0 35px #08091d80; touch-action: manipulation; }
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
@@ -6,6 +6,10 @@ const source = readFileSync(
new URL('./WeatherApp.vue', import.meta.url),
'utf8',
)
const styles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
describe('WeatherApp layout contract', () => {
it('preserves its exact custom forecast gutter instead of generic page padding', () => {
@@ -19,4 +23,22 @@ describe('WeatherApp layout contract', () => {
/<SkyScrollArea[\s\S]*?class="weather-scroll"[\s\S]*?\spadded(?:\s|=)[\s\S]*?>/,
)
})
it('removes generic card margins from the compact forecast layout', () => {
expect(styles).toMatch(
/\.weather-details > \.weather-detail-card\s*{[\s\S]*?margin:\s*0;/,
)
expect(styles).toMatch(
/\.weather-scroll > \.weather-panel\s*{\s*margin:\s*0;/,
)
})
it('keeps hourly separators straight outside the highlighted current hour', () => {
expect(styles).toMatch(
/\.weather-hour\s*{[\s\S]*?border-left:[\s\S]*?border-radius:\s*0;/,
)
expect(styles).toMatch(
/\.weather-hour:first-child\s*{[\s\S]*?border-radius:\s*var\(--sky-radius-control\);/,
)
})
})
+2 -3
View File
@@ -1193,7 +1193,7 @@ onBeforeUnmount(() => {
<sky-button
icon-only
rounded
tonal
glass
class="weazel-detail-gallery-control is-previous"
:aria-label="t('accessibility.previousPhoto')"
@click="showPreviousDetailImage"
@@ -1203,7 +1203,7 @@ onBeforeUnmount(() => {
<sky-button
icon-only
rounded
tonal
glass
class="weazel-detail-gallery-control is-next"
:aria-label="t('accessibility.nextPhoto')"
@click="showNextDetailImage"
@@ -2112,7 +2112,6 @@ onBeforeUnmount(() => {
min-width: var(--sky-touch-target) !important;
min-height: var(--sky-touch-target) !important;
transform: translateY(-50%);
background: rgb(0 0 0 / 58%) !important;
color: #fff !important;
}
+9 -9
View File
@@ -2206,12 +2206,12 @@ const attachmentAssets = {
video: new Set(['city-loop', 'ocean-loop', 'sunset-loop']),
}
const gifMocks = [
['ICOgUNjpvO0PC', 'Cat reaction'],
['MDJ9IbxxvDUQM', 'Happy dog'],
['l0HlPystfePnAI3G8', 'Celebrate'],
['26ufdipQqU2lhNA4g', 'Wow'],
['3o7abKhOpu0NwenH3O', 'Perfect'],
['xT0xeJpnrWC4XWblEk', 'Party'],
['JIX9t2j0ZTN9S', 'Cat reaction', 200, 200],
['MDJ9IbxxvDUQM', 'Happy dog', 200, 112],
['l0HlPystfePnAI3G8', 'Celebrate', 200, 200],
['26ufdipQqU2lhNA4g', 'Wow', 200, 200],
['3o7abKhOpu0NwenH3O', 'Perfect', 200, 112],
['xT0xeJpnrWC4XWblEk', 'Party', 200, 132],
['111ebonMs90YLu', 'Thumbs up'],
['5GoVLqeAOo6PK', 'Excited'],
['TdfyKrN7HGTIY', 'Happy dance'],
@@ -9648,13 +9648,13 @@ app.post('/api/:endpoint', (request, response) => {
const pageSize = 6
const results = gifMocks
.slice(offset, offset + pageSize)
.map(([id, title]) => ({
height: 200,
.map(([id, title, width, height]) => ({
height: height ?? 200,
id,
previewUrl: `https://media.giphy.com/media/${id}/200w.gif`,
title,
url: `https://media.giphy.com/media/${id}/giphy.gif`,
width: 200,
width: width ?? 200,
}))
response.json({
success: true,