Compare commits

...

28 Commits

Author SHA1 Message Date
DerEchteAlec 3f192ae389 Revert "ADD - add in-game phone administration and configuration (#21)"
This reverts commit edd9677f43.
2026-08-21 21:47:54 +02:00
DerEchteAlec 21913b585c FIX - use repository-native NUI build instructions (#22)
Replace the unavailable workspace-only build_frontend.bat hint with the frontend pnpm commands shipped in the GitHub source tree. Link the pushed frontend development guide and keep the local-only script name out of the startup diagnostic through regression coverage.
2026-08-21 18:14:39 +02:00
Alec Schitzkat e056d0f05c BLD - Bump version to 0.2.5 2026-08-21 17:35:36 +02:00
Leon.Schmidt edd9677f43 ADD - add in-game phone administration and configuration (#21)
* ADD - build in-game admin command center

* ADD - open admin panel by command

* ENH - rebuild admin panel as standalone editor

* ENH - compact admin workspace navigation

* ENH - expand admin device controls

* ENH - refine admin panel navigation style

* ADD - add SQL phone configurator

* FIX - expose complete phone configuration

* FIX - make company jobs freely configurable

* FIX - align configurator tables to left

* ENH - organize configurator collection controls

* ENH - organize nested configurator sections

* ENH - add configurator field descriptions

* FIX - hide fixed configurator add controls

* ENH - refine configurator editing experience

* ENH - refine admin configurator controls

* FIX - apply configurator settings instantly

* FIX - close live activity command registration
2026-08-21 17:28:30 +02:00
DerEchteAlec 97db163cd0 FIX - harden inventory contracts and resolve phone interaction issues (#20)
* FIX - repair inventory metadata and ESX contracts

Use the stable core_inventory metadata setter and validate the complete inventory adapter contract after provider bridges load. Preserve the real ESX configuration error instead of cascading into a missing RegisterUsableItem failure.

* FIX - clarify phone opening without SIM

* FIX - harden inventory and device contracts

Validate SIM number configuration before item handling, preserve QB metadata mutation contracts, and propagate phone-open failures to callers. Extend regression coverage for inventory bridges and device bootstrap errors.

* FIX - resolve reported phone interaction issues

Stop game-input passthrough while NUI text fields are focused and add an independent hold-to-look option. Propagate SIM number formatting to the frontend, migrate configured currency symbols to utf8mb4, and clarify SkyRide, VaultX, and DarkChat behavior with focused contract coverage.

* FIX - detect missing packaged phone UI

Validate the generated NUI entrypoint, its referenced assets, and required static media when the resource starts. Print an actionable server-console warning for incomplete source archives, package phone sounds through the manifest, and cover the detection contract with Lua regressions.
2026-08-21 17:19:27 +02:00
Alec Schitzkat 698420584d ADD - Restore sky_phone fxmanifest.lua
Restore the resource manifest file to define the project structure, dependencies, and script entry points for version 0.2.1.
2026-08-21 01:33:59 +02:00
Alec Schitzkat 56901621a0 CLN - Remove sky_phone fxmanifest.lua
Remove the manifest file for the sky_phone resource, effectively disabling the resource or preparing it for structural relocation.
2026-08-21 01:33:29 +02:00
Leon.Schmidt 7b4e445394 FIX - restore FiveManage camera and memo uploads (#19)
* TRY - isolate FiveManage media upload verification

* TRY - verify unindexed FiveManage storage objects

* FIX - bind FiveManage uploads by storage key

* FIX - match FiveManage camera upload flow

* FIX - match FiveManage memo upload flow
2026-08-21 01:29:56 +02:00
Alec Schitzkat 7bbafd0026 BLD - Bump version to 0.2.0 2026-08-20 21:35:31 +02:00
DerEchteAlec 300329825f Merge pull request #4 from sky-systems/feat/inter-font
ENH - use Inter across phone UI and improve home label readability
2026-08-20 19:21:05 +02:00
DerEchteAlec 93d2263f14 FIX - resolve PR 4 merge conflict 2026-08-20 19:18:23 +02:00
DerEchteAlec dca0868753 FIX - stabilize homescreen dragging across scaling (#18)
* FIX - stabilize homescreen dragging across scaling

Render the home drag preview in an unzoomed viewport portal and normalize FiveM CEF DOM geometry before hit testing and drop settling. Cover Chrome 103, fractional scaling, 4K, ultrawide, and non-16:9 viewport behavior.

* FIX - Implement LB guest app layout and lifecycle bridge

Ensure custom LB apps are properly sized and visible by applying the required CSS contract on initialization and upon receiving the 'componentsLoaded' signal.

* FIX - match uppercase script tags in LB bridge test
2026-08-20 19:14:00 +02:00
Leon.Schmidt 36b859cabb ENH - add persistent dynamic island activities (#17)
* FIX - layer dynamic island above phone camera

The phone screen stacking context kept the Dynamic Island below the physical frame and its camera lens. Render the island at device-shell level, position it around the lens, and keep expanded notification spacing in sync.

* ADD - preview all dynamic island states

Reuse the runtime component for a development-only gallery and size expanded previews to the actual phone display so they cannot be clipped.

* FIX - match dynamic island live activity behavior

Show each activity only outside its owning app, use reference-sized activity layouts, and remove the separate expand glyph in favor of direct island taps.

* FIX - refine dynamic island live controls

Keep background activity state separate from foreground visibility so the island remains visible in the compact closed-phone peek. Match the timer, stopwatch, music, collapse, and spacing behavior to the supplied references.

* FIX - keep business call availability after closing

Stop treating the closed phone UI as the end of business-call readiness. Route background service-line calls only after revalidating the employee, owned device, and registered SIM.

* FIX - remove dock apps from home grid

The stored Home layout treated dock and grid shortcuts independently, so default dock apps appeared twice. Let the dock own those default placements and migrate saved layouts by removing matching grid shortcuts.

* FIX - keep memo island active after closing

Memo recording was cancelled as soon as the UI closed, while uploads required an active UI session and reopening a locked phone forced the home route. Keep the recorder bound to the physical device, revalidate device ownership server-side for uploads, preserve the last route behind the lock screen, and render only a compact frame, display, and live Dynamic Island while closed.

* ENH - align live activity controls with phone hardware

Report compact live-activity state to the client so Space opens Home or the enabled lock screen contextually. Apply the phone hardware volume as a shared output level for app media, games, YouTube playback, radio, and auxiliary sounds, and render the volume speaker icon in grey.

* FIX - hide paused music live activity

The music island previously depended only on the selected track, which remains set after playback pauses. Require active playback as well so the island closes when music stops while preserving the track for later resume.

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
2026-08-20 18:59:51 +02:00
DerEchteAlec d0317a35c0 ENH - refine phone setup (#15)
* ENH - refine phone setup experience

* FIX - refine settings header and search icon

* FIX - refine compact weather layout

* ENH - fade empty music widget content

* ENH - align gallery filter navigation

* FIX - align memo player content

* ENH - refine notes header and compose controls

* ENH - refine phone liquid glass controls

* ENH - apply liquid glass across app controls

* FIX - correct messages GIF picker layout

---------

Co-authored-by: smx.pusha <139338836+smxpusha@users.noreply.github.com>
2026-08-20 18:26:03 +02:00
Leon.Schmidt 30559048fd FIX - complete LB custom app compatibility (#11)
* FIX - preserve LB custom app lifecycle

* FIX - reset LB export aliases before startup

* FIX - report competing custom app providers

* FIX - identify LB app frames as live NUI

* FIX - complete LB custom app compatibility

* FIX - provide LB PicChat runtime exports

* FIX - route LB PicChat through Sky Phone

* FIX - return cached LB equipped phone number

* FIX - harden PicChat compatibility migration

* ENH - complete modular phone provider and Creator APIs

* DOC - document the Sky Phone Creator API

---------

Co-authored-by: DerEchteAlec <bycraky@gmail.com>
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-20 18:19:16 +02:00
DerEchteAlec 9aec3eefef FIX - resolve CodeQL security alerts (#16) 2026-08-20 17:26:26 +02:00
Leon.Schmidt 5b5e9fe914 FIX - allow movement outside phone text fields (#14) 2026-08-20 15:47:39 +02:00
smx.pusha daa67cfb1e ENH - refine phone setup experience (#12) 2026-08-20 08:55:03 +02:00
DerEchteAlec 3eae2b8d30 ADD - check GitHub releases on startup (#10)
Compare the fxmanifest version against the latest published sky_phone release tag and report update, current, ahead, and failure states without blocking resource startup.

Document the startup check and cover the HTTP and version comparison behavior with a focused Lua test.
2026-08-19 20:57:02 +02:00
DerEchteAlec 074117fe33 Merge branch 'dev' into feat/inter-font 2026-08-19 20:11:48 +02:00
Dominik 734f4651cb ADD - add housing provider bridges (#6)
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-19 20:11:36 +02:00
DerEchteAlec 98373e8bd8 FIX - await setup persistence before completion (#9) 2026-08-19 20:08:33 +02:00
DerEchteAlec a1025f4520 Merge branch 'dev' into feat/inter-font 2026-08-19 19:21:42 +02:00
DerEchteAlec 7b8bab75a8 Merge branch 'dev' into feat/inter-font 2026-08-19 15:56:56 +02:00
DerEchteAlec c0492a70cb BLD - merge dev into Inter font branch 2026-08-19 15:34:49 +02:00
DerEchteAlec 05169acbfe BLD - add GitHub contribution and release automation 2026-08-19 14:20:46 +02:00
DerEchteAlec 086b77e464 ENH - enlarge home screen labels 2026-08-19 13:52:06 +02:00
DerEchteAlec de613e2502 ENH - use Inter across phone UI 2026-08-19 13:41:45 +02:00
248 changed files with 21656 additions and 4541 deletions
+2
View File
@@ -17,6 +17,8 @@ for (const requiredFragment of [
"fx_version 'cerulean'",
"node_version '22'",
"use_experimental_fxv2_oal 'yes'",
"'source/server/nui_build_check.lua'",
"'source/html/sounds/**'",
"ui_page 'source/html/index.html'",
]) {
if (!manifest.includes(requiredFragment)) {
+750
View File
@@ -0,0 +1,750 @@
# Sky Phone Creator API
This is the first-party integration contract for resources that integrate directly with `sky_phone`. It covers the shared, client, and server exports, native custom apps, and the Sky iframe bridge protocol.
The current native API version is `1.0.0`. The current custom-app schema and iframe protocol version is `1`.
> Provider compatibility is a separate surface. New integrations should call `exports["sky_phone"]` directly, not the provided `lb-phone`, `17mov_Phone`, `high-phone`, `qs-smartphone`, or `yseries` aliases.
## Quick start
Declare Sky Phone as a dependency so FiveM starts it before your resource. A custom app normally has both client and server code and exposes its web files:
~~~lua
fx_version "cerulean"
game "gta5"
dependency "sky_phone"
client_script "client.lua"
server_script "server.lua"
files {
"web/index.html",
"web/**/*",
}
~~~
The server must have both `Config.CustomApps.Enabled` and `Config.CustomApps.ExternalApps` enabled. Treat capability discovery as authoritative because a server can disable or tighten parts of this contract.
### Export context and ownership
Client and server exports are different runtimes even when they share a name. Call each export from the matching client or server script.
Native custom-app exports derive the owner from FiveM's `GetInvokingResource()`. Therefore:
- Call them directly as `exports["sky_phone"]:ExportName(...)`.
- Do not proxy creator calls through another resource; the proxy becomes the owner.
- Register the client app and server policy from the same resource name.
- Only the owner can update, remove, open, close, message, or notify its app.
- Owned client registrations and server policies are removed automatically when the owner resource stops.
All input from an iframe or game client remains untrusted. Perform permission, identity, money, inventory, and other consequential validation on the server.
## Readiness and capabilities
The client API is ready when its exports load. The server API becomes ready after Sky Phone's database migration and domain services are available.
| Side | Poll | Local event |
| --- | --- | --- |
| Client | `exports["sky_phone"]:IsApiReady()` | `sky_phone:client:apiReady` with `apiVersion` |
| Server | `exports["sky_phone"]:IsApiReady()` | `sky_phone:server:apiReady` with `apiVersion` |
The ready events are local events, not network events. Check first and subscribe second so your resource also works when the event has already fired:
~~~lua
local function on_sky_phone_ready()
if not exports["sky_phone"]:IsApiReady() then
return
end
local capabilities = exports["sky_phone"]:GetApiCapabilities()
print(("[my_resource] Sky Phone API %s is ready on %s."):format(
capabilities.apiVersion,
capabilities.side
))
end
CreateThread(on_sky_phone_ready)
AddEventHandler("sky_phone:client:apiReady", on_sky_phone_ready) -- client.lua
-- Use sky_phone:server:apiReady in server.lua.
~~~
`GetApiCapabilities()` returns a fresh snapshot with `apiVersion`, `side`, `ready`, and `features`. The current client feature groups include calls, camera, custom apps, equipped phone number, navigation, custom-app notifications, phone game input, and phone state. The current server feature groups include calls, custom apps, device directory, custom-app notifications, and phone-number lookup.
`features.customApps` is an object with `enabled` and `external`. `features.notifications` is an object with `customApps = true` and `system = false`. There is intentionally no generic first-party system-notification export for creators.
For custom-app-specific export names, bridge methods, protocol version, and configured size limits, call `GetCustomAppCapabilities()` on the relevant side. Do not infer a feature from the API version alone.
## Return and error conventions
The API uses these conventions:
- Mutations normally return `true` on success or `false, errorCode` on rejection.
- Lookups normally return a value or `nil, errorCode`. State queries such as `GetActiveCall()` and identity shortcuts can return plain `nil` when no value exists.
- Predicates return a boolean and generally collapse invalid input into `false`.
- `TogglePhone()` returns only a boolean.
- Server `SendCustomAppNotification()` returns `true, { delivered = 1 }` on delivery or `false, errorCode`.
- Returned tables are snapshots. Mutating them does not update Sky Phone.
- Error codes are machine-readable strings. Handle unknown future codes as well as the documented common codes.
A missing resource or export is a FiveM invocation error rather than one of these return values. The manifest dependency avoids the normal load-order case.
## Shared exports
These exports exist in both client and server scripts.
| Export | Signature | Result |
| --- | --- | --- |
| `GetApiVersion` | `()` | `"1.0.0"` |
| `NormalizePhoneNumber` | `(value)` | Configured normalized number, or `nil, "invalid_phone_number"` |
| `FormatPhoneNumber` | `(value)` | Configured display format, or `nil, "invalid_phone_number"` |
| `IsValidImei` | `(value)` | Boolean |
Normalization uses the server's configured SIM prefix and length; formatting uses its configured number groups.
## Client exports
### Phone state and focus
| Export | Signature | Result |
| --- | --- | --- |
| `TogglePhone` | `(open?, noFocus?)` | Boolean |
| `GetPhoneState` | `()` | Phone-state snapshot |
| `GetEquippedPhoneNumber` | `()` | Authoritative normalized number or `nil` |
| `SetPhoneGameInputEnabled` | `(enabled)` | `true` or `false, errorCode` |
`TogglePhone(nil)` toggles. `TogglePhone(true, true)` requests an open phone without cursor focus. A successful open request is asynchronous; observe `sky_phone:client:phoneToggled` or read `GetPhoneState()` for the confirmed UI state.
The phone-state snapshot is:
~~~lua
{
inCall = boolean,
onScreen = boolean, -- compatibility alias of open
open = boolean,
phoneNumber = string | nil,
}
~~~
`SetPhoneGameInputEnabled(true)` allows game input while the phone is open; `false` applies the external override in the other direction. The claim is associated with the invoking resource and is cleared when that resource stops or the phone closes. It can return `resource_required`, `invalid_focus_claim`, or `phone_closed`.
### Navigation
| Export | Signature | Result |
| --- | --- | --- |
| `OpenApp` | `(appId)` | `true` or `false, errorCode` |
| `CloseApp` | `(appId?)` | `true` or `false, errorCode` |
| `GetNavigationState` | `()` | Navigation snapshot |
| `GetCurrentApp` | `()` | Active app ID, `"home"`, or `nil` if the phone is closed |
| `GetCurrentApp` | `(appId)` | Boolean |
| `IsAppDataLoaded` | `()` | Boolean |
| `IsAppInstalled` | `(appId)` | Boolean |
`OpenApp` requires an open phone and an installed app. `CloseApp` optionally verifies the expected active app before closing it.
~~~lua
local state = exports["sky_phone"]:GetNavigationState()
-- {
-- currentApp = string | nil,
-- dataLoaded = boolean,
-- installedApps = { [appId] = true },
-- }
~~~
API readiness and app-data readiness are separate. Wait for `IsAppDataLoaded()` before assuming the installed-app catalog is populated.
Common navigation errors are `invalid_app_id`, `phone_closed`, `app_not_installed`, and `app_not_active`.
### Calls
| Export | Signature | Result |
| --- | --- | --- |
| `Dial` | `(phoneNumber?, companyId?)` | `true` or `false, errorCode` |
| `AnswerCall` | `()` | `true` or `false, errorCode` |
| `DeclineCall` | `()` | `true` or `false, errorCode` |
| `HangupCall` | `()` | `true` or `false, errorCode` |
| `TerminateCall` | `()` | `true` or `false, errorCode` |
| `GetActiveCall` | `()` | Call snapshot or `nil` |
| `IsInCall` | `()` | Boolean |
At least one non-empty dial target is required. Use `Dial("5550100")` for a number or `Dial(nil, "police")` for a company service line. If a valid company ID is supplied, its service line takes precedence.
`AnswerCall` and `DeclineCall` require an incoming ringing call. `HangupCall` and `TerminateCall` accept a ringing or connected call. Normal hangup/decline semantics can reroute an unanswered company call; termination force-finishes it.
A client call snapshot contains:
~~~lua
{
id = string,
state = "ringing" | "connected",
direction = "incoming" | "outgoing",
otherNumber = string,
startedAt = number,
answeredAt = number | nil,
channel = number | nil,
speakerEnabled = boolean,
speakerSupported = boolean,
muted = boolean,
muteSupported = boolean,
-- Outgoing payphone calls can also contain:
elapsedSeconds = number | nil,
totalCost = number | nil,
}
~~~
Listen to the local `sky_phone:client:callChanged` event for state transitions. Its payload is the latest call snapshot, a terminal call-state payload, or `nil` when state is reset.
### Camera claims
| Export | Signature | Result |
| --- | --- | --- |
| `GetCameraState` | `()` | Camera-state snapshot |
| `SetFlashlight` | `(enabled)` | `true` or `false, errorCode` |
| `SetSelfieCamera` | `(enabled)` | `true` or `false, errorCode` |
| `EnableWalkableCamera` | `(selfie?)` | `true` or `false, errorCode` |
| `DisableWalkableCamera` | `()` | `true` or `false, errorCode` |
| `SetCameraFrozen` | `(frozen)` | `true, state` or `false, errorCode` |
| `ToggleCameraFrozen` | `()` | `true, state` or `false, errorCode` |
| `ReleaseCamera` | `()` | `true` or `false, errorCode` |
The first state-changing call that needs camera control claims it for the invoking resource. A second resource receives `camera_claimed`. If the built-in camera is already active before an external claim, the caller receives `camera_in_use`.
~~~lua
local ok, err = exports["sky_phone"]:EnableWalkableCamera(true)
if not ok then
print(("Could not claim the phone camera: %s"):format(err))
return
end
exports["sky_phone"]:SetFlashlight(true)
-- Always release on the normal completion/cancel path.
exports["sky_phone"]:ReleaseCamera()
~~~
`ReleaseCamera` disables walkable mode, flashlight, and selfie mode and releases the claim. `DisableWalkableCamera` performs the same full release for the caller's claimed session. Sky Phone also performs this cleanup automatically if the owning resource stops. `SetCameraFrozen` and `ToggleCameraFrozen` require an active camera and can return `camera_not_active`.
The state snapshot is:
~~~lua
{
active = boolean,
flashEnabled = boolean,
frozen = boolean,
selfie = boolean,
walkable = boolean,
}
~~~
Other common errors are `resource_required` and `invalid_state`. Observe `sky_phone:client:cameraActiveChanged` for active-state changes.
## Native custom apps
A complete server-backed app normally registers a client definition and a matching server policy. The app ID and permission list should match on both sides.
### Client registration
~~~lua
local APP_ID = "my-resource-app"
local PERMISSIONS = {
"app.close",
"app.open",
"device.storage",
"locale.read",
"notifications",
"theme.read",
}
local registered = false
local function register_app()
if registered or not exports["sky_phone"]:IsApiReady() then
return
end
local capabilities = exports["sky_phone"]:GetCustomAppCapabilities()
if not capabilities.externalApps then
print("[my_resource] External Sky Phone apps are disabled.")
return
end
local ok, err = exports["sky_phone"]:AddCustomApp({
schemaVersion = 1,
id = APP_ID,
name = { en = "My App", de = "Meine App" },
description = {
en = "An example Sky Phone app.",
de = "Eine Sky-Phone-Beispiel-App.",
},
developer = "My Studio",
category = "utilities",
ui = "web/index.html",
icon = "web/icon.png",
permissions = PERMISSIONS,
orientation = "portrait",
defaultInstalled = false,
removable = true,
bridgeMode = "sky",
onInstall = function(data) end,
onDelete = function(data) end,
onOpen = function(data) end,
onReady = function(data) end,
onClose = function(data) end,
})
if not ok then
print(("[my_resource] AddCustomApp failed: %s"):format(err))
return
end
registered = true
end
CreateThread(register_app)
AddEventHandler("sky_phone:client:apiReady", register_app)
~~~
Set `bridgeMode = "sky"` explicitly for new apps. The default for external definitions is `legacy` for compatibility.
#### Definition fields
| Field | Required | Contract |
| --- | --- | --- |
| `schemaVersion` | Recommended | If present, must be `1` |
| `id` | Yes | 2-64 lowercase characters matching `^[a-z0-9][a-z0-9._-]+$`; built-in IDs are reserved |
| `name` | Yes | Non-empty string or locale map, maximum 64 UTF-8 bytes per value |
| `description` | No | String or locale map, maximum 320 UTF-8 bytes per value |
| `developer` | No | Non-empty string, maximum 96 UTF-8 bytes |
| `category` | No | `games`, `productivity`, `shopping`, `social`, or `utilities`; default `utilities` |
| `ui` | Yes | Relative resource asset or HTTPS URL |
| `icon` | No | Relative resource asset or HTTPS URL; omitted uses the default icon |
| `permissions` | No | Unique permission array; default empty |
| `orientation` | No | `portrait`, `landscape`, or `any`; default `portrait` |
| `defaultInstalled` | No | Boolean; default `false` |
| `removable` | No | Boolean; default `true` |
| `gridOrder` | No | Integer from 0 through 9999 |
| `iconBackground` | No | Non-empty string up to 64 UTF-8 bytes; CR/LF, semicolons, and braces are rejected |
| `bridgeMode` | No | `sky` or `legacy`; external default `legacy` |
| lifecycle hooks | No | Callable `onInstall`, `onDelete`, `onOpen`, `onReady`, and `onClose` |
Relative `ui` and `icon` values resolve to the calling resource's `https://cfx-nui-RESOURCE/...` origin. Path traversal, unsupported schemes, and cross-resource CFX-NUI ownership are rejected. Add every local asset to the creator resource's `files` list.
Registration is not the same as installation. `onInstall` and `onDelete` represent the user's App Store lifecycle. `onReady` occurs after the Sky bridge handshake (or frame load in legacy mode). Hook payloads are JSON-safe data or `nil`. Keep authoritative mutations on the server.
`UpdateCustomApp(definition)` takes a complete definition with the same owned ID. `RemoveCustomApp(appId)` removes the owned registration and closes it if active. Resource-stop cleanup is automatic.
### Permissions
| Permission | Current native effect |
| --- | --- |
| `app.close` | Enables iframe method `app.close` |
| `app.open` | Enables iframe method `app.open` |
| `device.storage` | Enables iframe methods `device.storage.get` and `device.storage.set` |
| `locale.read` | Adds `locale.read` capability and conditionally exposes `language` and `locale` in context |
| `notifications` | Enables iframe method `notification.create` and owner-validated Lua notifications |
| `notifications.critical` | Enables `critical = true` only for the client Lua notification export |
| `theme.read` | Adds `theme.read` capability and conditionally exposes `colorScheme` in context |
`camera.capture`, `contacts.pick`, `location.read`, `media.pick`, and `nui.fetch` are accepted permission identifiers, but the current v1 iframe bridge exposes no method or context field for them. Do not infer support from the permission list; inspect `GetCustomAppCapabilities().bridgeMethods` and the context `capabilities` array.
### Client lifecycle exports
| Export | Signature | Notes |
| --- | --- | --- |
| `AddCustomApp` | `(definition)` | Register a new app owned by the caller |
| `UpdateCustomApp` | `(definition)` | Replace the complete owned definition |
| `RemoveCustomApp` | `(appId)` | Remove the owned app |
| `OpenCustomApp` | `(appId, payload?)` | Requires an open phone and an owned registered app |
| `CloseCustomApp` | `(appId)` | Requires that owned app to be active |
| `SendAppMessage` | `(appId, payload)` | Canonical host-to-iframe message export |
| `SendCustomAppMessage` | `(appId, payload)` | Exact compatibility alias of `SendAppMessage` |
| `SendCustomAppNotification` | `(appId, notification)` | Owner- and permission-validated local notification |
| `GetCustomAppCapabilities` | `()` | Custom-app ABI, methods, export names, and configured limits |
Client message dispatch requires the owned app to be active. If its frame is active but has not completed readiness, up to 64 messages are queued and flushed after readiness. An inactive app returns `app_not_active`. Payloads must be JSON-safe and fit `maximumMessageBytes`.
~~~lua
local ok, err = exports["sky_phone"]:SendAppMessage(APP_ID, {
type = "job:update",
data = { available = true },
})
~~~
The `sky-phone-app:message` iframe envelope is documented below.
### Client custom-app notifications
The invoking resource must own the registered client app, and that definition must include `notifications`.
~~~lua
local ok, err = exports["sky_phone"]:SendCustomAppNotification(APP_ID, {
title = "My App", -- optional; defaults to the registered app name
text = "The job is ready.",
subtitle = "Dispatch",
sound = "chime", -- chime, signal, or soft
persistent = false,
critical = false,
route = "/apps/" .. APP_ID,
})
~~~
`text` and `content` are aliases. Title and subtitle allow up to 160 UTF-8 bytes; text allows up to 2000 UTF-8 bytes. If supplied, `route` must be exactly `/apps/APP_ID`. `critical = true` additionally requires `notifications.critical`.
### Server policy registration
Register a policy even if the client definition already lists permissions. Server-backed storage, server messages, and server notifications use the server-owned policy.
~~~lua
local APP_ID = "my-resource-app"
local PERMISSIONS = {
"app.close",
"app.open",
"device.storage",
"locale.read",
"notifications",
"theme.read",
}
local registered = false
local function register_policy()
if registered or not exports["sky_phone"]:IsApiReady() then
return
end
local ok, err = exports["sky_phone"]:AddCustomAppPolicy({
schemaVersion = 1,
id = APP_ID,
permissions = PERMISSIONS,
})
if not ok then
print(("[my_resource] AddCustomAppPolicy failed: %s"):format(err))
return
end
registered = true
end
CreateThread(register_policy)
AddEventHandler("sky_phone:server:apiReady", register_policy)
~~~
| Export | Signature | Result |
| --- | --- | --- |
| `AddCustomAppPolicy` | `(definition)` | `true` or `false, errorCode` |
| `UpdateCustomAppPolicy` | `(definition)` | `true` or `false, errorCode` |
| `RemoveCustomAppPolicy` | `(appId)` | `true` or `false, errorCode` |
| `GetCustomAppPolicy` | `(appId)` | Policy snapshot or `nil` |
| `HasCustomAppPermission` | `(appId, permission)` | Boolean |
| `GetCustomAppCapabilities` | `()` | Custom-app server capabilities |
A policy snapshot is:
~~~lua
{
bundled = boolean,
id = string,
ownerResource = string,
permissions = { "sorted", "permission.list" },
}
~~~
### Server-to-player messages
`SendAppMessage(playerSource, appId, payload)` is canonical. `SendCustomAppMessage(playerSource, appId, payload)` is its exact compatibility alias.
~~~lua
local ok, err = exports["sky_phone"]:SendAppMessage(player_source, APP_ID, {
type = "server:update",
data = { status = "ready" },
})
~~~
The export validates the direct policy owner, player source, online player, JSON payload, and configured message-size limit. `true` means the server accepted and dispatched the event. It does not acknowledge iframe delivery. The target client still needs the matching owned app to be registered and active; otherwise it rejects the message locally.
### Server custom-app notifications
The server form is `SendCustomAppNotification(playerSource, appId, notification)`. It verifies that the invoking resource owns the policy and that the policy contains `notifications`.
~~~lua
local ok, result_or_error =
exports["sky_phone"]:SendCustomAppNotification(player_source, APP_ID, {
title = "My App",
text = "The job is ready.", -- content is also accepted
})
if ok then
print(("Delivered %d notification."):format(result_or_error.delivered))
else
print(("Notification failed: %s"):format(result_or_error))
end
~~~
The server form supports a required title (up to 160 UTF-8 bytes) and required `text` or `content` (up to 2000 UTF-8 bytes). It delivers only to an online player whose equipped device can be revalidated. Success is `true, { delivered = 1 }`. There is no offline queue. Use the client export for subtitle, sound, persistence, critical state, or an explicit app route.
## Sky iframe protocol v1
Use `bridgeMode = "sky"`. Sky Phone appends `skyPhoneAppId=APP_ID` to the iframe URL.
### Secure handshake
Install the message listener before announcing readiness. In production the parent NUI origin is `https://cfx-nui-sky_phone`. If you use a development host, inject its expected parent origin explicitly rather than accepting every origin.
~~~js
const appId = new URL(window.location.href).searchParams.get("skyPhoneAppId");
const protocolVersion = 1;
const phoneOrigin = "https://cfx-nui-sky_phone";
function postToPhone(message) {
window.parent.postMessage(
{ ...message, appId, protocolVersion },
phoneOrigin,
);
}
window.addEventListener("message", (event) => {
if (event.source !== window.parent || event.origin !== phoneOrigin) return;
const message = event.data;
if (
!message ||
message.appId !== appId ||
message.protocolVersion !== protocolVersion
) return;
if (message.type === "sky-phone-app:context") {
// Read only fields allowed by message.context.capabilities.
} else if (message.type === "sky-phone-app:open") {
// Handle message.data.
} else if (message.type === "sky-phone-app:message") {
// Handle message.payload.
} else if (message.type === "sky-phone-app:response") {
// Resolve the matching message.requestId.
}
});
postToPhone({ type: "sky-phone-app:ready" });
~~~
Sky Phone validates the iframe window, exact iframe origin, app ID, and protocol version before accepting a message.
### Host-to-iframe messages
| Type | Payload |
| --- | --- |
| `sky-phone-app:context` | `{ appId, protocolVersion, context }` |
| `sky-phone-app:open` | `{ appId, protocolVersion, data }` |
| `sky-phone-app:message` | `{ appId, protocolVersion, payload }` |
| `sky-phone-app:response` | `{ appId, protocolVersion, requestId, success, data?, error? }` |
The v1 context is:
~~~ts
type SkyPhoneAppContextV1 = {
appId: string;
capabilities: string[];
phoneScale: number;
protocolVersion: 1;
safeArea: { top: number; right: number; bottom: number; left: number };
colorScheme?: "dark" | "light"; // only with theme.read
language?: string; // only with locale.read
locale?: { name?: string; description?: string }; // only with locale.read
};
~~~
`colorScheme` is not present without `theme.read`. `language` and `locale` are not present without `locale.read`. Treat all permission-gated fields as optional and use the returned `capabilities` array as the authority.
### Iframe requests
A request has this envelope:
~~~js
postToPhone({
type: "sky-phone-app:request",
requestId: crypto.randomUUID(),
method: "device.storage.get",
payload: { key: "preferences" },
});
~~~
`requestId` must be unique, non-empty, and at most 128 characters. `method` must be non-empty and at most 64 characters. Duplicate request IDs are ignored without another response.
| Method | Required permission | Request payload | Success data |
| --- | --- | --- | --- |
| `app.close` | `app.close` | Omit | None |
| `app.open` | `app.open` | `{ appId, data? }` | `{ appId }` |
| `device.storage.get` | `device.storage` | `{ key }` | `{ exists, revision, value? }` |
| `device.storage.set` | `device.storage` | `{ key, revision, value }` | `{ revision }` |
| `notification.create` | `notifications` | `{ title, text, subtitle?, sound? }` | `{ notificationId }` |
`app.open` accepts an app ID up to 64 characters. Optional `data` must be a JSON object and is supported only when the target is an external app; its absolute v1 limit is 16384 bytes.
Storage is scoped to the equipped device IMEI and app ID. Keys match `^[A-Za-z0-9._-]{1,64}$`. A get for a missing key returns `{ exists = false, revision = 0 }`. Set requires a non-null JSON value and the revision returned by the latest get/set. New keys use revision `0`; a successful insert returns `1`. A stale write returns:
~~~js
{
success: false,
error: "storage_conflict",
data: { exists: true, revision: 3, value: currentValue },
}
~~~
The absolute v1 value ceiling is 65536 bytes, but the server can configure a lower per-value limit, total app quota, key count, key length, and request rate. Read the client custom-app capabilities instead of hardcoding server policy.
Iframe notifications require non-empty `title` (maximum 80 characters) and `text` (maximum 240). Optional `subtitle` is at most 80. `sound` is `chime`, `signal`, or `soft`. The route is always the source app. This iframe method does not expose critical or persistent notifications.
Bridge JSON values are bounded to depth 8, 512 nodes, 128 entries per array/object, and 64 characters per object key. Functions, cyclic objects, non-finite numbers, and prototype-sensitive keys are rejected.
### Legacy mode
`bridgeMode = "legacy"` exists for provider compatibility. It becomes ready on iframe load, sends host-message payloads without the Sky v1 envelope, and does not provide the native handshake/context/request-response contract above. New first-party apps should always select `sky`.
## Server phone and device API
Server exports are for trusted server resources. Do not relay unrestricted device-directory results to clients.
### Phone identity
| Export | Signature | Result |
| --- | --- | --- |
| `GetEquippedPhoneNumber` | `(playerSourceOrIdentifier)` | Normalized number, `nil`, or `nil, "api_not_ready"` |
| `GetSourceFromPhoneNumber` | `(phoneNumber)` | Online player source, `nil`, or `nil, "api_not_ready"` |
`GetEquippedPhoneNumber` accepts a positive numeric player source or a non-empty framework identifier. It revalidates the equipped device. `GetSourceFromPhoneNumber` normalizes the input and resolves only an online source currently equipped with that number.
### Device directory
| Export | Input | Scope |
| --- | --- | --- |
| `GetOnlineDeviceBySource` | player source | Online and currently equipped |
| `GetOnlineDeviceByPhoneNumber` | phone number | Online and currently equipped |
| `GetOnlineDeviceByIdentifier` | framework identifier | Online and currently equipped |
| `GetOnlineDeviceByImei` | IMEI | Online and currently equipped |
| `GetStoredDeviceByImei` | IMEI | Persistent device record |
| `GetStoredDeviceByPhoneNumber` | phone number | Persistent device record |
| `GetStoredDeviceByIdentifier` | framework identifier | Persistent character device in non-unique-phone mode only |
| `GetStoredSimByPhoneNumber` | phone number | Persistent SIM record |
Device lookups return a snapshot or `nil, errorCode`:
~~~lua
{
accountId = number | nil,
deviceName = string,
equipped = boolean,
imei = string,
mappedIdentifier = string | nil,
online = boolean,
phoneNumber = string | nil,
registeredIdentifier = string | nil,
simId = string | nil,
simType = string | nil,
source = number | nil,
identifier = string | nil, -- added to applicable identifier/online results
}
~~~
Stored device results always report `online = false`, `equipped = false`, and `source = nil`. Online lookups revalidate framework identity and inventory ownership before returning. `GetStoredDeviceByIdentifier` returns `identity_scope_unsupported` when `Config.Phone.Unique` is enabled.
A SIM snapshot is:
~~~lua
{
deviceImei = string | nil,
phoneNumber = string,
registeredIdentifier = string | nil,
simId = string,
simType = string | nil,
}
~~~
Common directory errors include `invalid_source`, `invalid_identifier`, `invalid_imei`, `invalid_phone_number`, `player_unavailable`, `device_not_found`, `device_not_equipped`, `equipped_device_ambiguous`, `identifier_ambiguous`, `device_holder_ambiguous`, `identity_inconsistent`, `identity_scope_unsupported`, and `sim_not_found`.
## Server call API
| Export | Signature | Result |
| --- | --- | --- |
| `GetActiveCallBySource` | `(playerSource)` | Server call snapshot or `nil, errorCode` |
| `GetActiveCallById` | `(callId)` | Server call snapshot or `nil, errorCode` |
| `IsPlayerInCall` | `(playerSource)` | Boolean |
| `EndCallForSource` | `(playerSource)` | `true` or `false, errorCode` |
| `TerminateCallForSource` | `(playerSource)` | `true` or `false, errorCode` |
The server snapshot contains the client call fields plus:
~~~lua
{
anonymous = false,
caller = { source = number, number = string },
callee = { source = number | nil, number = string },
companyId = string | nil,
payphone = boolean,
video = false,
}
~~~
`GetActiveCallBySource` calculates `direction` and `otherNumber` for that participant. `GetActiveCallById` uses the caller perspective. `EndCallForSource` preserves normal company rerouting/decline behavior; `TerminateCallForSource` force-finishes the call. Common errors are `invalid_source`, `invalid_call_id`, and `call_not_found`.
## Useful client events
These are local observation events. They are not authorization boundaries.
| Event | Payload |
| --- | --- |
| `sky_phone:client:apiReady` | `apiVersion` |
| `sky_phone:client:phoneToggled` | `open` boolean |
| `sky_phone:client:phoneNumberChanged` | normalized number or `nil` |
| `sky_phone:client:callChanged` | call state table or `nil` |
| `sky_phone:client:cameraActiveChanged` | `active` boolean |
The server readiness event is `sky_phone:server:apiReady` with `apiVersion`.
## Common custom-app errors
This list is intentionally not closed.
| Area | Common error codes |
| --- | --- |
| Readiness/config | `api_not_ready`, `external_apps_disabled` |
| Caller/owner | `missing_invoking_resource`, `resource_required`, `owner_resource_not_running`, `app_owner_mismatch` |
| Registration | `invalid_definition`, `unsupported_schema_version`, `invalid_app_id`, `reserved_app_id`, `duplicate_app_id`, `app_not_found`, `bundled_app` |
| Definition | `invalid_name`, `invalid_description`, `invalid_developer`, `invalid_category`, `invalid_asset_url`, `asset_owner_mismatch`, `invalid_permissions`, `unknown_permission`, `duplicate_permission`, `invalid_orientation`, `invalid_bridge_mode` |
| State/message | `phone_closed`, `app_not_active`, `invalid_payload`, `payload_too_large`, `message_queue_full` |
| Notification | `permission_denied`, `invalid_notification`, `invalid_notification_title`, `invalid_notification_text`, `invalid_notification_subtitle`, `invalid_notification_sound`, `invalid_notification_route`, `invalid_title`, `invalid_text`, `invalid_app_id`, `device_not_equipped` |
| Iframe request | `permission_denied`, `unsupported_method`, `invalid_storage_request`, `invalid_notification`, `invalid_app_open`, `app_not_found`, `open_data_not_supported`, `open_failed`, `request_failed` |
| Storage | `storage_not_allowed`, `rate_limited`, `invalid_storage_key`, `invalid_storage_value`, `invalid_storage_revision`, `storage_value_too_large`, `storage_conflict`, `storage_quota_exceeded`, `storage_key_limit` |
## Trusted adapter API
This section is not the normal Creator API. It exists only for isolated phone-provider compatibility adapters.
Only a resource explicitly listed in `Config.CustomApps.TrustedAdapters` can call a `FromAdapter` export. The adapter passes the original owner resource explicitly; Sky Phone validates the adapter, the original owner, resource state, and the existing adapter/owner binding.
~~~lua
Config.CustomApps.TrustedAdapters = {
["my_phone_adapter"] = true,
}
~~~
Client-only trusted adapter exports:
- `AddCustomAppFromAdapter(ownerResource, definition)`
- `UpdateCustomAppFromAdapter(ownerResource, definition)`
- `RemoveCustomAppFromAdapter(ownerResource, appId)`
- `OpenCustomAppFromAdapter(ownerResource, appId, payload?)`
- `CloseCustomAppFromAdapter(ownerResource, appId)`
- `CloseActiveCustomAppFromAdapter(ownerResource)`
- `SendCustomAppMessageFromAdapter(ownerResource, appId, payload)`
- `SendCustomAppNotificationFromAdapter(ownerResource, appId, notification)`
Server-only trusted adapter exports:
- `AddCustomAppPolicyFromAdapter(ownerResource, definition)`
- `UpdateCustomAppPolicyFromAdapter(ownerResource, definition)`
- `RemoveCustomAppPolicyFromAdapter(ownerResource, appId)`
There is currently no server `SendAppMessageFromAdapter` or `SendCustomAppNotificationFromAdapter` export. `assetResource` and `compatibility` are adapter mapping fields, not part of the native first-party creator schema.
Normal app resources must use the direct exports and must never pass an owner resource themselves. Provider aliases and their provider-specific signatures are compatibility contracts, not aliases for every native export documented here.
+31 -7
View File
@@ -29,7 +29,7 @@
<p align="center">
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
&nbsp;&bull;&nbsp;
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
&nbsp;&bull;&nbsp;
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
</p>
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
## Why Sky Phone stands out
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -160,8 +160,8 @@ Start the selected voice resource before Sky Phone.
## Quick installation
1. Copy the resource into your FiveM resources directory.
2. Keep the resource folder name `sky_phone`.
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
5. Add the required inventory items.
@@ -182,6 +182,13 @@ Replace the example framework, inventory, and voice resources with the providers
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
### How players open the phone
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
- Players can use that inventory item or press the configured keybind (default: `F1`).
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
## Configuration
Customer settings are organized in:
@@ -249,6 +256,11 @@ When enabled, Sky Phone prints debug and informational messages. Warnings and er
The short LB Phone detection notice also remains visible when debug mode is disabled.
On every resource start, Sky Phone compares the `version` in `fxmanifest.lua` with the tag of the
latest published [GitHub release](https://github.com/sky-systems/sky_phone/releases/latest). The
server console reports whether the installed version is current and shows the release link when an
update is available. A failed GitHub request is reported but does not prevent the phone from starting.
## Security values
Sky Phone ships with stable generated defaults in `Config.Server`:
@@ -339,7 +351,7 @@ With unique phones, using an inventory item selects that exact handset whenever
| SIM mode | Behavior |
| --- | --- |
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
@@ -525,7 +537,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
### Companies
@@ -548,6 +560,8 @@ Unlisted jobs can read news but cannot manage articles.
Sky Phone is not limited to the apps that ship with it. Other resources can register installable custom apps, publish them through the App Store, exchange messages with their NUI, send notifications, and use server-controlled permissions and storage.
For the complete first-party export, ownership, readiness, and iframe protocol contract, see the [Creator API](CREATOR_API.md).
Its native custom app surface includes client and server exports for app registration, lifecycle control, messaging, notifications, capability discovery, and policy management. Sky Phone also normalizes supported custom-app contracts from:
- LB Phone
@@ -560,6 +574,8 @@ The resource provides the compatibility aliases `lb-phone`, `17mov_Phone`, `high
That means servers can replace LB Phone without giving up supported custom apps, while developers can build directly against Sky Phone for deeper lifecycle, permission, and storage integration.
Start Sky Phone before the custom app resources and do not start the original phone resource for an alias at the same time. For example, an unchanged app using `exports["lb-phone"]:AddCustomApp(...)` must run with `sky_phone`, not with the original `lb-phone`, as the active provider. Two active providers expose the same FiveM export event and can send registrations to the wrong phone.
## Frontend development
Customers installing a release do not need to build the frontend.
@@ -593,12 +609,20 @@ pnpm build
### The phone item does nothing
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
- Confirm the framework and inventory are supported and started first.
- Confirm the item name matches `Config.Phone.Item`.
- Confirm the item is usable.
- In unique mode, confirm the phone is non-stackable.
- Check the server console for inventory adapter warnings.
### The resource starts but the phone UI is missing
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE`, lists the missing or invalid packaged files, and shows repository-native build commands.
- Install the latest published release package rather than GitHub's automatically generated source archive.
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
- Developers working from source must run the frontend production build before starting the resource.
### Calls connect without audio
- Confirm the configured voice resource is running.
+94
View File
@@ -26,6 +26,100 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Inter 4.1
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
## Framework7 documentation placeholder media
The development-only Sky UI Kitchen Sink includes placeholder images mirrored
+192 -17
View File
@@ -51,11 +51,12 @@ import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppCatalogStore } from '@/stores/app-catalog'
import { useAppStoreStore } from '@/stores/app-store'
import { useWidgetsStore } from '@/stores/widgets'
import { isPhoneAppId } from '@/config/apps'
import { isPhoneAppId, PHONE_APPS } from '@/config/apps'
import { useNotesStore } from '@/stores/notes'
import { useMemosStore } from '@/stores/memos'
import { useWeatherStore } from '@/stores/weather'
import { useEasyShareStore } from '@/stores/easyshare'
import { useRadioStore } from '@/stores/radio'
import {
useNotificationsStore,
type PhoneNotification,
@@ -69,18 +70,26 @@ import type {
CompanyChangedPayload,
CompanyUnreadCounts,
} from '@/types/companies'
import type { PhoneCall } from '@/types/phone'
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
import type { EasyShareEvent } from '@/types/easyshare'
import type { CryptoMarketChangedData } from '@/types/crypto'
import type { CityWarnEventData } from '@/types/citywarn'
import { nuiCall } from '@/utils/nui'
import {
installPhoneAudioController,
setPhoneOutputVolume,
} from '@/utils/phoneAudio'
import { formatTimer } from '@/utils/clock'
import { parsePhonePreferences } from '@/utils/preferences'
import { getHairlinePixelStyle } from '@/utils/rendering'
import { isTextInputElement } from '@/utils/textInputFocus'
import { configurePhoneNumberFormat } from '@/utils/phone'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
openHome?: boolean
type?: string
data?:
| CalendarReminderData
@@ -105,6 +114,7 @@ type AppMessage = {
| PhoneOpenPayload
| CustomAppCatalogEventData
| CustomAppEventData
| NavigationEventData
}
type CustomAppCatalogEventData = {
@@ -117,9 +127,14 @@ type CustomAppEventData = {
payload?: unknown
}
type NavigationEventData = {
appId?: unknown
}
type SimPickerPayload = {
choices: SimPhoneChoice[]
number: string
phoneNumberFormat?: PhoneNumberFormat
}
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
@@ -271,12 +286,14 @@ const MIN_PRODUCTION_PHONE_ZOOM = 260 / PHONE_PORTRAIT_WIDTH
const developmentParameters = new URLSearchParams(window.location.search)
const isBrowserPreview =
developmentParameters.has('browserPreview') &&
developmentParameters.get('apiBase')?.startsWith('/') === true
(import.meta.env.DEV ||
developmentParameters.get('apiBase')?.startsWith('/') === true)
const isDevelopment =
import.meta.env.DEV ||
developmentParameters.get('apiBase')?.startsWith('/') === true
const developmentLockScreenPreview =
isDevelopment && developmentParameters.has('lockScreenPreview')
let textInputFocused = false
const phone = usePhoneStore()
const account = useAccountStore()
@@ -305,6 +322,7 @@ const notes = useNotesStore()
const memos = useMemosStore()
const weather = useWeatherStore()
const easyShare = useEasyShareStore()
const radio = useRadioStore()
const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
@@ -327,8 +345,13 @@ const DARK_STATUS_BAR_APP_IDS = new Set([
'minesweeper',
'number-merge',
])
const isDynamicIslandGalleryRoute = computed(
() => isDevelopment && route.name === 'development-dynamic-islands',
)
const isDevelopmentRoute = computed(
() => isDevelopment && route.name === 'development-sky-ui',
() =>
isDevelopment &&
(route.name === 'development-sky-ui' || isDynamicIslandGalleryRoute.value),
)
const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
@@ -346,10 +369,14 @@ const previousHardwareAlertVolume = ref(75)
const hardwareVolumeHudVisible = ref(false)
const setupPreviewDismissed = ref(false)
const setupDevelopmentSkipped = ref(false)
const setupAppearanceSelected = ref(false)
const pendingUnlockRoute = ref<string | null>(null)
const openHomeRequested = ref(false)
const unlockedServicesLoaded = ref(false)
const controlCenterOpened = ref(false)
const activitySuspended = ref(false)
const dynamicIslandExpanded = ref(false)
const dynamicIslandActivity = ref<DynamicIslandActivity | null>(null)
const simPicker = ref<SimPickerPayload | null>(null)
const setupRequired = computed(
() =>
@@ -359,6 +386,13 @@ const setupRequired = computed(
developmentParameters.has('setupPreview') &&
!setupPreviewDismissed.value)),
)
const displayedDarkMode = computed(
() =>
phone.isDarkMode &&
(!setupRequired.value ||
setupAppearanceSelected.value ||
phone.preferences.settings.setupStep > 4),
)
const hardwareAlertVolume = computed(() =>
Math.round(
(phone.preferences.settings.notificationVolume +
@@ -416,6 +450,12 @@ const phoneResolutionStyle = computed<CSSProperties>(() => ({
}))
const phoneStageStyle = computed<CSSProperties>(() => ({
...phoneResolutionStyle.value,
'--phone-live-activity-peek-height':
dynamicIslandActivity.value === 'music'
? '190px'
: dynamicIslandActivity.value === 'recording'
? '132px'
: '112px',
visibility: activitySuspended.value ? 'hidden' : 'visible',
}))
const phoneDisplayStyle = computed<CSSProperties>(() => ({
@@ -433,6 +473,7 @@ let unlockTimer: number | undefined
let passcodeLockTimer: number | undefined
let hardwareVolumeHudTimer: number | undefined
let unlockedServicesIdle: number | undefined
let removePhoneAudioController: (() => void) | undefined
let phoneClosePending = false
let simPickerClosePending = false
@@ -452,6 +493,7 @@ function getViewportScale(): number {
}
function hydratePhone(payload: PhoneOpenPayload): void {
configurePhoneNumberFormat(payload.phoneNumberFormat)
if (payload.device?.imei) {
companies.bindDeviceScope(
payload.device.imei,
@@ -483,6 +525,23 @@ function hydratePhone(payload: PhoneOpenPayload): void {
widgets.hydrate(payload.device?.data.widgets?.payload)
}
function getInstalledNavigationAppIds(): string[] {
const installedAppIds: string[] = []
for (const app of PHONE_APPS) {
if (isPhoneAppId(app.id) && appStore.isInstalled(app.id)) {
installedAppIds.push(app.id)
}
}
return installedAppIds
}
function syncNavigationState(): ReturnType<typeof nuiCall> {
return nuiCall('navigation:state', {
currentApp: activeAppId.value || null,
installedApps: getInstalledNavigationAppIds(),
})
}
function cancelUnlockedPhoneDataLoad(): void {
if (unlockedServicesIdle === undefined) return
if (typeof window.cancelIdleCallback === 'function') {
@@ -572,6 +631,7 @@ function loadUnlockedPhoneData(): void {
function completePhoneSetup(): void {
setupPreviewDismissed.value = true
setupAppearanceSelected.value = false
isLocked.value = false
isUnlocking.value = false
passcodeVisible.value = false
@@ -699,9 +759,34 @@ function onMessage(event: MessageEvent<AppMessage>): void {
if (typeof data?.appId === 'string' && route.params.appId === data.appId) {
void router.push('/')
}
} else if (event.data?.type === 'navigation:open-app') {
const data = event.data.data as NavigationEventData | undefined
if (
typeof data?.appId === 'string' &&
isPhoneAppId(data.appId) &&
appStore.isInstalled(data.appId)
) {
void router.push(`/apps/${data.appId}`)
} else {
console.error('[Navigation] Ignored an unavailable app target.')
}
} else if (event.data?.type === 'navigation:close-app') {
const data = event.data.data as NavigationEventData | undefined
const currentApp = route.params.appId
if (data?.appId === undefined || currentApp === data.appId) {
void router.push('/')
}
} else if (event.data?.type === 'compat:open-messages') {
const data = event.data.data as MessagesEventData | undefined
if (typeof data?.phoneNumber === 'string') {
void messages.openThread(data.phoneNumber).then((opened) => {
if (opened) void router.push('/apps/messages')
})
}
} else if (event.data?.type === 'app:open') {
openHomeRequested.value = event.data.openHome === true
hydratePhone(event.data.data as PhoneOpenPayload)
void nuiCall('ui:opened')
void syncNavigationState().then(() => nuiCall('ui:opened'))
} else if (event.data?.type === 'device:updated') {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
@@ -1140,7 +1225,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
loadUnlockedPhoneData()
}
} else if (event.data?.type === 'sim:picker' && event.data.data) {
simPicker.value = event.data.data as unknown as SimPickerPayload
const payload = event.data.data as unknown as SimPickerPayload
configurePhoneNumberFormat(payload.phoneNumberFormat)
simPicker.value = payload
} else if (event.data?.type === 'sim:picker-close') {
simPicker.value = null
}
@@ -1394,7 +1481,30 @@ function unlockCamera(): void {
window.setTimeout(() => void router.push('/apps/camera'), 0)
}
function updateTextInputFocus(active: boolean): void {
if (textInputFocused === active) return
textInputFocused = active
void nuiCall('ui:input-focus', { active })
}
function onFocusIn(event: FocusEvent): void {
const target = event.target
updateTextInputFocus(
target instanceof HTMLElement && isTextInputElement(target),
)
}
function onFocusOut(event: FocusEvent): void {
const nextTarget = event.relatedTarget
updateTextInputFocus(
nextTarget instanceof HTMLElement && isTextInputElement(nextTarget),
)
}
onMounted(() => {
removePhoneAudioController = installPhoneAudioController()
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage)
window.addEventListener('keydown', onKeydown)
window.addEventListener('resize', updateViewportScale)
@@ -1488,6 +1598,18 @@ watch(
},
)
watch(
() => ({
appIds: getInstalledNavigationAppIds(),
currentApp: activeAppId.value,
open: phone.isOpen,
}),
() => {
if (phone.isOpen && appStore.hydrated) void syncNavigationState()
},
{ deep: true },
)
watch(
() => notifications.requiresAttention,
(requiresAttention) => {
@@ -1495,11 +1617,29 @@ watch(
},
)
watch(
() => Boolean(dynamicIslandActivity.value || calls.activeCall),
(active) => {
void nuiCall('ui:live-activity', { active })
},
{ immediate: true },
)
watch(
hardwareAlertVolume,
(volume) => {
setPhoneOutputVolume(volume / 100)
if (radio.data.connected) void radio.setVolume(volume)
},
{ immediate: true },
)
watch(
() => phone.isOpen,
(isOpen) => {
if (unlockTimer !== undefined) window.clearTimeout(unlockTimer)
if (!isOpen) {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
appStore.cancelPendingInstalls()
activitySuspended.value = false
@@ -1526,7 +1666,8 @@ watch(
}
isLocked.value = setupRequired.value
? false
: !isDevelopment || developmentLockScreenPreview
: developmentLockScreenPreview ||
(!isDevelopment && phone.security.enabled)
passcodeRequired.value = isLocked.value && phone.security.enabled
unlockedServicesLoaded.value = false
controlCenterOpened.value = false
@@ -1544,8 +1685,18 @@ watch(
startPasscodeLock(passcodeRetrySeconds.value)
}
phone.setLaunchOrigin(null)
if (isLocked.value || setupRequired.value) void router.replace('/')
else loadUnlockedPhoneData()
if (setupRequired.value) {
void router.replace('/')
} else if (openHomeRequested.value) {
openHomeRequested.value = false
if (isLocked.value) pendingUnlockRoute.value = '/'
else {
void router.replace('/')
loadUnlockedPhoneData()
}
} else if (!isLocked.value) {
loadUnlockedPhoneData()
}
},
)
@@ -1557,6 +1708,8 @@ watch(
)
onBeforeUnmount(() => {
removePhoneAudioController?.()
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
weather.stop()
if (clockTicker) clearInterval(clockTicker)
@@ -1571,6 +1724,8 @@ onBeforeUnmount(() => {
window.removeEventListener('message', onMessage)
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('resize', updateViewportScale)
document.removeEventListener('focusin', onFocusIn)
document.removeEventListener('focusout', onFocusOut)
systemColorScheme.removeEventListener('change', onSystemColorSchemeChange)
})
</script>
@@ -1592,12 +1747,15 @@ onBeforeUnmount(() => {
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity ||
notifications.devicePreviews.length
"
class="phone-stage"
:class="{
'phone-stage--browser-preview': isBrowserPreview,
'phone-stage--landscape': phone.cameraLandscape,
'phone-stage--live-activity':
!phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall),
'phone-stage--peek': notifications.isPeeking,
}"
:style="phoneStageStyle"
@@ -1617,14 +1775,25 @@ onBeforeUnmount(() => {
@open="openNotificationPreview"
/>
<div
v-if="phone.isOpen || notifications.current || calls.activeCall"
v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity
"
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
>
<div
id="phone-home-drag-portal"
class="phone-home-drag-portal"
aria-hidden="true"
></div>
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
<section
class="phone-device"
:class="{
'phone-app--light': !phone.isDarkMode,
'phone-app--light': !displayedDarkMode,
'phone-device--island-expanded': dynamicIslandExpanded,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
@@ -1674,7 +1843,7 @@ onBeforeUnmount(() => {
class="phone-screen"
:class="{
'phone-screen--app': isAppRoute || isDevelopmentRoute,
'phone-app--light': !phone.isDarkMode,
'phone-app--light': !displayedDarkMode,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
>
@@ -1711,18 +1880,19 @@ onBeforeUnmount(() => {
</Transition>
<k-app
theme="ios"
:dark="phone.isDarkMode"
:dark="displayedDarkMode"
safe-areas
class="phone-app"
:style="phoneDisplayStyle"
:class="{
dark: phone.isDarkMode,
'phone-app--light': !phone.isDarkMode,
dark: displayedDarkMode,
'phone-app--light': !displayedDarkMode,
'phone-app--messages': route.params.appId === 'messages',
'phone-app--status-light':
WHITE_STATUS_BAR_APP_IDS.has(activeAppId),
'phone-app--status-dark':
DARK_STATUS_BAR_APP_IDS.has(activeAppId),
'phone-app--setup': setupRequired,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
'phone-app--unlocking': isUnlocking,
}"
@@ -1735,14 +1905,13 @@ onBeforeUnmount(() => {
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
<PhoneDynamicIsland v-if="!setupRequired" />
<SpringboardView
v-if="!isDevelopmentRoute && !setupRequired"
@edit-mode-change="springboardEditing = $event"
/>
<SkyProvider
class="phone-app-theme"
:dark="phone.isDarkMode"
:dark="displayedDarkMode"
safe-areas
>
<RouterView v-slot="{ Component }">
@@ -1801,6 +1970,7 @@ onBeforeUnmount(() => {
</Transition>
<PhoneSetupAssistant
v-if="setupRequired"
@appearance-selected="setupAppearanceSelected = $event"
@complete="completePhoneSetup"
@skip="skipPhoneSetupForDevelopment"
/>
@@ -1820,6 +1990,11 @@ onBeforeUnmount(() => {
aria-hidden="true"
draggable="false"
/>
<PhoneDynamicIsland
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
@expanded-change="dynamicIslandExpanded = $event"
@live-activity-change="dynamicIslandActivity = $event"
/>
</section>
</div>
</div>
@@ -2,24 +2,52 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
)
describe('browser development preview contract', () => {
it('keeps setup light until the appearance choice has been confirmed', () => {
expect(source).toContain('const displayedDarkMode = computed(')
expect(source).toContain('phone.preferences.settings.setupStep > 4')
expect(source).toContain('setupAppearanceSelected.value')
expect(source).toContain(
'@appearance-selected="setupAppearanceSelected = $event"',
)
expect(source).toContain(':dark="displayedDarkMode"')
expect(source).toContain('dark: displayedDarkMode')
})
it('starts unlocked while preserving an explicit lock screen preview', () => {
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
expect(source).toContain(
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
)
expect(source).toContain("developmentParameters.has('setupPreview')")
})
it('loads authenticated app data without replacing direct app routes', () => {
expect(source).toContain(
it('restores the active route after the lock screen', () => {
expect(source).toMatch(
/if \(setupRequired\.value\) \{[\s\S]*?router\.replace\('\/'\)/,
)
expect(source).toMatch(
/else if \(!isLocked\.value\) \{\s*loadUnlockedPhoneData\(\)/,
)
expect(source).not.toContain(
"if (isLocked.value || setupRequired.value) void router.replace('/')",
)
expect(source).toContain('else loadUnlockedPhoneData()')
})
it('opens Space-triggered live activities on Home or the enabled lock screen', () => {
expect(source).toContain(
'openHomeRequested.value = event.data.openHome === true',
)
expect(source).toContain(
"if (isLocked.value) pendingUnlockRoute.value = '/'",
)
expect(source).toContain("void router.replace('/')")
})
it('requires the passcode again after a full device lock', () => {
@@ -64,6 +92,10 @@ describe('browser development preview contract', () => {
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
expect(source).toContain('phone-resolution-canvas--primary')
expect(source).toMatch(
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
)
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
expect(source).toContain("'--phone-rendered-height'")
expect(source).toContain("'--phone-rendered-width'")
expect(mainCss).toMatch(
@@ -87,8 +119,11 @@ describe('browser development preview contract', () => {
it('fills the dedicated browser embed without clipping the hardware controls', () => {
expect(source).toContain("developmentParameters.has('browserPreview')")
expect(source).toContain('import.meta.env.DEV ||')
expect(source).toContain("'phone-stage--browser-preview': isBrowserPreview")
expect(source).toContain('return (availableScale * 0.94) / PHONE_BASE_SCALE')
expect(source).toContain(
'return (availableScale * 0.94) / PHONE_BASE_SCALE',
)
expect(mainCss).toMatch(
/\.phone-stage--browser-preview\s*\{[^}]*place-items:\s*center;[^}]*padding:\s*0;/s,
)
Binary file not shown.
Binary file not shown.
+161 -421
View File
@@ -211,9 +211,11 @@
}
}
:root {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue',
sans-serif;
font-family: var(--sky-font-family);
font-feature-settings:
'liga' 1,
'calt' 1;
font-optical-sizing: auto;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -234,7 +236,9 @@ body,
user-select: none;
}
button,
input {
input,
textarea,
select {
font: inherit;
}
button {
@@ -259,6 +263,20 @@ button {
transform: translateY(calc(100% - 190px));
transform-origin: right bottom;
}
.phone-stage--live-activity .phone-resolution-wrapper--primary .phone-device {
pointer-events: none;
transform: translateY(
calc(100% - var(--phone-live-activity-peek-height, 145px))
);
transform-origin: right bottom;
}
.phone-stage--live-activity
.phone-device
> :not(.phone-screen):not(.phone-device__frame):not(.phone-dynamic-island),
.phone-stage--live-activity .phone-screen > * {
visibility: hidden;
}
.phone-lift-enter-active {
transition: opacity 0.52s linear;
}
@@ -281,6 +299,18 @@ button {
height: var(--phone-rendered-height, 844px);
}
.phone-home-drag-portal {
position: absolute;
z-index: 70;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.phone-home-drag-portal * {
pointer-events: none !important;
}
.phone-resolution-canvas {
position: absolute;
top: 0;
@@ -384,7 +414,7 @@ button {
overflow: hidden;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 15px;
color: #0a84ff;
color: #a9abb2;
background: rgb(47 47 51 / 98%);
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
pointer-events: none;
@@ -672,6 +702,9 @@ button {
transition: transform 280ms var(--sky-ease-out, ease-out);
will-change: transform;
}
.phone-device--island-expanded .phone-notification {
top: 130px !important;
}
.phone-notification__icon {
width: 38px;
height: 38px;
@@ -795,78 +828,6 @@ button {
cursor: pointer;
pointer-events: auto;
}
.phone-dynamic-island {
position: absolute;
z-index: 98;
top: 12px;
left: 50%;
display: flex;
align-items: center;
gap: 10px;
width: 300px;
min-height: 72px;
padding: 10px 12px 10px 16px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 28px;
color: #fff;
background: #050505;
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
transform: translateX(-50%);
}
.phone-dynamic-island__caller {
min-width: 0;
flex: 1;
}
.phone-dynamic-island__caller span,
.phone-dynamic-island__caller strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.phone-dynamic-island__caller span {
color: rgb(255 255 255 / 58%);
font-size: 11px;
line-height: 14px;
}
.phone-dynamic-island__caller strong {
margin-top: 2px;
font-size: 15px;
line-height: 18px;
}
.phone-dynamic-island__actions {
display: flex;
gap: 8px;
}
.phone-dynamic-island__actions .sky-button--icon-only {
width: 44px;
min-width: 44px;
height: 44px;
}
.phone-dynamic-island__actions svg {
width: 19px;
height: 19px;
}
.phone-dynamic-island__answer {
--sky-app-accent: #34c759;
}
.phone-dynamic-island-enter-active,
.phone-dynamic-island-leave-active {
transition:
opacity 180ms ease,
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.phone-dynamic-island-enter-from,
.phone-dynamic-island-leave-to {
opacity: 0;
transform: translateX(-50%) scale(0.86);
}
@media (prefers-reduced-motion: reduce) {
.phone-dynamic-island-enter-active,
.phone-dynamic-island-leave-active {
transition: none;
}
}
.phone-home-indicator {
position: absolute;
z-index: 90;
@@ -904,361 +865,63 @@ button {
}
.wallpaper--midnight {
background:
radial-gradient(circle at 76% 17%, #ffffff 0 0.7px, transparent 1.2px),
radial-gradient(circle at 28% 8%, #9fceff 0 0.6px, transparent 1px),
radial-gradient(circle at 82% 48%, #d8c8ff 0 0.8px, transparent 1.3px),
radial-gradient(
circle at 69% 19%,
#faf7ff 0 3%,
#a57be74d 8%,
transparent 23%
),
conic-gradient(
from 214deg at 63% 35%,
transparent 0 29%,
#7352a51f 36%,
transparent 44%
),
radial-gradient(ellipse at 10% 84%, #1767999e 0, transparent 45%),
linear-gradient(158deg, #050511 0%, #11132d 42%, #120a25 68%, #02040c 100%);
background-size:
43px 47px,
67px 71px,
89px 83px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 78% 12%, rgb(87 103 160 / 18%), transparent 38%),
linear-gradient(160deg, #141724 0%, #090b12 100%);
}
.wallpaper--aurora {
background:
radial-gradient(circle at 17% 14%, #dfffff 0 0.6px, transparent 1.1px),
radial-gradient(circle at 78% 9%, #c6e7ff 0 0.7px, transparent 1.2px),
conic-gradient(
from 218deg at 84% 8%,
transparent 0 18%,
#4effbf70 27%,
#6bf5e220 35%,
transparent 43%
),
conic-gradient(
from 224deg at 62% 26%,
transparent 0 20%,
#27cfd46b 28%,
#695eff42 38%,
transparent 47%
),
radial-gradient(ellipse at 12% 80%, #1860bb9c 0%, transparent 48%),
radial-gradient(ellipse at 76% 20%, #2adfbb6e 0%, transparent 38%),
linear-gradient(155deg, #031715 0%, #08293a 43%, #151643 68%, #040914 100%);
background-size:
59px 61px,
83px 79px,
auto,
auto,
auto,
auto,
auto;
radial-gradient(circle at 74% 18%, rgb(80 188 166 / 22%), transparent 42%),
linear-gradient(155deg, #163c3c 0%, #17263c 56%, #0b101b 100%);
}
.wallpaper--ember {
background:
radial-gradient(
circle at 80% 18%,
#fff4ca 0 1.7%,
#ffbd67 5%,
#ff62335c 17%,
transparent 31%
),
repeating-radial-gradient(
ellipse at 78% 21%,
transparent 0 16px,
#ffb15e12 18px 20px
),
conic-gradient(
from 196deg at 12% 78%,
transparent 0 18%,
#ff3d4c66 28%,
#f3943a29 38%,
transparent 48%
),
conic-gradient(
from 22deg at 78% 90%,
transparent 0 22%,
#b52a755e 31%,
transparent 44%
),
radial-gradient(ellipse at 8% 71%, #c8205c91 0, transparent 46%),
linear-gradient(152deg, #20080a 0%, #4b171d 37%, #3c1537 66%, #090710 100%);
radial-gradient(circle at 82% 14%, rgb(237 142 96 / 28%), transparent 40%),
linear-gradient(155deg, #5b2925 0%, #321b25 58%, #17131b 100%);
}
.wallpaper--ocean {
background:
linear-gradient(116deg, transparent 43%, #c7fbff1c 47%, transparent 51%),
linear-gradient(64deg, transparent 44%, #8defff17 48%, transparent 52%),
radial-gradient(
ellipse at 18% 12%,
#d7ffff 0 1%,
#70e7ff85 12%,
transparent 34%
),
repeating-radial-gradient(
ellipse at 48% 107%,
transparent 0 24px,
#73e8ff2e 27px 30px,
transparent 33px 49px
),
radial-gradient(ellipse at 87% 70%, #056bd1ce 0, transparent 48%),
linear-gradient(
172deg,
#b5f4f8 0%,
#148eb0 22%,
#075b91 54%,
#03284d 78%,
#020c20 100%
);
background-size:
82px 82px,
82px 82px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 22% 10%, rgb(119 203 222 / 28%), transparent 40%),
linear-gradient(175deg, #327e98 0%, #185273 46%, #0b2944 100%);
}
.wallpaper--sunrise {
background:
linear-gradient(18deg, #281c46 0 13%, transparent 13.3%),
linear-gradient(-22deg, #51304a 0 18%, transparent 18.3%),
radial-gradient(
circle at 58% 69%,
#fffbd7 0 5.5%,
#ffd28d 6%,
#ff9f666e 18%,
transparent 35%
),
repeating-linear-gradient(0deg, transparent 0 16px, #ffe2ae0d 17px 18px),
radial-gradient(ellipse at 20% 44%, #f888a360 0, transparent 42%),
linear-gradient(
178deg,
#342b78 0%,
#8a477f 35%,
#ed706f 57%,
#ffc277 72%,
#672e50 100%
);
radial-gradient(circle at 68% 68%, rgb(255 205 153 / 30%), transparent 42%),
linear-gradient(175deg, #6b6488 0%, #b46f78 52%, #d29673 100%);
}
.wallpaper--violet {
background:
radial-gradient(
circle at 78% 14%,
transparent 0 9%,
#e0b6ff35 9.5% 10.3%,
transparent 11%
),
radial-gradient(
circle at 18% 74%,
transparent 0 16%,
#a1a2ff29 16.5% 17.2%,
transparent 18%
),
conic-gradient(
from 202deg at 83% 25%,
transparent 0 17%,
#e48dff91 25%,
#8d63ff32 35%,
transparent 45%
),
linear-gradient(122deg, transparent 36%, #f0c9ff13 39%, transparent 42%),
radial-gradient(circle at 13% 81%, #5555e5c4 0, transparent 46%),
radial-gradient(circle at 72% 28%, #a53dcc70 0, transparent 41%),
linear-gradient(148deg, #11072b 0%, #391164 48%, #20103f 72%, #080519 100%);
radial-gradient(circle at 74% 18%, rgb(164 129 199 / 24%), transparent 42%),
linear-gradient(150deg, #513d69 0%, #302b50 56%, #171828 100%);
}
.wallpaper--forest {
background:
radial-gradient(
ellipse at 82% 12%,
transparent 0 7%,
#c8f49c2c 7.5% 8.2%,
transparent 9%
),
repeating-radial-gradient(
ellipse at -8% 108%,
transparent 0 34px,
#c1e6a218 36px 39px
),
linear-gradient(
135deg,
transparent 42%,
#bfe8a31c 45% 47%,
transparent 50%
),
conic-gradient(
from 55deg at 85% 24%,
transparent 0 24%,
#8dcf7361 31%,
transparent 39%
),
radial-gradient(ellipse at 76% 16%, #b7df8675 0, transparent 36%),
radial-gradient(ellipse at 10% 78%, #0b8b70b8 0, transparent 49%),
linear-gradient(162deg, #061713 0%, #144833 45%, #123326 68%, #040d0b 100%);
background-size:
auto,
auto,
auto,
auto,
auto,
auto,
100% 100%;
radial-gradient(circle at 76% 14%, rgb(126 161 121 / 24%), transparent 42%),
linear-gradient(160deg, #395c4c 0%, #243e35 54%, #13231f 100%);
}
.wallpaper--cobalt {
background:
linear-gradient(
145deg,
transparent 0 47%,
#c4ddff18 48% 49%,
transparent 50%
),
linear-gradient(
35deg,
transparent 0 47%,
#70a5ff12 48% 49%,
transparent 50%
),
radial-gradient(
circle at 76% 25%,
transparent 0 12%,
#62b8ff45 12.5% 13.2%,
transparent 14%
),
conic-gradient(
from 215deg at 80% 28%,
transparent 0 22%,
#52b5ff85 30%,
#5457d62e 41%,
transparent 48%
),
radial-gradient(circle at 14% 78%, #134ed1a3 0, transparent 43%),
linear-gradient(152deg, #041438 0%, #0b3182 43%, #172d82 64%, #030a25 100%);
background-size:
58px 58px,
58px 58px,
auto,
auto,
auto,
auto;
radial-gradient(circle at 72% 16%, rgb(91 142 218 / 28%), transparent 40%),
linear-gradient(155deg, #264f8b 0%, #1b3768 54%, #101e3b 100%);
}
.wallpaper--rose {
background:
radial-gradient(
circle at 16% 18%,
#fff6f3 0 1.5%,
#ffd9df9e 8%,
transparent 27%
),
repeating-radial-gradient(
ellipse at 108% 8%,
transparent 0 24px,
#ffd5e121 26px 28px
),
conic-gradient(
from 214deg at 86% 72%,
transparent 0 18%,
#f078aa8c 27%,
#8d3d7b38 39%,
transparent 48%
),
linear-gradient(138deg, transparent 38%, #fff0f217 43%, transparent 48%),
radial-gradient(circle at 87% 77%, #e84e92a8 0, transparent 43%),
linear-gradient(148deg, #40132f 0%, #8e3159 39%, #b64b70 59%, #351128 100%);
radial-gradient(circle at 24% 12%, rgb(224 154 172 / 26%), transparent 42%),
linear-gradient(155deg, #9a596c 0%, #724354 54%, #412936 100%);
}
.wallpaper--sand {
background:
radial-gradient(
circle at 76% 16%,
#fffce7 0 2.5%,
#ffe5a5a8 9%,
transparent 25%
),
repeating-radial-gradient(
ellipse at -2% 110%,
transparent 0 28px,
#fff3d12b 31px 34px,
transparent 37px 52px
),
conic-gradient(
from 82deg at 110% 76%,
transparent 0 24%,
#ecc37e52 32%,
transparent 43%
),
linear-gradient(115deg, transparent 41%, #fff6d21a 44%, transparent 47%),
radial-gradient(ellipse at 15% 78%, #9f654b9e 0, transparent 48%),
linear-gradient(163deg, #8b5c45 0%, #d4a262 43%, #b87950 68%, #4f302b 100%);
radial-gradient(circle at 76% 14%, rgb(222 190 145 / 25%), transparent 42%),
linear-gradient(160deg, #a17d61 0%, #80604e 56%, #513e36 100%);
}
.wallpaper--graphite {
background:
radial-gradient(circle at 77% 17%, #ffffff42 0 0.8%, transparent 12%),
linear-gradient(
125deg,
transparent 39%,
#ffffff12 40% 42%,
transparent 43%
),
linear-gradient(35deg, transparent 39%, #ffffff0a 40% 42%, transparent 43%),
repeating-linear-gradient(
92deg,
transparent 0 3px,
#ffffff09 4px,
transparent 5px 9px
),
conic-gradient(
from 215deg at 79% 24%,
transparent 0 25%,
#b7c1d045 32%,
transparent 43%
),
radial-gradient(circle at 17% 78%, #51596c73 0, transparent 44%),
linear-gradient(157deg, #06070a 0%, #242832 43%, #171920 67%, #030405 100%);
background-size:
auto,
54px 54px,
54px 54px,
100% 100%,
auto,
auto,
auto;
radial-gradient(circle at 76% 14%, rgb(133 140 151 / 18%), transparent 40%),
linear-gradient(160deg, #363a42 0%, #23262c 54%, #121417 100%);
}
.wallpaper--prism {
background:
linear-gradient(
132deg,
transparent 0 32%,
#ffffff36 32.5% 33.2%,
transparent 34%
),
linear-gradient(
42deg,
transparent 0 56%,
#bffcff25 56.5% 57.2%,
transparent 58%
),
conic-gradient(
from 218deg at 70% 29%,
#ff527ca1,
#ffc75f91,
#4cead2a6,
#4f72ffae,
#c64deaa3,
#ff527ca1
),
conic-gradient(
from 38deg at 16% 78%,
transparent 0 19%,
#4ae3d8a1 28%,
#596cff48 39%,
transparent 48%
),
radial-gradient(circle at 16% 76%, #35d8d399 0, transparent 38%),
linear-gradient(152deg, #120d35 0%, #47205f 46%, #10205a 70%, #041321 100%);
background-blend-mode: screen, screen, screen, screen, screen, normal;
radial-gradient(circle at 72% 16%, rgb(165 151 218 / 24%), transparent 42%),
linear-gradient(150deg, #655b86 0%, #42516f 52%, #293545 100%);
}
.wallpaper--custom {
background-color: #090a0d;
@@ -1639,8 +1302,7 @@ button {
overflow: hidden;
background: #000;
color: #f5f5f7;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
}
.darkchat-page button,
.darkchat-page input,
@@ -2975,8 +2637,30 @@ button {
.springboard--widget-dragging .springboard-widget-page-scroll {
overflow: visible;
}
.springboard--home-dragging .springboard-page--apps {
overflow: visible;
.home-drag-layer {
position: absolute;
overflow: hidden;
pointer-events: none;
}
.home-drag-layer * {
pointer-events: none !important;
}
.home-drag-position {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.home-drag-ghost {
position: absolute;
top: 0;
left: 0;
margin: 0;
opacity: 1;
transition: none !important;
animation: none !important;
transform-origin: 0 0;
will-change: transform;
}
.app-grid {
display: grid;
@@ -3011,6 +2695,9 @@ button {
transition: transform var(--springboard-page-duration)
var(--springboard-page-easing);
}
.app-icon-item--drag-source {
opacity: 0;
}
.app-icon-remove {
position: absolute;
z-index: 4;
@@ -3136,7 +2823,6 @@ button {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.app-icon--calculator {
background: linear-gradient(145deg, #76767b, #1b1b1d);
@@ -4476,8 +4162,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 {
@@ -4529,7 +4215,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 {
@@ -4540,18 +4226,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 {
@@ -4571,7 +4260,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;
@@ -4579,7 +4268,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);
@@ -4613,10 +4302,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 {
@@ -5475,6 +5165,13 @@ button {
color: #111;
text-shadow: 0 1px 3px #fff8;
}
.phone-app--setup .phone-status-bar {
color: #1d1d1f;
text-shadow: none;
}
.phone-app.dark.phone-app--setup .phone-status-bar {
color: #f5f5f7;
}
* {
scrollbar-width: none;
}
@@ -6902,9 +6599,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;
@@ -7226,6 +6961,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%);
@@ -7840,18 +7581,17 @@ button {
min-height: var(--springboard-grid-row);
}
.app-icon-button {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.app-icon-label {
min-height: 15px;
font-size: 11.5px;
min-height: var(--sky-home-label-height);
font-size: var(--sky-home-label-font-size);
font-weight: 500;
letter-spacing: -0.15px;
line-height: 15px;
line-height: var(--sky-home-label-height);
text-align: center;
}
.springboard-edit-add {
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const client = readFileSync(
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
new URL('../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const companiesServer = readFileSync(
@@ -14,6 +14,10 @@ const callsServer = readFileSync(
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const phoneServer = readFileSync(
new URL('../../sky_phone/source/server/phone.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const companiesStore = readFileSync(
new URL('./stores/companies.ts', import.meta.url),
'utf8',
@@ -32,7 +36,7 @@ function sourceBlock(source: string, startMarker: string, endMarker: string) {
describe('Companies outbound service-line call contract', () => {
it('exposes the dedicated callback through the NUI client bridge', () => {
expect(client).toContain(`${quote}companies:dial-service-line${quote}`)
expect(client).toMatch(/companies\s*=\s*\[\[[^\]]*dial-service-line/)
})
it('accepts only a target number and derives the company from the live server member', () => {
@@ -85,3 +89,35 @@ describe('Companies outbound service-line call contract', () => {
expect(startCompanyCall).not.toContain('data.callerNumber')
})
})
describe('Companies background call availability contract', () => {
it('keeps call availability enabled after the phone UI closes', () => {
const closeDevice = sourceBlock(
phoneServer,
`Bridge.Callbacks.Register(${quote}sky_phone:device:close${quote}`,
`Bridge.Callbacks.Register(${quote}sky_phone:device:notification-open${quote}`,
)
expect(closeDevice).toContain('sessions[source] = nil')
expect(closeDevice).not.toContain(
'SkyPhoneCompanies.ClearCallAvailability(source)',
)
})
it('routes background calls only to an owned phone with the same registered SIM', () => {
const getCallTargets = sourceBlock(
companiesServer,
'function SkyPhoneCompanies.GetCallTargets(',
'\n\nlocal function profile_row(',
)
expect(getCallTargets).toContain('SkyPhone.LoadDevice(readiness.imei)')
expect(getCallTargets).toContain(
'SkyPhone.FindDeviceSlots(source, readiness.imei)',
)
expect(getCallTargets).toContain('device.sim_id == readiness.sim_id')
expect(getCallTargets).toContain('device.sim_type == "registered"')
expect(getCallTargets).toContain('device.registered_at ~= nil')
expect(getCallTargets).not.toContain('current_device(source, true)')
})
})
+5 -2
View File
@@ -30,11 +30,13 @@ const props = withDefaults(
app: PhoneAppDefinition
compact?: boolean
editMode?: boolean
externalDragVisual?: boolean
showLabel?: boolean
}>(),
{
compact: false,
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -63,14 +65,14 @@ let dragStartPage = 0
let dragPageWidth = 0
let dragPageMetrics: SpringboardDragMetrics | null = null
const dragStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -300,6 +302,7 @@ onBeforeUnmount(() => {
class="app-icon-item"
:class="{
'app-icon-item--compact': compact,
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode,
}"
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const frame = readFileSync(
new URL('./CustomAppFrame.vue', import.meta.url),
'utf8',
)
const appTypes = readFileSync(
new URL('../types/apps.ts', import.meta.url),
'utf8',
)
describe('Custom app frame context permissions', () => {
it('only exposes locale and theme context when their capabilities are granted', () => {
expect(frame).toContain("capabilities.includes('theme.read')")
expect(frame).toContain("capabilities.includes('locale.read')")
expect(frame).toMatch(
/capabilities\.includes\('theme\.read'\)[\s\S]*colorScheme/,
)
expect(frame).toMatch(
/capabilities\.includes\('locale\.read'\)[\s\S]*language:[\s\S]*locale:/,
)
expect(appTypes).toContain("colorScheme?: 'dark' | 'light'")
expect(appTypes).toContain('language?: string')
expect(appTypes).toContain('locale?: Record<string, unknown>')
})
})
+114 -13
View File
@@ -10,6 +10,8 @@ import { useRouter } from 'vue-router'
import { getPhoneApp, isExternalPhoneApp } from '@/config/apps'
import { useAppCatalogStore } from '@/stores/app-catalog'
import { useCallsStore } from '@/stores/calls'
import { useMessagesStore } from '@/stores/messages'
import { useNotificationsStore } from '@/stores/notifications'
import { usePhoneStore } from '@/stores/phone'
import type {
@@ -33,13 +35,18 @@ import {
getCustomAppSafeArea,
} from '@/utils/customAppLifecycle'
import {
LB_PHONE_STORAGE_MESSAGE_TYPE,
LB_PHONE_ACTION_MESSAGE_TYPE,
createLbPhoneFrameDocument,
createLbPhoneHostSettings,
getLbPhoneCallbackResource,
readLbPhoneStorage,
usesLbPhoneHostRuntime,
writeLbPhoneStorage,
} from '@/utils/lbPhoneAppBridge'
import { cloneJsonData } from '@/utils/clone'
import { nuiCall } from '@/utils/nui'
import type { PhoneCall } from '@/types/phone'
const props = defineProps<{
app: ExternalPhoneAppDefinition
@@ -48,6 +55,8 @@ const props = defineProps<{
const PROTOCOL_VERSION = 1
const catalog = useAppCatalogStore()
const calls = useCallsStore()
const messages = useMessagesStore()
const notifications = useNotificationsStore()
const phone = usePhoneStore()
const router = useRouter()
@@ -128,19 +137,28 @@ const frameReady = computed(
frameLoaded.value &&
(props.app.bridgeMode === 'legacy' || skyBridgeReady.value),
)
const context = computed<SkyPhoneAppContextV1>(() => ({
appId: props.app.id,
capabilities: getSkyPhoneAppCapabilities(props.app.capabilities),
colorScheme: phone.isDarkMode ? 'dark' : 'light',
language: phone.lang,
locale: {
description: props.app.description,
name: props.app.name,
},
phoneScale: phone.preferences.settings.phoneScale / 100,
protocolVersion: PROTOCOL_VERSION,
safeArea: getCustomAppSafeArea(props.app.orientation),
}))
const context = computed<SkyPhoneAppContextV1>(() => {
const capabilities = getSkyPhoneAppCapabilities(props.app.capabilities)
return {
appId: props.app.id,
capabilities,
...(capabilities.includes('theme.read')
? { colorScheme: phone.isDarkMode ? ('dark' as const) : ('light' as const) }
: {}),
...(capabilities.includes('locale.read')
? {
language: phone.lang,
locale: {
description: props.app.description,
name: props.app.name,
},
}
: {}),
phoneScale: phone.preferences.settings.phoneScale / 100,
protocolVersion: PROTOCOL_VERSION,
safeArea: getCustomAppSafeArea(props.app.orientation),
}
})
const lbSettings = computed(() =>
createLbPhoneHostSettings({
deviceName: phone.device?.name ?? '',
@@ -191,6 +209,16 @@ async function prepareLbFrameDocument(): Promise<void> {
const controller = new AbortController()
frameDocumentController = controller
try {
let appStorage = {}
try {
appStorage = readLbPhoneStorage(window.localStorage, props.app.id)
} catch (error) {
console.error(
`[Custom apps] Could not read LB Phone storage for ${props.app.id}.`,
error,
)
}
const response = await fetch(frameUrl.value, {
credentials: 'omit',
signal: controller.signal,
@@ -201,6 +229,7 @@ async function prepareLbFrameDocument(): Promise<void> {
const html = await response.text()
lbFrameDocument.value = createLbPhoneFrameDocument(html, {
appName: props.app.id,
localStorage: appStorage,
resourceName: getLbPhoneCallbackResource(props.app),
settings: lbSettings.value,
ui: props.app.ui,
@@ -282,6 +311,55 @@ async function handleBridgeRequest(
}
}
async function handleLbPhoneAction(message: Record<string, unknown>) {
if (message.action === 'createCall') {
const options = message.options
if (!options || typeof options !== 'object' || Array.isArray(options)) {
console.error(
`[Custom apps] Rejected invalid LB call action from ${props.app.id}.`,
)
return
}
const target = options as Record<string, unknown>
if (
typeof target.number !== 'string' &&
typeof target.company !== 'string'
) {
console.error(
`[Custom apps] Rejected invalid LB call target from ${props.app.id}.`,
)
return
}
const response = await nuiCall<PhoneCall>('calls:dial', {
company: target.company,
phoneNumber: target.number,
})
if (response.success && response.data) calls.applyCallState(response.data)
return
}
if (message.action === 'createSMS') {
const options = message.options
const phoneNumber =
typeof options === 'string'
? options
: options && typeof options === 'object' && !Array.isArray(options)
? ((options as Record<string, unknown>).number ??
(options as Record<string, unknown>).phoneNumber)
: undefined
if (
typeof phoneNumber !== 'string' ||
!(await messages.openThread(phoneNumber))
) {
console.error(
`[Custom apps] Rejected invalid LB SMS target from ${props.app.id}.`,
)
return
}
void router.push('/apps/messages')
}
}
function isTrustedFrameMessage(event: MessageEvent): boolean {
if (event.source !== frame.value?.contentWindow) return false
if (props.app.bundled || lbHostRuntime.value) {
@@ -308,6 +386,29 @@ function onFrameMessage(event: MessageEvent): void {
return
}
if (message.type === LB_PHONE_STORAGE_MESSAGE_TYPE) {
try {
if (
!writeLbPhoneStorage(window.localStorage, props.app.id, message.storage)
) {
console.error(
`[Custom apps] Rejected invalid LB Phone storage for ${props.app.id}.`,
)
}
} catch (error) {
console.error(
`[Custom apps] Could not persist LB Phone storage for ${props.app.id}.`,
error,
)
}
return
}
if (message.type === LB_PHONE_ACTION_MESSAGE_TYPE) {
void handleLbPhoneAction(message)
return
}
if (message.type === 'sky-phone-app:ready') {
if (!skyBridgeReady.value) {
if (loadTimeout !== undefined) clearTimeout(loadTimeout)
+5 -2
View File
@@ -22,11 +22,13 @@ const props = withDefaults(
apps: PhoneAppDefinition[]
defaultName: string
editMode?: boolean
externalDragVisual?: boolean
folder: HomeFolder
showLabel?: boolean
}>(),
{
editMode: false,
externalDragVisual: false,
showLabel: true,
},
)
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
}
: undefined,
)
const dragPointerStyle = computed(() =>
isDragging.value
isDragging.value && !props.externalDragVisual
? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
}
@@ -232,6 +234,7 @@ onBeforeUnmount(() => {
<div
class="home-folder-item app-icon-item"
:class="{
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode,
'home-folder-item--dragging': isDragging,
@@ -331,8 +331,7 @@ function finishPageSwipe(event: PointerEvent): void {
z-index: 70;
inset: 0;
color: #fff;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
}
.home-folder-backdrop {
+5 -2
View File
@@ -3,6 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import payphoneFrame from '@/assets/img/payphone/american-payphone-frame.png'
import { nuiCall } from '@/utils/nui'
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type PayphoneState =
@@ -70,7 +71,9 @@ const buttonSounds: HTMLAudioElement[] = []
function prepareButtonSounds(): void {
if (buttonSounds.length) return
for (let index = 0; index < 4; index += 1) {
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
const sound = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
)
sound.preload = 'auto'
sound.volume = 0.55
buttonSounds.push(sound)
@@ -399,7 +402,7 @@ onBeforeUnmount(() => {
rgb(30 38 42 / 35%),
rgb(0 0 0 / 84%) 72%
);
font-family: 'Segoe UI', Arial, sans-serif;
font-family: var(--sky-font-family);
pointer-events: auto;
user-select: none;
}
@@ -5,8 +5,15 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./PhoneDynamicIsland.vue', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const appSource = readFileSync(
new URL('../App.vue', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const appSource = readFileSync(new URL('../App.vue', import.meta.url), 'utf8')
const recorderSource = readFileSync(
new URL('./PhoneMemoRecorder.vue', import.meta.url),
'utf8',
@@ -19,16 +26,23 @@ const clockSource = readFileSync(
new URL('../views/apps/ClockApp.vue', import.meta.url),
'utf8',
)
const serverMemosSource = readFileSync(
new URL('../../../sky_phone/source/server/memos.lua', import.meta.url),
'utf8',
)
describe('Phone Dynamic Island contract', () => {
it('renders once at phone shell level instead of forcing calls into Phone', () => {
expect(appSource).toContain(
"import PhoneDynamicIsland from '@/components/PhoneDynamicIsland.vue'",
)
expect(appSource).toContain('<PhoneDynamicIsland v-if="!setupRequired" />')
expect(appSource).toContain(
'phone.isOpen || notifications.current || calls.activeCall',
"'phone-device--island-expanded': dynamicIslandExpanded",
)
expect(appSource).toMatch(
/class="phone-device__frame"[\s\S]*?<PhoneDynamicIsland[\s\S]*?@expanded-change="dynamicIslandExpanded = \$event"[\s\S]*?@live-activity-change="dynamicIslandActivity = \$event"/,
)
expect(appSource).toContain('dynamicIslandActivity ||')
expect(appSource).not.toContain(
"window.setTimeout(() => void router.push('/apps/phone'), 0)",
)
@@ -45,7 +59,44 @@ describe('Phone Dynamic Island contract', () => {
expect(source).toContain('call.answeredAt ?? call.startedAt')
})
it('hides an activity in its owning foreground app but restores it after closing', () => {
expect(source).toContain(
'if (!currentActivity || !phone.isOpen) return currentActivity',
)
expect(source).toContain("activeAppId.value === 'phone'")
expect(source).toContain(
"currentActivity === 'recording' && activeAppId.value === 'memos'",
)
expect(source).toContain("activeAppId.value === 'clock'")
expect(source).toContain(
"currentActivity === 'music' && activeAppId.value === 'music'",
)
})
it('shows only a compact frame and island for background live activities', () => {
expect(source).toContain("'live-activity-change': [activity:")
expect(source).toContain("emit('live-activity-change', nextActivity)")
expect(appSource).toContain(
"'phone-stage--live-activity':\n !phone.isOpen && Boolean(dynamicIslandActivity || calls.activeCall)",
)
expect(mainCss).toMatch(
/\.phone-stage--live-activity[\s\S]*?pointer-events:\s*none;[\s\S]*?--phone-live-activity-peek-height/,
)
expect(mainCss).toMatch(
/\.phone-stage--live-activity[\s\S]*?> :not\(\.phone-screen\):not\(\.phone-device__frame\):not\(\.phone-dynamic-island\)[\s\S]*?\.phone-screen > \*[\s\S]*?visibility:\s*hidden;/,
)
expect(appSource).toContain("? '190px'")
expect(appSource).toContain("? '132px'")
expect(appSource).toContain(": '112px'")
expect(source).toContain(
"!phone.isOpen ||\n activity.value === 'incoming-call' ||",
)
})
it('connects music, recorder, timer, and stopwatch controls to their stores', () => {
expect(source).toContain(
"if (music.isPlaying && music.currentTrack) return 'music'",
)
expect(source).toContain('@click.stop="music.previous()"')
expect(source).toContain('@click.stop="music.toggle()"')
expect(source).toContain('@click.stop="music.next()"')
@@ -53,9 +104,31 @@ describe('Phone Dynamic Island contract', () => {
expect(source).toContain('clock.pauseTimer(Date.now())')
expect(source).toContain('clock.pauseStopwatch(Date.now())')
expect(source).toContain('clock.addLap(Date.now())')
expect(source).not.toContain('phone-dynamic-island__lap')
expect(source).toContain('phone-dynamic-island__stopwatch-meta')
expect(source).toContain('{{ stopwatchLapLabel }}')
expect(source).toContain('{{ stopwatchLapValue }}')
expect(source).toContain('{{ stopwatchTotalDisplay }}')
})
it('keeps recorder state available across app changes', () => {
it('matches the reference music player and timer control layouts', () => {
expect(source).toContain('phone-dynamic-island__music-equalizer')
expect(source).toContain('phone-dynamic-island__progress-track')
expect(source).toContain('{{ musicElapsedLabel }}')
expect(source).toContain('{{ musicRemainingLabel }}')
expect(source).not.toContain('Airplay')
expect(source).toContain('<X aria-hidden="true" />')
expect(source).toMatch(
/\.phone-dynamic-island--timer\.phone-dynamic-island__copy|\.phone-dynamic-island--timer \.phone-dynamic-island__copy/,
)
expect(source).toContain(
'.phone-dynamic-island--music .phone-dynamic-island__actions--media',
)
expect(source).toContain('justify-content: center')
expect(source).toContain('gap: 34px')
})
it('keeps recorder state available across app changes and phone closes', () => {
expect(recorderSource).toContain(
"message.type === 'memo:recordStateRequest'",
)
@@ -65,6 +138,15 @@ describe('Phone Dynamic Island contract', () => {
expect(memosSource).not.toContain(
"if (recordingActive.value) postRecorderCommand('memo:recordCancel')",
)
expect(recorderSource).toContain('() => phone.device?.imei ?? null')
expect(recorderSource).not.toContain(
'if (!isOpen || deviceSessionChanged) cancelRecording()',
)
expect(recorderSource).toContain('deviceImei: finalDeviceImei')
expect(serverMemosSource).toContain('device_owner(src, data.deviceImei)')
expect(serverMemosSource).toContain(
'SkyPhone.FindDeviceSlots(source, imei)[1]',
)
})
it('opens both clock live activities on the correct clock tab', () => {
@@ -75,13 +157,68 @@ describe('Phone Dynamic Island contract', () => {
)
})
it('opens activities by tapping the island without a separate expand icon', () => {
expect(source).toContain('@click.stop="toggleExpanded"')
expect(source).toContain('@click.stop="openActivity"')
expect(source).not.toContain('Maximize2')
expect(source).not.toContain('phone-dynamic-island__open-icon')
})
it('collapses expanded activities on taps, swipes, and scrolling outside', () => {
expect(source).toContain('ref="islandElement"')
expect(source).toContain(
"document.addEventListener('pointerdown', onOutsidePointerDown, true)",
)
expect(source).toContain(
"document.addEventListener('scroll', collapseExpanded, true)",
)
expect(source).toContain('islandElement.value?.contains(event.target)')
expect(source).toContain('expanded.value = false')
expect(source).toContain(
"document.removeEventListener('pointerdown', onOutsidePointerDown, true)",
)
expect(source).toContain(
"document.removeEventListener('scroll', collapseExpanded, true)",
)
})
it('animates state changes and moves popup notifications below expanded UI', () => {
expect(source).toContain('<Transition name="phone-dynamic-island">')
expect(source).toContain(
'<Transition name="phone-dynamic-island-content" mode="out-in">',
)
expect(source).toContain(".phone-dynamic-island[data-expanded='true']")
expect(source).toContain('~ .phone-notification-provider')
expect(source).toContain("emit('expanded-change', false)")
expect(mainCss).toContain(
'.phone-device--island-expanded .phone-notification',
)
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
})
it('renders below the top edge and above the physical camera frame', () => {
expect(source).toMatch(
/\.phone-dynamic-island\s*\{[^}]*z-index:\s*102;[^}]*top:\s*30px;/s,
)
expect(mainCss).not.toMatch(/\.phone-dynamic-island\s*\{/)
})
it('keeps compact and expanded islands close to the physical camera proportions', () => {
expect(source).toMatch(
/\.phone-dynamic-island\s*\{[^}]*width:\s*126px;[^}]*height:\s*38px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island\[data-expanded='true'\]\s*\{[^}]*width:\s*318px;[^}]*height:\s*74px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--incoming-call\[data-expanded='true'\]\s*\{[^}]*height:\s*68px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--music\[data-expanded='true'\]\s*\{[^}]*width:\s*316px;[^}]*height:\s*150px;/s,
)
expect(source).toMatch(
/\.phone-dynamic-island--stopwatch\[data-expanded='true'\]\s*\{[^}]*height:\s*70px;/s,
)
expect(source).toContain('box-sizing: border-box')
expect(source).toContain('padding: 8px 16px 8px 10px')
})
})
File diff suppressed because it is too large Load Diff
+89 -14
View File
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type RecordingChunk = { blob: Blob; durationMs: number }
type PendingVideo = { blob: Blob; fileName: string }
type UploadFailureDebug = {
correlationId: string
message: string
stage: string
status?: number
}
const canvasRef = ref<HTMLCanvasElement | null>(null)
const pendingVideos = new Map<string, PendingVideo>()
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
try {
activeRecorder.stop()
} catch (error) {
console.error('[Camera] Could not stop the failed media recorder.', error)
console.error(
'[Camera] Could not stop the failed media recorder.',
error,
)
}
}
}
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
const activeRecorder = recorder
removeRecorderErrorListener = bindMediaRecorderError(
activeRecorder,
() =>
generation === recordingGeneration && recorder === activeRecorder,
() => generation === recordingGeneration && recorder === activeRecorder,
(event) => {
console.error('[Camera] Media recorder failed while recording.', event)
cleanupRecording()
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
}
}
async function failUpload(requestId: string, error: string): Promise<void> {
await nuiCall('media:failUpload', { error, requestId })
async function failUpload(
requestId: string,
error: string,
debug: UploadFailureDebug,
): Promise<void> {
console.error('[Sky Phone Media] Upload failed.', {
correlationId: debug.correlationId,
detail: debug.message,
error,
stage: debug.stage,
status: debug.status,
})
const response = await nuiCall('media:failUpload', {
correlationId: debug.correlationId,
debugMessage: debug.message,
debugStage: debug.stage,
debugStatus: debug.status,
error,
requestId,
})
if (!response.success) {
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
correlationId: debug.correlationId,
error: response.error,
})
}
}
async function uploadReady(ready: UploadReady): Promise<void> {
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
blob = await capturePhotoBlob(ready)
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
}
} catch {
await failUpload(ready.requestId, 'capture_failed')
} catch (error) {
await failUpload(ready.requestId, 'capture_failed', {
correlationId: ready.correlationId,
message: error instanceof Error ? error.message : String(error),
stage: 'capture',
})
return
}
console.info('[Sky Phone Media] Capture prepared for upload.', {
bytes: blob.size,
correlationId: ready.correlationId,
mimeType: blob.type,
type: ready.mediaType,
})
const form = new FormData()
form.append('file', blob, fileName)
form.append(
'metadata',
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
)
const controller = new AbortController()
const timeout = window.setTimeout(
() => controller.abort(),
ready.uploadTimeoutMs ?? 25000,
)
let debugStage = 'provider_request'
let debugStatus: number | undefined
try {
const response = await fetch(ready.presignedUrl, {
body: form,
method: 'POST',
signal: controller.signal,
})
debugStatus = response.status
debugStage = 'provider_response'
console.info('[Sky Phone Media] FiveManage upload responded.', {
correlationId: ready.correlationId,
status: response.status,
})
const text = await response.text()
const body = JSON.parse(text) as {
data?: { id?: string; url?: string }
error?: string
id?: string
message?: string
url?: string
}
const uploaded = body.data ?? body
if (!response.ok || !uploaded.id || !uploaded.url) {
throw new Error('upload_failed')
throw new Error(
(typeof body.error === 'string' && body.error) ||
(typeof body.message === 'string' && body.message) ||
'upload_failed',
)
}
await nuiCall('media:completeUpload', {
debugStage = 'completion_callback'
const completion = await nuiCall('media:completeUpload', {
correlationId: ready.correlationId,
remoteId: uploaded.id,
requestId: ready.requestId,
url: uploaded.url,
})
if (!completion.success) {
throw new Error(completion.error ?? 'completion_callback_failed')
}
console.info(
'[Sky Phone Media] Upload completion forwarded to the server.',
{
correlationId: ready.correlationId,
},
)
} catch (error) {
await failUpload(
ready.requestId,
error instanceof DOMException && error.name === 'AbortError'
? 'upload_timeout'
: 'upload_failed',
{
correlationId: ready.correlationId,
message: error instanceof Error ? error.message : String(error),
stage: debugStage,
status: debugStatus,
},
)
} finally {
window.clearTimeout(timeout)
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
)
}
} else if (message.type === 'media:uploadReady') {
void uploadReady(message.data as UploadReady)
const ready = message.data as UploadReady
console.info('[Sky Phone Media] Upload-ready received.', {
correlationId: ready.correlationId,
type: ready.mediaType,
})
void uploadReady(ready)
} else if (message.type === 'media:uploadResult') {
const correlationId = String(message.data?.correlationId ?? '')
if (correlationId) pendingVideos.delete(correlationId)
+11 -21
View File
@@ -54,6 +54,7 @@ let metadata: MemoRecordingMetadata = { note: '', pinned: false, title: '' }
let currentState: MemoRecorderStateName = 'idle'
let currentElapsedMs = 0
let currentCorrelationId = ''
let recordingDeviceImei = ''
let removeRecorderErrorListener: (() => void) | null = null
function postRecorderState(state: MemoRecorderStateName, error?: string): void {
@@ -153,6 +154,7 @@ function resetRecordingData(): void {
pausedStartedAt = 0
totalPausedMs = 0
currentElapsedMs = 0
recordingDeviceImei = ''
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
}
@@ -189,7 +191,8 @@ function failRecording(error: string, generation = recordingGeneration): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (!phone.isOpen) {
const deviceImei = phone.device?.imei
if (!phone.isOpen || !deviceImei) {
console.error('[Memos] Cannot start a recording while the phone is closed.')
return
}
@@ -217,6 +220,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
metadata = { note: '', pinned: false, title: '' }
updateMetadata(data)
resetRecordingData()
recordingDeviceImei = deviceImei
postRecorderState('starting')
try {
const acquiredStream = await navigator.mediaDevices.getUserMedia({
@@ -340,6 +344,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
if (generation !== recordingGeneration) return
const waveform = compressedWaveform()
const finalMetadata = { ...metadata }
const finalDeviceImei = recordingDeviceImei
const exceededSizeLimit = recordingTooLarge
cleanupRecorder(false)
resetRecordingData()
@@ -355,10 +360,12 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
liveLevels = waveform.slice(-LIVE_LEVEL_SAMPLES)
const uploadData = {
correlationId,
deviceImei: finalDeviceImei,
durationMs,
mimeType,
note: finalMetadata.note,
pinned: finalMetadata.pinned,
sizeBytes: blob.size,
title: finalMetadata.title,
waveform,
}
@@ -452,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
pending.requestId = ready.requestId
const form = new FormData()
form.append('file', pending.blob, pending.fileName)
form.append(
'metadata',
JSON.stringify({
captureToken: ready.captureToken,
purpose: 'memo',
source: 'sky_phone',
}),
)
const controller = new AbortController()
pending.abortController = controller
const timeout = window.setTimeout(
@@ -569,18 +568,9 @@ function onMessage(event: MessageEvent): void {
onMounted(() => window.addEventListener('message', onMessage))
watch(
() =>
[
phone.isOpen,
phone.device?.imei ?? null,
phone.deviceSessionToken,
] as const,
([isOpen, imei, sessionToken], previous) => {
const deviceSessionChanged =
previous !== undefined &&
previous[0] &&
(previous[1] !== imei || previous[2] !== sessionToken)
if (!isOpen || deviceSessionChanged) cancelRecording()
() => phone.device?.imei ?? null,
(imei, previousImei) => {
if (previousImei && imei !== previousImei) cancelRecording()
},
)
@@ -11,13 +11,20 @@ describe('PhoneSetupAssistant contract', () => {
it('uses first-party controls and exposes the complete setup journey', () => {
expect(source).not.toContain("from 'konsta/vue'")
expect(source).toContain('PhonePasscode')
expect(source).toContain("step === 1")
expect(source).toContain("step === 8")
expect(source).toContain('step === 1')
expect(source).toContain('step === 8')
expect(source).toContain("['performance', 'ultimate']")
expect(source).toContain('setup-mode-preview__card--front')
expect(source).toContain('setup-mode-preview__card--back')
expect(source).toContain('WALLPAPER_IDS')
expect(source).toContain('setAllAppNotifications')
expect(source).toContain('appStore.claimApp')
expect(source).toContain('phone.completeSetup()')
expect(source).toContain('await phone.completeSetup()')
expect(source).toMatch(
/if \(step\.value === 8\) \{[\s\S]*void finish\(\)[\s\S]*return/,
)
expect(source).toContain(':disabled="setupCompleteBusy"')
expect(source).toContain("phone.t('Setup.ready.saveFailed')")
})
it('persists progress and supports resuming or moving backward', () => {
@@ -25,4 +32,30 @@ describe('PhoneSetupAssistant contract', () => {
expect(source).toContain('phone.setSetupStep(step.value)')
expect(source).toContain('@click="moveTo(step - 1)"')
})
it('keeps the development skip control away from setup progress', () => {
expect(source).toContain('v-if="showDevelopmentSkip && step === 0"')
})
it('follows the selected system appearance throughout setup', () => {
expect(source).toContain(
'(appearanceSelected || step > 4) && phone.isDarkMode',
)
expect(source).toContain("emit('appearanceSelected', true)")
expect(source).toContain("phone.setPreference('appearanceMode', 'light')")
expect(source).toContain('.setup-assistant--dark.setup-assistant--step-0')
expect(source).toContain('--setup-background: #000000')
expect(source).toContain('background: var(--setup-background)')
})
it('applies the selected graphics mode to the setup experience immediately', () => {
expect(source).toContain("'setup-assistant--performance':")
expect(source).toContain("'setup-assistant--ultimate':")
expect(source).toContain(
'.setup-assistant--performance .setup-forward-enter-active',
)
expect(source).toContain(
'.setup-assistant--ultimate .setup-mode-stack button',
)
})
})
+537 -32
View File
@@ -9,6 +9,7 @@ import {
Palette,
ShieldCheck,
Signal,
Smartphone,
Sparkles,
Wifi,
} from 'lucide-vue-next'
@@ -30,7 +31,11 @@ import {
type WallpaperId,
} from '@/utils/preferences'
const emit = defineEmits<{ complete: []; skip: [] }>()
const emit = defineEmits<{
appearanceSelected: [selected: boolean]
complete: []
skip: []
}>()
const phone = usePhoneStore()
const account = useAccountStore()
@@ -38,6 +43,10 @@ const appStore = useAppStoreStore()
const step = ref(
Math.min(PHONE_SETUP_LAST_STEP, phone.preferences.settings.setupStep),
)
const appearanceSelected = ref(step.value > 4)
if (step.value === 4) {
phone.setPreference('appearanceMode', 'light')
}
const direction = ref<'back' | 'forward'>('forward')
const accountMode = ref<'login' | 'register'>('login')
const email = ref('')
@@ -53,6 +62,8 @@ const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
const notificationsEnabled = ref(true)
const notificationSounds = ref(true)
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
const setupCompleteBusy = ref(false)
const setupCompleteError = ref('')
const setupApps = (
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
@@ -91,6 +102,13 @@ const showDevelopmentSkip = import.meta.env.DEV
function moveTo(nextStep: number): void {
direction.value = nextStep < step.value ? 'back' : 'forward'
step.value = Math.min(PHONE_SETUP_LAST_STEP, Math.max(0, nextStep))
if (step.value < 4) {
appearanceSelected.value = false
emit('appearanceSelected', false)
}
if (step.value === 4 && !appearanceSelected.value) {
phone.setPreference('appearanceMode', 'light')
}
phone.setSetupStep(step.value)
}
@@ -103,12 +121,16 @@ function continueSetup(): void {
}
if (step.value === 8) {
for (const appId of selectedApps.value) appStore.claimApp(appId)
void finish()
return
}
moveTo(step.value + 1)
}
function chooseAppearance(mode: AppearanceMode): void {
appearanceSelected.value = true
phone.setPreference('appearanceMode', mode)
emit('appearanceSelected', true)
}
function choosePerformance(mode: GraphicsMode): void {
@@ -204,8 +226,16 @@ function choosePasscodeLength(length: 4 | 6): void {
passcodeResetKey.value += 1
}
function finish(): void {
phone.completeSetup()
async function finish(): Promise<void> {
if (setupCompleteBusy.value) return
setupCompleteBusy.value = true
setupCompleteError.value = ''
const completed = await phone.completeSetup()
setupCompleteBusy.value = false
if (!completed) {
setupCompleteError.value = phone.t('Setup.ready.saveFailed')
return
}
emit('complete')
}
@@ -217,13 +247,22 @@ function skipSetupForDevelopment(): void {
<template>
<section
class="setup-assistant"
:class="`setup-assistant--step-${step}`"
:class="[
`setup-assistant--step-${step}`,
{
'setup-assistant--dark':
(appearanceSelected || step > 4) && phone.isDarkMode,
'setup-assistant--performance':
phone.preferences.settings.graphicsMode === 'performance',
'setup-assistant--ultimate':
phone.preferences.settings.graphicsMode === 'ultimate',
},
]"
:style="currentWallpaperStyle"
:aria-label="phone.t('Setup.title')"
>
<div class="setup-assistant__aurora" aria-hidden="true"></div>
<button
v-if="showDevelopmentSkip"
v-if="showDevelopmentSkip && step === 0"
type="button"
class="setup-assistant__development-skip"
@click="skipSetupForDevelopment"
@@ -252,15 +291,8 @@ function skipSetupForDevelopment(): void {
<main :key="step" class="setup-assistant__page">
<template v-if="step === 0">
<div class="setup-welcome__hero" aria-hidden="true">
<div class="setup-welcome__greetings">
<span>{{ phone.t('Setup.welcome.hello') }}</span>
<span>{{ phone.t('Setup.welcome.hallo') }}</span>
<span>{{ phone.t('Setup.welcome.bonjour') }}</span>
</div>
<div class="setup-welcome__signature">
<span></span>
<span></span>
<span></span>
<div class="setup-welcome__device">
<Smartphone :size="43" :stroke-width="1.55" />
</div>
</div>
<div class="setup-welcome__copy">
@@ -273,19 +305,6 @@ function skipSetupForDevelopment(): void {
</p>
</div>
<footer class="setup-welcome__footer">
<div class="setup-welcome__privacy">
<span
><ShieldCheck :size="14" />{{
phone.t('Setup.welcome.private')
}}</span
>
<i aria-hidden="true"></i>
<span
><Sparkles :size="14" />{{
phone.t('Setup.welcome.personal')
}}</span
>
</div>
<SkyButton class="setup-assistant__primary" @click="continueSetup">
{{ phone.t('Setup.getStarted') }}
</SkyButton>
@@ -622,7 +641,20 @@ function skipSetupForDevelopment(): void {
<span
class="setup-mode-stack__orb"
:class="`setup-mode-stack__orb--${mode}`"
></span>
aria-hidden="true"
>
<i class="setup-mode-preview__backdrop"></i>
<i
class="setup-mode-preview__card setup-mode-preview__card--back"
></i>
<i
class="setup-mode-preview__card setup-mode-preview__card--front"
></i>
<i
class="setup-mode-preview__line setup-mode-preview__line--wide"
></i>
<i class="setup-mode-preview__line"></i>
</span>
<span
><strong>{{ phone.t(`Apps.settings.${mode}Mode`) }}</strong
><small>{{ phone.t(`Setup.performance.${mode}`) }}</small></span
@@ -794,12 +826,29 @@ function skipSetupForDevelopment(): void {
}}</b></span
>
</div>
<SkyButton class="setup-assistant__primary" @click="finish">{{
phone.t('Setup.ready.enter')
}}</SkyButton>
<SkyButton
class="setup-assistant__primary"
:disabled="setupCompleteBusy"
@click="finish"
>
<SkySpinner
v-if="setupCompleteBusy"
:label="phone.t('Setup.ready.saving')"
:size="18"
/>
<span v-else>{{ phone.t('Setup.ready.enter') }}</span>
</SkyButton>
<p
v-if="setupCompleteError"
class="setup-assistant__error"
role="alert"
>
{{ setupCompleteError }}
</p>
<button
type="button"
class="setup-assistant__later"
:disabled="setupCompleteBusy"
@click="moveTo(0)"
>
{{ phone.t('Setup.ready.review') }}
@@ -2099,6 +2148,462 @@ function skipSetupForDevelopment(): void {
opacity: 0;
transform: translateX(16px);
}
/* Minimal system setup language, matching the restrained iOS setup hierarchy. */
.setup-assistant,
.setup-assistant--step-0 {
--setup-background: #ffffff;
--setup-text: #1d1d1f;
--setup-secondary: #6e6e73;
--setup-tertiary: #8e8e93;
--setup-muted: #aeaeb2;
--setup-surface: #f2f2f7;
--setup-selected-surface: #eef6ff;
--setup-control: #e9e9eb;
--setup-control-fill: #ffffff;
--setup-separator: #d1d1d6;
--setup-blue: #007aff;
--setup-green: #34c759;
--setup-red: #ff3b30;
--setup-orb: #dcecff;
--setup-orb-ultimate: #e7e2ff;
--setup-home-indicator: #1d1d1f;
color: var(--setup-text);
background: var(--setup-background);
}
.setup-assistant--dark,
.setup-assistant--dark.setup-assistant--step-0 {
--setup-background: #000000;
--setup-text: #f5f5f7;
--setup-secondary: #98989d;
--setup-tertiary: #8e8e93;
--setup-muted: #636366;
--setup-surface: #1c1c1e;
--setup-selected-surface: #102a43;
--setup-control: #2c2c2e;
--setup-control-fill: #636366;
--setup-separator: #38383a;
--setup-blue: #0a84ff;
--setup-green: #30d158;
--setup-red: #ff453a;
--setup-orb: #102a43;
--setup-orb-ultimate: #28203d;
--setup-home-indicator: #ffffff;
}
.setup-assistant__development-skip {
top: 48px;
right: 13px;
border: 0;
color: var(--setup-blue);
background: transparent;
font-size: 11px;
font-weight: 600;
}
.setup-assistant__chrome {
gap: 10px;
}
.setup-assistant__back {
border: 0;
color: var(--setup-blue);
background: transparent;
backdrop-filter: none;
}
.setup-assistant__progress {
height: 3px;
background: var(--setup-separator);
}
.setup-assistant__progress span {
background: var(--setup-blue);
}
.setup-assistant__counter {
color: var(--setup-tertiary);
font-weight: 600;
}
.setup-assistant__page h1 {
margin: 12px 0 10px;
color: var(--setup-text);
font-size: 31px;
font-weight: 700;
line-height: 1.08;
letter-spacing: -0.04em;
}
.setup-assistant__lead {
color: var(--setup-secondary);
font-size: 14px;
line-height: 1.42;
}
.setup-assistant__eyebrow,
.setup-welcome__eyebrow {
display: none;
}
.setup-assistant__icon {
width: 72px;
height: 72px;
border: 0;
border-radius: 50%;
color: var(--setup-blue);
background: var(--setup-surface);
box-shadow: none;
backdrop-filter: none;
}
.setup-assistant__icon--signal,
.setup-assistant__icon--cloud,
.setup-assistant__icon--security,
.setup-assistant__icon--appearance,
.setup-assistant__icon--performance,
.setup-assistant__icon--wallpaper,
.setup-assistant__icon--notifications,
.setup-assistant__icon--apps {
color: var(--setup-blue);
}
.setup-assistant__primary {
min-height: 50px;
border-radius: 14px !important;
background: var(--setup-blue) !important;
box-shadow: none !important;
font-weight: 650;
}
.setup-assistant__later {
color: var(--setup-blue);
font-weight: 500;
}
.setup-assistant__notice {
padding: 5px 4px;
border: 0;
color: var(--setup-blue);
background: transparent;
}
.setup-assistant__notice p {
color: var(--setup-secondary);
}
.setup-assistant__error {
color: var(--setup-red);
}
.setup-assistant--step-0 .setup-assistant__page {
padding: 105px 28px 0;
}
.setup-welcome__hero {
width: 94px;
height: 94px;
border-radius: 50%;
background: var(--setup-surface);
}
.setup-welcome__hero::before,
.setup-welcome__hero::after {
content: none;
}
.setup-welcome__device {
display: grid;
width: 94px;
height: 94px;
color: var(--setup-blue);
place-items: center;
}
.setup-welcome__copy {
margin-top: 31px;
}
.setup-welcome__copy h1 {
margin-top: 0;
font-size: 32px;
}
.setup-welcome__footer {
width: calc(100% + 56px);
margin-right: -28px;
margin-left: -28px;
padding: 0 28px 13px;
border: 0;
background: var(--setup-background);
backdrop-filter: none;
}
.setup-welcome__footer .setup-assistant__primary {
margin-top: 0;
box-shadow: none;
}
.setup-welcome__home-indicator {
background: var(--setup-home-indicator);
}
.setup-connectivity-card {
min-height: 142px;
border: 0;
border-radius: 22px;
color: var(--setup-text);
background: var(--setup-surface);
box-shadow: none;
}
.setup-connectivity-card__waves {
display: none;
}
.setup-connectivity-card__label,
.setup-connectivity-card svg {
color: var(--setup-blue);
}
.setup-connectivity-card > span:last-of-type {
color: var(--setup-tertiary);
}
.setup-cloud-hero {
width: 72px;
height: 72px;
border-radius: 50%;
color: var(--setup-blue);
background: var(--setup-surface);
}
.setup-cloud-hero__orbit,
.setup-cloud-hero__glow,
.setup-cloud-hero i {
display: none;
}
.setup-cloud-hero svg {
filter: none;
}
.setup-cloud-identity {
border: 0;
background: var(--setup-surface);
}
.setup-cloud-identity > span {
border: 0;
background: var(--setup-blue);
box-shadow: none;
}
.setup-cloud-identity small,
.setup-cloud-strength,
.setup-cloud-security-note {
color: var(--setup-tertiary);
}
.setup-cloud-identity strong,
.setup-cloud-connected strong {
color: var(--setup-text);
}
.setup-cloud-identity em,
.setup-cloud-suffix {
color: var(--setup-blue);
}
.setup-cloud-fields {
border: 0;
background: var(--setup-surface);
}
.setup-cloud-fields :deep(.sky-field + .sky-field) {
border-top-color: var(--setup-separator);
}
.setup-cloud-fields :deep(.sky-field__label) {
color: var(--setup-tertiary);
}
.setup-cloud-fields :deep(.sky-field__input) {
color: var(--setup-text);
caret-color: var(--setup-blue);
}
.setup-cloud-fields :deep(.sky-field__input::placeholder) {
color: var(--setup-muted);
}
.setup-cloud-strength span {
background: var(--setup-separator);
}
.setup-cloud-strength span.active {
background: var(--setup-blue);
box-shadow: none;
}
.setup-assistant__selector {
background: var(--setup-control);
}
.setup-assistant__selector-indicator {
border: 0;
background: var(--setup-control-fill);
box-shadow: 0 1px 4px rgb(0 0 0 / 14%);
}
.setup-assistant__selector button {
color: var(--setup-secondary);
}
.setup-assistant__selector button.active {
color: var(--setup-text);
}
.setup-cloud-connected {
color: var(--setup-green);
}
.setup-cloud-connected span {
color: var(--setup-secondary);
}
.setup-security-visual {
border: 0;
background: var(--setup-surface);
}
.setup-security-visual span {
background: var(--setup-text);
box-shadow: none;
}
.setup-security-visual svg {
color: var(--setup-muted);
}
.setup-passcode-length button,
.setup-choice-grid button,
.setup-mode-stack button,
.setup-toggle-card,
.setup-app-list button,
.setup-ready__summary span {
border-color: transparent;
color: var(--setup-text);
background: var(--setup-surface);
box-shadow: none;
}
.setup-passcode-length button.selected,
.setup-choice-grid button.selected,
.setup-mode-stack button.selected,
.setup-app-list button.selected {
border-color: var(--setup-blue);
background: var(--setup-selected-surface);
box-shadow: none;
}
.setup-passcode-length small,
.setup-mode-stack small,
.setup-toggle-card small,
.setup-app-list small {
color: var(--setup-secondary);
}
.setup-mode-stack__orb {
position: relative;
overflow: hidden;
isolation: isolate;
border-radius: 18px;
background: var(--setup-orb);
box-shadow: none;
}
.setup-mode-stack__orb--ultimate {
background: var(--setup-orb-ultimate);
box-shadow: none;
}
.setup-mode-preview__backdrop,
.setup-mode-preview__card,
.setup-mode-preview__line {
position: absolute;
display: block;
pointer-events: none;
}
.setup-mode-preview__backdrop {
inset: 0;
background: linear-gradient(145deg, #d9eaff 0%, #b9d8ff 100%);
}
.setup-mode-preview__card {
width: 37px;
height: 26px;
border-radius: 8px;
}
.setup-mode-preview__card--back {
top: 11px;
left: 9px;
background: #ffffff;
}
.setup-mode-preview__card--front {
right: 8px;
bottom: 10px;
background: #e7f1ff;
}
.setup-mode-preview__line {
z-index: 2;
right: 15px;
bottom: 17px;
width: 17px;
height: 3px;
border-radius: 999px;
background: #5d80ad;
}
.setup-mode-preview__line--wide {
bottom: 23px;
width: 24px;
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__backdrop {
background:
radial-gradient(circle at 20% 25%, #f4c7ff 0 12%, transparent 35%),
linear-gradient(145deg, #776de4 0%, #342760 100%);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card {
border: 1px solid rgb(255 255 255 / 38%);
background: rgb(255 255 255 / 25%);
box-shadow: 0 7px 14px rgb(31 18 70 / 24%);
backdrop-filter: blur(5px);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card--back {
transform: rotate(-8deg);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__card--front {
background: rgb(255 255 255 / 34%);
transform: rotate(5deg);
}
.setup-mode-stack__orb--ultimate .setup-mode-preview__line {
background: rgb(255 255 255 / 72%);
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__backdrop {
background: linear-gradient(145deg, #203147 0%, #142133 100%);
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__card--back {
background: #334965;
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__card--front {
background: #29405d;
}
.setup-assistant--dark
.setup-mode-stack__orb--performance
.setup-mode-preview__line {
background: #a9c7e9;
}
.setup-mode-stack__check,
.setup-app-list button > i {
border-color: var(--setup-muted);
}
.selected .setup-mode-stack__check,
.setup-app-list button.selected > i {
border-color: var(--setup-blue);
background: var(--setup-blue);
}
.setup-wallpapers button.selected {
border-color: var(--setup-background);
box-shadow: 0 0 0 2px var(--setup-blue);
}
.setup-toggle-card {
border: 0;
}
.setup-toggle-card button {
color: var(--setup-text);
}
.setup-toggle-card button + button {
border-top-color: var(--setup-separator);
}
.setup-toggle-card i {
background: var(--setup-separator);
}
.setup-ready__halo {
width: 112px;
height: 112px;
border: 0;
color: var(--setup-blue);
background: var(--setup-surface);
box-shadow: none;
}
.setup-ready__summary span {
color: var(--setup-blue);
}
.setup-ready__summary b {
color: var(--setup-text);
}
.setup-assistant--performance .setup-forward-enter-active,
.setup-assistant--performance .setup-forward-leave-active,
.setup-assistant--performance .setup-back-enter-active,
.setup-assistant--performance .setup-back-leave-active,
.setup-assistant--performance .setup-assistant__progress span,
.setup-assistant--performance .setup-mode-stack button,
.setup-assistant--performance .setup-mode-stack__orb {
transition-duration: 0.01ms;
}
.setup-assistant--ultimate .setup-mode-stack button,
.setup-assistant--ultimate .setup-mode-stack__orb {
transition:
border-color 0.2s ease,
background-color 0.2s ease,
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
.setup-assistant,
.setup-assistant *,
+1 -1
View File
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
z-index: 40;
display: flex;
pointer-events: none;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.radio-hud[data-horizontal='left'] {
@@ -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',
+37 -6
View File
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
backdrop-filter: blur(26px) saturate(125%);
-webkit-backdrop-filter: blur(26px) saturate(125%);
cursor: pointer;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Segoe UI', sans-serif;
font-family: var(--sky-font-family);
user-select: none;
-webkit-user-select: none;
touch-action: none;
@@ -1076,6 +1074,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 +1199,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;
+6 -1
View File
@@ -1,3 +1,5 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
type Tone = {
@@ -46,7 +48,10 @@ export function playMemorySound(sound: MemorySound, enabled: boolean): void {
oscillator.type = tone.type
oscillator.frequency.setValueAtTime(tone.frequency, start)
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
start + 0.012,
)
gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain)
gain.connect(audioContext.destination)
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
import clearUrl from '@/assets/audio/minesweeper/clear.wav?url'
import mineUrl from '@/assets/audio/minesweeper/mine.wav?url'
@@ -28,7 +30,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,3 +1,5 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type NeonDropSound =
| 'clear'
| 'drop'
@@ -48,7 +50,10 @@ function playSequence(sound: NeonDropSound): void {
oscillator.type = type
oscillator.frequency.setValueAtTime(frequency, start + delay)
gain.gain.setValueAtTime(0.0001, start + delay)
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
start + delay + 0.008,
)
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
oscillator.connect(gain)
gain.connect(context.destination)
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import gameOverUrl from '@/assets/audio/number-merge/game-over.wav?url'
import mergeUrl from '@/assets/audio/number-merge/merge.wav?url'
import moveUrl from '@/assets/audio/number-merge/move.wav?url'
@@ -18,7 +20,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
@@ -11,7 +13,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
let players = pools.get(sound)
if (!players) {
players = Array.from({ length: 3 }, () => {
const player = new Audio(urls[sound])
const player = registerPhoneMediaElement(new Audio(urls[sound]))
player.preload = 'auto'
player.volume = 0.84
return player
@@ -1,3 +1,5 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import fallUrl from '@/assets/audio/tower-stack/fall.wav?url'
import hitUrl from '@/assets/audio/tower-stack/hit.wav?url'
import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url'
@@ -18,7 +20,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = new Audio(soundUrls[sound])
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
player.preload = 'auto'
player.volume = 0.84
return player
@@ -0,0 +1,62 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
describe('LB runtime compatibility contracts', () => {
it('routes legacy call and SMS actions through Sky Phone', () => {
const callsClient = readResourceFile('source/client/calls.lua')
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
const callsServer = readResourceFile('source/server/calls.lua')
const phoneUi = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const customAppFrame = readFileSync(
new URL('./components/CustomAppFrame.vue', import.meta.url),
'utf8',
)
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CreateCall", create_call)',
)
expect(callsClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:calls:dial"',
)
expect(callsServer).toContain(
'SkyPhoneCompanies.GetServiceLineForCompany(data.company)',
)
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "CreateSMS", create_sms)',
)
expect(phoneBridge).toContain('type = "compat:open-messages"')
expect(phoneUi).toContain("event.data?.type === 'compat:open-messages'")
expect(phoneUi).toContain('messages.openThread(data.phoneNumber)')
expect(customAppFrame).toContain("message.action === 'createCall'")
expect(customAppFrame).toContain("message.action === 'createSMS'")
})
it('redirects PicChat phone identity to the Sky SIM table without deleting orphaned data', () => {
const manifest = readResourceFile('fxmanifest.lua')
const migration = readResourceFile(
'source/server/lb_app_compat_migration.lua',
)
expect(manifest).toContain("'source/server/lb_app_compat_migration.lua'")
expect(
manifest.indexOf("'source/server/lb_app_compat_migration.lua'"),
).toBeGreaterThan(manifest.indexOf("'source/server/testdata.lua'"))
expect(migration).toContain('"lbpicchat_logged_in"')
expect(migration).toContain('"phone_phones"')
expect(migration).toContain('"sky_phone_sims"')
expect(migration).toContain('DROP FOREIGN KEY')
expect(migration).toContain('ON DELETE CASCADE ON UPDATE CASCADE')
expect(migration).toContain('Legacy data was preserved')
expect(migration).toContain('FROM `INFORMATION_SCHEMA`.`KEY_COLUMN_USAGE`')
expect(migration).not.toMatch(/KEY_COLUMN_USAGE`\s+keys/i)
expect(migration).toContain(
'xpcall(migrate_picchat_phone_reference, debug.traceback)',
)
expect(migration).toContain('Sky Phone startup will continue')
expect(migration).not.toMatch(/DELETE\s+FROM\s+/i)
})
})
@@ -0,0 +1,103 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const mediaConfig = readFileSync(
new URL('../../sky_phone/config/media.lua', import.meta.url),
'utf8',
)
const manifest = readFileSync(
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
'utf8',
)
const mediaProviderConfig = readFileSync(
new URL(
'../../sky_phone/source/server/media_provider_config.lua',
import.meta.url,
),
'utf8',
)
const mediaImportAdapter = readFileSync(
new URL(
'../../sky_phone/source/server/media_import/fivemanage.lua',
import.meta.url,
),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
const memoServer = readFileSync(
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
'utf8',
)
const mediaCapture = readFileSync(
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
)
const memoRecorder = readFileSync(
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
'utf8',
)
describe('FiveManage server configuration contract', () => {
it('keeps the provider token in the server-only media config', () => {
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
expect(mediaProviderConfig).toContain(
'return trim_key(Config.Media.FiveManage.ApiKey)',
)
expect(mediaProviderConfig).not.toContain('GetConvar')
})
it('uses one resolver for Camera uploads and FiveManage imports', () => {
expect(
manifest.indexOf("'source/server/media_provider_config.lua'"),
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
expect(mediaServer).toContain(
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
)
expect(mediaImportAdapter).toContain(
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
)
})
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
expect(mediaCapture).not.toContain("form.append('path'")
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
expect(memoRecorder).toContain(
"form.append('file', pending.blob, pending.fileName)",
)
expect(memoRecorder).not.toContain("form.append('path'")
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
expect(mediaServer).toContain(
'Accepting the direct FiveManage upload response',
)
expect(mediaServer).toContain('remote_id = remote_id')
expect(mediaServer).toContain('url = uploaded_url')
expect(mediaServer).not.toContain('"HEAD"')
expect(mediaServer).not.toContain('authenticated upload-path lookup')
})
it('allowlists the FiveManage API and media hosts', () => {
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
expect(mediaServer).toContain('["fmapi.net"] = true')
expect(mediaServer).toContain(
'uploaded_host:lower() ~= "r2.fivemanage.com"',
)
})
it('validates and preserves the recorded memo size before upload', () => {
expect(memoRecorder).toContain('sizeBytes: blob.size')
expect(memoServer).toContain(
'local size_bytes = tonumber(data.sizeBytes)',
)
expect(memoServer).toContain(
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
)
expect(memoServer).toContain('size_bytes = memo.size_bytes')
expect(mediaServer).toContain('size = state.size_bytes')
})
})
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const mediaCapture = readFileSync(
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
describe('media upload diagnostics contracts', () => {
it('forwards browser upload failure context to the server log', () => {
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
expect(mediaCapture).toContain('debugStatus: debug.status')
expect(mediaServer).toContain('Client-reported upload failure')
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
})
})
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const appSource = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const audioSource = readFileSync(
new URL('./utils/phoneAudio.ts', import.meta.url),
'utf8',
)
const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
)
const musicSource = readFileSync(
new URL('./stores/music.ts', import.meta.url),
'utf8',
)
describe('phone audio output contract', () => {
it('applies the hardware volume to app media and live YouTube playback', () => {
expect(appSource).toContain('installPhoneAudioController()')
expect(appSource).toContain('setPhoneOutputVolume(volume / 100)')
expect(appSource).toContain(
'if (radio.data.connected) void radio.setVolume(volume)',
)
expect(audioSource).toContain(
"document.addEventListener('play', onMediaPlay, true)",
)
expect(audioSource).toContain('localVolume * outputVolume')
expect(musicSource).toContain('registerPhoneMediaElement(new Audio())')
expect(musicSource).toContain('store.volume * getPhoneOutputVolume() * 100')
})
it('renders the hardware speaker symbol in grey', () => {
expect(mainCss).toMatch(/\.phone-volume-hud\s*\{[\s\S]*?color:\s*#a9abb2;/)
})
})
+187 -3
View File
@@ -3,7 +3,9 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const readFrontendFile = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
const inventoryAdapters = [
['ox', 'source/bridge/server/inventory/ox.lua'],
@@ -52,11 +54,165 @@ describe('phone inventory contracts', () => {
it('provides the LB IsOpen export alias from the authoritative client state', () => {
const phoneClient = readResourceFile('source/client/main.lua')
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
expect(phoneClient).toContain(
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen"',
)
expect(phoneClient).toContain('return is_open')
expect(phoneClient).toContain('open = is_open')
expect(phoneBridge).toContain('return get_phone_state_value("open")')
})
it('provides the LB equipped phone number exports from authoritative device state', () => {
const phoneClient = readResourceFile('source/client/main.lua')
const phoneServer = readResourceFile('source/server/phone.lua')
const clientBridge = readResourceFile('source/bridge/phones/client/lb.lua')
const serverBridge = readResourceFile('source/bridge/phones/server/lb.lua')
const serverLifecycle = readResourceFile(
'source/bridge/phones/server/lifecycle.lua',
)
expect(clientBridge).toMatch(
/"GetEquippedPhoneNumber",\s+phone\.GetEquippedPhoneNumber/,
)
expect(phoneClient).toContain('return device_payload.device.sim.number')
expect(phoneClient).toContain(
'Bridge.Callbacks.Trigger("sky_phone:device:equipped-number", {})',
)
expect(phoneServer).toContain(
'Bridge.Callbacks.Register("sky_phone:device:equipped-number", function(source)',
)
expect(phoneServer).toContain(
'function SkyPhone.GetEquippedPhoneNumber(player)',
)
expect(phoneServer).toContain(
'cache_equipped_phone_number(source, identifier, device.phone_number)',
)
expect(phoneServer).toContain(
'equipped_phone_sources[phone_number] = source',
)
const equippedNumberExport = phoneServer.match(
/function SkyPhone\.GetEquippedPhoneNumber\(player\)([\s\S]*?)\nfunction SkyPhone\.GetSourceFromNumber/,
)?.[1]
const equippedNumberResolver = phoneServer.match(
/local function resolve_equipped_phone_number\(source\)([\s\S]*?)\nfunction SkyPhone\.GetEquippedPhoneNumber/,
)?.[1]
expect(equippedNumberExport).toBeDefined()
expect(equippedNumberResolver).toBeDefined()
expect(equippedNumberExport).toContain('type(player) == "number"')
expect(equippedNumberExport).toContain('online_source_for_identifier(player)')
expect(phoneServer).toContain('Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item)')
expect(phoneServer).toContain('return resolve_equipped_phone_number(player)')
expect(equippedNumberExport).not.toContain('tonumber(player)')
expect(equippedNumberExport).not.toContain('equipped_phone_numbers[player]')
expect(equippedNumberResolver).not.toContain('return cached_number')
expect(phoneServer).toContain(
'player_source and resolve_equipped_phone_number(player_source) == normalized',
)
expect(serverBridge).toMatch(
/"GetEquippedPhoneNumber",\s+phone\.GetEquippedPhoneNumber/,
)
expect(serverBridge).toMatch(
/"GetSourceFromNumber",\s+phone\.GetSourceFromNumber/,
)
expect(phoneServer).toContain(
'TriggerEvent("sky_phone:server:phoneNumberChanged", source, phone_number)',
)
expect(serverLifecycle).toContain(
'SkyPhoneCompatibility.EmitServerProviderStop(LB_PROVIDER_NAME)',
)
expect(serverLifecycle).toContain(
'SkyPhoneCompatibility.EmitServerProviderStart(LB_PROVIDER_NAME)',
)
})
it('maps LB client lifecycle and state contracts', () => {
const phoneClient = readResourceFile('source/client/main.lua')
const phoneBridge = readResourceFile('source/bridge/phones/client/lb.lua')
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"',
)
expect(phoneClient).toContain(
'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})',
)
expect(phoneClient).toMatch(
/result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/,
)
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"',
)
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsInCall"',
)
expect(phoneBridge).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "FormatNumber", client_bridge.FormatNumber)',
)
expect(phoneClient).toContain(
'TriggerEvent("sky_phone:client:phoneNumberChanged", next_number)',
)
expect(phoneClient).toContain(
'TriggerEvent("sky_phone:client:phoneToggled", true)',
)
expect(phoneClient).toContain(
'TriggerEvent("sky_phone:client:phoneToggled", false)',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:numberChanged", phone_number)')
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneToggled", open)')
})
it('keeps vendor contracts outside the phone business core', () => {
const corePaths = [
'source/client/main.lua',
'source/client/camera.lua',
'source/client/custom_apps.lua',
'source/server/phone.lua',
'source/server/sim.lua',
'source/server/media.lua',
]
for (const path of corePaths) {
expect(readResourceFile(path)).not.toMatch(
/lb-phone|17mov|high-phone|qs-smartphone|yseries|SkyPhoneCompatibility/,
)
}
})
it('maps the LB custom-app delete lifecycle from the App Store to Lua', () => {
const appStore = readFrontendFile('stores/app-store.ts')
const customApps = readResourceFile('source/client/custom_apps.lua')
const compatibility = readResourceFile('source/bridge/phones/shared/lb.lua')
expect(appStore).toContain("event: 'delete'")
expect(customApps).toContain('and lifecycle_event ~= "delete"')
expect(customApps).toContain(
'invoke_or_defer_hook(app, "onDelete", lifecycle_payload, deferred_hooks)',
)
expect(compatibility).toContain('onDelete = app_data.onDelete')
})
it('emits exact LB observer events after authoritative state changes', () => {
const phonePersistence = readResourceFile(
'source/server/phone_persistence.lua',
)
const simServer = readResourceFile('source/server/sim.lua')
const mediaServer = readResourceFile('source/server/media.lua')
const phoneBridge = readResourceFile(
'source/bridge/phones/server/lifecycle.lua',
)
expect(simServer).toContain(
'TriggerEvent("sky_phone:server:phoneNumberGenerated", source, sim.phone_number)',
)
expect(phonePersistence).toContain(
'TriggerEvent("sky_phone:server:factoryReset", source, phone_number)',
)
expect(mediaServer).toContain(
'TriggerEvent("sky_phone:server:galleryMediaDeleted", src, phone_number, deleted_link)',
)
expect(phoneBridge).toContain('TriggerEvent("lb-phone:phoneNumberGenerated"')
expect(phoneBridge).toContain('TriggerEvent("lb-phone:factoryReset"')
expect(phoneBridge).toContain('TriggerEvent("lb-phone:deletedFromGallery"')
})
it('opens from a configurable F1 mapping without client-provided device identity', () => {
@@ -76,6 +232,22 @@ describe('phone inventory contracts', () => {
)
})
it('opens a running live activity with Space without affecting normal gameplay', () => {
const phoneClient = readResourceFile('source/client/main.lua')
expect(phoneClient).toContain(
'RegisterCommand("sky_phone_live_activity_open"',
)
expect(phoneClient).toContain(
'if not live_activity_active or is_open or open_requested then',
)
expect(phoneClient).toContain(
'RegisterKeyMapping(\n "sky_phone_live_activity_open"',
)
expect(phoneClient).toContain('"SPACE"')
expect(phoneClient).toContain('RegisterNUICallback("ui:live-activity"')
})
it('keeps a server-selected unique handset as the preferred hotkey device', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
@@ -85,4 +257,16 @@ describe('phone inventory contracts', () => {
)
expect(phoneServer).toContain('preferred_device_imeis[source] = imei')
})
it('keeps non-unique phones bound to one persistent device per character', () => {
const phoneServer = readResourceFile('source/server/phone.lua')
const migration = readResourceFile('source/server/db_migrate.lua')
expect(phoneServer).toContain('if not unique_phones then')
expect(phoneServer).toContain('return map_character_device(source, slot)')
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
expect(migration).toContain('name = "sky_phone_character_devices"')
expect(migration).toContain('primaryKey = "owner_identifier"')
})
})
@@ -0,0 +1,32 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const rootDirectory = join(import.meta.dirname, '..')
const appSource = readFileSync(join(rootDirectory, 'src/App.vue'), 'utf8')
const navigationSource = readFileSync(
join(rootDirectory, '../sky_phone/source/client/navigation.lua'),
'utf8',
)
describe('neutral phone navigation contract', () => {
it('synchronizes installed renderer apps before acknowledging an opened phone', () => {
expect(appSource).toContain("return nuiCall('navigation:state'")
expect(appSource).toContain(
"void syncNavigationState().then(() => nuiCall('ui:opened'))",
)
expect(navigationSource).toContain(
'RegisterNUICallback("navigation:state"',
)
})
it('routes only installed apps and closes only the requested current app', () => {
expect(appSource).toContain("event.data?.type === 'navigation:open-app'")
expect(appSource).toContain('appStore.isInstalled(data.appId)')
expect(appSource).toContain("event.data?.type === 'navigation:close-app'")
expect(appSource).toContain('currentApp === data.appId')
expect(navigationSource).toContain('if not installed_apps[normalized_app_id] then')
expect(navigationSource).toContain('if current_app_id ~= normalized_app_id then')
})
})
+6
View File
@@ -15,6 +15,12 @@ const developmentRoutes: RouteRecordRaw[] = import.meta.env.DEV
name: 'development-sky-ui',
path: '/development/sky-ui/:demo?',
},
{
component: () =>
import('@/views/development/PhoneDynamicIslandGallery.vue'),
name: 'development-dynamic-islands',
path: '/development/dynamic-islands',
},
]
: []
+41 -6
View File
@@ -64,6 +64,41 @@ describe('app store', () => {
expect(apps.isInstalled('snake')).toBe(false)
expect(apps.isInstalled('health')).toBe(true)
expect(apps.isInstalled('citywarn')).toBe(true)
expect(apps.homeLayout.dock).toEqual([
'phone',
'messages',
'camera',
'clock',
])
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
expect(apps.homeLayout.grid).not.toContain(dockAppId)
}
})
it('migrates current layouts so dock apps are not repeated in the grid', () => {
const apps = useAppStoreStore()
apps.hydrate({
homeLayout: {
dock: ['phone', 'messages', 'camera', 'clock'],
grid: ['phone', 'messages', 'calculator', 'camera', 'clock'],
hidden: [],
pageCount: 1,
version: HOME_LAYOUT_VERSION,
},
})
expect(apps.homeLayout.dock).toEqual([
'phone',
'messages',
'camera',
'clock',
])
for (const dockAppId of ['phone', 'messages', 'camera', 'clock']) {
expect(apps.homeLayout.grid).not.toContain(dockAppId)
}
expect(apps.homeLayout.grid).toContain('calculator')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
it('removes old automatic apps unless the player installed them', () => {
@@ -320,7 +355,7 @@ describe('app store', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect(
@@ -329,7 +364,7 @@ describe('app store', () => {
HOME_GRID_PAGE_SIZE,
]),
).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
@@ -386,18 +421,18 @@ describe('app store', () => {
mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const clockIndex = apps.homeLayout.grid.indexOf('clock')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
clockIndex,
settingsIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
'clock',
'settings',
'notes',
])
const mailIndex = apps.homeLayout.grid.indexOf('mail')
@@ -405,7 +440,7 @@ describe('app store', () => {
apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work')
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
apps: ['mail', 'notes', 'clock'],
apps: ['mail', 'notes', 'settings'],
name: 'Work',
})
+22 -7
View File
@@ -25,6 +25,7 @@ import {
parseHomeLayout,
reflowHomeGridForWidgetChange,
renameHomeFolder,
removeDockGridDuplicates,
removeHomeApp,
restoreHomeApp,
type HomeArea,
@@ -45,7 +46,7 @@ const pendingInstallations = new WeakMap<
>()
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return [...PHONE_APPS]
return PHONE_APPS.filter((app) => app.dockOrder === null)
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
}
@@ -263,12 +264,16 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultGridIds(),
getDefaultDockIds(),
)
this.homeLayout = parseHomeLayout(
const parsedHomeLayout = parseHomeLayout(
data?.homeLayout,
defaults,
installedIds,
false,
)
const normalizedHomeLayout = removeDockGridDuplicates(parsedHomeLayout)
const removedDockGridDuplicates =
normalizedHomeLayout !== parsedHomeLayout
this.homeLayout = normalizedHomeLayout
const protectedHiddenAppIds =
this.homeLayout.hidden.filter(isProtectedHomeApp)
for (const appId of protectedHiddenAppIds) {
@@ -292,6 +297,7 @@ export const useAppStoreStore = defineStore('app-store', {
this.hydrated = true
if (
protectedHiddenAppIds.length ||
removedDockGridDuplicates ||
removedLegacyDefaults ||
layoutVersion === 2 ||
layoutVersion === 3 ||
@@ -320,11 +326,8 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultDockIds(),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = parseHomeLayout(
this.homeLayout,
defaults,
installedIds,
false,
this.homeLayout = removeDockGridDuplicates(
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
)
for (const appId of [...this.homeLayout.hidden]) {
@@ -494,6 +497,18 @@ export const useAppStoreStore = defineStore('app-store', {
}
this.homeLayout = removeHomeApp(this.homeLayout, appId)
this.persist()
if (isExternalPhoneApp(app)) {
void nuiCall('custom-app:lifecycle', {
appId,
event: 'delete',
}).then((response) => {
if (!response.success) {
console.error(
`[Custom apps] Delete lifecycle failed for ${appId}: ${response.error ?? 'request_failed'}`,
)
}
})
}
return true
},
+16 -4
View File
@@ -7,6 +7,11 @@ import type {
MusicTrack,
} from '@/types/music'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
registerPhoneMediaElement,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
export type YouTubePlayer = {
destroy: () => void
@@ -48,7 +53,7 @@ declare global {
}
}
const audio = new Audio()
const audio = registerPhoneMediaElement(new Audio())
audio.preload = 'auto'
const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false
@@ -59,6 +64,11 @@ let youtubeApiPromise: Promise<YouTubeApi> | null = null
let youtubePlayer: YouTubePlayer | null = null
let youtubeProgressTimer: number | null = null
subscribePhoneOutputVolume((volume) => {
const localVolume = useMusicStore().volume
youtubePlayer?.setVolume(localVolume * volume * 100)
})
export function musicTrackKey(
track: Pick<MusicTrack, 'id' | 'source'>,
): string {
@@ -276,7 +286,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
const api = await loadYouTubeApi()
const store = useMusicStore()
if (youtubePlayer) {
youtubePlayer.setVolume(store.volume * 100)
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
youtubePlayer.loadVideoById(videoId)
youtubePlayer.playVideo()
startYoutubeProgress()
@@ -319,7 +329,9 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
},
onReady: (event) => {
youtubePlayer = event.target
event.target.setVolume(useMusicStore().volume * 100)
event.target.setVolume(
useMusicStore().volume * getPhoneOutputVolume() * 100,
)
event.target.playVideo()
startYoutubeProgress()
resolve()
@@ -554,7 +566,7 @@ export const useMusicStore = defineStore('music', {
setVolume(value: number): void {
this.volume = Math.max(0, Math.min(1, value))
audio.volume = this.volume
youtubePlayer?.setVolume(this.volume * 100)
youtubePlayer?.setVolume(this.volume * getPhoneOutputVolume() * 100)
},
stop(): void {
stopActiveMedia()
@@ -167,4 +167,66 @@ describe('phone device persistence scope', () => {
expect(mockNuiCall).toHaveBeenCalledTimes(2)
expect(phone.deviceRevisions.widgets).toBe(1)
})
it('marks setup complete only after the settings save is acknowledged', async () => {
const completion = deferredResponse<{ revision: number }>()
mockNuiCall.mockReturnValueOnce(completion.promise)
const phone = usePhoneStore()
phone.open({
device: {
data: {
settings: {
payload: {
settings: { setupCompleted: false, setupStep: 9 },
version: 1,
},
revision: 3,
},
},
imei: '111',
name: 'Phone 111',
sim: null,
},
token: 'session-a',
})
const completed = phone.completeSetup()
await Promise.resolve()
expect(phone.preferences.settings.setupCompleted).toBe(false)
completion.resolve({ data: { revision: 4 }, success: true })
await expect(completed).resolves.toBe(true)
expect(phone.preferences.settings.setupCompleted).toBe(true)
expect(phone.deviceRevisions.settings).toBe(4)
})
it('keeps setup open when the completion save is rejected', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'request_failed',
success: false,
})
const phone = usePhoneStore()
phone.open({
device: {
data: {
settings: {
payload: {
settings: { setupCompleted: false, setupStep: 9 },
version: 1,
},
revision: 3,
},
},
imei: '111',
name: 'Phone 111',
sim: null,
},
token: 'session-a',
})
await expect(phone.completeSetup()).resolves.toBe(false)
expect(phone.preferences.settings.setupCompleted).toBe(false)
expect(phone.deviceRevisions.settings).toBe(3)
})
})
+37 -11
View File
@@ -38,12 +38,13 @@ export type PhoneOpenPayload = {
locales?: LocaleTree
memos?: DeviceBootstrap['memos']
notes?: DeviceBootstrap['notes']
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
player?: DeviceBootstrap['player']
security?: DeviceSecurity
token?: string
}
const namespaceQueues = new Map<string, Promise<void>>()
const namespaceQueues = new Map<string, Promise<boolean>>()
let nextPersistenceSession = 0
const companiesFallbackLocales = {
@@ -638,7 +639,8 @@ const cryptoFallbackLocales = {
invalid_handle: 'Use 320 letters, numbers, dots or underscores.',
handle_taken: 'That VaultX handle is already taken.',
profile_exists: 'This character already owns a VaultX profile.',
invalid_password: 'Password must be 872 characters.',
invalid_password:
'Use 872 characters with uppercase, lowercase, a number, and a special character.',
password_mismatch: 'The passwords do not match.',
accept_terms: 'Confirm that this is a fictional in-game wallet.',
invalid_credentials: 'The password is incorrect.',
@@ -2086,7 +2088,7 @@ const defaultLocales: LocaleTree = {
to: 'To:',
connectPrivately: 'Connect privately',
newChatBody:
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
darkIdOrInvite: 'Dark-ID or invitation code',
continue: 'Continue',
contacts: 'DarkChat Contacts',
@@ -3036,6 +3038,8 @@ const defaultLocales: LocaleTree = {
viewRides: 'View Ride Options',
change: 'Change',
requestRide: 'Request SkyRide',
playerDriverNotice:
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
serviceMeta: '{eta} min away · {seats} seats',
distanceMeters: '{distance} m',
distanceKilometers: '{distance} km',
@@ -3106,7 +3110,8 @@ const defaultLocales: LocaleTree = {
cancelled: 'Cancelled',
},
statusBody: {
searching: 'We are matching you with a nearby driver.',
searching:
'Your request is waiting for an available player driver to accept it.',
accepted: 'Your driver is preparing to pick you up.',
driver_arriving: 'Your driver is on the way to your pickup.',
arrived: 'Your driver is waiting at the pickup point.',
@@ -4012,6 +4017,11 @@ const defaultLocales: LocaleTree = {
invalid_media_type: 'The uploaded media type is invalid.',
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
media_provider_failed: 'The camera upload service is unavailable.',
media_provider_rate_limited:
'The camera upload service is busy. Try again shortly.',
media_provider_unauthorized:
'The configured FiveManage API key was rejected.',
missing_config: 'Camera uploads are not configured.',
microphone_unavailable:
'Allow microphone access or mute the microphone before recording.',
@@ -4949,6 +4959,8 @@ const defaultLocales: LocaleTree = {
localOnly: 'Stored on this phone',
enter: 'Enter Sky Phone',
review: 'Review Setup',
saveFailed: 'Setup could not be saved. Try again.',
saving: 'Saving setup',
},
},
Common: {
@@ -5223,13 +5235,13 @@ export const usePhoneStore = defineStore('phone', {
JSON.stringify(device.data.settings?.payload ?? null),
)
},
saveDeviceNamespace(namespace: string, payload: unknown): void {
saveDeviceNamespace(namespace: string, payload: unknown): Promise<boolean> {
const imei = this.device?.imei
if (!imei) {
console.error(
`[Phone persistence] Could not save ${namespace} without an active device.`,
)
return
return Promise.resolve(false)
}
const generation = this.persistenceGeneration
const session = this.persistenceSession
@@ -5243,7 +5255,7 @@ export const usePhoneStore = defineStore('phone', {
this.deviceSessionToken === token
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
const queued = previous.then(async () => {
if (!isCurrentScope()) return
if (!isCurrentScope()) return false
const response = await nuiCall<{ revision: number }>('device:save', {
imei,
namespace,
@@ -5258,13 +5270,21 @@ export const usePhoneStore = defineStore('phone', {
Number(response.data?.revision) >= 0
) {
this.deviceRevisions[namespace] = Number(response.data?.revision)
return true
}
if (isCurrentScope()) {
console.error(
`[Phone persistence] Could not save ${namespace}: ${response.error ?? 'request_failed'}`,
)
}
return false
})
const tracked = queued.finally(() => {
if (namespaceQueues.get(queueKey) === tracked)
namespaceQueues.delete(queueKey)
})
namespaceQueues.set(queueKey, tracked)
return tracked
},
async flushDevicePersistence(): Promise<void> {
const imei = this.device?.imei
@@ -5329,10 +5349,16 @@ export const usePhoneStore = defineStore('phone', {
)
this.saveDeviceNamespace('settings', this.preferences)
},
completeSetup(): void {
this.preferences.settings.setupCompleted = true
this.preferences.settings.setupStep = PHONE_SETUP_LAST_STEP
this.saveDeviceNamespace('settings', this.preferences)
async completeSetup(): Promise<boolean> {
const completedPreferences = cloneJsonData(this.preferences)
completedPreferences.settings.setupCompleted = true
completedPreferences.settings.setupStep = PHONE_SETUP_LAST_STEP
const saved = await this.saveDeviceNamespace(
'settings',
completedPreferences,
)
if (saved) this.preferences = completedPreferences
return saved
},
resetAfterFactoryReset(): void {
this.persistenceGeneration += 1
+3 -3
View File
@@ -159,9 +159,9 @@ export type SkyPhoneAppBridgeResponse = {
export type SkyPhoneAppContextV1 = {
appId: string
capabilities: SkyPhoneAppCapability[]
colorScheme: 'dark' | 'light'
language: string
locale: Record<string, unknown>
colorScheme?: 'dark' | 'light'
language?: string
locale?: Record<string, unknown>
phoneScale: number
protocolVersion: 1
safeArea: {
+2 -1
View File
@@ -1,6 +1,6 @@
import type { Note } from '@/utils/notes'
import type { MemoDto } from '@/types/memos'
import type { PhoneSim } from '@/types/phone'
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
export type DeviceDataEntry<T = unknown> = {
payload: T
@@ -50,6 +50,7 @@ export type DeviceBootstrap = {
device: PhoneDevice
memos: MemoDto[]
notes: Note[]
phoneNumberFormat: PhoneNumberFormat
player: PhonePlayerIdentity
security: DeviceSecurity
token: string
+7
View File
@@ -0,0 +1,7 @@
export type DynamicIslandActivity =
| 'call'
| 'incoming-call'
| 'music'
| 'recording'
| 'stopwatch'
| 'timer'
+2 -1
View File
@@ -3,6 +3,7 @@ export type HousingAccess = 'owner' | 'keyholder'
export type HousingCapabilities = {
cctv: boolean
garageStatus: boolean
keyGrant?: boolean
keys: boolean
lock: boolean
waypoint: boolean
@@ -24,7 +25,7 @@ export type HousingProperty = {
access: HousingAccess
capabilities: HousingCapabilities
cctv: { enabled: boolean }
entrance: { x: number; y: number; z: number }
entrance?: { x: number; y: number; z: number }
garage: { enabled: boolean; storedVehicles: number } | null
id: string
keys?: HousingKey[]
-1
View File
@@ -70,7 +70,6 @@ export type MediaImportResult = {
}
export type UploadReady = {
captureToken: string
correlationId: string
mediaType: MediaType
photo?: {
-1
View File
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
export type MemoUploadReady = {
requestId: string
correlationId: string
captureToken: string
presignedUrl: string
uploadTimeoutMs?: number
}
+5
View File
@@ -1,5 +1,10 @@
export type SimType = 'registered' | 'anonymous'
export type PhoneNumberFormat = {
groups: number[]
length: number
}
export type PhoneSim = {
id: string
number: string
@@ -0,0 +1,59 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
const mainCssSource = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
function styleSources(directory: string): Array<{
file: string
source: string
}> {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) return styleSources(path)
if (!/\.(?:css|vue)$/.test(entry.name)) return []
return [{ file: path, source: readFileSync(path, 'utf8') }]
})
}
describe('Inter font contract', () => {
it('bundles normal and italic variable fonts for every supported weight', () => {
expect(
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
).toBe(true)
expect(
existsSync(
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
),
).toBe(true)
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
})
it('applies the shared font to the document and native form controls', () => {
expect(mainCssSource).toMatch(
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
)
expect(mainCssSource).toMatch(
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
)
})
it('does not bypass the shared token with a generic system UI stack', () => {
const genericSystemStack =
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
const violations = styleSources(sourceDirectory)
.filter(({ source }) => genericSystemStack.test(source))
.map(({ file }) => file.slice(sourceDirectory.length + 1))
expect(violations).toEqual([])
})
})
@@ -169,6 +169,9 @@ describe('Sky UI Konsta 5.3 parity contracts', () => {
expect(html).toContain('id="directory-search"')
expect(html).toContain('sky-glass')
expect(html).not.toContain('sky-glass--highlight')
expect(html).toContain('<svg class="sky-searchbar__icon"')
expect(html).toContain('fill-rule="evenodd"')
expect(html).not.toContain('<circle')
expect(html).toContain('sky-searchbar__clear')
expect(html).toContain('aria-label="Clear query"')
expect(html).toContain('sky-searchbar__disable')
+37 -19
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);
}
@@ -1465,26 +1477,13 @@ label.sky-list-item__row {
}
.sky-searchbar__icon {
width: 14px;
height: 14px;
position: relative;
width: 18px;
height: 18px;
display: block;
flex: none;
border: 1.6px solid var(--sky-muted, rgba(0, 0, 0, 0.55));
border-radius: 50%;
opacity: 0.8;
}
.sky-searchbar__icon::after {
width: 6px;
height: 1.6px;
position: absolute;
right: -5px;
bottom: -2px;
content: '';
border-radius: 2px;
background: var(--sky-muted, rgba(0, 0, 0, 0.55));
transform: rotate(45deg);
transform-origin: left center;
color: var(--sky-muted, rgba(0, 0, 0, 0.55));
fill: currentColor;
opacity: 0.72;
}
.sky-searchbar__input {
@@ -2746,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;
}
@@ -2754,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 {
@@ -2768,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;
@@ -2838,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,
+7 -1
View File
@@ -141,7 +141,13 @@ function disable(event: MouseEvent): void {
<label v-if="label" class="sky-visually-hidden" :for="resolvedInputId">
{{ label }}
</label>
<span class="sky-searchbar__icon" aria-hidden="true" />
<svg class="sky-searchbar__icon" aria-hidden="true" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.5 3a6.5 6.5 0 1 0 3.98 11.64l4.44 4.44a1 1 0 0 0 1.42-1.42l-4.44-4.44A6.5 6.5 0 0 0 9.5 3Zm0 2a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z"
/>
</svg>
<input
:id="resolvedInputId"
ref="input"
+2 -3
View File
@@ -525,9 +525,8 @@
height: var(--sky-widget-label-height);
overflow: hidden;
color: var(--sky-widget-label-color, #fff);
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
font-size: 11.5px;
font-family: var(--sky-font-family);
font-size: var(--sky-home-label-font-size);
font-weight: 500;
letter-spacing: -0.15px;
line-height: var(--sky-widget-label-height);
+20 -4
View File
@@ -1,3 +1,19 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
}
:root {
--sky-device-pixel-ratio: 1;
--sky-hairline-scale: 1;
@@ -26,9 +42,7 @@
--sky-font-title: 17px;
--sky-font-medium-title: 24px;
--sky-font-large-title: 34px;
--sky-font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
'Helvetica Neue', Helvetica, Arial, sans-serif;
--sky-font-family: 'Inter', Arial, sans-serif;
--sky-transition-fast: 100ms;
--sky-transition-normal: 200ms;
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
@@ -55,8 +69,10 @@
--sky-shadow-thumb:
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
--sky-home-label-font-size: 13px;
--sky-home-label-height: 16px;
--sky-widget-label-gap: 5px;
--sky-widget-label-height: 15px;
--sky-widget-label-height: var(--sky-home-label-height);
--sky-widget-radius-small: 23px;
--sky-widget-radius-medium: 25px;
--sky-widget-radius-large: 28px;
+6 -1
View File
@@ -10,6 +10,10 @@ import {
PREVIEWABLE_BUILTIN_APP_IDS,
} from '@/utils/appStorePreviews'
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
describe('App Store preview catalog', () => {
it('contains a real captured screenshot for every built-in store app', () => {
const storeAppIds = PHONE_APPS.filter(
@@ -56,10 +60,11 @@ describe('App Store preview catalog', () => {
]
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
const escapedAppId = escapeRegExp(appId)
for (const source of localeSources) {
expect(source).toMatch(
new RegExp(
`(?:["']${appId}["']\\]?|${appId.replace(/-/g, '\\-')})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
`(?:["']${escapedAppId}["']\\]?|${escapedAppId})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
),
)
}
+35
View File
@@ -21,6 +21,7 @@ import {
moveHomeFolderApp,
parseHomeLayout,
reflowHomeGridForWidgetChange,
removeDockGridDuplicates,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
@@ -78,6 +79,40 @@ describe('home layout', () => {
expect(layout.version).toBe(HOME_LAYOUT_VERSION)
})
it('removes dock apps from the grid and normalizes affected folders', () => {
const layout: HomeLayout = {
...defaults,
dock: [
'phone',
{
apps: ['messages', 'mail'],
id: 'folder-abcdef',
name: 'Dock',
type: 'folder',
},
null,
null,
],
grid: [
'phone',
{
apps: ['messages', 'notes'],
id: 'folder-ghijkl',
name: 'Grid',
type: 'folder',
},
'mail',
'clock',
...Array.from({ length: HOME_GRID_PAGE_SIZE - 4 }, () => null),
],
}
const normalized = removeDockGridDuplicates(layout)
expect(normalized.grid.slice(0, 3)).toEqual(['notes', 'clock', null])
expect(normalized.dock).toEqual(layout.dock)
})
it('migrates compact persisted arrays and appends newly installed apps', () => {
const layout = parseHomeLayout(
{
+31
View File
@@ -505,6 +505,37 @@ export function createDefaultHomeLayout(
}
}
export function removeDockGridDuplicates(layout: HomeLayout): HomeLayout {
const dockAppIds = new Set<LaunchablePhoneAppId>()
for (const item of layout.dock) {
if (typeof item === 'string') dockAppIds.add(item)
if (isHomeFolder(item)) {
for (const appId of item.apps) dockAppIds.add(appId)
}
}
if (!dockAppIds.size) return layout
let changed = false
const grid = layout.grid.map((item): HomeSlot => {
if (typeof item === 'string') {
if (!dockAppIds.has(item)) return item
changed = true
return null
}
if (!isHomeFolder(item)) return null
const apps = item.apps.filter((appId) => !dockAppIds.has(appId))
if (apps.length === item.apps.length) return cloneItem(item)
changed = true
return normalizeFolder({ ...item, apps })
})
if (!changed) return layout
const next = cloneLayout(layout)
next.grid = compactGridPages(grid)
return next
}
export function parseHomeLayout(
value: unknown,
defaults: HomeLayout,
+122 -3
View File
@@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest'
import { runInNewContext } from 'node:vm'
import type { ExternalPhoneAppDefinition } from '@/types/apps'
import {
createLbPhoneFrameDocument,
createLbPhoneHostSettings,
getLbPhoneCallbackResource,
getLbPhoneStorageKey,
readLbPhoneStorage,
usesLbPhoneHostRuntime,
writeLbPhoneStorage,
} from '@/utils/lbPhoneAppBridge'
import { DEFAULT_PHONE_PREFERENCES } from '@/utils/preferences'
@@ -90,9 +94,10 @@ describe('LB Phone app bridge', () => {
it('injects the LB runtime and asset base before the vendor bundle', () => {
const html =
'<!doctype html><html><head><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></html>'
'<!doctype html><html><head><script>globalThis.previewMode = !window.invokeNative</script><script type="module" src="/ui/dist/assets/index.js"></script></head><body></body></html>'
const document = createLbPhoneFrameDocument(html, {
appName: 'snake-game',
localStorage: { theme: 'dark' },
resourceName: 'snake_app',
settings: createLbPhoneHostSettings({
deviceName: '</script><script>window.injected=true</script>',
@@ -109,11 +114,125 @@ describe('LB Phone app bridge', () => {
)
expect(document).toContain('globalThis.fetchNui = async')
expect(document).toContain('globalThis.onNuiEvent = globalThis.useNuiEvent')
expect(document).toContain('globalThis.createCall = globalThis.CreateCall')
expect(document).toContain('globalThis.createSMS = globalThis.CreateSMS')
expect(document).toContain('globalThis.invokeNative = () => undefined')
expect(document).toContain(
"Object.defineProperty(globalThis, 'localStorage'",
)
expect(document).toContain('"localStorage":{"theme":"dark"}')
expect(document).toContain('https://cfx-nui-snake_app/ui/dist/')
expect(document).not.toContain('</script><script>window.injected=true')
expect(document.indexOf('globalThis.invokeNative')).toBeLessThan(
document.indexOf('globalThis.previewMode'),
)
const runtime = /<script>([\s\S]*?)<\/script>/.exec(document)?.[1]
const openingTag = '<script>'
const runtimeStart = document.indexOf(openingTag)
const runtimeEnd = document.indexOf(
'</script>',
runtimeStart + openingTag.length,
)
expect(runtimeStart).toBeGreaterThanOrEqual(0)
expect(runtimeEnd).toBeGreaterThan(runtimeStart)
const runtime = document.slice(runtimeStart + openingTag.length, runtimeEnd)
expect(() => new Function(runtime)).not.toThrow()
})
it('applies the LB iframe layout contract before revealing vendor apps', () => {
const document = createLbPhoneFrameDocument(
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
{
appName: 'radio-app',
localStorage: {},
resourceName: 'lb-radioapp',
settings: createLbPhoneHostSettings({
deviceName: 'Main phone',
isDarkMode: true,
language: 'en',
preferences: DEFAULT_PHONE_PREFERENCES,
securityEnabled: false,
}),
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
},
)
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
expect(runtime).toBeTruthy()
expect(() => new Function(runtime ?? '')).not.toThrow()
const messageListeners: Array<(event: { data: unknown }) => void> = []
const readyListeners: Array<() => void> = []
const documentElement = { dataset: {}, style: {} }
const body = {
dataset: {},
style: { visibility: 'hidden' },
}
const sandbox = {
addEventListener(
eventName: string,
listener: (event: { data: unknown }) => void,
) {
if (eventName === 'message') messageListeners.push(listener)
},
componentsLoaded: undefined as boolean | undefined,
console,
document: {
addEventListener(eventName: string, listener: () => void) {
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
},
body,
documentElement,
},
parent: { postMessage() {} },
}
runInNewContext(runtime ?? '', sandbox)
expect(body.style.visibility).toBe('hidden')
expect(readyListeners).toHaveLength(1)
readyListeners[0]?.()
expect(documentElement.style).toMatchObject({
height: '100%',
margin: '0',
padding: '0',
width: '100%',
})
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
expect(body.style).toMatchObject({
height: '100%',
margin: '0',
padding: '0',
visibility: 'visible',
width: '100%',
})
body.style.visibility = 'hidden'
expect(messageListeners).toHaveLength(1)
messageListeners[0]?.({ data: 'componentsLoaded' })
expect(body.style.visibility).toBe('visible')
expect(sandbox.componentsLoaded).toBe(true)
})
it('persists isolated LB localStorage snapshots without app changes', () => {
const values = new Map<string, string>()
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
}
expect(
writeLbPhoneStorage(storage, 'snake-game', {
language: 'de',
volume: '0.8',
}),
).toBe(true)
expect(values.has(getLbPhoneStorageKey('snake-game'))).toBe(true)
expect(readLbPhoneStorage(storage, 'snake-game')).toEqual({
language: 'de',
volume: '0.8',
})
expect(writeLbPhoneStorage(storage, 'snake-game', { invalid: 5 })).toBe(
false,
)
})
})
+168 -1
View File
@@ -4,6 +4,15 @@ import type { PhonePreferencesV1 } from '@/utils/preferences'
const LB_PHONE_PROVIDER = 'lb_phone'
const RESOURCE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
const MAX_FRAME_DOCUMENT_BYTES = 1_048_576
const MAX_STORAGE_BYTES = 65_536
const MAX_STORAGE_ENTRIES = 128
const MAX_STORAGE_KEY_LENGTH = 512
const STORAGE_KEY_PREFIX = 'sky_phone:lb-app-storage:v1:'
export const LB_PHONE_STORAGE_MESSAGE_TYPE = 'sky-phone:lb-storage'
export const LB_PHONE_ACTION_MESSAGE_TYPE = 'sky-phone:lb-action'
export type LbPhoneStorageSnapshot = Record<string, string>
export type LbPhoneHostSettings = {
airplaneMode: boolean
@@ -41,6 +50,7 @@ export type LbPhoneHostSettings = {
type LbPhoneFrameDocumentOptions = {
appName: string
localStorage: LbPhoneStorageSnapshot
resourceName: string
settings: LbPhoneHostSettings
ui: string
@@ -60,6 +70,59 @@ const settingsListeners = new Set();
const resourcePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
const eventPattern = /^[A-Za-z0-9][A-Za-z0-9:._/-]{0,127}$/;
function createStorage(initialValues, onChange) {
const values = new Map(Object.entries(initialValues ?? {}));
const snapshot = () => Object.fromEntries(values);
const storage = {
clear() {
if (values.size === 0) return;
values.clear();
onChange(snapshot());
},
getItem(key) {
const normalizedKey = String(key);
return values.has(normalizedKey) ? values.get(normalizedKey) : null;
},
key(index) {
const normalizedIndex = Number(index);
if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0) return null;
return Array.from(values.keys())[normalizedIndex] ?? null;
},
removeItem(key) {
if (!values.delete(String(key))) return;
onChange(snapshot());
},
setItem(key, value) {
values.set(String(key), String(value));
onChange(snapshot());
}
};
Object.defineProperty(storage, 'length', {
enumerable: true,
get: () => values.size
});
return storage;
}
const localStorageBridge = createStorage(config.localStorage, (storage) => {
globalThis.parent.postMessage({
appId: config.appName,
protocolVersion: 1,
storage,
type: '${LB_PHONE_STORAGE_MESSAGE_TYPE}'
}, '*');
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
enumerable: true,
value: localStorageBridge
});
Object.defineProperty(globalThis, 'sessionStorage', {
configurable: true,
enumerable: true,
value: createStorage({}, () => undefined)
});
function applySettings(nextSettings) {
globalThis.settings = nextSettings;
const theme = nextSettings?.display?.theme === 'dark' ? 'dark' : 'light';
@@ -67,10 +130,44 @@ function applySettings(nextSettings) {
if (document.body) document.body.dataset.theme = theme;
}
function prepareDocument() {
Object.assign(document.documentElement.style, {
height: '100%',
margin: '0',
padding: '0',
width: '100%'
});
if (!document.body) return;
document.body.dataset.device = 'phone';
Object.assign(document.body.style, {
height: '100%',
margin: '0',
padding: '0',
visibility: 'visible',
width: '100%'
});
}
globalThis.resourceName = config.resourceName;
globalThis.appName = config.appName;
globalThis.components = globalThis.components ?? {};
// Official LB app templates use this binding to distinguish live NUI from browser preview mode.
if (typeof globalThis.invokeNative !== 'function') {
globalThis.invokeNative = () => undefined;
}
globalThis.GetParentResourceName = () => config.resourceName;
function requestPhoneAction(action, options) {
globalThis.parent.postMessage({
action,
appId: config.appName,
options,
protocolVersion: 1,
type: '${LB_PHONE_ACTION_MESSAGE_TYPE}'
}, '*');
}
globalThis.createCall = globalThis.CreateCall = (options) => requestPhoneAction('createCall', options);
globalThis.createSMS = globalThis.CreateSMS = (options) => requestPhoneAction('createSMS', options);
globalThis.fetchNui = async (eventName, data, requestedResource) => {
if (typeof eventName !== 'string' || !eventPattern.test(eventName) || eventName.includes('..')) {
throw new TypeError('Invalid NUI callback name');
@@ -111,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
globalThis.addEventListener('message', (event) => {
const message = event.data;
if (message === 'componentsLoaded') {
globalThis.componentsLoaded = true;
prepareDocument();
return;
}
if (!message || typeof message !== 'object') return;
if (message.type === 'sky-phone:lb-settings') {
@@ -132,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
});
applySettings(config.settings);
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true });
document.addEventListener('DOMContentLoaded', () => {
prepareDocument();
applySettings(globalThis.settings);
}, { once: true });
`
function escapeAttribute(value: string): string {
@@ -150,6 +255,67 @@ function serializeForInlineScript(value: unknown): string {
.replace(/\u2029/g, '\\u2029')
}
function normalizeStorageSnapshot(
value: unknown,
): LbPhoneStorageSnapshot | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const entries = Object.entries(value)
if (entries.length > MAX_STORAGE_ENTRIES) return null
const normalized: LbPhoneStorageSnapshot = {}
for (const [key, item] of entries) {
if (
key.length > MAX_STORAGE_KEY_LENGTH ||
typeof item !== 'string' ||
key === '__proto__' ||
key === 'constructor' ||
key === 'prototype'
) {
return null
}
normalized[key] = item
}
return new TextEncoder().encode(JSON.stringify(normalized)).byteLength <=
MAX_STORAGE_BYTES
? normalized
: null
}
export function getLbPhoneStorageKey(appName: string): string {
if (!RESOURCE_NAME_PATTERN.test(appName)) {
throw new Error('invalid_lb_phone_storage_app')
}
return `${STORAGE_KEY_PREFIX}${appName}`
}
export function readLbPhoneStorage(
storage: Pick<Storage, 'getItem'>,
appName: string,
): LbPhoneStorageSnapshot {
const serialized = storage.getItem(getLbPhoneStorageKey(appName))
if (serialized === null) return {}
const normalized = normalizeStorageSnapshot(JSON.parse(serialized))
if (!normalized) throw new Error('invalid_lb_phone_storage')
return normalized
}
export function writeLbPhoneStorage(
storage: Pick<Storage, 'setItem'>,
appName: string,
value: unknown,
): boolean {
const normalized = normalizeStorageSnapshot(value)
if (!normalized) return false
storage.setItem(getLbPhoneStorageKey(appName), JSON.stringify(normalized))
return true
}
export function usesLbPhoneHostRuntime(
app: ExternalPhoneAppDefinition,
): boolean {
@@ -228,6 +394,7 @@ export function createLbPhoneFrameDocument(
const baseUrl = new URL('.', options.ui).href
const config = serializeForInlineScript({
appName: options.appName,
localStorage: options.localStorage,
resourceName: options.resourceName,
settings: options.settings,
})
+7
View File
@@ -158,6 +158,13 @@ describe('media utilities', () => {
expect(mediaErrorKey('profile_photo_required')).toBe(
'profile_photo_required',
)
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
'media_provider_rate_limited',
)
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
'media_provider_unauthorized',
)
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
+3
View File
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
'invalid_import_url',
'invalid_upload',
'invalid_upload_token',
'media_provider_failed',
'media_provider_rate_limited',
'media_provider_unauthorized',
'missing_config',
'import_media_not_allowed',
'import_media_too_large',
+8
View File
@@ -47,4 +47,12 @@ describe('notes rich text', () => {
'Briefing\nMeet outside.\n• Radio\n• Vest',
)
})
it('keeps encoded markup as literal preview text', () => {
const body = serializeRichNoteBody(
'<p>&lt;script&gt;literal&lt;/script&gt;</p>',
)
expect(noteBodyToPlainText(body)).toBe('<script>literal</script>')
})
})
+14 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
import {
configurePhoneNumberFormat,
formatPhoneNumber,
normalizePhoneNumber,
} from './phone'
describe('phone numbers', () => {
it('normalizes formatted ten digit values', () => {
@@ -13,4 +17,13 @@ describe('phone numbers', () => {
expect(formatPhoneNumber('5551')).toBe('555 1')
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
})
it('uses the server-provided number length and display groups', () => {
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
configurePhoneNumberFormat()
})
})
+45 -4
View File
@@ -1,12 +1,53 @@
import type { PhoneNumberFormat } from '@/types/phone'
export const PHONE_NUMBER_LENGTH = 10
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
groups: [3, 3, 4],
length: PHONE_NUMBER_LENGTH,
}
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
const length = value?.length
const groups = value?.groups
if (
typeof length !== 'number' ||
!Number.isInteger(length) ||
length < 1 ||
length > 24 ||
!Array.isArray(groups) ||
groups.length === 0 ||
groups.some((group) => !Number.isInteger(group) || group < 1)
) {
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
return
}
phoneNumberFormat = {
groups: [...groups],
length,
}
}
export function normalizePhoneNumber(value: string): string | null {
const digits = value.replace(/\D/g, '')
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
return digits.length === phoneNumberFormat.length ? digits : null
}
export function formatPhoneNumber(value: string | number): string {
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
return groups.filter(Boolean).join(' ')
const digits = String(value)
.replace(/\D/g, '')
.slice(0, phoneNumberFormat.length)
const formatted: string[] = []
let offset = 0
for (const size of phoneNumberFormat.groups) {
const group = digits.slice(offset, offset + size)
if (!group) break
formatted.push(group)
offset += size
}
if (offset < digits.length) formatted.push(digits.slice(offset))
return formatted.join(' ')
}
+96
View File
@@ -0,0 +1,96 @@
type PhoneAudioVolumeListener = (volume: number) => void
const mediaElements = new Map<HTMLMediaElement, boolean>()
const mediaLocalVolumes = new WeakMap<HTMLMediaElement, number>()
const volumeListeners = new Set<PhoneAudioVolumeListener>()
let documentListenerReferences = 0
let outputVolume = 1
function clampVolume(volume: number): number {
return Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0))
}
function applyMediaVolume(element: HTMLMediaElement): void {
const localVolume = mediaLocalVolumes.get(element) ?? element.volume
const nextVolume = clampVolume(localVolume * outputVolume)
if (Math.abs(element.volume - nextVolume) < 0.001) return
element.volume = nextVolume
}
function onMediaVolumeChange(event: Event): void {
const element = event.currentTarget as HTMLMediaElement
const currentLocalVolume = mediaLocalVolumes.get(element) ?? element.volume
const expectedVolume = clampVolume(currentLocalVolume * outputVolume)
if (Math.abs(element.volume - expectedVolume) < 0.001) return
mediaLocalVolumes.set(element, clampVolume(element.volume))
applyMediaVolume(element)
}
function onMediaPlay(event: Event): void {
if (event.target instanceof HTMLMediaElement) {
trackPhoneMediaElement(event.target, false)
}
}
function trackPhoneMediaElement<T extends HTMLMediaElement>(
element: T,
persistent: boolean,
): T {
if (mediaElements.has(element)) {
if (persistent) mediaElements.set(element, true)
return element
}
mediaElements.set(element, persistent)
mediaLocalVolumes.set(element, clampVolume(element.volume))
element.addEventListener('volumechange', onMediaVolumeChange)
applyMediaVolume(element)
return element
}
export function getPhoneOutputVolume(): number {
return outputVolume
}
export function installPhoneAudioController(): () => void {
documentListenerReferences += 1
if (documentListenerReferences === 1) {
document.addEventListener('play', onMediaPlay, true)
document
.querySelectorAll<HTMLMediaElement>('audio, video')
.forEach((element) => trackPhoneMediaElement(element, false))
}
return () => {
documentListenerReferences = Math.max(0, documentListenerReferences - 1)
if (documentListenerReferences === 0) {
document.removeEventListener('play', onMediaPlay, true)
}
}
}
export function registerPhoneMediaElement<T extends HTMLMediaElement>(
element: T,
): T {
return trackPhoneMediaElement(element, true)
}
export function setPhoneOutputVolume(volume: number): void {
outputVolume = clampVolume(volume)
for (const [element, persistent] of mediaElements) {
if (!persistent && !element.isConnected && element.paused) {
element.removeEventListener('volumechange', onMediaVolumeChange)
mediaElements.delete(element)
continue
}
applyMediaVolume(element)
}
for (const listener of volumeListeners) listener(outputVolume)
}
export function subscribePhoneOutputVolume(
listener: PhoneAudioVolumeListener,
): () => void {
volumeListeners.add(listener)
return () => volumeListeners.delete(listener)
}
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
>
function measuredRect(
left: number,
top: number,
width: number,
height: number,
): RectMeasurement {
return { height, left, top, width }
}
function expectRectClose(
actual: PhoneViewportRect,
expected: PhoneViewportRect,
): void {
for (const key of [
'bottom',
'height',
'left',
'right',
'top',
'width',
] as const) {
expect(actual[key]).toBeCloseTo(expected[key], 6)
}
}
function createGeometryFixture(options: {
canvasOffsetHeight: number
canvasOffsetWidth: number
rawCanvasRect: RectMeasurement
wrapperRect: RectMeasurement
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
const wrapper = {
getBoundingClientRect: () => options.wrapperRect,
} as unknown as HTMLElement
const canvas = {
closest: (selector: string) =>
selector === '.phone-resolution-wrapper' ? wrapper : null,
getBoundingClientRect: () => options.rawCanvasRect,
offsetHeight: options.canvasOffsetHeight,
offsetWidth: options.canvasOffsetWidth,
} as unknown as HTMLElement
return {
anchor: {
closest: (selector: string) =>
selector === '.phone-resolution-canvas' ? canvas : null,
} as unknown as Element,
element: (rect) =>
({ getBoundingClientRect: () => rect }) as unknown as Element,
}
}
describe('phone viewport geometry', () => {
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
bottom: layer.top + layer.height,
height: layer.height,
left: layer.left,
right: layer.left + layer.width,
top: layer.top,
width: layer.width,
})
})
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
expect(corrected.left).toBeGreaterThan(wrapper.left)
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
})
it.each([
['80%', 0.6624],
['100%', 0.828],
['120%', 0.9936],
])(
'normalizes fractional positions, sizes, and deltas at %s scaling',
(_label, zoom) => {
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
const rawStart = measuredRect(
rawCanvas.left + 20.125,
rawCanvas.top + 50.375,
349.75,
73.125,
)
const rawEnd = measuredRect(
rawStart.left + 73.25,
rawStart.top - 41.75,
rawStart.width,
rawStart.height,
)
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
},
)
it('reads visual scale and normalized element rects from a canvas anchor', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
canvasOffsetWidth: 390,
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
})
const geometry = readPhoneViewportGeometry(fixture.anchor)
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
expect(geometry).not.toBeNull()
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
})
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
const rawCanvas = measuredRect(100, 50, 0, 0)
const corrected = normalizePhoneViewportRect(
measuredRect(112.5, 58.25, 40, 20),
rawCanvas,
measuredRect(500, 300, 0, 0),
)
const fixture = createGeometryFixture({
canvasOffsetHeight: 0,
canvasOffsetWidth: 0,
rawCanvasRect: rawCanvas,
wrapperRect: measuredRect(500, 300, 0, 0),
})
const geometry = readPhoneViewportGeometry(fixture.anchor)
expect(corrected).toEqual({
bottom: 328.25,
height: 20,
left: 512.5,
right: 552.5,
top: 308.25,
width: 40,
})
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
expect(geometry?.scaleX).toBe(1)
expect(geometry?.scaleY).toBe(1)
expect(readPhoneViewportGeometry(null)).toBeNull()
expect(
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
).toBeNull()
})
})
@@ -0,0 +1,73 @@
export type PhoneViewportRect = {
bottom: number
height: number
left: number
right: number
top: number
width: number
}
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
>
export type PhoneViewportGeometry = {
readonly scaleX: number
readonly scaleY: number
rect(element: Element): PhoneViewportRect
}
function positiveRatio(numerator: number, denominator: number): number {
return Number.isFinite(numerator) &&
Number.isFinite(denominator) &&
numerator > 0 &&
denominator > 0
? numerator / denominator
: 1
}
export function normalizePhoneViewportRect(
rawRect: RectMeasurement,
rawCanvasRect: RectMeasurement,
wrapperRect: RectMeasurement,
): PhoneViewportRect {
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
const width = rawRect.width * factorX
const height = rawRect.height * factorY
return {
bottom: top + height,
height,
left,
right: left + width,
top,
width,
}
}
export function readPhoneViewportGeometry(
anchor: Element | null,
): PhoneViewportGeometry | null {
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
if (!canvas || !wrapper) return null
const rawCanvasRect = canvas.getBoundingClientRect()
const wrapperRect = wrapper.getBoundingClientRect()
return {
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
rect(element: Element): PhoneViewportRect {
return normalizePhoneViewportRect(
element.getBoundingClientRect(),
rawCanvasRect,
wrapperRect,
)
},
}
}
@@ -140,4 +140,56 @@ describe('springboard widget drag', () => {
expect(delta.x).toBeCloseTo(100)
expect(delta.y).toBeCloseTo(200)
})
it.each([
['80% preview', 0.6624],
['80% production clamp', 2 / 3],
['100%', 0.828],
['120%', 0.9936],
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
const layoutWidth = 350
const layoutHeight = 808
const viewportLeft = 128.25
const viewportTop = 64.5
const viewportWidth = layoutWidth * zoom
const viewportHeight = layoutHeight * zoom
const pointerStart = {
x: viewportLeft + 48.5 * zoom,
y: viewportTop + 132.25 * zoom,
}
const viewportDelta = { x: 73.25, y: -41.75 }
const start = springboardViewportToLocal(
pointerStart.x,
pointerStart.y,
viewportLeft,
viewportTop,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
const end = springboardViewportToLocal(
pointerStart.x + viewportDelta.x,
pointerStart.y + viewportDelta.y,
viewportLeft,
viewportTop,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
const localDelta = springboardViewportDeltaToLocal(
viewportDelta.x,
viewportDelta.y,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
})
})
+5 -1
View File
@@ -1,3 +1,5 @@
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
export type PageTurnDirection = -1 | 0 | 1
export type SpringboardEdgeTurn = {
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
'.springboard-page, .home-folder-panel',
)
if (!surface) return null
const bounds = surface.getBoundingClientRect()
const bounds =
readPhoneViewportGeometry(surface)?.rect(surface) ??
surface.getBoundingClientRect()
return {
layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth,
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { isTextInputElement } from '@/utils/textInputFocus'
function element(
tagName: string,
attributes: Record<string, string> = {},
isContentEditable = false,
) {
return {
getAttribute(name: string) {
return attributes[name] ?? null
},
isContentEditable,
tagName,
}
}
describe('text input focus', () => {
it('recognizes fields that accept typed text', () => {
expect(isTextInputElement(element('INPUT'))).toBe(true)
expect(isTextInputElement(element('INPUT', { type: 'number' }))).toBe(true)
expect(isTextInputElement(element('TEXTAREA'))).toBe(true)
expect(isTextInputElement(element('DIV', {}, true))).toBe(true)
expect(isTextInputElement(element('DIV', { role: 'textbox' }))).toBe(true)
})
it('ignores non-text and read-only controls', () => {
expect(isTextInputElement(element('INPUT', { type: 'checkbox' }))).toBe(
false,
)
expect(isTextInputElement(element('INPUT', { type: 'range' }))).toBe(false)
expect(isTextInputElement(element('INPUT', { readonly: '' }))).toBe(false)
expect(isTextInputElement(element('BUTTON'))).toBe(false)
})
})
+34
View File
@@ -0,0 +1,34 @@
const nonTextInputTypes = new Set([
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
])
type FocusableElement = Pick<
HTMLElement,
'getAttribute' | 'isContentEditable' | 'tagName'
>
export function isTextInputElement(element: FocusableElement): boolean {
if (
element.getAttribute('readonly') !== null ||
element.getAttribute('aria-readonly') === 'true'
) {
return false
}
if (element.isContentEditable || element.getAttribute('role') === 'textbox') {
return true
}
if (element.tagName === 'TEXTAREA') return true
if (element.tagName !== 'INPUT') return false
const inputType = element.getAttribute('type')?.toLowerCase() ?? 'text'
return !nonTextInputTypes.has(inputType)
}
+2 -1
View File
@@ -32,7 +32,7 @@ describe('phone tones', () => {
}> = []
vi.stubGlobal(
'Audio',
class {
class extends EventTarget {
currentTime = 7
loop = false
pause = pause
@@ -42,6 +42,7 @@ describe('phone tones', () => {
volume = 0
constructor(src: string) {
super()
this.src = src
players.push(this)
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences'
@@ -495,8 +496,8 @@ export function playPhoneVibration(
kind: PhoneVibrationKind,
loop: boolean,
): () => void {
const player = new Audio(
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
const player = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
)
let stopped = false
player.loop = loop
@@ -19,7 +19,37 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
const tokensCss = readFileSync(
new URL('../ui/tokens.css', import.meta.url),
'utf8',
)
const foundationCss = readFileSync(
new URL('../ui/foundation.css', import.meta.url),
'utf8',
)
const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'),
)
describe('Springboard page swipe contract', () => {
it('keeps app and widget labels on the larger shared home typography', () => {
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
expect(mainCss).toMatch(
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
)
expect(foundationCss).toMatch(
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
)
})
it('keeps the built-in wallpapers visually restrained', () => {
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
expect(builtInWallpaperCss.match(/linear-gradient/g)).toHaveLength(12)
})
it('replaces the home status row with the edit controls', () => {
expect(viewSource).toContain("emit('editModeChange', editing)")
expect(viewSource).toContain("emit('editModeChange', false)")
@@ -84,36 +114,99 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).toContain(':style="dragPointerStyle"')
})
it('uses the same scale-aware in-phone drag path as widgets', () => {
expect(viewSource).not.toContain('source.cloneNode(true)')
expect(viewSource).not.toContain('<Teleport to="body">')
expect(viewSource).not.toContain(':external-drag-visual')
it('renders the home drag visual through the unzoomed phone portal', () => {
expect(viewSource).toContain('source.cloneNode(true)')
expect(viewSource).toContain(
"'springboard--home-dragging': draggingHomeApp !== null",
'<Teleport defer to="#phone-home-drag-portal">',
)
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()')
expect(appIconSource).not.toContain('externalDragVisual')
expect(folderIconSource).not.toContain('externalDragVisual')
expect(mainCss).not.toContain('.home-drag-layer')
expect(mainCss).not.toContain('.home-drag-ghost')
expect(mainCss).not.toContain('.app-icon-item--drag-source')
expect(viewSource).toContain(
'<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
)
expect(viewSource).toContain('readPhoneViewportGeometry')
expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
expect(viewSource).not.toContain('homeDragLocalPoint')
expect(viewSource).not.toContain('springboardViewportToLocal')
expect(viewSource).toContain("position.className = 'home-drag-position'")
expect(viewSource).toMatch(
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
)
expect(viewSource).toMatch(
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
).toHaveLength(4)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
expect(viewSource).not.toContain('springboard--home-dragging')
expect(appIconSource).toContain('externalDragVisual?: boolean')
expect(folderIconSource).toContain('externalDragVisual?: boolean')
expect(appIconSource).toContain('app-icon-item--drag-source')
expect(folderIconSource).toContain('app-icon-item--drag-source')
expect(mainCss).toMatch(
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s,
/\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
)
expect(mainCss).toContain('.home-drag-position')
expect(mainCss).toContain('.home-drag-ghost')
expect(mainCss).toContain('.app-icon-item--drag-source')
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
expect(mainCss).toMatch(
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
)
})
it('cleans up the external drag visual on every terminal path', () => {
expect(
viewSource.match(
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
),
).toHaveLength(4)
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
expect(viewSource).toContain(
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
)
expect(viewSource).toMatch(
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
)
expect(viewSource).toMatch(
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
)
expect(viewSource).toMatch(
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
)
expect(viewSource).toMatch(
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
)
expect(viewSource).toMatch(
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('targets the active visual page and lets grid or dock drags turn pages', () => {
expect(viewSource).toContain(':data-home-page="page.page"')
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
expect(viewSource).toContain('nearestGridDropTarget')
expect(viewSource).toContain('moveHomeAppToGridPage')
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
expect(viewSource).toContain('event.clientX < pageBounds.left')
expect(viewSource).toContain('event.clientY > pageBounds.bottom')
expect(viewSource).toContain('event.clientX < springboardBounds.left')
expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
expect(viewSource).toContain('function homeDragViewportRect(')
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
expect(viewSource).toMatch(
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
)
expect(viewSource).toContain(
'springboardBounds.left + (bounds.left - pageBounds.left)',
)
expect(viewSource).toContain('nearestSpringboardRectIndex')
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
+273 -35
View File
@@ -41,6 +41,11 @@ import {
type HomeItem,
} from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard'
import {
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
import {
maximumRenderedWidgetPage,
@@ -106,6 +111,8 @@ const draggingHomeApp = ref<{
area: HomeArea
index: number
} | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null)
const folderDraggingOutside = ref(false)
@@ -125,6 +132,11 @@ let edgePageDirection = 0
let edgePageLocked = false
let folderHoverTimer: number | undefined
let lastHomePointer: { clientX: number; clientY: number } | null = null
let homeDragGhost: HTMLElement | null = null
let homeDragGrip: { x: number; y: number } | null = null
let homeDragGeometry: PhoneViewportGeometry | null = null
let homeDragClipBounds: PhoneViewportRect | null = null
let homeDragPreviewBounds: PhoneViewportRect | null = null
const installedApps = computed(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
).find((candidate) => candidate.dataset.widgetId === id)
if (!widget) return
pageTransitioning.value = false
const bounds = widget.getBoundingClientRect()
const bounds = homeDragViewportRect(widget)
draggingWidgetId.value = id
widgetDragGrip.value = {
x: event.clientX - bounds.left,
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return
const renderedGridBounds = grid.getBoundingClientRect()
const renderedGridBounds = homeDragViewportRect(grid)
const gridStyle = getComputedStyle(grid)
const scaleX =
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
function resolveHomeEdgeTurn(event: { clientX: number }) {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return null
const bounds = springboard.getBoundingClientRect()
const bounds = homeDragViewportRect(springboard)
const renderedLastPage = appPages.value.length
return resolveSpringboardHomeEdgeTurn(
event.clientX,
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
): void {
const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return
const bounds = springboard.getBoundingClientRect()
const bounds = homeDragViewportRect(springboard)
const turn =
dragType === 'app'
? resolveHomeEdgeTurn(event)
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page)
}
function startHomeDrag(area: HomeArea, index: number): void {
function homeDragViewportRect(
element: Element,
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
): PhoneViewportRect {
if (geometry) return geometry.rect(element)
const bounds = element.getBoundingClientRect()
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
}
}
function viewportRectAt(
left: number,
top: number,
width: number,
height: number,
): PhoneViewportRect {
return {
bottom: top + height,
height,
left,
right: left + width,
top,
width,
}
}
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
if (expectedGhost && homeDragGhost !== expectedGhost) return
homeDragGhost?.remove()
homeDragGhost = null
homeDragGrip = null
homeDragGeometry = null
homeDragClipBounds = null
homeDragPreviewBounds = null
homeDragVisualActive.value = false
const layer = homeDragLayer.value
layer?.style.removeProperty('left')
layer?.style.removeProperty('top')
layer?.style.removeProperty('width')
layer?.style.removeProperty('height')
}
function updateHomeDragGhost(event: {
clientX: number
clientY: number
}): void {
const ghost = homeDragGhost
const grip = homeDragGrip
const clipBounds = homeDragClipBounds
const previewBounds = homeDragPreviewBounds
if (!ghost || !grip || !clipBounds || !previewBounds) return
const left = event.clientX - grip.x
const top = event.clientY - grip.y
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
homeDragPreviewBounds = viewportRectAt(
left,
top,
previewBounds.width,
previewBounds.height,
)
}
function createHomeDragGhost(event: PointerEvent): void {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
? event.currentTarget
: event.target instanceof Element
? event.target
: null
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
const layer = homeDragLayer.value
const springboard = source?.closest<HTMLElement>('.springboard')
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
const geometry = readPhoneViewportGeometry(source ?? null)
if (!source || !layer || !springboard || !portal || !geometry) return
const portalBounds = portal.getBoundingClientRect()
const clipBounds = geometry.rect(springboard)
const sourceBounds = geometry.rect(source)
if (
clipBounds.width <= 0 ||
clipBounds.height <= 0 ||
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
return
}
layer.style.left = `${clipBounds.left - portalBounds.left}px`
layer.style.top = `${clipBounds.top - portalBounds.top}px`
layer.style.width = `${clipBounds.width}px`
layer.style.height = `${clipBounds.height}px`
const ghost = source.cloneNode(true) as HTMLElement
ghost.classList.remove(
'app-icon-item--dragging',
'app-icon-item--editing',
'app-icon-item--drag-source',
'app-icon-item--drop-settling',
'home-folder-item--dragging',
'home-item--folder-hover',
)
ghost.classList.add('home-drag-ghost')
ghost.removeAttribute('style')
ghost
.querySelector<HTMLElement>('.app-icon-button')
?.style.removeProperty('transform')
ghost.setAttribute('aria-hidden', 'true')
ghost.querySelector('.app-icon-remove')?.remove()
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
'a, button, input, select, textarea, [tabindex]',
)) {
control.tabIndex = -1
}
const position = document.createElement('div')
position.className = 'home-drag-position'
position.style.width = `${sourceBounds.width}px`
position.style.height = `${sourceBounds.height}px`
ghost.style.width = `${source.offsetWidth}px`
ghost.style.height = `${source.offsetHeight}px`
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
position.appendChild(ghost)
homeDragGhost = position
homeDragGrip = {
x: event.clientX - sourceBounds.left,
y: event.clientY - sourceBounds.top,
}
homeDragGeometry = geometry
homeDragClipBounds = clipBounds
homeDragPreviewBounds = sourceBounds
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
}
function startHomeDrag(
area: HomeArea,
index: number,
event: PointerEvent,
): void {
clearTemporaryHomePage()
lastHomePointer = null
draggingHomeApp.value = { area, index }
createHomeDragGhost(event)
}
function clearFolderHover(): void {
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
return
}
const targetElement = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-home-index]'))
.find((element) => element && !element.closest('.app-icon-item--dragging'))
const targetElement = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
)
.filter((element) => {
const area = element.dataset.homeArea as HomeArea | undefined
const index = Number(element.dataset.homeIndex)
if (
(area !== 'grid' && area !== 'dock') ||
!Number.isInteger(index) ||
(area === dragged.area && index === dragged.index)
) {
return false
}
if (
area === 'grid' &&
Number(
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
) !== phone.currentPage
) {
return false
}
const bounds = homeDragViewportRect(element)
return (
event.clientX >= bounds.left &&
event.clientX <= bounds.right &&
event.clientY >= bounds.top &&
event.clientY <= bounds.bottom
)
})
.sort((left, right) => {
const leftIsDock = left.dataset.homeArea === 'dock'
const rightIsDock = right.dataset.homeArea === 'dock'
return Number(rightIsDock) - Number(leftIsDock)
})[0]
const area = targetElement?.dataset.homeArea as HomeArea | undefined
const targetIndex = Number(targetElement?.dataset.homeIndex)
if (
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
if (folderId) {
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
openedFolderId.value = folderId
}
@@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void {
function moveHomeDrag(event: PointerEvent): void {
if (!draggingHomeApp.value) return
lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (resolveHomeEdgeTurn(event)) {
clearFolderHover()
queueEdgePageTurn(event, 'app')
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
item: HomeItem,
area: HomeArea,
index: number,
from: DOMRect,
from: PhoneViewportRect,
): Promise<void> {
await nextTick()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
)
if (!appElement) return
const target = appElement.getBoundingClientRect()
const target = homeDragViewportRect(
appElement,
readPhoneViewportGeometry(appElement),
)
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
from.left - target.left,
from.top - target.top,
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
`.springboard-page--apps[data-home-page="${page}"] .app-grid`,
)
const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return null
const pageBounds = pageElement.getBoundingClientRect()
const springboard = grid?.closest<HTMLElement>('.springboard')
if (!grid || !pageElement || !springboard) return null
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
const pageBounds = homeDragViewportRect(pageElement, geometry)
const springboardBounds = homeDragViewportRect(springboard, geometry)
if (
event.clientX < pageBounds.left ||
event.clientX > pageBounds.right ||
event.clientY < pageBounds.top ||
event.clientY > pageBounds.bottom
event.clientX < springboardBounds.left ||
event.clientX > springboardBounds.right ||
event.clientY < springboardBounds.top ||
event.clientY > springboardBounds.bottom
) {
return null
}
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => slot.getBoundingClientRect()),
slots.map((slot) => {
const bounds = homeDragViewportRect(slot, geometry)
return {
height: bounds.height,
left: springboardBounds.left + (bounds.left - pageBounds.left),
top: springboardBounds.top + (bounds.top - pageBounds.top),
width: bounds.width,
}
}),
)
const closest = closestIndex === null ? null : slots[closestIndex]
const targetOffset = Number(closest?.dataset.homeTargetOffset)
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
}): HTMLElement | null {
const dock = document.querySelector<HTMLElement>('.app-dock')
if (!dock) return null
const dockBounds = dock.getBoundingClientRect()
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
const dockBounds = homeDragViewportRect(dock, geometry)
if (
event.clientX < dockBounds.left ||
event.clientX > dockBounds.right ||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex(
event.clientX,
event.clientY,
slots.map((slot) => slot.getBoundingClientRect()),
slots.map((slot) => homeDragViewportRect(slot, geometry)),
)
return closestIndex === null ? null : slots[closestIndex]
}
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn()
clearFolderHover()
const dragged = draggingHomeApp.value
if (!dragged) return
if (!dragged) {
clearHomeDragGhost()
return
}
updateHomeDragGhost(event)
const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
const draggedElement = document.querySelector<HTMLElement>(
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`,
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const dropGhost = homeDragGhost
const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin)
void animateHomeItemDrop(
draggedItem,
dropArea,
dropIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
} else {
clearHomeDragGhost()
}
}
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
clearFolderHover()
draggingHomeApp.value = null
lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage()
}
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
const appElement = document.querySelector<HTMLElement>(
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
)
const moveOrigin = appElement?.getBoundingClientRect()
const moveOrigin = appElement
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
: null
const targetIndex = homeKeyboardTarget(
appStore.homeLayout,
area,
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
)
?.closest<HTMLElement>('.app-icon-item')
const sourceBounds = sourceElement?.getBoundingClientRect()
const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => {
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
})
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate
const bounds = candidate.getBoundingClientRect()
const closestBounds = closest.getBoundingClientRect()
const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = homeDragViewportRect(closest, geometry)
const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2),
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
pendingWidgetPointer = null
lastWidgetPointer = null
lastHomePointer = null
clearHomeDragGhost()
temporaryHomePage.value = null
draggingHomeApp.value = null
draggingWidgetId.value = null
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
'springboard--dragging': dragging,
'springboard--editing': editMode,
'springboard--folder-open': folderOverlayVisible,
'springboard--home-dragging': draggingHomeApp !== null,
'springboard--widget-dragging': draggingWidgetId !== null,
},
]"
@@ -1303,6 +1533,7 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:class="{
'home-item--folder-hover':
folderHoverTarget === `grid:${cell.sourceIndex}`,
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1325,6 +1556,7 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="cell.folder"
:class="{
'home-item--folder-hover':
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)"
@dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@edit="enterEditMode"
@open="openFolder(cell.folder.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
</section>
</div>
<Teleport defer to="#phone-home-drag-portal">
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
</Teleport>
<Transition name="edit-done">
<k-glass
v-if="editMode && isEditablePage && !folderOverlayVisible"
@@ -1530,6 +1766,7 @@ onBeforeUnmount(() => {
:data-home-item-key="`app-${slot.app.id}`"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:show-label="false"
:class="{
'home-item--folder-hover':
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index)"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@edit="enterEditMode"
@remove="removeHomeApp(slot.app.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1552,6 +1789,7 @@ onBeforeUnmount(() => {
:data-home-item-key="slot.folder.id"
:data-home-index="slot.index"
:edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="slot.folder"
:show-label="false"
:class="{
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag"
@dragend="finishHomeDrag"
@dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index)"
@dragstart="startHomeDrag('dock', slot.index, $event)"
@edit="enterEditMode"
@open="openFolder(slot.folder.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -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);
}
}
@@ -22,7 +22,7 @@ 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).toContain(':dark="displayedDarkMode"')
expect(appShellSource).toMatch(
/<SkyProvider[\s\S]*?<RouterView[\s\S]*?<component/,
)
@@ -15,7 +15,7 @@ const appSource = readFileSync(
'utf8',
)
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
new URL('../../../../sky_phone/source/client/nui_events.lua', import.meta.url),
'utf8',
)
const serverSource = readFileSync(
@@ -69,7 +69,10 @@ describe('Banking app Sky UI migration', () => {
expect(serverSource).toContain('kind = "transfer_in"')
expect(serverSource).toContain('currency = Config.Banking.Currency')
expect(clientSource).toContain(
'SendNUIMessage({ type = "banking:changed", data = data })',
'["sky_phone:banking:changed"] = "banking:changed"',
)
expect(clientSource).toContain(
'SendNUIMessage({ type = nui_type, data = data })',
)
expect(appSource).toContain("data?.kind === 'transfer_in'")
expect(appSource).toContain("appId: 'banking'")
+1 -1
View File
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
flex-direction: column;
color: #f6f7f9;
background: #07090c;
font-family: Inter, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.billing-app--light {
--billing-border: rgb(15 23 42 / 10%);
@@ -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);
}
}
+1 -7
View File
@@ -1156,13 +1156,7 @@ onMounted(async () => {
padding: 47px 0 24px;
background: var(--bg);
color: var(--label);
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'SF Pro Display',
system-ui,
sans-serif;
font-family: var(--sky-font-family);
}
.calendar--light {
@@ -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"')
+68 -52
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,
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
return
}
await nuiCall('media:requestUpload', {
console.info('[Sky Phone Media] Camera requested a photo upload.', {
correlationId: id,
})
const response = await nuiCall('media:requestUpload', {
correlationId: id,
mediaType: 'photo',
})
if (!response.success) {
console.error('[Sky Phone Media] Photo upload request was rejected.', {
correlationId: id,
error: response.error,
})
window.postMessage(
{
data: {
correlationId: id,
error: response.error ?? 'request_failed',
success: false,
},
type: 'media:uploadResult',
},
'*',
)
}
}
function startRecording(): void {
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
} else if (message.type === 'media:uploadResult') {
const result = message.data as UploadResult
if (!result?.correlationId) return
console.info('[Sky Phone Media] Camera received an upload result.', {
correlationId: result.correlationId,
error: result.error,
success: result.success,
})
savingVideo.value = false
if (result.success && result.media) {
latestMedia.value = result.media
@@ -421,13 +446,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 +509,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 +541,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 +568,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 +588,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 +615,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 +628,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 +655,7 @@ onBeforeUnmount(() => {
/>
<Video v-else-if="latestMedia" :size="22" />
<Images v-else :size="22" />
</button>
</sky-glass>
<button
class="camera-shutter"
@@ -650,7 +680,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 +829,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 +842,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 +855,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 +877,7 @@ onBeforeUnmount(() => {
}
.camera-lock-control {
min-height: 44px;
border: 0;
padding: 7px 10px;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -902,27 +926,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 +950,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 +970,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;
+1 -1
View File
@@ -2195,7 +2195,7 @@ onMounted(async () => {
overflow: hidden;
background: #151613;
color: #f8f8f4;
font-family: Inter, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.citymarkt--light {
--ink: #fff;
@@ -119,6 +119,8 @@ describe('VaultX crypto app contracts', () => {
expect(source).not.toContain('class="auth-panel__heading"')
expect(source).not.toContain('class="auth-action-dock"')
expect(source).not.toContain('class="password-rules"')
expect(source).toContain('class="auth-password-hint"')
expect(source).toContain("t('auth.ruleSpecial')")
expect(source).toMatch(
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
)
+10
View File
@@ -824,6 +824,10 @@ onUnmounted(() => {
:size="18"
/></button></template
></SkyField>
<p v-if="authMode === 'register'" class="auth-password-hint">
{{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} ·
{{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }}
</p>
<p v-if="formError" class="error">{{ formError }}</p>
<SkyButton block large class="auth-submit" type="submit">{{
t(
@@ -2060,6 +2064,12 @@ onUnmounted(() => {
.auth-form {
gap: 12px;
}
.auth-password-hint {
margin: -3px 4px 0;
color: rgba(255, 255, 255, 0.58);
font-size: 11px;
line-height: 15px;
}
.auth-account {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) 18px;
@@ -37,10 +37,17 @@ describe('DarkChatApp Sky UI contract', () => {
expect(newChat).toContain('class="dc-recipient-field"')
expect(newChat).toContain('layout="inline"')
expect(newChat).toContain('class="dc-new-chat-contacts"')
expect(newChat).toContain("{{ t('newChatBody') }}")
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
expect(newChat).not.toContain('class="dc-hero"')
})
it('rejects the current profile identifiers before opening confirmation', () => {
expect(source).toContain('darkchat.profile?.darkId')
expect(source).toContain('darkchat.profile?.inviteCode')
expect(source).toContain("showToast(errorText('self_chat'))")
})
it('keeps the search visually compact without shrinking its wrapper', () => {
expect(source).toMatch(
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
+22
View File
@@ -427,6 +427,20 @@ function closeReport(): void {
function requestStart(value = identifier.value): void {
const clean = value.trim()
if (!clean) return
const ownIdentifiers = [
darkchat.profile?.darkId,
darkchat.profile?.inviteCode,
]
if (
ownIdentifiers.some(
(ownIdentifier) =>
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
clean.toLocaleLowerCase(phone.lang),
)
) {
showToast(errorText('self_chat'))
return
}
pendingIdentifier.value = clean
safetyOpen.value = true
}
@@ -1262,6 +1276,13 @@ onBeforeUnmount(() => {
padding-top: 2px;
}
.dc-new-chat-intro {
margin: 2px 2px 12px;
color: var(--dc-muted);
font-size: 13px;
line-height: 18px;
}
.dc-recipient-field,
.dc-new-chat-contacts {
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
@@ -2071,6 +2092,7 @@ onBeforeUnmount(() => {
</template>
</SkyNavbar>
<SkyScrollArea padded class="dc-new-chat-scroll">
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
<SkyList class="dc-recipient-field" density="compact" flush>
<SkyField
:label="t('to')"
@@ -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,
)
+11 -26
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 {
@@ -3590,7 +3586,7 @@ onMounted(async () => {
overflow: hidden;
background: #12171b !important;
color: #f7f8f4;
font-family: Inter, system-ui, sans-serif;
font-family: var(--sky-font-family);
}
.feather-app--active.feather-app--light {
--feather-panel: #f0f1ec;
@@ -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;
}
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./FlareApp.vue', import.meta.url), 'utf8')
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
new URL('../../../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
'utf8',
)
const serverSource = readFileSync(
@@ -210,7 +210,7 @@ describe('FlareApp profile editing contract', () => {
})
it('deletes all account-owned Flare data in one server transaction', () => {
expect(clientSource).toContain('"flare:delete-profile"')
expect(clientSource).toMatch(/flare\s*=\s*\[\[[^\]]*delete-profile/)
expect(serverSource).toContain(
'Bridge.Callbacks.Register("sky_phone:flare:delete-profile"',
)
+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;

Some files were not shown because too many files have changed in this diff Show More