From b6679a5a7d822a8ccd24a957ceeb5a18a4a6b95d Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sun, 16 Aug 2026 00:49:24 +0200 Subject: [PATCH 01/31] FIX - stabilize camera movement and selfie view --- .../src/views/apps/CameraApp.contract.test.ts | 37 ++++- frontend/src/views/apps/CameraApp.vue | 47 +++++- sky_phone/source/client/camera.lua | 145 ++++++++++++------ sky_phone/source/client/focus.lua | 22 +-- sky_phone/source/client/main.lua | 16 +- tests/client_focus.lua | 74 +++++++-- 6 files changed, 259 insertions(+), 82 deletions(-) diff --git a/frontend/src/views/apps/CameraApp.contract.test.ts b/frontend/src/views/apps/CameraApp.contract.test.ts index 47a9906..a4640cf 100644 --- a/frontend/src/views/apps/CameraApp.contract.test.ts +++ b/frontend/src/views/apps/CameraApp.contract.test.ts @@ -38,7 +38,16 @@ describe('Camera app controls', () => { it('keeps continuous wheel zoom without an extra slider bar', () => { expect(cameraView).not.toContain('camera-zoom-slider') expect(cameraView).not.toContain('type="range"') - expect(cameraView).toContain('event.deltaY * 0.0025') + expect(cameraView).toContain('@wheel.prevent.stop="zoomWithWheel"') + expect(cameraView).toContain('Math.min(120, event.deltaY * deltaMultiplier)') + expect(cameraView).toContain('wheelDelta * 0.00075') + expect(cameraView).toContain("message.type === 'camera:zoom'") + expect(cameraClient).toContain('mouse_wheel_zoom_step = 0.08') + expect(cameraClient).toContain('INPUT_CURSOR_SCROLL_UP') + expect(cameraClient).toContain('INPUT_CURSOR_SCROLL_DOWN') + expect(cameraClient).toContain('IsDisabledControlJustPressed(0, 241)') + expect(cameraClient).toContain('IsDisabledControlJustPressed(0, 242)') + expect(cameraClient).toContain('type = "camera:zoom"') expect(mediaCapture).toContain('nextZoom < 0.5 || nextZoom > 3') expect(mediaCapture).not.toContain('[0.5, 1, 2, 3].includes(nextZoom)') }) @@ -65,10 +74,36 @@ describe('Camera app controls', () => { expect(cameraClient).toContain( 'SetFollowVehicleCamViewMode(first_person_view_mode)', ) + expect(cameraClient).toMatch( + /while camera_state\.active do[\s\S]*else\s+apply_rear_camera_view\(\)/, + ) + expect(cameraClient).not.toContain('next_view_apply') expect(cameraClient).not.toContain('ultrawide_camera_handle') expect(cameraClient).not.toContain('ensure_ultrawide_camera') }) + it('keeps the selfie camera stable while Space still allows movement', () => { + expect(cameraView).toMatch( + /event\.code !== 'Space'[\s\S]*cameraLocked\.value/, + ) + expect(cameraClient).toContain( + 'if camera_state.locked or camera_state.front_camera then', + ) + expect(cameraClient).toContain('capture_front_camera_transform') + expect(cameraClient).toContain('forward_vector * front_camera_distance') + expect(cameraClient).toContain('SetCamCoord(') + expect(cameraClient).toContain('SetCamRot(') + expect(cameraClient).not.toContain('SetEntityHeading(') + expect(cameraClient).not.toContain('front_camera_position') + expect(cameraClient).not.toContain('AttachCamToEntity(') + expect(cameraClient).not.toContain('PointCamAtEntity(') + expect(cameraClient).not.toContain('PointCamAtCoord(') + expect(cameraView).toContain("window.addEventListener('keyup', onKeyup)") + expect(cameraView).toContain( + "nuiCall('camera:setFocus', { focused: true })", + ) + }) + it('uses a looping camera-hold pose instead of the old selfie dance', () => { expect(cameraConfig).toContain('Camera = "cellphone@self"') expect(cameraConfig).toContain('Camera = "selfie"') diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index dd183d6..86a0dfe 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -50,8 +50,8 @@ const flashEnabled = ref(false) const microphoneEnabled = ref(true) const frontCamera = ref(false) const shutterActive = ref(false) -const focused = ref(true) const cameraLocked = ref(false) +const movementEnabled = ref(false) const recording = ref(false) const savingVideo = ref(false) const recordingStartedAt = ref(0) @@ -231,6 +231,10 @@ async function toggleFacing(): Promise { async function toggleCameraLock(): Promise { cameraLocked.value = !cameraLocked.value + if (cameraLocked.value && movementEnabled.value) { + movementEnabled.value = false + await nuiCall('camera:setFocus', { focused: true }) + } await nuiCall('camera:setLocked', { locked: cameraLocked.value }) } @@ -265,7 +269,17 @@ function setZoom(zoom: number): void { } function zoomWithWheel(event: WheelEvent): void { - setZoom(selectedZoom.value - event.deltaY * 0.0025) + const deltaMultiplier = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? window.innerHeight + : 1 + const wheelDelta = Math.max( + -120, + Math.min(120, event.deltaY * deltaMultiplier), + ) + setZoom(selectedZoom.value - wheelDelta * 0.00075) } function resizeGameView(entry?: ResizeObserverEntry): void { @@ -323,23 +337,34 @@ function onKeydown(event: KeyboardEvent): void { if ( event.code !== 'Space' || event.repeat || - !focused.value || - cameraLocked.value + cameraLocked.value || + movementEnabled.value ) return event.preventDefault() - focused.value = false + movementEnabled.value = true void nuiCall('camera:setFocus', { focused: false }) } +function onKeyup(event: KeyboardEvent): void { + if (event.code !== 'Space' || !movementEnabled.value) return + event.preventDefault() + movementEnabled.value = false + void nuiCall('camera:setFocus', { focused: true }) +} + function onMessage(event: MessageEvent): void { if (!isTrustedRootMessageSource(event.source, window)) return const message = event.data as { data?: Record type?: string } - if (message.type === 'camera:focus') { - focused.value = message.data?.focused === true + if (message.type === 'camera:zoom') { + const zoom = Number(message.data?.zoom) + if (Number.isFinite(zoom) && zoom >= minimumZoom && zoom <= maximumZoom) { + selectedZoom.value = zoom + resizeGameView() + } } else if (message.type === 'camera:recordState') { const active = message.data?.active === true savingVideo.value = message.data?.saving === true @@ -406,6 +431,7 @@ onMounted(() => { '*', ) window.addEventListener('keydown', onKeydown) + window.addEventListener('keyup', onKeyup) window.addEventListener('message', onMessage) void nuiCall('camera:setActive', { active: true }) void nuiCall<{ videoBitrateKbps?: number }>('media:config').then( @@ -424,7 +450,12 @@ onBeforeUnmount(() => { if (noticeTimer !== undefined) window.clearTimeout(noticeTimer) if (recordingTimer !== undefined) window.clearInterval(recordingTimer) window.removeEventListener('keydown', onKeydown) + window.removeEventListener('keyup', onKeyup) window.removeEventListener('message', onMessage) + if (movementEnabled.value) { + movementEnabled.value = false + void nuiCall('camera:setFocus', { focused: true }) + } if (renderFrameId !== undefined) window.cancelAnimationFrame(renderFrameId) resizeObserver?.disconnect() gameView?.dispose() @@ -446,7 +477,7 @@ onBeforeUnmount(() => { :class="{ 'camera-page--landscape': phone.cameraLandscape }" :aria-label="phone.t('Apps.camera.name')" > -
+
maximum_zoom then + return false + end + zoom = math.floor((zoom * 100.0) + 0.5) / 100.0 + if camera_state.zoom == zoom then + return true + end + camera_state.zoom = zoom + SendNUIMessage({ type = "camera:zoom", data = { zoom = zoom } }) + return true +end + local function watch_camera_controls() if camera_state.focus_watcher then return @@ -163,8 +211,25 @@ local function watch_camera_controls() CreateThread(function() while camera_state.active do apply_camera_controls() - if not camera_state.applied_nui_focus and IsDisabledControlJustReleased(0, 22) then - set_camera_focus(true) + if camera_state.game_input then + if + IsDisabledControlJustPressed(0, 241) + or IsDisabledControlJustPressed(0, 261) + then + set_camera_zoom( + math.min(maximum_zoom, camera_state.zoom + mouse_wheel_zoom_step) + ) + elseif + IsDisabledControlJustPressed(0, 242) + or IsDisabledControlJustPressed(0, 262) + then + set_camera_zoom( + math.max(minimum_zoom, camera_state.zoom - mouse_wheel_zoom_step) + ) + end + if IsDisabledControlJustReleased(0, 22) then + set_camera_focus(true) + end end Wait(0) end @@ -180,11 +245,13 @@ end AddEventHandler("sky_phone:client:cameraFocusApplied", function(data) if type(data) ~= "table" or type(data.active) ~= "boolean" + or type(data.cursor) ~= "boolean" or type(data.focused) ~= "boolean" or type(data.gameInput) ~= "boolean" then return end + camera_state.game_input = data.gameInput if camera_state.applied_nui_focus ~= data.focused then camera_state.applied_nui_focus = data.focused SendNUIMessage({ type = "camera:focus", data = { focused = data.focused } }) @@ -221,26 +288,12 @@ local function set_camera_active(active) end camera_state.enforcing = true CreateThread(function() - local next_view_apply = 0 while camera_state.active do HideHudAndRadarThisFrame() if camera_state.front_camera then - local ped = PlayerPedId() - ensure_front_camera() - local camera_position, target = front_camera_position(ped) - SetCamCoord( - camera_state.front_camera_handle, - camera_position.x, - camera_position.y, - camera_position.z - ) - PointCamAtCoord(camera_state.front_camera_handle, target.x, target.y, target.z) + apply_front_camera(PlayerPedId()) else - local now = GetGameTimer() - if now >= next_view_apply then - apply_rear_camera_view() - next_view_apply = now + 250 - end + apply_rear_camera_view() end Wait(0) end @@ -250,6 +303,7 @@ local function set_camera_active(active) end set_flash_enabled(false) camera_state.front_camera = false + camera_state.game_input = false camera_state.landscape = false camera_state.locked = false clear_front_camera() @@ -272,7 +326,7 @@ local function set_front_camera(active) return end if active then - ensure_front_camera() + apply_front_camera(PlayerPedId()) else clear_front_camera() apply_rear_camera_view() @@ -298,15 +352,6 @@ local function set_camera_landscape(active) end end -local function set_camera_zoom(zoom) - if not zoom or zoom < minimum_zoom or zoom > maximum_zoom then - return false - end - zoom = math.floor((zoom * 100.0) + 0.5) / 100.0 - camera_state.zoom = zoom - return true -end - RegisterNUICallback("camera:setActive", function(data, cb) if type(data) ~= "table" then cb({ success = false, error = "invalid_request" }) diff --git a/sky_phone/source/client/focus.lua b/sky_phone/source/client/focus.lua index e5c3103..a565ea3 100644 --- a/sky_phone/source/client/focus.lua +++ b/sky_phone/source/client/focus.lua @@ -2,20 +2,24 @@ SkyPhoneFocus = {} function SkyPhoneFocus.Resolve(state) if state.activity_suspended then - return { focused = false, keep_input = false } + return { cursor = false, focused = false, keep_input = false, movement_only = false } end if state.call_focus then - return { focused = true, keep_input = false } + return { cursor = true, focused = true, keep_input = false, movement_only = false } end if state.camera_active and not state.camera_nui_focused then - return { focused = false, keep_input = true } + return { cursor = false, focused = true, keep_input = true, movement_only = false } end + local movement_only = state.is_open and state.allow_movement and not state.camera_active + local focused = state.is_open + or state.notification_focus + or state.payphone_focus + or state.sim_picker_open + or (state.camera_active and state.camera_nui_focused) return { - focused = state.is_open - or state.notification_focus - or state.payphone_focus - or state.sim_picker_open - or (state.camera_active and state.camera_nui_focused), - keep_input = state.is_open and state.allow_movement and not state.camera_active, + cursor = focused, + focused = focused, + keep_input = movement_only, + movement_only = movement_only, } end diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 9faa30f..9b6aa64 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -12,6 +12,7 @@ local active_call_payload = nil local call_channel = 0 local nui_generation = 0 local activity_suspended = false +local movement_only_input = false Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true }) @@ -301,15 +302,28 @@ local function update_nui_focus() payphone_focus = payphone_focus, sim_picker_open = sim_picker_open, }) - SetNuiFocus(focus.focused, focus.focused) + SetNuiFocus(focus.focused, focus.cursor) SetNuiFocusKeepInput(focus.keep_input) + movement_only_input = focus.movement_only TriggerEvent("sky_phone:client:cameraFocusApplied", { active = camera_active, + cursor = focus.cursor, focused = focus.focused, gameInput = focus.keep_input, }) end +CreateThread(function() + while true do + if movement_only_input then + SkyPhoneFocus.ApplyMovementOnlyControls() + Wait(0) + else + Wait(250) + end + end +end) + AddEventHandler("sky_phone:client:setSuspended", function(suspended) activity_suspended = suspended == true update_nui_focus() diff --git a/tests/client_focus.lua b/tests/client_focus.lua index 5cad84b..11b4269 100644 --- a/tests/client_focus.lua +++ b/tests/client_focus.lua @@ -1,3 +1,25 @@ +local disabled_control_group = nil +local enabled_controls = {} +local firing_disabled = false + +function DisableAllControlActions(group) + disabled_control_group = group +end + +function EnableControlAction(group, control, enabled) + assert(group == 0 and enabled, "movement controls must be enabled in the primary input group") + enabled_controls[control] = true +end + +function PlayerId() + return 7 +end + +function DisablePlayerFiring(player, disabled) + assert(player == 7, "movement filtering must target the local player") + firing_disabled = disabled +end + dofile("sky_phone/source/client/focus.lua") local function resolve(overrides) @@ -19,26 +41,45 @@ local function resolve(overrides) end local idle = resolve() -assert(not idle.focused and not idle.keep_input, "idle NUI must release focus and game input override") +assert( + not idle.cursor and not idle.focused and not idle.keep_input, + "idle NUI must release focus and game input override" +) local minimized_call = resolve() assert(not minimized_call.focused, "a replayed call without an attention claim must stay unfocused") local incoming_call = resolve({ call_focus = true }) -assert(incoming_call.focused and not incoming_call.keep_input, "incoming call attention must focus the NUI") +assert( + incoming_call.cursor and incoming_call.focused and not incoming_call.keep_input, + "incoming call attention must focus the NUI" +) local stationary_phone = resolve({ is_open = true }) assert( - stationary_phone.focused and not stationary_phone.keep_input, + stationary_phone.cursor and stationary_phone.focused and not stationary_phone.keep_input, "an open phone must block game input when movement is disabled" ) local movable_phone = resolve({ allow_movement = true, is_open = true }) assert( - movable_phone.focused and movable_phone.keep_input, - "an open phone must keep game input when movement is enabled" + movable_phone.cursor + and movable_phone.focused + and movable_phone.keep_input + and movable_phone.movement_only, + "an open phone must keep only movement input when movement is enabled" ) +SkyPhoneFocus.ApplyMovementOnlyControls() +assert(disabled_control_group == 0, "movement filtering must disable the primary input group") +for _, control in ipairs({ 21, 30, 31, 32, 33, 34, 35 }) do + assert(enabled_controls[control], ("movement control %d must stay enabled"):format(control)) +end +assert(not enabled_controls[1] and not enabled_controls[2], "look controls must stay disabled") +assert(not enabled_controls[24] and not enabled_controls[25], "combat controls must stay disabled") +assert(not enabled_controls[22], "jump must stay disabled") +assert(firing_disabled, "player firing must remain disabled while the phone is open") + local movable_notification = resolve({ allow_movement = true, notification_focus = true }) assert( movable_notification.focused and not movable_notification.keep_input, @@ -51,8 +92,11 @@ local camera_game_input = resolve({ is_open = true, }) assert( - not camera_game_input.focused and camera_game_input.keep_input, - "unfocused camera must own game input over the open phone" + not camera_game_input.cursor + and camera_game_input.focused + and camera_game_input.keep_input + and not camera_game_input.movement_only, + "camera movement must keep keyboard focus without retaining the NUI cursor" ) local focused_camera = resolve({ @@ -62,8 +106,8 @@ local focused_camera = resolve({ is_open = true, }) assert( - focused_camera.focused and not focused_camera.keep_input, - "focused camera must override movement configuration until Space releases NUI focus" + focused_camera.cursor and focused_camera.focused and not focused_camera.keep_input, + "focused camera must override movement configuration until Space enables passthrough" ) local camera_interrupted_by_call = resolve({ @@ -73,8 +117,10 @@ local camera_interrupted_by_call = resolve({ is_open = true, }) assert( - camera_interrupted_by_call.focused and not camera_interrupted_by_call.keep_input, - "incoming call attention must override unfocused camera input" + camera_interrupted_by_call.cursor + and camera_interrupted_by_call.focused + and not camera_interrupted_by_call.keep_input, + "incoming call attention must override camera passthrough input" ) local camera_after_connected_call = resolve({ @@ -84,8 +130,10 @@ local camera_after_connected_call = resolve({ is_open = true, }) assert( - not camera_after_connected_call.focused and camera_after_connected_call.keep_input, - "connected call without an attention claim must restore unfocused camera input" + not camera_after_connected_call.cursor + and camera_after_connected_call.focused + and camera_after_connected_call.keep_input, + "connected call without an attention claim must restore camera movement input" ) local payphone_closed_behind_phone = resolve({ is_open = true, payphone_focus = false }) From eb534af888de54b3883cccd5786a001f296a148f Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:38:08 +0200 Subject: [PATCH 02/31] ENH - refine calculator interface Polish calculator modes, key geometry, operator icon states, and the calculation history navigation. Add focused contract coverage for the updated layout and interaction styling. --- .../views/apps/CalculatorApp.contract.test.ts | 70 ++++++ frontend/src/views/apps/CalculatorApp.vue | 212 ++++++++++++++---- 2 files changed, 234 insertions(+), 48 deletions(-) create mode 100644 frontend/src/views/apps/CalculatorApp.contract.test.ts diff --git a/frontend/src/views/apps/CalculatorApp.contract.test.ts b/frontend/src/views/apps/CalculatorApp.contract.test.ts new file mode 100644 index 0000000..7444d2f --- /dev/null +++ b/frontend/src/views/apps/CalculatorApp.contract.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + fileURLToPath(new URL('./CalculatorApp.vue', import.meta.url)), + 'utf8', +) + +describe('CalculatorApp layout contract', () => { + it('opens in standard mode with circular equal-sized keys', () => { + expect(source).toContain('const scientificOpened = ref(false)') + expect(source).toContain('aspect-ratio: 1;') + expect(source).toContain('border-radius: 50%;') + }) + + it('keeps the compact advanced keys fully rounded', () => { + expect(source).toMatch( + /\.calculator-key--scientific\s*{[^}]*border-radius: 999px;/s, + ) + expect(source).toMatch( + /\.calculator-key--basic\s*{[^}]*border-radius: 999px;/s, + ) + }) + + it('keeps selected orange operator keys orange instead of white', () => { + expect(source).toMatch( + /\.calculator-key--operator\.calculator-key--selected\s*{[^}]*background: linear-gradient\(180deg, #e98700, #c96800\);[^}]*color: #fff;/s, + ) + }) + + it('renders centered icons for every orange operator key', () => { + for (const icon of ['Divide', 'X', 'Minus', 'Plus', 'Equal']) { + expect(source).toContain(`icon: ${icon}`) + } + expect(source).toContain('class="calculator-key__operator-icon"') + expect(source).toContain(':aria-label="key.icon ? key.label : undefined"') + expect(source).toMatch( + /\.calculator-key__operator-icon\s*{[^}]*width: 24px;[^}]*height: 24px;[^}]*display: block;[^}]*margin: 0;/s, + ) + }) + + it('uses the shared navigation controls in calculation history', () => { + expect(source).toContain('SkyButton, SkyNavbar, SkySheet') + expect(source).toContain( + 'calculator-history sky-ui-provider sky-ui-provider--dark', + ) + expect(source).toContain('class="calculator-history__navbar"') + expect(source).toContain( + 'grid-template-columns: 106px minmax(0, 1fr) 44px;', + ) + 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).toContain('-webkit-backdrop-filter: none;') + expect(source).toContain('backdrop-filter: none;') + expect(source).toContain('min-width: 106px;') + expect(source).toContain('class="calculator-history__edit-label"') + expect(source).toContain('position: absolute;') + expect(source).toContain('inset: 0;') + 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, + ) + expect(source).not.toContain('class="calculator-history__edit"') + expect(source).not.toContain('class="calculator-history__close"') + }) +}) diff --git a/frontend/src/views/apps/CalculatorApp.vue b/frontend/src/views/apps/CalculatorApp.vue index 143600f..23c6139 100644 --- a/frontend/src/views/apps/CalculatorApp.vue +++ b/frontend/src/views/apps/CalculatorApp.vue @@ -1,13 +1,23 @@ diff --git a/frontend/src/ui/SkyTabBar.test.ts b/frontend/src/ui/SkyTabBar.test.ts index 280ccee..ca12d1b 100644 --- a/frontend/src/ui/SkyTabBar.test.ts +++ b/frontend/src/ui/SkyTabBar.test.ts @@ -51,6 +51,21 @@ describe('SkyTabBar', () => { expect(html).toContain('sky-tabbar--labels') }) + it('keeps migrated component and styling hooks on their intended layers', async () => { + const html = await renderToString( + createSSRApp(SkyTabBar, { + bgClass: 'custom-background', + component: 'footer', + innerClass: 'custom-inner', + label: 'Navigation', + }), + ) + + expect(html).toMatch(/^
{ expect(foundation).toMatch( /\.sky-tabbar\s*\{[^}]*--sky-tabbar-pane-height:\s*48px[^}]*padding-right:\s*calc\(var\(--sky-safe-area-right\) \+ 16px\)[^}]*padding-bottom:\s*calc\(var\(--sky-safe-area-bottom\) \+ 16px\)/s, diff --git a/frontend/src/ui/SkyTabBar.vue b/frontend/src/ui/SkyTabBar.vue index 4374f5b..7422d52 100644 --- a/frontend/src/ui/SkyTabBar.vue +++ b/frontend/src/ui/SkyTabBar.vue @@ -6,6 +6,7 @@ import { onMounted, onUpdated, ref, + useAttrs, type CSSProperties, } from 'vue' @@ -15,24 +16,37 @@ defineOptions({ inheritAttrs: false }) const props = withDefaults( defineProps<{ + bgClass?: string + component?: string floating?: boolean icons?: boolean - label: string + innerClass?: string + label?: string labels?: boolean }>(), { + bgClass: '', + component: 'nav', floating: true, icons: true, + innerClass: '', + label: '', labels: true, }, ) +const attrs = useAttrs() const links = ref(null) const hasLinks = ref(false) const pressed = ref(false) const highlightWidth = ref('0%') const highlightTransform = ref('translateX(0%)') const transitionTimingFunction = ref('') +const accessibleLabel = computed(() => { + if (props.label) return props.label + const attribute = attrs['aria-label'] + return typeof attribute === 'string' && attribute ? attribute : undefined +}) let activeIndex = 0 let newActiveIndex = 0 let touchRect: DOMRect | null = null @@ -191,7 +205,8 @@ onBeforeUnmount(() => { diff --git a/frontend/src/ui/controls.css b/frontend/src/ui/controls.css index d75bb86..48405da 100644 --- a/frontend/src/ui/controls.css +++ b/frontend/src/ui/controls.css @@ -80,7 +80,7 @@ border: 1px solid transparent; border-radius: 4px; background: var(--sky-app-accent, #007aff); - color: #ffffff; + color: var(--sky-button-text, #ffffff); font-size: 15px; font-weight: 500; line-height: 1.2; @@ -1037,7 +1037,7 @@ label.sky-list-item__row { min-width: 0; flex: 1 1 auto; overflow: hidden; - color: inherit; + color: var(--sky-list-item-title-color, inherit); font-size: 17px; font-weight: 400; line-height: 24px; diff --git a/frontend/src/ui/controls/SkyLink.vue b/frontend/src/ui/controls/SkyLink.vue index a09a2d2..98b936a 100644 --- a/frontend/src/ui/controls/SkyLink.vue +++ b/frontend/src/ui/controls/SkyLink.vue @@ -9,6 +9,7 @@ const props = withDefaults( disabled?: boolean href?: string iconOnly?: boolean + linkProps?: Record type?: 'button' | 'reset' | 'submit' }>(), { @@ -16,6 +17,7 @@ const props = withDefaults( disabled: false, href: undefined, iconOnly: false, + linkProps: () => ({}), type: 'button', }, ) @@ -27,6 +29,7 @@ const emit = defineEmits<{ const elementProps = computed>(() => { if (props.component === 'a') { return { + ...props.linkProps, 'aria-disabled': props.disabled || undefined, href: props.disabled ? undefined : props.href, tabindex: props.disabled ? -1 : 0, @@ -34,8 +37,9 @@ const elementProps = computed>(() => { } return { + ...props.linkProps, disabled: props.disabled, - type: props.type, + type: props.linkProps.type ?? props.type, } }) diff --git a/frontend/src/ui/controls/SkyNavbarBackLink.vue b/frontend/src/ui/controls/SkyNavbarBackLink.vue index d609e10..1d68a21 100644 --- a/frontend/src/ui/controls/SkyNavbarBackLink.vue +++ b/frontend/src/ui/controls/SkyNavbarBackLink.vue @@ -5,18 +5,21 @@ defineOptions({ inheritAttrs: false }) const props = withDefaults( defineProps<{ - ariaLabel: string + ariaLabel?: string component?: 'a' | 'button' disabled?: boolean href?: string + linkProps?: Record showText?: boolean text?: string type?: 'button' | 'reset' | 'submit' }>(), { component: 'button', + ariaLabel: '', disabled: false, href: undefined, + linkProps: () => ({}), showText: false, text: '', type: 'button', @@ -28,7 +31,7 @@ const emit = defineEmits<{ }>() const accessibleLabel = computed( - () => props.ariaLabel || (props.showText ? props.text : undefined), + () => props.ariaLabel || props.text || undefined, ) const elementProps = computed>(() => { const common = { @@ -37,6 +40,7 @@ const elementProps = computed>(() => { if (props.component === 'a') { return { + ...props.linkProps, ...common, 'aria-disabled': props.disabled || undefined, href: props.disabled ? undefined : props.href, @@ -45,9 +49,10 @@ const elementProps = computed>(() => { } return { + ...props.linkProps, ...common, disabled: props.disabled, - type: props.type, + type: props.linkProps.type ?? props.type, } }) diff --git a/frontend/src/ui/controls/SkySearchbar.vue b/frontend/src/ui/controls/SkySearchbar.vue index 2da2ea8..090e256 100644 --- a/frontend/src/ui/controls/SkySearchbar.vue +++ b/frontend/src/ui/controls/SkySearchbar.vue @@ -7,7 +7,7 @@ defineOptions({ inheritAttrs: false }) const props = withDefaults( defineProps<{ - clearLabel: string + clearLabel?: string cancelLabel?: string cancelButton?: boolean clearButton?: boolean @@ -29,6 +29,7 @@ const props = withDefaults( cancelButton: false, cancelLabel: '', clearButton: true, + clearLabel: '', component: 'div', disableButton: false, disableLabel: '', @@ -167,7 +168,7 @@ function disable(event: MouseEvent): void { v-if="clearButton && localValue" class="sky-searchbar__clear" type="button" - :aria-label="clearLabel" + :aria-label="clearLabel || 'Clear search'" :disabled="disabled" @pointerdown.prevent @click="clear($event)" diff --git a/frontend/src/ui/controls/SkyTabButton.vue b/frontend/src/ui/controls/SkyTabButton.vue index 1f7c363..44dbb32 100644 --- a/frontend/src/ui/controls/SkyTabButton.vue +++ b/frontend/src/ui/controls/SkyTabButton.vue @@ -1,19 +1,27 @@ diff --git a/frontend/src/ui/foundation.css b/frontend/src/ui/foundation.css index 94cb11c..c0b3180 100644 --- a/frontend/src/ui/foundation.css +++ b/frontend/src/ui/foundation.css @@ -785,6 +785,27 @@ align-items: stretch; } +.sky-tabbar__links > .sky-toolbar-pane { + width: 100%; + height: 100%; + min-width: 0; + display: flex; + align-items: stretch; + border-radius: inherit; + background: transparent; + box-shadow: none; +} + +.sky-tabbar__links > .sky-toolbar-pane::after { + display: none; +} + +.sky-tabbar__links > .sky-toolbar-pane > .sky-tab-button { + min-width: 0; + min-height: var(--sky-tabbar-pane-height); + flex: 1 1 0; +} + .sky-tabbar__highlight { height: 100%; position: absolute; diff --git a/frontend/src/views/apps/AppTheme.contract.test.ts b/frontend/src/views/apps/AppTheme.contract.test.ts new file mode 100644 index 0000000..cdd38a8 --- /dev/null +++ b/frontend/src/views/apps/AppTheme.contract.test.ts @@ -0,0 +1,53 @@ +import { readdirSync, readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const appShellSource = readFileSync( + new URL('../../App.vue', import.meta.url), + 'utf8', +) +const mainCssSource = readFileSync( + new URL('../../assets/main.css', import.meta.url), + 'utf8', +) +const appDirectory = new URL('.', import.meta.url) +const appSources = readdirSync(appDirectory) + .filter((file) => file.endsWith('.vue')) + .map((file) => ({ + file, + source: readFileSync(new URL(file, appDirectory), 'utf8'), + })) + +describe('phone app theme contract', () => { + it('provides the reactive phone theme to every routed app', () => { + expect(appShellSource).toContain('import { SkyProvider }') + expect(appShellSource).toContain('class="phone-app-theme"') + expect(appShellSource).toContain(':dark="phone.isDarkMode"') + expect(appShellSource).toMatch( + / { + for (const { file, source } of appSources) { + expect(source, file).not.toMatch(/:dark="(?:true|false)"/) + } + expect(appShellSource).not.toContain("'phone-app--darkchat'") + expect(mainCssSource).not.toContain('.phone-app--darkchat') + }) + + it('keeps shared app surfaces and known custom apps theme-aware', () => { + expect(mainCssSource).toContain('background: var(--sky-bg);') + expect(mainCssSource).toContain('color: var(--sky-text);') + expect(mainCssSource).toContain('.phone-app--light .banking-app') + + const calculator = appSources.find( + ({ file }) => file === 'CalculatorApp.vue', + )?.source + const mail = appSources.find(({ file }) => file === 'MailApp.vue')?.source + + expect(calculator).toContain("'calculator-app--light': !phone.isDarkMode") + expect(mail).toContain('background: var(--sky-bg) !important;') + expect(mail).toContain('color: var(--sky-text);') + }) +}) diff --git a/frontend/src/views/apps/BankingApp.contract.test.ts b/frontend/src/views/apps/BankingApp.contract.test.ts new file mode 100644 index 0000000..2587e7c --- /dev/null +++ b/frontend/src/views/apps/BankingApp.contract.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync( + new URL('./BankingApp.vue', import.meta.url), + 'utf8', +) + +describe('Banking app Sky UI migration', () => { + it('uses Sky UI instead of Konsta components', () => { + expect(source).not.toContain("from 'konsta/vue'") + expect(source).not.toMatch(/<\/?k-/) + + for (const component of [ + 'SkyAppPage', + 'SkyNavbar', + 'SkyGlass', + 'SkyCard', + 'SkyList', + 'SkyListItem', + 'SkyField', + 'SkyTabBar', + 'SkyTabButton', + 'SkySheet', + 'SkyButton', + 'SkySpinner', + 'SkyEmptyState', + 'SkyToast', + ]) { + expect(source).toContain(`<${component}`) + } + }) + + it('delegates sheet focus and escape handling to SkySheet', () => { + expect(source).toContain('@escape="closeAction"') + expect(source).not.toContain('handleSheetKeydown') + expect(source).not.toContain('handleWindowKeydown') + }) +}) diff --git a/frontend/src/views/apps/BankingApp.vue b/frontend/src/views/apps/BankingApp.vue index 570d6be..c5d7118 100644 --- a/frontend/src/views/apps/BankingApp.vue +++ b/frontend/src/views/apps/BankingApp.vue @@ -1,21 +1,4 @@ - - - + + +
- - - - - - - - - - - - - - - @@ -428,44 +442,44 @@ onBeforeUnmount(() => { v-if="billing.isLoading && !billing.overview" class="billing-loading" > - + {{ phone.t('Common.loading') }}
{{ t(`errors.${billing.error}`) }} - + {{ t('tryAgain') }} - +
- - - - - - - - - - - - - - - - + + + + + + + + + + -
-

{{ t('payment.title') }}

+

{{ t('payment.title') }}

{{ t('payment.body', { issuer: billing.detail.issuerLabel }) }}

- + {{ billing.detail.title }} {{ formatMoney(billing.detail.amount, billing.detail.currency) }} - - - - {{ t('payment.confirm') }} - - + + + {{ t('payment.confirm') }} + + {{ t('payment.cancel') }} - +
-
+ - + {{ toastText }} - - + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 8512cb2..7dae5c0 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -2800,6 +2800,7 @@ mockMedia.push( })), ) const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business'] +const weazelNewsMaxImages = 6 let weazelNewsSequence = 8 let weazelNewsArticles = [ { @@ -2809,8 +2810,7 @@ let weazelNewsArticles = [ excerpt: 'Temporary navigation restrictions are in effect around the southern harbor while crews inspect the main shipping channel.', category: 'official', - imageUrl: 'https://picsum.photos/seed/weazel-harbor/1200/760', - imageMediaId: null, + images: weazelNewsImages([15, 13, 20]), authorName: 'Avery Brooks', createdAt: Date.now() - 35 * 60 * 1000, updatedAt: Date.now() - 28 * 60 * 1000, @@ -2825,8 +2825,7 @@ let weazelNewsArticles = [ excerpt: 'Food stands, live performers, and classic cars are coming to Vinewood Boulevard this weekend.', category: 'events', - imageUrl: 'https://picsum.photos/seed/weazel-vinewood/1200/760', - imageMediaId: null, + images: weazelNewsImages([11]), authorName: 'Maya Chen', createdAt: Date.now() - 2 * 60 * 60 * 1000, updatedAt: Date.now() - 2 * 60 * 60 * 1000, @@ -2841,8 +2840,7 @@ let weazelNewsArticles = [ excerpt: 'City departments are recruiting new staff across emergency response, transport, and public administration.', category: 'jobs', - imageUrl: null, - imageMediaId: null, + images: [], authorName: 'Jordan Hayes', createdAt: Date.now() - 4 * 60 * 60 * 1000, updatedAt: Date.now() - 3 * 60 * 60 * 1000, @@ -2857,8 +2855,7 @@ let weazelNewsArticles = [ excerpt: 'Every lane through Del Perro has reopened after crews cleared an earlier road obstruction.', category: 'news', - imageUrl: 'https://picsum.photos/seed/weazel-del-perro/1200/760', - imageMediaId: null, + images: weazelNewsImages([9]), authorName: 'Avery Brooks', createdAt: Date.now() - 7 * 60 * 60 * 1000, updatedAt: Date.now() - 6 * 60 * 60 * 1000, @@ -2873,8 +2870,7 @@ let weazelNewsArticles = [ excerpt: 'Independent downtown retailers are seeing stronger evening trade during a trial of extended opening hours.', category: 'business', - imageUrl: 'https://picsum.photos/seed/weazel-downtown/1200/760', - imageMediaId: null, + images: weazelNewsImages([5]), authorName: 'Maya Chen', createdAt: Date.now() - 26 * 60 * 60 * 1000, updatedAt: Date.now() - 25 * 60 * 60 * 1000, @@ -2889,8 +2885,7 @@ let weazelNewsArticles = [ excerpt: 'Local racing teams are preparing vehicles and reviewing safety procedures for the next sanctioned season.', category: 'events', - imageUrl: 'https://picsum.photos/seed/sky-phone-3/800/600', - imageMediaId: 3, + images: weazelNewsImages([3, 7]), authorName: 'Jordan Hayes', createdAt: Date.now() - 55 * 60 * 1000, updatedAt: Date.now() - 12 * 60 * 1000, @@ -2905,8 +2900,7 @@ let weazelNewsArticles = [ excerpt: 'The editorial desk is collecting confirmed service notices and transport updates for Monday morning.', category: 'official', - imageUrl: null, - imageMediaId: null, + images: [], authorName: 'Jordan Hayes', createdAt: Date.now() - 18 * 60 * 1000, updatedAt: Date.now() - 8 * 60 * 1000, @@ -2914,7 +2908,7 @@ let weazelNewsArticles = [ status: 'draft', revision: 2, }, -] +].map(syncWeazelNewsArticleImages) function weazelNewsExcerpt(body) { const normalized = body.replace(/\s+/g, ' ').trim() @@ -2931,6 +2925,24 @@ function weazelNewsImageUrl(imageMediaId) { return media?.url ?? null } +function weazelNewsImages(imageMediaIds) { + return imageMediaIds.map((mediaId) => ({ + mediaId, + url: weazelNewsImageUrl(mediaId), + })) +} + +function syncWeazelNewsArticleImages(article) { + const images = Array.isArray(article.images) ? article.images : [] + const firstImage = images[0] ?? null + return { + ...article, + imageMediaId: firstImage?.mediaId ?? null, + imageUrl: firstImage?.url ?? null, + images, + } +} + function validateWeazelNewsDraft(data) { const title = typeof data.title === 'string' ? data.title.trim() : '' const body = typeof data.body === 'string' ? data.body.trim() : '' @@ -2948,24 +2960,45 @@ function validateWeazelNewsDraft(data) { return { error: status === 'draft' ? 'invalid_draft' : 'invalid_publish' } } - let imageMediaId = null - if (data.imageMediaId !== null && data.imageMediaId !== undefined) { - imageMediaId = Number(data.imageMediaId) + const requestedImageMediaIds = + data.imageMediaIds === undefined + ? data.imageMediaId === null || data.imageMediaId === undefined + ? [] + : [data.imageMediaId] + : data.imageMediaIds + if ( + !Array.isArray(requestedImageMediaIds) || + requestedImageMediaIds.length > weazelNewsMaxImages + ) { + return { error: 'invalid_attachment' } + } + + const imageMediaIds = [] + const seenImageMediaIds = new Set() + for (const requestedImageMediaId of requestedImageMediaIds) { + const imageMediaId = Number(requestedImageMediaId) if ( !Number.isSafeInteger(imageMediaId) || + seenImageMediaIds.has(imageMediaId) || !weazelNewsImageUrl(imageMediaId) ) { return { error: 'invalid_attachment' } } + seenImageMediaIds.add(imageMediaId) + imageMediaIds.push(imageMediaId) } + const images = weazelNewsImages(imageMediaIds) + const firstImage = images[0] ?? null + return { article: { body, category: data.category, excerpt: weazelNewsExcerpt(body), - imageMediaId, - imageUrl: weazelNewsImageUrl(imageMediaId), + imageMediaId: firstImage?.mediaId ?? null, + imageUrl: firstImage?.url ?? null, + images, status, title, }, @@ -4822,6 +4855,7 @@ app.post('/api/:endpoint', (request, response) => { ...(canManageWeazelNews ? { jobGradeLabel: 'Senior Reporter', jobLabel: 'Weazel News' } : {}), + maximumImages: weazelNewsMaxImages, }, }) return @@ -7730,9 +7764,7 @@ app.post('/api/:endpoint', (request, response) => { const property = mockHousingOverview.properties.find( (item) => item.id === request.body.propertyId, ) - const existingNames = new Set( - (property?.keys ?? []).map((key) => key.name), - ) + const existingNames = new Set((property?.keys ?? []).map((key) => key.name)) response.json({ success: true, data: { @@ -7774,8 +7806,7 @@ app.post('/api/:endpoint', (request, response) => { if (request.body.action === 'revoke_key') { property.keys = (property.keys ?? []).filter( (key) => - key.identifier !== request.body.identifier || - key.revocable === false, + key.identifier !== request.body.identifier || key.revocable === false, ) } response.json({ success: true, data: { accepted: true } }) diff --git a/frontend/testserver/smoke.cjs b/frontend/testserver/smoke.cjs index 8b17496..c0b5247 100644 --- a/frontend/testserver/smoke.cjs +++ b/frontend/testserver/smoke.cjs @@ -65,6 +65,8 @@ const browserDataRequests = [ ['skyride:history', {}], ['skyride:get-player-coords', {}], ['weather:get', {}], + ['weazel-news:context', {}], + ['weazel-news:list', { category: null, offset: 0, search: '' }], ] async function post(baseUrl, endpoint, body = {}) { @@ -104,7 +106,89 @@ async function verifyStatefulActions(baseUrl) { true, ) gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true) - assert.equal(gallery.find((item) => item.id === gallery[0].id)?.favorite, true) + assert.equal( + gallery.find((item) => item.id === gallery[0].id)?.favorite, + true, + ) + + const articlePhotos = gallery + .filter((item) => item.mediaType === 'photo') + .slice(0, 7) + assert.equal( + articlePhotos.length, + 7, + 'gallery:list did not include enough photos for Weazel News', + ) + const weazelContext = await expectSuccess( + baseUrl, + 'weazel-news:context', + {}, + true, + ) + assert.equal(weazelContext.maximumImages, 6) + const createdArticleResponse = await expectSuccess( + baseUrl, + 'weazel-news:create', + { + body: 'Created by the browser mock smoke test with several photos.', + category: 'news', + imageMediaIds: articlePhotos.slice(0, 3).map((item) => item.id), + status: 'published', + title: 'Browser test Weazel article', + }, + true, + ) + const createdArticle = createdArticleResponse.article + assert.deepEqual( + createdArticle.images.map((image) => image.mediaId), + articlePhotos.slice(0, 3).map((item) => item.id), + 'weazel-news:create did not preserve image order', + ) + assert.equal(createdArticle.imageMediaId, articlePhotos[0].id) + + const updatedArticleResponse = await expectSuccess( + baseUrl, + 'weazel-news:update', + { + body: createdArticle.body, + category: 'business', + id: createdArticle.id, + imageMediaIds: [articlePhotos[2].id, articlePhotos[0].id], + revision: createdArticle.revision, + status: 'draft', + title: 'Updated browser test Weazel article', + }, + true, + ) + const updatedArticle = updatedArticleResponse.article + assert.deepEqual( + updatedArticle.images.map((image) => image.mediaId), + [articlePhotos[2].id, articlePhotos[0].id], + 'weazel-news:update did not preserve the reordered images', + ) + assert.equal(updatedArticle.imageMediaId, articlePhotos[2].id) + + const loadedArticleResponse = await expectSuccess( + baseUrl, + 'weazel-news:get', + { id: updatedArticle.id, manage: true }, + true, + ) + assert.deepEqual(loadedArticleResponse.article.images, updatedArticle.images) + + const tooManyImages = await post(baseUrl, 'weazel-news:create', { + body: 'This article must be rejected because it has too many photos.', + category: 'news', + imageMediaIds: articlePhotos.map((item) => item.id), + status: 'draft', + title: 'Invalid Weazel article', + }) + assert.equal(tooManyImages.success, false) + assert.equal(tooManyImages.error, 'invalid_attachment') + await expectSuccess(baseUrl, 'weazel-news:delete', { + id: updatedArticle.id, + revision: updatedArticle.revision, + }) const memoBootstrap = await expectSuccess( baseUrl, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 0eca1cf..df39654 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -302,6 +302,14 @@ Locales["en"] = { category = "Category", status = "Publication Status", cover = "Cover Image", + photos = "Article Photos", + photoCount = "{count} of {maximum} photos", + addPhotos = "Add Photos", + choosePhotos = "Choose from Photos", + takePhoto = "Take Photo", + photoSourceHint = "Choose several photos from your library or take a new one.", + primaryPhoto = "Cover", + makePrimary = "Use as Cover", chooseCover = "Choose from Photos", changeCover = "Change Cover", removeCover = "Remove Cover", @@ -335,8 +343,10 @@ Locales["en"] = { deleteArticle = "Delete {title}", search = "Search Weazel News", clearSearch = "Clear article search", + articleImages = "Article photos", coverPreview = "Article cover preview", removeCover = "Remove the selected cover image", + removePhoto = "Remove this article photo", status = "Article status: {status}", }, errors = { diff --git a/sky_phone/config/weazel_news.lua b/sky_phone/config/weazel_news.lua index af0ecc0..868e8a2 100644 --- a/sky_phone/config/weazel_news.lua +++ b/sky_phone/config/weazel_news.lua @@ -2,6 +2,7 @@ Config.WeazelNews = { Enabled = true, PageSize = 20, MaximumOffset = 10000, + MaximumImages = 6, SearchMaxLength = 80, DraftTitleMinLength = 1, DraftBodyMinLength = 1, diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 2305a49..2db600b 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -2663,9 +2663,34 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_weazel_article_media", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "article_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "media_id", type = "BIGINT UNSIGNED NOT NULL" }, + { name = "position", type = "TINYINT UNSIGNED NOT NULL" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_weazel_article_position", columns = "(`article_id`, `position`)" }, + { name = "uniq_sky_phone_weazel_article_media", columns = "(`article_id`, `media_id`)" }, + }, + foreignKeys = { + { column = "article_id", references = "`sky_phone_weazel_articles` (`id`) ON DELETE CASCADE" }, + { column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) +Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`) + SELECT `id`, `image_media_id`, 1 + FROM `sky_phone_weazel_articles` + WHERE `image_media_id` IS NOT NULL +]], {}) Bridge.Database.Query("DELETE FROM `sky_phone_feather_notifications` WHERE `kind` = 'repost'", {}) Bridge.Database.Query("DELETE FROM `sky_phone_feather_reactions` WHERE `kind` = 'repost'", {}) Bridge.Database.Query([[ diff --git a/sky_phone/source/server/weazel_news.lua b/sky_phone/source/server/weazel_news.lua index a70f797..bfde62e 100644 --- a/sky_phone/source/server/weazel_news.lua +++ b/sky_phone/source/server/weazel_news.lua @@ -17,6 +17,7 @@ if type(config.Enabled) ~= "boolean" then end require_integer_config("PageSize", 1, 100) require_integer_config("MaximumOffset", 0, 1000000) +require_integer_config("MaximumImages", 1, 6) require_integer_config("SearchMaxLength", 1, 256) require_integer_config("DraftTitleMinLength", 1, 160) require_integer_config("DraftBodyMinLength", 1, 12000) @@ -219,6 +220,7 @@ local function article_dto(row) category = row.category, imageUrl = row.image_url, imageMediaId = row.image_media_id and tonumber(row.image_media_id) or nil, + images = {}, authorName = row.author_name, createdAt = created_at * 1000, updatedAt = updated_at * 1000, @@ -228,6 +230,54 @@ local function article_dto(row) } end +local function attach_article_images(articles) + if #articles < 1 then + return articles + end + + local placeholders = {} + local article_ids = {} + local articles_by_id = {} + for index, article in ipairs(articles) do + placeholders[index] = "?" + article_ids[index] = article.id + article.images = {} + articles_by_id[article.id] = article + end + + local rows = Bridge.Database.Query(([[ + SELECT relation.`article_id`, relation.`media_id`, relation.`position`, media.`url` + FROM `sky_phone_weazel_article_media` relation + JOIN `sky_phone_media` media + ON media.`id` = relation.`media_id` AND media.`media_type` = 'photo' + WHERE relation.`article_id` IN (%s) + ORDER BY relation.`article_id`, relation.`position`, relation.`id` + ]]):format(table.concat(placeholders, ", ")), article_ids) + for _, row in ipairs(rows) do + local article = articles_by_id[row.article_id] + local media_id = tonumber(row.media_id) + if article and media_id and type(row.url) == "string" then + article.images[#article.images + 1] = { + mediaId = media_id, + url = row.url, + } + end + end + + for _, article in ipairs(articles) do + if #article.images < 1 and article.imageMediaId and article.imageUrl then + article.images[1] = { + mediaId = article.imageMediaId, + url = article.imageUrl, + } + end + local cover = article.images[1] + article.imageMediaId = cover and cover.mediaId or nil + article.imageUrl = cover and cover.url or nil + end + return articles +end + local function load_article(id, include_drafts) local visibility = include_drafts and "" or " AND article.`status` = 'published'" local rows = Bridge.Database.Query(([[ @@ -237,7 +287,8 @@ local function load_article(id, include_drafts) WHERE article.`id` = ? AND article.`deleted_at` IS NULL%s LIMIT 1 ]]):format(article_detail_columns, visibility), { id }) - return article_dto(rows[1]) + local article = article_dto(rows[1]) + return article and attach_article_images({ article })[1] or nil end local function query_articles(where_clause, parameters, order_clause, offset) @@ -264,10 +315,42 @@ local function query_articles(where_clause, parameters, order_clause, offset) for _, row in ipairs(rows) do articles[#articles + 1] = article_dto(row) end - return articles, has_more + return attach_article_images(articles), has_more end -local function validate_article(source, data, retained_media_id) +local function validate_article_media(source, data, retained_media_ids) + local values = data.imageMediaIds + if values == nil then + values = data.imageMediaId ~= nil and { data.imageMediaId } or {} + end + if type(values) ~= "table" or #values > config.MaximumImages then + return nil + end + for key in pairs(values) do + if type(key) ~= "number" or key < 1 or key > #values or key ~= math.floor(key) then + return nil + end + end + + local media_ids = {} + local seen = {} + for index, value in ipairs(values) do + local media_id = valid_integer(value, 1, 9007199254740991) + if not media_id or seen[media_id] then + return nil + end + if not retained_media_ids[media_id] + and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo") + then + return nil + end + seen[media_id] = true + media_ids[index] = media_id + end + return media_ids +end + +local function validate_article(source, data, retained_media_ids) if type(data) ~= "table" then return nil, "invalid_article" end @@ -287,29 +370,40 @@ local function validate_article(source, data, retained_media_id) return nil, status == "draft" and "invalid_draft" or "invalid_publish" end - local media_id - if data.imageMediaId ~= nil then - media_id = valid_integer(data.imageMediaId, 1, 9007199254740991) - if not media_id then - return nil, "invalid_attachment" - end - if media_id ~= retained_media_id then - local url = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo") - if not url then - return nil, "invalid_attachment" - end - end + local media_ids = validate_article_media(source, data, retained_media_ids or {}) + if not media_ids then + return nil, "invalid_attachment" end return { title = title, body = body, excerpt = make_excerpt(body), category = data.category, - image_media_id = media_id, + image_media_id = media_ids[1], + image_media_ids = media_ids, status = status, } end +local function article_matches(article, expected, revision) + if not article or article.revision ~= revision + or article.title ~= expected.title + or article.body ~= expected.body + or article.excerpt ~= expected.excerpt + or article.category ~= expected.category + or article.status ~= expected.status + or #article.images ~= #expected.image_media_ids + then + return false + end + for index, media_id in ipairs(expected.image_media_ids) do + if article.images[index].mediaId ~= media_id then + return false + end + end + return true +end + Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source) local _, error_response = require_phone(source, "read", config.RateLimits.Read) if error_response then @@ -340,6 +434,7 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source) jobLabel = access and access.job_label or nil, jobGradeLabel = access and access.grade_label or nil, categories = category_context, + maximumImages = config.MaximumImages, }, } end) @@ -466,24 +561,39 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:create", function(source, data) if not valid_uuid(id) then error("[sky_phone] Database did not generate a Weazel News article id.") end - Bridge.Database.Query([[ - INSERT INTO `sky_phone_weazel_articles` - (`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`, - `author_name`, `updated_by_identifier`, `status`, `published_at`) - VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL)) - ]], { - id, - article.title, - article.body, - article.excerpt, - article.category, - article.image_media_id or 0, - actor.identifier, - actor.name, - actor.identifier, - article.status, - article.status, - }) + local statements = {{ + query = [[ + INSERT INTO `sky_phone_weazel_articles` + (`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`, + `author_name`, `updated_by_identifier`, `status`, `published_at`) + VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL)) + ]], + params = { + id, + article.title, + article.body, + article.excerpt, + article.category, + article.image_media_id or 0, + actor.identifier, + actor.name, + actor.identifier, + article.status, + article.status, + }, + }} + for position, media_id in ipairs(article.image_media_ids) do + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`) + VALUES (?, ?, ?) + ]], + params = { id, media_id, position }, + } + end + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } + end local created = load_article(id, true) if not created then error(("[sky_phone] Could not reload created Weazel News article '%s'."):format(id)) @@ -517,8 +627,23 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data) if tonumber(current.revision) ~= revision then return { success = false, error = "revision_conflict" } end - local retained_media_id = current.image_media_id and tonumber(current.image_media_id) or nil - local article, validation_error = validate_article(source, data, retained_media_id) + local retained_media_ids = {} + local retained_cover_id = current.image_media_id and tonumber(current.image_media_id) or nil + if retained_cover_id then + retained_media_ids[retained_cover_id] = true + end + local retained_rows = Bridge.Database.Query([[ + SELECT `media_id` + FROM `sky_phone_weazel_article_media` + WHERE `article_id` = ? + ]], { data.id }) + for _, row in ipairs(retained_rows) do + local media_id = tonumber(row.media_id) + if media_id then + retained_media_ids[media_id] = true + end + end + local article, validation_error = validate_article(source, data, retained_media_ids) if not article then return { success = false, error = validation_error } end @@ -526,34 +651,80 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data) if not actor then return { success = false, error = "request_failed" } end - local result = Bridge.Database.Query([[ - UPDATE `sky_phone_weazel_articles` - SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?, `image_media_id` = NULLIF(?, 0), - `published_at` = CASE - WHEN ? = 'draft' THEN NULL - WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP - ELSE `published_at` - END, - `status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1 - WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL - ]], { - article.title, - article.body, - article.excerpt, - article.category, - article.image_media_id or 0, - article.status, - article.status, - actor.identifier, - data.id, - revision, - }) - if affected_rows(result) ~= 1 then - return { success = false, error = "revision_conflict" } + local mutation_rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + local mutation_id = mutation_rows[1] and mutation_rows[1].id + if not valid_uuid(mutation_id) then + error("[sky_phone] Database did not generate a Weazel News mutation id.") + end + local mutation_token = "weazel:" .. mutation_id + local next_revision = revision + 1 + local statements = { + { + query = [[ + UPDATE `sky_phone_weazel_articles` + SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?, + `image_media_id` = NULLIF(?, 0), + `published_at` = CASE + WHEN ? = 'draft' THEN NULL + WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP + ELSE `published_at` + END, + `status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1 + WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL + ]], + params = { + article.title, + article.body, + article.excerpt, + article.category, + article.image_media_id or 0, + article.status, + article.status, + mutation_token, + data.id, + revision, + }, + }, + { + query = [[ + DELETE relation + FROM `sky_phone_weazel_article_media` relation + JOIN `sky_phone_weazel_articles` article ON article.`id` = relation.`article_id` + WHERE article.`id` = ? AND article.`revision` = ? + AND article.`updated_by_identifier` = ? + ]], + params = { data.id, next_revision, mutation_token }, + }, + } + for position, media_id in ipairs(article.image_media_ids) do + statements[#statements + 1] = { + query = [[ + INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`) + SELECT article.`id`, ?, ? + FROM `sky_phone_weazel_articles` article + WHERE article.`id` = ? AND article.`revision` = ? + AND article.`updated_by_identifier` = ? AND article.`deleted_at` IS NULL + ]], + params = { media_id, position, data.id, next_revision, mutation_token }, + } + end + statements[#statements + 1] = { + query = [[ + UPDATE `sky_phone_weazel_articles` + SET `updated_by_identifier` = ? + WHERE `id` = ? AND `revision` = ? AND `updated_by_identifier` = ? + ]], + params = { actor.identifier, data.id, next_revision, mutation_token }, + } + if not Bridge.Database.Transaction(statements) then + return { success = false, error = "request_failed" } end local updated = load_article(data.id, true) if not updated then - error(("[sky_phone] Could not reload updated Weazel News article '%s'."):format(data.id)) + return { success = false, error = "not_found" } + end + if not article_matches(updated, article, next_revision) then + return { success = false, error = "revision_conflict" } end return { success = true, data = { article = updated } } end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 96ca343..f15348a 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -1244,3 +1244,15 @@ CREATE TABLE IF NOT EXISTS `sky_phone_weazel_articles` ( KEY `idx_sky_phone_weazel_media` (`image_media_id`), FOREIGN KEY (`image_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_weazel_article_media` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `article_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_id` BIGINT UNSIGNED NOT NULL, + `position` TINYINT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_weazel_article_position` (`article_id`,`position`), + UNIQUE KEY `uniq_sky_phone_weazel_article_media` (`article_id`,`media_id`), + FOREIGN KEY (`article_id`) REFERENCES `sky_phone_weazel_articles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; From 8de16131533a6803e182224c6545707f1b089110 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:49:08 +0200 Subject: [PATCH 08/31] ENH - refine Photos gallery interactions --- frontend/src/ui/overlays.css | 112 ++++++ frontend/src/ui/overlays/SkyDropdown.test.ts | 62 ++++ frontend/src/ui/overlays/SkyDropdown.vue | 82 +++++ frontend/src/ui/overlays/index.ts | 1 + frontend/src/utils/media.test.ts | 48 +++ .../views/apps/GalleryApp.contract.test.ts | 95 +++++- frontend/src/views/apps/GalleryApp.vue | 321 ++++++++++-------- .../sky-ui-demo/pages/PopoverDemo.vue | 33 ++ 8 files changed, 605 insertions(+), 149 deletions(-) create mode 100644 frontend/src/ui/overlays/SkyDropdown.test.ts create mode 100644 frontend/src/ui/overlays/SkyDropdown.vue diff --git a/frontend/src/ui/overlays.css b/frontend/src/ui/overlays.css index cde0128..ae9b224 100644 --- a/frontend/src/ui/overlays.css +++ b/frontend/src/ui/overlays.css @@ -858,6 +858,106 @@ border-radius: inherit; } +.sky-dropdown .sky-popover__panel { + width: 252px; + border: 1px solid var(--sky-hairline); + border-radius: 28px; + background: var(--sky-glass-solid, var(--sky-surface)); + box-shadow: + var(--sky-shadow-glass), + 0 16px 36px rgba(0, 0, 0, 0.28); +} + +.sky-dropdown__menu { + padding: 6px; +} + +.sky-dropdown__item { + width: 100%; + min-height: var(--sky-touch-target, 44px); + position: relative; + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 22px; + align-items: center; + gap: 8px; + border: 0; + border-radius: 12px; + padding: 8px 14px 8px 12px; + background: transparent; + color: var(--sky-text); + font: inherit; + font-size: 17px; + line-height: 1.25; + text-align: left; + cursor: pointer; + transition: + background-color var(--sky-transition-fast, 100ms) ease, + box-shadow var(--sky-transition-fast, 100ms) ease; +} + +.sky-dropdown__item--separator { + margin-top: 5px; + padding-top: 13px; +} + +.sky-dropdown__item--separator::before { + content: ''; + height: 1px; + position: absolute; + top: 0; + right: 14px; + left: 52px; + background: var(--sky-hairline); + transform: scaleY(var(--sky-hairline-scale, 1)); + transform-origin: center top; +} + +.sky-dropdown__indicator, +.sky-dropdown__chevron { + display: grid; + place-items: center; +} + +.sky-dropdown__label { + min-width: 0; + overflow-wrap: anywhere; +} + +.sky-dropdown__chevron { + color: var(--sky-muted); +} + +.sky-dropdown__item--destructive { + color: var(--sky-danger); +} + +.sky-dropdown__item:focus-visible, +.sky-dropdown__item:active { + background: var(--sky-pressed); +} + +@media (hover: hover) { + .sky-dropdown__item:hover:not(:disabled) { + background: var(--sky-pressed); + box-shadow: inset 0 0 0 1px var(--sky-hairline); + } +} + +.sky-dropdown__item:focus-visible { + outline: 2px solid var(--sky-app-accent); + outline-offset: -2px; +} + +.sky-dropdown__item:disabled { + opacity: 0.45; + cursor: default; +} + +.sky-dropdown__item:disabled:hover, +.sky-dropdown__item:disabled:active { + background: transparent; +} + .sky-popover__arrow-wrap { position: absolute; z-index: 2; @@ -951,6 +1051,18 @@ @supports ( (-webkit-backdrop-filter: blur(16px)) or (backdrop-filter: blur(16px)) ) { + .sky-dropdown .sky-popover__panel { + background: + linear-gradient( + 145deg, + var(--sky-dropdown-glass-highlight, rgba(255, 255, 255, 0.12)) 0%, + transparent 42% + ), + var(--sky-glass); + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + } + .sky-popover__arrow { background: var(--sky-glass); -webkit-backdrop-filter: blur(16px); diff --git a/frontend/src/ui/overlays/SkyDropdown.test.ts b/frontend/src/ui/overlays/SkyDropdown.test.ts new file mode 100644 index 0000000..a444945 --- /dev/null +++ b/frontend/src/ui/overlays/SkyDropdown.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +const component = readFileSync( + fileURLToPath(new URL('./SkyDropdown.vue', import.meta.url)), + 'utf8', +) +const overlays = readFileSync( + fileURLToPath(new URL('../overlays.css', import.meta.url)), + 'utf8', +) + +describe('SkyDropdown', () => { + it('builds the shared dropdown on the positioned SkyPopover primitive', () => { + expect(component).toContain(' { + expect(component).toContain("'menuitemradio'") + expect(component).toContain(':aria-checked=') + expect(component).toContain(':aria-haspopup=') + expect(component).toContain('item.destructive') + expect(component).toContain('item.disabled') + expect(component).toContain('item.separatorBefore') + expect(component).toContain(' { + expect(overlays).toMatch( + /\.sky-dropdown__item\s*\{[^}]*min-height:\s*var\(--sky-touch-target, 44px\)/s, + ) + expect(overlays).toMatch( + /\.sky-dropdown__menu\s*\{[^}]*padding:\s*6px;/s, + ) + expect(overlays).toMatch( + /\.sky-dropdown__item\s*\{[^}]*border-radius:\s*12px;/s, + ) + expect(overlays).toContain('.sky-dropdown__item:hover') + expect(overlays).toContain( + 'box-shadow: inset 0 0 0 1px var(--sky-hairline);', + ) + expect(overlays).toContain('.sky-dropdown__item:focus-visible') + expect(overlays).toContain('.sky-dropdown__item:active') + expect(overlays).toContain('background: var(--sky-pressed);') + }) + + it('uses a readable solid fallback with progressive glass enhancement', () => { + expect(overlays).toMatch( + /\.sky-dropdown \.sky-popover__panel\s*\{[^}]*background:\s*var\(--sky-glass-solid, var\(--sky-surface\)\)/s, + ) + expect(overlays).toMatch( + /@supports[\s\S]*?\.sky-dropdown \.sky-popover__panel\s*\{[^}]*var\(--sky-glass\)[^}]*backdrop-filter:\s*blur\(16px\)/s, + ) + expect(overlays).toContain('border: 1px solid var(--sky-hairline);') + }) +}) diff --git a/frontend/src/ui/overlays/SkyDropdown.vue b/frontend/src/ui/overlays/SkyDropdown.vue new file mode 100644 index 0000000..e87c135 --- /dev/null +++ b/frontend/src/ui/overlays/SkyDropdown.vue @@ -0,0 +1,82 @@ + + + diff --git a/frontend/src/ui/overlays/index.ts b/frontend/src/ui/overlays/index.ts index b31f340..a51777a 100644 --- a/frontend/src/ui/overlays/index.ts +++ b/frontend/src/ui/overlays/index.ts @@ -4,6 +4,7 @@ export { default as SkyActionSheet } from './SkyActionSheet.vue' export { default as SkyActionsLabel } from './SkyActionsLabel.vue' export { default as SkyDialog } from './SkyDialog.vue' export { default as SkyDialogButton } from './SkyDialogButton.vue' +export { default as SkyDropdown } from './SkyDropdown.vue' export { default as SkyMessage } from './SkyMessage.vue' export { default as SkyMessagebar } from './SkyMessagebar.vue' export { default as SkyMessages } from './SkyMessages.vue' diff --git a/frontend/src/utils/media.test.ts b/frontend/src/utils/media.test.ts index 8a6e1a9..4fa6c6d 100644 --- a/frontend/src/utils/media.test.ts +++ b/frontend/src/utils/media.test.ts @@ -59,6 +59,54 @@ describe('media utilities', () => { expect(bottomRightGridPosition(10, 11)).toEqual({ column: 2, row: 1 }) }) + it('places newest-first media from the bottom-right toward older rows', () => { + const newestFirst = orderMedia( + [ + { + createdAt: 10, + favorite: false, + id: 1, + mediaType: 'photo', + url: 'oldest', + }, + { + createdAt: 20, + favorite: false, + id: 2, + mediaType: 'photo', + url: 'middle', + }, + { + createdAt: 30, + favorite: false, + id: 3, + mediaType: 'photo', + url: 'newest', + }, + { + createdAt: 5, + favorite: false, + id: 4, + mediaType: 'photo', + url: 'older-row', + }, + ], + 'newest', + ) + + expect( + newestFirst.map((entry, index) => ({ + id: entry.id, + ...bottomRightGridPosition(index, newestFirst.length), + })), + ).toEqual([ + { column: 3, id: 3, row: 2 }, + { column: 2, id: 2, row: 2 }, + { column: 1, id: 1, row: 2 }, + { column: 3, id: 4, row: 1 }, + ]) + }) + it('loads another gallery page only after a full 30-item batch', () => { expect(hasNextMediaPage(30)).toBe(true) expect(hasNextMediaPage(29)).toBe(false) diff --git a/frontend/src/views/apps/GalleryApp.contract.test.ts b/frontend/src/views/apps/GalleryApp.contract.test.ts index acb0999..d0da3dd 100644 --- a/frontend/src/views/apps/GalleryApp.contract.test.ts +++ b/frontend/src/views/apps/GalleryApp.contract.test.ts @@ -12,7 +12,7 @@ const headerActions = source.slice( ) describe('GalleryApp import action', () => { - it('uses the photo viewer toolbar button design', () => { + it('keeps import available as a header button', () => { expect(headerActions).toContain(' { expect(headerActions).toContain( ':aria-label="phone.t(\'Apps.photos.import.action\')"', ) - expect(headerActions).toContain( - ':title="phone.t(\'Apps.photos.import.action\')"', - ) - expect(headerActions).not.toContain( - "{{ phone.t('Apps.photos.import.action') }}", - ) + expect(headerActions).toContain('@click="openImport"') + expect(source).not.toContain("id: 'import'") expect(headerActions).not.toContain('tonal') }) @@ -42,23 +38,62 @@ describe('GalleryApp import action', () => { expect(source).toContain('gridColumnStart: bottomRightGridPosition(') expect(source).toContain('gridRowStart: bottomRightGridPosition(') expect(source).toContain('var(--sky-navbar-large-title-height) - 30px') + expect(source).toContain( + 'grid-template-columns: minmax(0, 1fr) 0 max-content;', + ) + expect(source).toMatch( + /\.gallery-library-navbar :deep\(\.sky-navbar__right\)\s*\{[^}]*overflow:\s*visible;[^}]*background:\s*transparent;[^}]*box-shadow:\s*none;[^}]*backdrop-filter:\s*none;/s, + ) + expect(source).toMatch( + /\.gallery-header-tool\s*\{[^}]*height:\s*var\(--sky-touch-target\);/s, + ) }) it('opens an accessible sort menu from the large header', () => { expect(headerActions).toContain(' { + expect(source).toContain("const sortOrder = ref('newest')") + expect(source).toContain( + 'galleryContent.value.scrollTop = galleryContent.value.scrollHeight', + ) + expect(source).toContain('v-if="hasMore"') + expect(source).toContain('class="gallery-load-trigger"') + expect(source).toContain('position: absolute;') + expect(source).toContain('top: 0;') + }) + it('supports selecting, sharing, and deleting multiple media items', () => { expect(headerActions).toContain("phone.t('Apps.photos.selection.action')") expect(headerActions).toContain('enterSelectionMode') expect(source).toContain('v-if="selectionMode"') expect(source).toContain('selectedCountText') expect(source).toContain('shareSelection') + expect(source.match(//g)).toHaveLength( + 2, + ) + expect(source).toContain(' @@ -1296,29 +1364,33 @@ onBeforeUnmount(() => { {{ selectedCountText }} - - - - - - + @@ -1336,7 +1408,8 @@ onBeforeUnmount(() => { @@ -1371,6 +1444,7 @@ onBeforeUnmount(() => { @lostpointercapture="stopDragging" @keydown="moveImageWithKeyboard" @dblclick="setZoom(imageZoom === 1 ? 2 : 1)" + @wheel.prevent="zoomImageWithWheel" />