FIX - polish camera controls and landscape layout (#37)

Co-authored-by: Leon.Schmidt <159480018+leonw21342315@users.noreply.github.com>
This commit is contained in:
DerEchteAlec
2026-08-23 22:12:22 +02:00
committed by GitHub
parent 7ca39ef4b9
commit 829f8da82a
6 changed files with 186 additions and 33 deletions
+2
View File
@@ -1885,6 +1885,8 @@ onBeforeUnmount(() => {
class="phone-screen" class="phone-screen"
:class="{ :class="{
'phone-screen--app': isAppRoute || isDevelopmentRoute, 'phone-screen--app': isAppRoute || isDevelopmentRoute,
'phone-screen--camera-landscape':
activeAppId === 'camera' && phone.cameraLandscape,
'phone-app--light': !displayedDarkMode, 'phone-app--light': !displayedDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true, [`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}" }"
+10
View File
@@ -460,6 +460,7 @@ button {
} }
} }
.phone-screen { .phone-screen {
--phone-screen-portrait-ratio: 2.30951;
position: relative; position: relative;
container-type: size; container-type: size;
margin: auto; margin: auto;
@@ -470,6 +471,12 @@ button {
background: #08080a; background: #08080a;
border-radius: 40px; border-radius: 40px;
} }
.phone-screen--camera-landscape {
background: transparent;
}
.phone-screen--camera-landscape .springboard {
visibility: hidden;
}
.phone-app { .phone-app {
position: absolute !important; position: absolute !important;
inset: 0 !important; inset: 0 !important;
@@ -3239,6 +3246,9 @@ button {
backface-visibility: hidden; backface-visibility: hidden;
will-change: transform, border-radius, opacity; will-change: transform, border-radius, opacity;
} }
.app-window--camera-landscape {
background: transparent;
}
.app-window--citywarn { .app-window--citywarn {
backface-visibility: visible; backface-visibility: visible;
filter: none; filter: none;
+5 -1
View File
@@ -30,7 +30,11 @@ const launchStyle = computed(() => {
<div <div
v-if="app && !app.adminOnly" v-if="app && !app.adminOnly"
class="app-window" class="app-window"
:class="{ 'app-window--citywarn': app.id === 'citywarn' }" :class="{
'app-window--camera-landscape':
app.id === 'camera' && phone.cameraLandscape,
'app-window--citywarn': app.id === 'citywarn',
}"
:style="launchStyle" :style="launchStyle"
> >
<CustomAppFrame <CustomAppFrame
@@ -6,6 +6,15 @@ const cameraView = readFileSync(
new URL('./CameraApp.vue', import.meta.url), new URL('./CameraApp.vue', import.meta.url),
'utf8', 'utf8',
) )
const appShell = readFileSync(new URL('../../App.vue', import.meta.url), 'utf8')
const appWindow = readFileSync(
new URL('../PhoneAppWindow.vue', import.meta.url),
'utf8',
)
const shellStyles = readFileSync(
new URL('../../assets/main.css', import.meta.url),
'utf8',
)
const mediaCapture = readFileSync( const mediaCapture = readFileSync(
new URL('../../components/PhoneMediaCapture.vue', import.meta.url), new URL('../../components/PhoneMediaCapture.vue', import.meta.url),
'utf8', 'utf8',
@@ -47,6 +56,99 @@ describe('Camera app controls', () => {
expect(cameraView).not.toContain('k-navbar') expect(cameraView).not.toContain('k-navbar')
}) })
it('stays dark when the global phone appearance is light', () => {
const cameraPageTag = cameraView.match(/<sky-app-page\b[^>]*>/s)?.[0]
expect(cameraPageTag).toBeDefined()
expect(cameraPageTag).toMatch(/\bdark\b/)
expect(cameraView).toMatch(
/\.camera-page\s*\{[^}]*--sky-bg:\s*#000;[^}]*--sky-text:\s*#fff;[^}]*background:\s*var\(--sky-bg\);[^}]*color:\s*var\(--sky-text\);/s,
)
})
it('fills the translucent landscape shell without letterbox gaps', () => {
const landscapeViewport = cameraView.match(
/\.camera-page--landscape \.camera-viewport\s*\{([^}]*)\}/s,
)?.[1]
expect(cameraView).toMatch(
/\.camera-page--landscape\s*\{[^}]*--sky-bg:\s*rgba\(0, 0, 0, 0\.42\);/s,
)
expect(landscapeViewport).toBeDefined()
expect(landscapeViewport).toContain('top: 50%')
expect(landscapeViewport).toContain(
'width: calc(100% * var(--phone-screen-portrait-ratio))',
)
expect(landscapeViewport).toContain(
'aspect-ratio: var(--phone-screen-portrait-ratio)',
)
expect(landscapeViewport).not.toContain('16 / 9')
expect(cameraView).toMatch(
/\.camera-page--landscape \.camera-shade\s*\{[^}]*linear-gradient\([^)]*90deg,[^)]*rgba\(0, 0, 0, 0\.42\) 0 18%,[^)]*transparent 18% 75%,[^)]*rgba\(0, 0, 0, 0\.42\) 75% 100%/s,
)
expect(appShell).toMatch(
/'phone-screen--camera-landscape':\s*activeAppId === 'camera' && phone\.cameraLandscape/,
)
expect(appWindow).toMatch(
/'app-window--camera-landscape':\s*app\.id === 'camera' && phone\.cameraLandscape/,
)
expect(shellStyles).toMatch(
/\.phone-screen\s*\{[^}]*--phone-screen-portrait-ratio:\s*2\.30951;/s,
)
expect(shellStyles).toMatch(
/\.phone-screen--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
)
expect(shellStyles).toMatch(
/\.phone-screen--camera-landscape \.springboard\s*\{[^}]*visibility:\s*hidden;/s,
)
expect(shellStyles).toMatch(
/\.app-window--camera-landscape\s*\{[^}]*background:\s*transparent;/s,
)
})
it('renders camera notices as plain text without a glass pill', () => {
const noticeMarkup = cameraView.slice(
cameraView.indexOf('<span v-if="noticeText"'),
cameraView.indexOf('<span v-else-if="pendingCount"'),
)
const noticeStyles = cameraView.match(
/\.camera-notice-text\s*,[\s\S]*?\{([^}]*)\}/,
)?.[1]
expect(noticeMarkup).toContain('class="camera-notice-text"')
expect(noticeMarkup).not.toContain('camera-focus-pill')
expect(noticeStyles).toBeDefined()
expect(noticeStyles).toContain('color: #ffd60a')
expect(noticeStyles).not.toMatch(
/(?:background|backdrop-filter|border-radius|padding)\s*:/,
)
})
it('uses the current compact lens selector treatment', () => {
const zoomControl = cameraView.slice(
cameraView.indexOf('<div class="camera-zoom-control">'),
cameraView.indexOf('<footer class="camera-controls">'),
)
expect(zoomControl).toContain('variant="plain"')
expect(zoomControl).not.toContain('glass')
expect(zoomControl).toContain('{{ zoomPresetLabel(zoom) }}')
expect(cameraView).toContain("return zoom === 0.5 ? '.5' : `${zoom}`")
expect(cameraView).toMatch(
/\.camera-zoom-control\s*\{[^}]*bottom:\s*216px;/s,
)
expect(cameraView).toMatch(/\.camera-zoom-row\s*\{[^}]*gap:\s*10px;/s)
expect(cameraView).toMatch(
/\.camera-zoom-pill\s*\{[^}]*width:\s*36px;[^}]*min-width:\s*36px;[^}]*height:\s*36px;[^}]*min-height:\s*36px;[^}]*padding:\s*0;/s,
)
expect(cameraView).toMatch(
/\.camera-zoom-pill::before\s*\{[^}]*width:\s*var\(--sky-touch-target, 44px\);[^}]*inset-block:\s*-4px;/s,
)
expect(cameraView).toMatch(
/\.camera-zoom-pill\.active\s*\{[^}]*color:\s*#ffd60a;[^}]*background:\s*rgba\(28, 28, 30, 0\.78\);/s,
)
})
it('keeps continuous wheel zoom without an extra slider bar', () => { it('keeps continuous wheel zoom without an extra slider bar', () => {
expect(cameraView).not.toContain('camera-zoom-slider') expect(cameraView).not.toContain('camera-zoom-slider')
expect(cameraView).not.toContain('type="range"') expect(cameraView).not.toContain('type="range"')
+66 -31
View File
@@ -77,6 +77,16 @@ const pendingCount = computed(
() => () =>
captures.value.filter((capture) => capture.status === 'uploading').length, captures.value.filter((capture) => capture.status === 'uploading').length,
) )
function zoomPresetIsActive(zoom: (typeof zoomLevels)[number]): boolean {
return Math.abs(selectedZoom.value - zoom) < 0.03
}
function zoomPresetLabel(zoom: (typeof zoomLevels)[number]): string {
if (zoomPresetIsActive(zoom)) return `${zoom}x`
return zoom === 0.5 ? '.5' : `${zoom}`
}
function correlationId(): string { function correlationId(): string {
return `${Date.now()}-${crypto.randomUUID()}` return `${Date.now()}-${crypto.randomUUID()}`
} }
@@ -453,6 +463,7 @@ onBeforeUnmount(() => {
class="camera-page" class="camera-page"
:class="{ 'camera-page--landscape': phone.cameraLandscape }" :class="{ 'camera-page--landscape': phone.cameraLandscape }"
:aria-label="phone.t('Apps.camera.name')" :aria-label="phone.t('Apps.camera.name')"
dark
> >
<div class="camera-viewport" @wheel.prevent.stop="zoomWithWheel"> <div class="camera-viewport" @wheel.prevent.stop="zoomWithWheel">
<canvas <canvas
@@ -526,10 +537,7 @@ onBeforeUnmount(() => {
</template> </template>
</sky-fab> </sky-fab>
</div> </div>
<span <span v-if="noticeText" class="camera-notice-text">
v-if="noticeText"
class="camera-focus-pill camera-focus-pill--notice"
>
{{ noticeText }} {{ noticeText }}
</span> </span>
<span v-else-if="pendingCount" class="camera-upload-pill"> <span v-else-if="pendingCount" class="camera-upload-pill">
@@ -585,16 +593,16 @@ onBeforeUnmount(() => {
<SkyButton <SkyButton
v-for="zoom in zoomLevels" v-for="zoom in zoomLevels"
:key="zoom" :key="zoom"
glass
rounded rounded
variant="plain"
class="camera-zoom-pill" class="camera-zoom-pill"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }" :class="{ active: zoomPresetIsActive(zoom) }"
type="button" type="button"
:aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })" :aria-label="phone.t('Apps.camera.zoom', { zoom: `${zoom}x` })"
:aria-pressed="Math.abs(selectedZoom - zoom) < 0.03" :aria-pressed="zoomPresetIsActive(zoom)"
@click="setZoom(zoom)" @click="setZoom(zoom)"
> >
{{ zoom }}x {{ zoomPresetLabel(zoom) }}
</SkyButton> </SkyButton>
</div> </div>
</div> </div>
@@ -685,10 +693,15 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.camera-page { .camera-page {
--sky-bg: #000;
--sky-text: #fff;
position: relative; position: relative;
overflow: clip; overflow: clip;
background: #000; background: var(--sky-bg);
color: #fff; color: var(--sky-text);
}
.camera-page--landscape {
--sky-bg: rgba(0, 0, 0, 0.42);
} }
.camera-viewport { .camera-viewport {
position: absolute; position: absolute;
@@ -700,11 +713,11 @@ onBeforeUnmount(() => {
transform: translateY(-50%); transform: translateY(-50%);
} }
.camera-page--landscape .camera-viewport { .camera-page--landscape .camera-viewport {
top: 46%; top: 50%;
left: 50%; left: 50%;
width: calc(100% * 16 / 9); width: calc(100% * var(--phone-screen-portrait-ratio));
height: auto; height: auto;
aspect-ratio: 16 / 9; aspect-ratio: var(--phone-screen-portrait-ratio);
transform: translate(-50%, -50%) rotate(90deg); transform: translate(-50%, -50%) rotate(90deg);
} }
.camera-game-view, .camera-game-view,
@@ -762,6 +775,16 @@ onBeforeUnmount(() => {
pointer-events: none; pointer-events: none;
background: linear-gradient(#0008, transparent 22%); background: linear-gradient(#0008, transparent 22%);
} }
.camera-page--landscape .camera-shade {
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.42) 0 18%,
transparent 18% 75%,
rgba(0, 0, 0, 0.42) 75% 100%
),
linear-gradient(#0008, transparent 22%);
}
.camera-flash { .camera-flash {
z-index: 8; z-index: 8;
pointer-events: none; pointer-events: none;
@@ -822,25 +845,22 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg { .camera-page--landscape .camera-latest svg {
transform: rotate(90deg); transform: rotate(90deg);
} }
.camera-focus-pill:not(.sky-button--glass),
.camera-upload-pill { .camera-upload-pill {
min-width: 0;
padding: 7px 10px; padding: 7px 10px;
overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: #0006; background: #0006;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.camera-notice-text,
.camera-upload-pill {
min-width: 0;
color: #ffd60a;
overflow: hidden;
text-align: center; text-align: center;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 11px; font-size: 11px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.camera-upload-pill {
color: #ffd60a;
}
.camera-focus-pill--notice {
color: #ffd60a;
} }
.camera-lock-control { .camera-lock-control {
min-height: 44px; min-height: 44px;
@@ -891,7 +911,7 @@ onBeforeUnmount(() => {
.camera-zoom-control { .camera-zoom-control {
position: absolute; position: absolute;
z-index: 4; z-index: 4;
bottom: 196px; bottom: 216px;
left: 50%; left: 50%;
width: auto; width: auto;
transform: translateX(-50%); transform: translateX(-50%);
@@ -899,16 +919,19 @@ onBeforeUnmount(() => {
.camera-zoom-row { .camera-zoom-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 10px;
} }
.camera-zoom-pill { .camera-zoom-pill {
width: 44px; width: 36px;
min-width: 44px; min-width: 36px;
height: 44px; height: 36px;
min-height: 44px; min-height: 36px;
padding: 0; padding: 0;
border: 0;
color: #fff; color: #fff;
font-size: 10px; background: transparent;
font-size: 12px;
font-weight: 500;
text-align: center; text-align: center;
transition: transition:
background-color 0.2s ease, background-color 0.2s ease,
@@ -916,8 +939,20 @@ onBeforeUnmount(() => {
border-color 0.2s ease, border-color 0.2s ease,
box-shadow 0.2s ease; box-shadow 0.2s ease;
} }
.camera-zoom-pill::before {
width: var(--sky-touch-target, 44px);
inset-block: -4px;
}
.camera-zoom-pill.active { .camera-zoom-pill.active {
color: #ffd60a; color: #ffd60a;
background: rgba(28, 28, 30, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.camera-zoom-pill:active:not(:disabled) {
background: rgba(28, 28, 30, 0.42);
}
.camera-zoom-pill.active:active:not(:disabled) {
background: rgba(28, 28, 30, 0.86);
} }
.camera-controls { .camera-controls {
position: absolute; position: absolute;
@@ -93,7 +93,7 @@ describe('phone apps use Sky UI', () => {
it('uses shared liquid glass for remaining compact interaction controls', () => { it('uses shared liquid glass for remaining compact interaction controls', () => {
const minimumGlassButtons: Record<string, number> = { const minimumGlassButtons: Record<string, number> = {
'CameraApp.vue': 2, 'CameraApp.vue': 1,
'FeatherApp.vue': 2, 'FeatherApp.vue': 2,
'FlareApp.vue': 1, 'FlareApp.vue': 1,
'FlipTokApp.vue': 2, 'FlipTokApp.vue': 2,