Compare commits

..

6 Commits

Author SHA1 Message Date
Dominik 54a358ddd0 feat(skypic): add spotlight and polish social flows 2026-08-21 23:01:06 +02:00
Dominik 4b65d17acc feat(skypic): refine layouts and streaks 2026-08-21 19:35:54 +02:00
DerEchteAlec 691ba65559 Merge branch 'dev' into feat/skypic 2026-08-20 15:48:13 +02:00
Dominik 14d5304b10 fix(skypic): align auth UI and browser mock 2026-08-20 09:54:19 +02:00
Dominik 47c4d8c1d7 feat(skypic): add account controls and media batches 2026-08-20 01:34:05 +02:00
Dominik 12031a9c2e feat(skypic): add Sky UI photo messaging app 2026-08-20 00:38:24 +02:00
194 changed files with 22026 additions and 14675 deletions
-750
View File
@@ -1,750 +0,0 @@
# 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.
+5 -4
View File
@@ -553,8 +553,6 @@ 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
@@ -567,8 +565,6 @@ 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.
@@ -581,6 +577,11 @@ pnpm install
pnpm dev
```
The browser mock links `demo@ifruit.com` and signs in the seeded SkyPic profile
`@alexm` by default. To exercise SkyPic registration with an empty profile, open
`http://localhost:5174/?testScenario=skypic-onboarding#/apps/skypic` while the
development server is running.
Create a production frontend build with:
```powershell
+131 -131
View File
@@ -45,18 +45,18 @@ import { useDarkChatStore } from '@/stores/darkchat'
import { useFlareStore } from '@/stores/flare'
import { useFlipTokStore } from '@/stores/fliptok'
import { usePicstagramStore } from '@/stores/picstagram'
import { useSkyPicStore } from '@/stores/skypic'
import { useFeatherStore } from '@/stores/feather'
import { useMediaStore } from '@/stores/media'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useAppCatalogStore } from '@/stores/app-catalog'
import { useAppStoreStore } from '@/stores/app-store'
import { useWidgetsStore } from '@/stores/widgets'
import { isPhoneAppId, PHONE_APPS } from '@/config/apps'
import { isPhoneAppId } 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,
@@ -71,15 +71,10 @@ import type {
CompanyUnreadCounts,
} from '@/types/companies'
import type { PhoneCall } 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'
@@ -88,7 +83,6 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
import SpringboardView from '@/views/SpringboardView.vue'
type AppMessage = {
openHome?: boolean
type?: string
data?:
| CalendarReminderData
@@ -103,6 +97,8 @@ type AppMessage = {
| FlipTokNotificationData
| PicstagramVerificationData
| PicstagramNotificationData
| SkyPicNotificationData
| SkyPicChangedData
| FeatherNotificationData
| BankingChangedData
| CryptoMarketChangedData
@@ -113,7 +109,6 @@ type AppMessage = {
| PhoneOpenPayload
| CustomAppCatalogEventData
| CustomAppEventData
| NavigationEventData
}
type CustomAppCatalogEventData = {
@@ -126,10 +121,6 @@ type CustomAppEventData = {
payload?: unknown
}
type NavigationEventData = {
appId?: unknown
}
type SimPickerPayload = {
choices: SimPhoneChoice[]
number: string
@@ -242,6 +233,28 @@ type PicstagramNotificationData = {
title?: string
}
type SkyPicNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
kind?:
| 'friend_request'
| 'friend_accepted'
| 'snap'
| 'message'
| 'story_reply'
| 'snap_opened'
profileId?: string
snapId?: string
text?: string
title?: string
}
type SkyPicChangedData = {
device?: PhoneNotificationDevicePayload
profileId?: string
reason?: 'account_deleted'
}
type FeatherNotificationData = {
actor?: string
device?: PhoneNotificationDevicePayload
@@ -310,6 +323,7 @@ const darkchat = useDarkChatStore()
const flare = useFlareStore()
const fliptok = useFlipTokStore()
const picstagram = usePicstagramStore()
const skypic = useSkyPicStore()
const feather = useFeatherStore()
const media = useMediaStore()
const marketplace = useMarketplaceStore()
@@ -320,7 +334,6 @@ const notes = useNotesStore()
const memos = useMemosStore()
const weather = useWeatherStore()
const easyShare = useEasyShareStore()
const radio = useRadioStore()
const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
@@ -333,6 +346,7 @@ const WHITE_STATUS_BAR_APP_IDS = new Set([
'calculator',
'camera',
'fliptok',
'skypic',
'neon-drop',
'sky-flappy',
'snake',
@@ -343,13 +357,8 @@ 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' || isDynamicIslandGalleryRoute.value),
() => isDevelopment && route.name === 'development-sky-ui',
)
const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
@@ -369,12 +378,9 @@ 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(
() =>
@@ -448,12 +454,6 @@ 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>(() => ({
@@ -471,7 +471,6 @@ 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
@@ -522,23 +521,6 @@ 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') {
@@ -549,6 +531,35 @@ function cancelUnlockedPhoneDataLoad(): void {
unlockedServicesIdle = undefined
}
async function refreshSkyPicState(refreshThread = false): Promise<void> {
if (!account.email || !appAuth.isSignedIn('skypic')) {
if (!account.email) {
skypic.resetSession()
} else {
if (!skypic.bootstrapPending) skypic.resetSession()
}
return
}
if (skypic.accountDeletePending) return
const accountEmail = account.email
const imei = phone.device?.imei ?? ''
const loaded = await skypic.bootstrap()
if (
!loaded ||
account.email !== accountEmail ||
(phone.device?.imei ?? '') !== imei ||
!appAuth.isSignedIn('skypic')
) {
return
}
if (!skypic.profile) {
appAuth.signOut('skypic')
skypic.resetSession()
return
}
if (refreshThread) await skypic.refreshActiveThread()
}
function queueCompaniesChange(change: CompanyChangedPayload): void {
if (!pendingCompaniesChange) {
pendingCompaniesChange = { ...change }
@@ -576,6 +587,7 @@ function queueCompaniesChange(change: CompanyChangedPayload): void {
async function bootstrapUnlockedPhoneData(): Promise<void> {
const tasks: Array<() => Promise<unknown> | void> = [
() => refreshSkyPicState(),
() => calls.bootstrap(),
() => messages.loadConversations(),
() => billing.loadOverview(),
@@ -715,6 +727,19 @@ function openDevelopmentPayphonePreview(): void {
)
}
function skyPicNotificationRoute(data: SkyPicNotificationData): string {
const query = new URLSearchParams()
query.set(
'tab',
data.kind === 'friend_request' || data.kind === 'friend_accepted'
? 'friends'
: 'chats',
)
if (data.profileId) query.set('profileId', data.profileId)
if (data.kind === 'snap' && data.snapId) query.set('snap', data.snapId)
return `/apps/skypic?${query.toString()}`
}
function onMessage(event: MessageEvent<AppMessage>): void {
if (!isTrustedRootMessageSource(event.source, window)) return
@@ -756,34 +781,9 @@ 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 syncNavigationState().then(() => nuiCall('ui:opened'))
void nuiCall('ui:opened')
} else if (event.data?.type === 'device:updated') {
hydratePhone(event.data.data as PhoneOpenPayload)
} else if (event.data?.type === 'app:close') {
@@ -971,6 +971,47 @@ function onMessage(event: MessageEvent<AppMessage>): void {
}
notifications.show(notification)
if (phone.isOpen) void picstagram.loadActivities()
} else if (event.data?.type === 'skypic:new' && event.data.data) {
const data = event.data.data as SkyPicNotificationData
const targetsActiveDevice =
!data.device || data.device.imei === phone.device?.imei
const signedInOnActiveDevice = appAuth.isSignedIn('skypic')
const notification: PhoneNotificationInput = {
appId: 'skypic',
route: skyPicNotificationRoute(data),
subtitle: data.actor,
text: data.text ?? phone.t('Apps.skypic.notifications.default'),
title: data.title ?? phone.t('Apps.skypic.name'),
}
if (
data.device &&
(!phone.isOpen || data.device.imei !== phone.device?.imei)
) {
notification.device = {
imei: data.device.imei,
name: data.device.name,
preferences: parsePhonePreferences(data.device.settings ?? null),
}
}
if (!targetsActiveDevice || signedInOnActiveDevice) {
notifications.show(notification)
}
if (phone.isOpen && targetsActiveDevice && signedInOnActiveDevice) {
void refreshSkyPicState(true)
} else if (
targetsActiveDevice &&
!signedInOnActiveDevice &&
!skypic.bootstrapPending
) {
skypic.resetSession()
}
} else if (event.data?.type === 'skypic:changed' && event.data.data) {
const data = event.data.data as SkyPicChangedData
const targetsActiveDevice =
!data.device || data.device.imei === phone.device?.imei
if (phone.isOpen && targetsActiveDevice) {
void refreshSkyPicState(true)
}
} else if (
event.data?.type === 'marketplace:new-message' &&
event.data.data
@@ -1497,7 +1538,6 @@ function onFocusOut(event: FocusEvent): void {
}
onMounted(() => {
removePhoneAudioController = installPhoneAudioController()
document.addEventListener('focusin', onFocusIn)
document.addEventListener('focusout', onFocusOut)
window.addEventListener('message', onMessage)
@@ -1593,18 +1633,6 @@ watch(
},
)
watch(
() => ({
appIds: getInstalledNavigationAppIds(),
currentApp: activeAppId.value,
open: phone.isOpen,
}),
() => {
if (phone.isOpen && appStore.hydrated) void syncNavigationState()
},
{ deep: true },
)
watch(
() => notifications.requiresAttention,
(requiresAttention) => {
@@ -1612,23 +1640,6 @@ 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) => {
@@ -1636,6 +1647,7 @@ watch(
if (!isOpen) {
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
skypic.resetSession()
appStore.cancelPendingInstalls()
activitySuspended.value = false
weather.stop()
@@ -1661,8 +1673,7 @@ watch(
}
isLocked.value = setupRequired.value
? false
: developmentLockScreenPreview ||
(!isDevelopment && phone.security.enabled)
: !isDevelopment || developmentLockScreenPreview
passcodeRequired.value = isLocked.value && phone.security.enabled
unlockedServicesLoaded.value = false
controlCenterOpened.value = false
@@ -1680,16 +1691,19 @@ watch(
startPasscodeLock(passcodeRetrySeconds.value)
}
phone.setLaunchOrigin(null)
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) {
if (isLocked.value || setupRequired.value) void router.replace('/')
else loadUnlockedPhoneData()
},
)
watch(
() => [phone.device?.imei ?? '', account.email] as const,
([imei, email], [previousImei, previousEmail]) => {
if (imei === previousImei && email === previousEmail) return
skypic.resetSession()
cancelUnlockedPhoneDataLoad()
unlockedServicesLoaded.value = false
if (phone.isOpen && !isLocked.value && !setupRequired.value) {
loadUnlockedPhoneData()
}
},
@@ -1703,7 +1717,6 @@ watch(
)
onBeforeUnmount(() => {
removePhoneAudioController?.()
updateTextInputFocus(false)
cancelUnlockedPhoneDataLoad()
weather.stop()
@@ -1742,15 +1755,12 @@ 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"
@@ -1770,12 +1780,7 @@ onBeforeUnmount(() => {
@open="openNotificationPreview"
/>
<div
v-if="
phone.isOpen ||
notifications.current ||
calls.activeCall ||
dynamicIslandActivity
"
v-if="phone.isOpen || notifications.current || calls.activeCall"
class="phone-resolution-wrapper phone-resolution-wrapper--primary"
>
<div class="phone-resolution-canvas phone-resolution-canvas--primary">
@@ -1783,7 +1788,6 @@ onBeforeUnmount(() => {
class="phone-device"
:class="{
'phone-app--light': !displayedDarkMode,
'phone-device--island-expanded': dynamicIslandExpanded,
[`phone-app--${phone.preferences.settings.graphicsMode}`]: true,
}"
:aria-label="phone.t('Common.phone')"
@@ -1895,6 +1899,7 @@ onBeforeUnmount(() => {
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
<PhoneDynamicIsland v-if="!setupRequired" />
<SpringboardView
v-if="!isDevelopmentRoute && !setupRequired"
@edit-mode-change="springboardEditing = $event"
@@ -1980,11 +1985,6 @@ onBeforeUnmount(() => {
aria-hidden="true"
draggable="false"
/>
<PhoneDynamicIsland
v-if="!setupRequired && !isDynamicIslandGalleryRoute"
@expanded-change="dynamicIslandExpanded = $event"
@live-activity-change="dynamicIslandActivity = $event"
/>
</section>
</div>
</div>
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8').replace(/\r\n/g, '\n')
const source = readFileSync(new URL('./App.vue', import.meta.url), 'utf8')
const mainCss = readFileSync(
new URL('./assets/main.css', import.meta.url),
'utf8',
@@ -22,32 +22,15 @@ describe('browser development preview contract', () => {
it('starts unlocked while preserving an explicit lock screen preview', () => {
expect(source).toContain("developmentParameters.has('lockScreenPreview')")
expect(source).toContain(
'developmentLockScreenPreview ||\n (!isDevelopment && phone.security.enabled)',
)
expect(source).toContain(': !isDevelopment || developmentLockScreenPreview')
expect(source).toContain("developmentParameters.has('setupPreview')")
})
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(
it('loads authenticated app data without replacing direct app routes', () => {
expect(source).toContain(
"if (isLocked.value || setupRequired.value) void router.replace('/')",
)
})
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('/')")
expect(source).toContain('else loadUnlockedPhoneData()')
})
it('requires the passcode again after a full device lock', () => {
@@ -117,9 +100,7 @@ describe('browser development preview contract', () => {
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.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+85 -78
View File
@@ -259,20 +259,6 @@ 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;
}
@@ -398,7 +384,7 @@ button {
overflow: hidden;
border: 1px solid rgb(255 255 255 / 16%);
border-radius: 15px;
color: #a9abb2;
color: #0a84ff;
background: rgb(47 47 51 / 98%);
box-shadow: 0 8px 24px rgb(0 0 0 / 28%);
pointer-events: none;
@@ -686,9 +672,6 @@ 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;
@@ -812,6 +795,78 @@ 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;
@@ -4123,8 +4178,8 @@ button {
display: flex;
align-items: center;
flex-direction: column;
min-height: 206px;
padding: 1px 0 10px;
min-height: 218px;
padding: 1px 0 16px;
text-align: center;
}
.weather-location {
@@ -4176,7 +4231,7 @@ button {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-2);
margin-bottom: var(--sky-space-3);
margin-bottom: var(--sky-space-4);
}
.weather-detail-card,
.weather-panel {
@@ -4187,21 +4242,18 @@ button {
0 8px 22px rgba(5, 16, 32, 0.14);
color: #fff;
}
.weather-details > .weather-detail-card {
.weather-detail-card {
display: grid;
grid-template-columns: 21px 1fr;
grid-template-rows: auto auto;
align-content: center;
gap: 7px;
min-height: 76px;
gap: 4px 7px;
min-height: 94px;
margin: 0;
padding: 11px 12px;
padding: 14px;
border-radius: calc(var(--sky-radius-card) - 6px);
}
.weather-detail-card svg {
grid-row: span 2;
align-self: center;
margin-top: 0;
margin-top: 2px;
color: #b7d9ec;
}
.weather-detail-card:nth-child(1) svg {
@@ -4221,7 +4273,7 @@ button {
text-transform: uppercase;
}
.weather-detail-card strong {
align-self: auto;
align-self: end;
font-size: 18px;
font-weight: 680;
line-height: 1.1;
@@ -4229,7 +4281,7 @@ button {
.weather-detail-card:nth-child(4) strong {
color: var(--weather-accent-cyan);
}
.weather-scroll > .weather-panel {
.weather-panel {
margin: 0;
overflow: hidden;
border-radius: var(--sky-radius-card);
@@ -4263,11 +4315,10 @@ button {
min-height: 108px;
padding: 7px 2px 4px;
border-left: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 0;
border-radius: var(--sky-radius-control);
}
.weather-hour:first-child {
border-left: 0;
border-radius: var(--sky-radius-control);
background: rgba(255, 255, 255, 0.07);
}
.weather-hour span {
@@ -6560,47 +6611,9 @@ 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;
flex: 0 0 auto;
border: 0;
background: transparent;
box-shadow: none;
grid-column: 1 / -1;
color: var(--ios-blue);
font-size: 12px;
font-weight: 650;
@@ -6922,12 +6935,6 @@ 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%);
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const client = readFileSync(
new URL('../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
const companiesServer = readFileSync(
@@ -14,10 +14,6 @@ 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',
@@ -36,7 +32,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).toMatch(/companies\s*=\s*\[\[[^\]]*dial-service-line/)
expect(client).toContain(`${quote}companies:dial-service-line${quote}`)
})
it('accepts only a target number and derives the company from the live server member', () => {
@@ -89,35 +85,3 @@ 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)')
})
})
+3
View File
@@ -10,6 +10,7 @@ import { useBillingStore } from '@/stores/billing'
import { useCompaniesStore } from '@/stores/companies'
import { useMarketplaceStore } from '@/stores/marketplace'
import { useDarkChatStore } from '@/stores/darkchat'
import { useSkyPicStore } from '@/stores/skypic'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneAppDefinition } from '@/types/apps'
import {
@@ -54,6 +55,7 @@ const billing = useBillingStore()
const companies = useCompaniesStore()
const marketplace = useMarketplaceStore()
const darkchat = useDarkChatStore()
const skypic = useSkyPicStore()
const router = useRouter()
const iconFailed = ref(false)
const isDragging = ref(false)
@@ -102,6 +104,7 @@ const unreadCount = computed(() => {
if (props.app.id === 'darkchat') return darkchat.unreadCount
if (props.app.id === 'billing') return billing.overview?.unreadCount ?? 0
if (props.app.id === 'companies') return companies.unreadCount
if (props.app.id === 'skypic') return skypic.unreadCount
return 0
})
const calendarWeekday = computed(() =>
@@ -1,28 +0,0 @@
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>')
})
})
+13 -114
View File
@@ -10,8 +10,6 @@ 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 {
@@ -35,18 +33,13 @@ 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
@@ -55,8 +48,6 @@ 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()
@@ -137,28 +128,19 @@ const frameReady = computed(
frameLoaded.value &&
(props.app.bridgeMode === 'legacy' || skyBridgeReady.value),
)
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 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 lbSettings = computed(() =>
createLbPhoneHostSettings({
deviceName: phone.device?.name ?? '',
@@ -209,16 +191,6 @@ 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,
@@ -229,7 +201,6 @@ 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,
@@ -311,55 +282,6 @@ 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) {
@@ -386,29 +308,6 @@ 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)
+1 -4
View File
@@ -3,7 +3,6 @@ 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 =
@@ -71,9 +70,7 @@ const buttonSounds: HTMLAudioElement[] = []
function prepareButtonSounds(): void {
if (buttonSounds.length) return
for (let index = 0; index < 4; index += 1) {
const sound = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`),
)
const sound = new Audio(`${import.meta.env.BASE_URL}sounds/button.mp3`)
sound.preload = 'auto'
sound.volume = 0.55
buttonSounds.push(sound)
@@ -5,15 +5,8 @@ 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',
@@ -26,23 +19,16 @@ 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-device--island-expanded': dynamicIslandExpanded",
'phone.isOpen || notifications.current || calls.activeCall',
)
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)",
)
@@ -59,44 +45,7 @@ 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()"')
@@ -104,31 +53,9 @@ 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('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', () => {
it('keeps recorder state available across app changes', () => {
expect(recorderSource).toContain(
"message.type === 'memo:recordStateRequest'",
)
@@ -138,15 +65,6 @@ 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', () => {
@@ -157,68 +75,13 @@ 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("emit('expanded-change', false)")
expect(mainCss).toContain(
'.phone-device--island-expanded .phone-notification',
)
expect(source).toContain('~ .phone-notification-provider')
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
+13 -10
View File
@@ -54,7 +54,6 @@ 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 {
@@ -154,7 +153,6 @@ function resetRecordingData(): void {
pausedStartedAt = 0
totalPausedMs = 0
currentElapsedMs = 0
recordingDeviceImei = ''
liveLevels = Array(LIVE_LEVEL_SAMPLES).fill(0.08)
}
@@ -191,8 +189,7 @@ function failRecording(error: string, generation = recordingGeneration): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
const deviceImei = phone.device?.imei
if (!phone.isOpen || !deviceImei) {
if (!phone.isOpen) {
console.error('[Memos] Cannot start a recording while the phone is closed.')
return
}
@@ -220,7 +217,6 @@ 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({
@@ -344,7 +340,6 @@ 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()
@@ -360,7 +355,6 @@ 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,
@@ -575,9 +569,18 @@ function onMessage(event: MessageEvent): void {
onMounted(() => window.addEventListener('message', onMessage))
watch(
() => phone.device?.imei ?? null,
(imei, previousImei) => {
if (previousImei && imei !== previousImei) cancelRecording()
() =>
[
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()
},
)
@@ -34,15 +34,6 @@ 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',
+3 -36
View File
@@ -1076,39 +1076,6 @@ 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;
@@ -1201,12 +1168,12 @@ onBeforeUnmount(() => {
.widget-music-empty {
position: absolute;
right: 15px;
bottom: 14px;
bottom: 11px;
left: 15px;
z-index: 2;
z-index: 0;
margin: 0;
color: rgb(255 255 255 / 72%);
font-size: 14px;
font-size: 13px;
font-weight: 650;
line-height: 17px;
text-align: center;
@@ -0,0 +1,80 @@
import { readFileSync } from 'node:fs'
import { createSSRApp, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { describe, expect, it } from 'vitest'
import AppProfileAuth from './AppProfileAuth.vue'
const source = readFileSync(
new URL('./AppProfileAuth.vue', import.meta.url),
'utf8',
)
async function renderAuth(
mode: 'login' | 'register',
movingModeHighlight = true,
): Promise<string> {
return renderToString(
createSSRApp({
render: () =>
h(AppProfileAuth, {
avatarUrl: null,
body: 'Use the linked account.',
cameraLabel: 'Camera',
email: 'demo@ifruit.com',
emailLabel: 'SkyPic account',
error: '',
eyebrow: 'Your SkyPic account',
galleryLabel: 'Photos',
loginLabel: 'Continue to SkyPic',
loginModeLabel: 'Login',
mode,
movingModeHighlight,
pending: false,
registerLabel: 'Create profile',
registerModeLabel: 'Register',
title: 'Welcome back',
username: mode === 'login' ? 'alexm' : 'newprofile',
usernameLabel: 'Handle',
}),
}),
)
}
describe('AppProfileAuth', () => {
it('uses the rounded Sky UI moving highlight for its mode switch', async () => {
const html = await renderAuth('login')
expect(html).toContain('sky-segmented--strong')
expect(html).toContain('sky-segmented--rounded')
expect(html).toContain('sky-segmented__highlight')
expect(html).toContain('app-profile-auth__mode--moving-highlight')
expect(html).toContain('width:calc(50% - 4px)')
expect(html).toContain('--sky-segmented-indicator-offset:calc(0% + 0px)')
expect(html).toContain('aria-label="Your SkyPic account"')
})
it('moves the highlight and keeps short mode labels separate from actions', async () => {
const html = await renderAuth('register')
expect(html).toContain('--sky-segmented-indicator-offset:calc(100% + 4px)')
expect(html.match(/Login/g)).toHaveLength(1)
expect(html.match(/Register/g)).toHaveLength(1)
expect(html).toContain('Create profile')
expect(html).not.toContain('>Continue to SkyPic</button>')
})
it('keeps the moving highlight opt-in for SkyPic', async () => {
const html = await renderAuth('login', false)
expect(html).not.toContain('sky-segmented--strong')
expect(html).not.toContain('sky-segmented__highlight')
expect(html).toContain('app-profile-auth__mode-button--login')
expect(html).toContain('app-profile-auth__mode-button--active')
expect(source).toContain(':strong="movingModeHighlight"')
expect(source).toContain(
':not(.app-profile-auth__mode--moving-highlight)',
)
})
})
@@ -30,14 +30,17 @@ const props = withDefaults(
eyebrow: string
galleryLabel: string
loginLabel: string
loginModeLabel?: string
maxUsernameLength?: number
minUsernameLength?: number
mode: 'login' | 'register'
movingModeHighlight?: boolean
password?: string
passwordLabel?: string
passwordPlaceholder?: string
pending: boolean
registerLabel: string
registerModeLabel?: string
requirePassword?: boolean
submitEnabled?: boolean
title: string
@@ -62,10 +65,13 @@ const props = withDefaults(
emailAsField: false,
maxUsernameLength: 40,
minUsernameLength: 2,
loginModeLabel: '',
movingModeHighlight: false,
password: '',
passwordLabel: 'Password',
passwordPlaceholder: '',
requirePassword: false,
registerModeLabel: '',
showConfirmPassword: false,
submitEnabled: undefined,
usernameAutocomplete: 'username',
@@ -101,6 +107,10 @@ const canSubmit = computed(() => {
)
)
})
const modeLoginLabel = computed(() => props.loginModeLabel || props.loginLabel)
const modeRegisterLabel = computed(
() => props.registerModeLabel || props.registerLabel,
)
</script>
<template>
@@ -119,9 +129,15 @@ const canSubmit = computed(() => {
<SkyGlass class="app-profile-auth__card">
<SkySegmented
:active-index="movingModeHighlight ? (mode === 'login' ? 0 : 1) : undefined"
:aria-label="eyebrow"
:item-count="movingModeHighlight ? 2 : undefined"
raised
:rounded="movingModeHighlight"
:strong="movingModeHighlight"
class="app-profile-auth__mode"
:class="{
'app-profile-auth__mode--moving-highlight': movingModeHighlight,
'app-profile-auth__mode--register': mode === 'register',
}"
>
@@ -133,7 +149,7 @@ const canSubmit = computed(() => {
:active="mode === 'login'"
@click="emit('update:mode', 'login')"
>
{{ loginLabel }}
{{ modeLoginLabel }}
</SkySegmentedButton>
<SkySegmentedButton
class="app-profile-auth__mode-button app-profile-auth__mode-button--register"
@@ -143,7 +159,7 @@ const canSubmit = computed(() => {
:active="mode === 'register'"
@click="emit('update:mode', 'register')"
>
{{ registerLabel }}
{{ modeRegisterLabel }}
</SkySegmentedButton>
</SkySegmented>
@@ -372,21 +388,24 @@ const canSubmit = computed(() => {
position: relative;
z-index: 1;
margin: 0 0 12px;
}
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight) {
padding: 3px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(0, 0, 0, 0.16);
}
.app-profile-auth__mode :deep(.app-profile-auth__mode-button) {
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
:deep(.app-profile-auth__mode-button) {
border-radius: 3px;
}
.app-profile-auth__mode
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
:deep(
.app-profile-auth__mode-button--login.app-profile-auth__mode-button--active
) {
border-radius: 10px 3px 3px 10px;
}
.app-profile-auth__mode
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight)
:deep(
.app-profile-auth__mode-button--register.app-profile-auth__mode-button--active
) {
@@ -685,7 +704,8 @@ const canSubmit = computed(() => {
padding: 14px;
border-radius: 28px;
}
.app-profile-auth--centered .app-profile-auth__mode {
.app-profile-auth--centered
.app-profile-auth__mode:not(.app-profile-auth__mode--moving-highlight) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
+9
View File
@@ -170,6 +170,14 @@ describe('app registry', () => {
expect(isPhoneAppId('music')).toBe(true)
expect(isPhoneAppId('companies')).toBe(true)
expect(isPhoneAppId('weazel-news')).toBe(true)
expect(PHONE_APPS.find((app) => app.id === 'skypic')).toMatchObject({
category: 'social',
dockOrder: null,
gridOrder: 31,
labelKey: 'Apps.skypic.name',
route: '/apps/skypic',
})
expect(isPhoneAppId('skypic')).toBe(true)
expect(
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
).toEqual([
@@ -188,6 +196,7 @@ describe('app registry', () => {
).toEqual([
'weazel-news',
'picstagram',
'skypic',
'feather',
'fliptok',
'flare',
+15
View File
@@ -75,6 +75,7 @@ import localPagesIcon from '@/assets/img/app-icons/local-pages.webp'
import flareIcon from '@/assets/img/app-icons/flare.webp'
import flipTokIcon from '@/assets/img/app-icons/fliptok.webp'
import picstagramIcon from '@/assets/img/app-icons/picstagram.webp'
import skyPicIcon from '@/assets/img/app-icons/skypic-v2.jpg'
import skyRideIcon from '@/assets/img/app-icons/skyride.webp'
import musicIcon from '@/assets/img/app-icons/music.webp'
import featherIcon from '@/assets/img/app-icons/feather.webp'
@@ -191,6 +192,20 @@ export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
labelKey: 'Apps.picstagram.name',
route: '/apps/picstagram',
},
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/SkyPicApp.vue')),
),
dockOrder: null,
gridOrder: 31,
icon: markRaw(Camera),
iconClass: 'app-icon--skypic',
iconImage: skyPicIcon,
id: 'skypic',
labelKey: 'Apps.skypic.name',
route: '/apps/skypic',
},
{
category: 'social',
component: markRaw(
+1 -6
View File
@@ -1,5 +1,3 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
type Tone = {
@@ -48,10 +46,7 @@ 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(
Math.max(0.0001, tone.volume * getPhoneOutputVolume()),
start + 0.012,
)
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain)
gain.connect(audioContext.destination)
@@ -1,5 +1,3 @@
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'
@@ -30,7 +28,7 @@ function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,5 +1,3 @@
import { getPhoneOutputVolume } from '@/utils/phoneAudio'
export type NeonDropSound =
| 'clear'
| 'drop'
@@ -50,10 +48,7 @@ function playSequence(sound: NeonDropSound): void {
oscillator.type = type
oscillator.frequency.setValueAtTime(frequency, start + delay)
gain.gain.setValueAtTime(0.0001, start + delay)
gain.gain.exponentialRampToValueAtTime(
Math.max(0.0001, 0.12 * getPhoneOutputVolume()),
start + delay + 0.008,
)
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
oscillator.connect(gain)
gain.connect(context.destination)
@@ -1,5 +1,3 @@
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'
@@ -20,7 +18,7 @@ function getPlayers(sound: NumberMergeSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.82
return player
@@ -1,5 +1,3 @@
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'
@@ -13,7 +11,7 @@ export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): voi
let players = pools.get(sound)
if (!players) {
players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(urls[sound]))
const player = new Audio(urls[sound])
player.preload = 'auto'
player.volume = 0.84
return player
@@ -1,5 +1,3 @@
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'
@@ -20,7 +18,7 @@ function getPlayers(sound: TowerStackSound): HTMLAudioElement[] {
if (existing) return existing
const players = Array.from({ length: 3 }, () => {
const player = registerPhoneMediaElement(new Audio(soundUrls[sound]))
const player = new Audio(soundUrls[sound])
player.preload = 'auto'
player.volume = 0.84
return player
@@ -1,62 +0,0 @@
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)
})
})
-37
View File
@@ -1,37 +0,0 @@
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;/)
})
})
+3 -175
View File
@@ -3,9 +3,7 @@ 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').replace(/\r\n/g, '\n')
const readFrontendFile = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const inventoryAdapters = [
['ox', 'source/bridge/server/inventory/ox.lua'],
@@ -54,165 +52,11 @@ 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(phoneBridge).toContain(
expect(phoneClient).toContain(
'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsOpen"',
)
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"')
expect(phoneClient).toContain('return is_open')
})
it('opens from a configurable F1 mapping without client-provided device identity', () => {
@@ -232,22 +76,6 @@ 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')
@@ -1,32 +0,0 @@
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,12 +15,6 @@ 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',
},
]
: []
@@ -0,0 +1,586 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const read = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
const server = read('../../sky_phone/source/server/skypic.lua')
const migration = read('../../sky_phone/source/server/db_migrate.lua')
const install = read('../../sky_phone/sql/install.sql')
const media = read('../../sky_phone/source/server/media.lua')
const config = read('../../sky_phone/config/config.lua')
const mediaUtils = read('./utils/media.ts')
const fallbackLocales = read('./stores/phone.ts')
const englishLocale = read('../../sky_phone/config/locales/en.lua')
const germanLocale = read('../../sky_phone/config/locales/de.lua')
const mockServer = read('../testserver/index.cjs')
function block(source: string, startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start + startMarker.length)
expect(start, `missing ${startMarker}`).toBeGreaterThanOrEqual(0)
expect(end, `missing ${endMarker}`).toBeGreaterThan(start)
return source.slice(start, end)
}
function migrationTable(name: string): string {
return block(migration, `name = "sky_phone_skypic_${name}"`, 'tableOptions =')
}
function installTable(name: string): string {
return block(
install,
`CREATE TABLE IF NOT EXISTS \`sky_phone_skypic_${name}\``,
') ENGINE=InnoDB',
)
}
const callbacks = [
'bootstrap',
'create-profile',
'delete-account',
'update-profile',
'search',
'add-friend',
'respond-friend',
'remove-friend',
'block',
'send-snap',
'open-snap',
'replay-snap',
'publish-story',
'stories',
'view-story',
'story-viewers',
'remove-story',
'spotlight-feed',
'publish-spotlight',
'view-spotlight',
'like-spotlight',
'spotlight-comments',
'comment-spotlight',
'delete-spotlight-comment',
'remove-spotlight',
'report-spotlight',
'thread',
'send-message',
'mark-thread',
'save-message',
'delete-message',
] as const
const tables: Record<string, string[]> = {
profiles: [
'id',
'account_id',
'handle',
'display_name',
'bio',
'avatar_media_id',
'avatar_seed',
'story_privacy',
'quick_add',
'allow_story_replies',
'snap_score',
'friend_count',
'status',
'created_at',
'updated_at',
],
friendships: [
'id',
'profile_a_id',
'profile_b_id',
'requested_by_id',
'status',
'profile_a_last_snap_on',
'profile_b_last_snap_on',
'streak_updated_on',
'streak_count',
'best_streak',
'accepted_at',
'created_at',
'updated_at',
],
blocks: ['blocker_profile_id', 'blocked_profile_id', 'created_at'],
messages: [
'id',
'friendship_id',
'sender_profile_id',
'recipient_profile_id',
'message_type',
'body',
'caption',
'overlay_text',
'overlay_color',
'media_id',
'view_seconds',
'allow_replay',
'read_at',
'opened_at',
'replayed_at',
'saved_at',
'expires_at',
'sender_deleted_at',
'recipient_deleted_at',
'deleted_at',
'created_at',
],
stories: [
'id',
'profile_id',
'media_id',
'caption',
'overlay_text',
'overlay_color',
'view_seconds',
'privacy',
'status',
'expires_at',
'created_at',
'updated_at',
],
story_views: ['story_id', 'viewer_profile_id', 'viewed_at'],
spotlights: [
'id',
'profile_id',
'media_id',
'caption',
'overlay_text',
'overlay_color',
'kind',
'ad_headline',
'comments_enabled',
'status',
'expires_at',
'created_at',
'updated_at',
],
spotlight_views: ['spotlight_id', 'viewer_profile_id', 'viewed_at'],
spotlight_likes: ['spotlight_id', 'profile_id', 'created_at'],
spotlight_comments: [
'id',
'spotlight_id',
'profile_id',
'body',
'status',
'created_at',
],
spotlight_reports: [
'spotlight_id',
'reporter_profile_id',
'reason',
'details',
'status',
'created_at',
],
}
describe('SkyPic backend contracts', () => {
it('keeps migration and clean-install schemas synchronized', () => {
for (const [table, columns] of Object.entries(tables)) {
const migrationSource = migrationTable(table)
const installSource = installTable(table)
for (const column of columns) {
expect(migrationSource, `${table}.${column} migration`).toContain(
`name = "${column}"`,
)
expect(installSource, `${table}.${column} install`).toContain(
`\`${column}\``,
)
}
}
for (const table of ['messages', 'stories', 'spotlights']) {
expect(migrationTable(table)).toContain('ON DELETE RESTRICT')
expect(installTable(table)).toContain('ON DELETE RESTRICT')
}
})
it('backfills every normalized unique key on existing databases', () => {
const afterMigrate = migration.slice(
migration.indexOf('Bridge.Database.Migrate("sky_phone", schema)'),
)
for (const index of [
'uniq_sky_phone_skypic_profile_account',
'uniq_sky_phone_skypic_profile_handle',
'uniq_sky_phone_skypic_friend_pair',
]) {
expect(afterMigrate).toContain(`"${index}"`)
}
expect(
afterMigrate.match(/\{ unique = true \}/g)?.length ?? 0,
).toBeGreaterThanOrEqual(3)
})
it('registers the complete canonical callback surface', () => {
for (const callback of callbacks) {
expect(server).toContain(
`Bridge.Callbacks.Register("sky_phone:skypic:${callback}"`,
)
}
expect(server).toContain('Bridge.Database.AfterMigration("sky_phone"')
})
it('validates all submitted media through the owned-media resolver', () => {
const editor = block(
server,
'local function editor_payload',
'Bridge.Callbacks.Register("sky_phone:skypic:thread"',
)
expect(editor).toContain(
'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType)',
)
expect(
server.match(/SkyPhoneMedia\.ResolveOwnedMedia\(/g)?.length,
).toBeGreaterThanOrEqual(3)
expect(server).toContain(
'SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo")',
)
})
it('validates dense photo batches before creating their cartesian snap set', () => {
const editors = block(
server,
'local function snap_editor_payloads',
'Bridge.Callbacks.Register("sky_phone:skypic:thread"',
)
expect(config).toContain('MaximumMediaPerSend = 10')
expect(config).toContain('MaximumSnapMessagesPerSend = 40')
expect(editors).toContain('count > limit("MaximumMediaPerSend", 10)')
expect(editors).toContain('if key_count ~= count then')
expect(editors).toContain('seen[media_id]')
expect(editors).toContain('mediaType = "photo"')
expect(server).toContain(
'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, data.mediaType)',
)
const sendSnap = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:send-snap"',
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
)
expect(sendSnap).toContain(
'raw_media_count > limit("MaximumMediaPerSend", 10)',
)
expect(sendSnap).toContain(
'message_count > limit("MaximumSnapMessagesPerSend", 40)',
)
expect(sendSnap).toContain('editor = editor')
expect(sendSnap).toContain('Bridge.Database.Transaction(statements)')
expect(sendSnap).toContain('SELECT COUNT(*) FROM')
expect(sendSnap).toContain(') <> ?')
expect(sendSnap).toContain(
'assertion_params[#assertion_params + 1] = #entries',
)
expect(sendSnap).toContain('message_ids[#message_ids + 1] = entry.id')
expect(sendSnap).toContain(
'local sent = load_snap_metadata(message_ids, profile.profile_id)',
)
expect(mockServer).toContain('function skyPicMediaItems(body)')
expect(mockServer).toContain('submitted.length > 10')
expect(mockServer).toContain('seen.has(mediaId)')
expect(mockServer).toContain('mediaItems.length * recipients.length > 40')
})
it('maintains reciprocal UTC-day streaks and returns reconciled values', () => {
const friendshipMigration = migrationTable('friendships')
const friendshipInstall = installTable('friendships')
const friends = block(
server,
'local function list_friends',
'local function list_requests',
)
const conversations = block(
server,
'local function list_conversations',
'Bridge.Callbacks.Register("sky_phone:skypic:bootstrap"',
)
const metadata = block(
server,
'local function load_snap_metadata',
'local function opened_snap_from_row',
)
const sendSnap = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:send-snap"',
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
)
for (const schema of [friendshipMigration, friendshipInstall]) {
expect(schema).toContain('profile_a_last_snap_on')
expect(schema).toContain('profile_b_last_snap_on')
expect(schema).toContain('streak_updated_on')
expect(schema).toContain('streak_count')
expect(schema).toContain('best_streak')
expect(schema).toContain('idx_sky_phone_skypic_streaks')
}
expect(sendSnap).toContain('SET %s = UTC_DATE()')
expect(sendSnap).toContain(
'SELECT 1 FROM `sky_phone_skypic_messages` message WHERE message.`id` = ?',
)
expect(sendSnap).toContain('friendship.profile_a_id == profile.profile_id')
expect(sendSnap).toContain('`profile_a_last_snap_on` = UTC_DATE()')
expect(sendSnap).toContain('`profile_b_last_snap_on` = UTC_DATE()')
expect(sendSnap).toContain(
'`streak_updated_on` = DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)',
)
expect(sendSnap).toContain(
'`streak_updated_on` IS NULL OR `streak_updated_on` < UTC_DATE()',
)
expect(sendSnap).toContain('Bridge.Database.Transaction(statements)')
for (const serializer of [friends, conversations, metadata]) {
expect(serializer).toContain(
'`streak_updated_on` < DATE_SUB(UTC_DATE(), INTERVAL 1 DAY)',
)
expect(serializer).toContain(
'THEN 0 ELSE friendship.`streak_count` END AS `streak_count`',
)
expect(serializer).toContain('friendship.`best_streak`')
}
expect(metadata).toContain("friendship.`status` = 'accepted'")
expect(metadata).toContain(
'snap.streakCount = tonumber(row.streak_count) or 0',
)
expect(metadata).toContain(
'snap.bestStreak = tonumber(row.best_streak) or 0',
)
expect(server).toContain('SET `streak_count` = 0')
expect(server).toContain(
"WHERE `status` = 'accepted' AND `streak_count` > 0",
)
})
it('keeps browser payload limits and profile creation aligned with production', () => {
expect(config).toContain('CaptionMaxLength = 160')
expect(server).toContain('valid_integer(data.avatarSeed, 1, 2147483647)')
expect(mockServer).toContain('candidate <= 2_147_483_647')
expect(mockServer).toContain(
'return [...caption].length <= 160 ? caption : null',
)
expect(mockServer).toContain("error: 'invalid_avatar_seed'")
expect(mockServer).toContain("error: 'invalid_caption'")
expect(mockServer).toContain('value.length > 20')
expect(mockServer).toContain("error: 'message_too_long'")
expect(mockServer).not.toContain('body.slice(0, 1000)')
expect(mockServer).toContain("error: 'profile_exists'")
expect(mockServer).toContain(
"const onboardingScenario = testScenario === 'skypic-onboarding'",
)
expect(mockServer).toContain(
'onboardingScenario && skyPicOnboardingProfile',
)
expect(mockServer).toContain('const profile = skyPicOnboardingProfile')
expect(mockServer).toContain('suggestions: profile')
})
it('deletes only the confirmed SkyPic account and silently refreshes peers', () => {
const deletion = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:delete-account"',
'Bridge.Callbacks.Register("sky_phone:skypic:update-profile"',
)
expect(deletion).toContain('data.confirmed ~= true')
expect(deletion).toContain('error = "confirmation_required"')
expect(deletion).toContain('Bridge.Database.Transaction({')
expect(deletion).toContain('SET peer.')
expect(deletion).toContain('friend_count')
expect(deletion).toContain(' > 0, peer.')
expect(deletion).toContain('- 1, 0')
expect(deletion).toContain('DELETE FROM')
expect(deletion).toContain('sky_phone_skypic_profiles')
expect(deletion).toContain('sky_phone_skypic_blocks')
expect(deletion).toContain('UNION ALL')
expect(deletion).toContain(
"AND status = 'active'".replace(
'status',
String.fromCharCode(96) + 'status' + String.fromCharCode(96),
),
)
expect(deletion).not.toContain('sky_phone_media')
expect(deletion).toContain('"sky_phone:skypic:changed"')
expect(deletion).not.toContain('"sky_phone:skypic:new"')
expect(mockServer).toContain("if (endpoint === 'skypic:delete-account')")
expect(mockServer).toContain("error: 'confirmation_required'")
})
it('keeps direct snap secrets out of bootstrap and thread serializers', () => {
const safeSnap = block(
server,
'local function safe_snap_from_row',
'local function text_message_from_row',
)
for (const secret of [
'url = row.url',
'caption =',
'textOverlay =',
'overlayColor =',
]) {
expect(safeSnap).not.toContain(secret)
}
const thread = block(
server,
'local function list_thread',
'local function editor_payload',
)
expect(thread).not.toContain('message.`caption`')
expect(thread).not.toContain('message.`overlay_text`')
expect(thread).not.toContain('message.`overlay_color`')
expect(thread).not.toContain('message.`media_id`')
const storyList = block(
server,
'local function list_stories',
'local function list_conversations',
)
expect(storyList).not.toContain('story.`caption`')
expect(storyList).not.toContain('story.`overlay_text`')
expect(storyList).not.toContain('story.`overlay_color`')
expect(storyList).not.toContain('story.`media_id`')
expect(storyList).toContain(
'ORDER BY (story.`profile_id` = ?) DESC, story.`created_at` DESC, story.`id` DESC',
)
expect(storyList).not.toContain(
'ORDER BY (story.`profile_id` = ?) DESC, `seen`',
)
})
it('releases snap contents only after atomic one-time state changes', () => {
const open = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:open-snap"',
'Bridge.Callbacks.Register("sky_phone:skypic:replay-snap"',
)
expect(open).toContain('message.`opened_at` IS NULL')
expect(open).toContain('message.`recipient_profile_id` = ?')
expect(open).toContain(
'IF(message.`allow_replay` = 1, ?, message.`view_seconds`)',
)
expect(open.indexOf('affected_rows(result) ~= 1')).toBeLessThan(
open.indexOf('released_snap('),
)
const replay = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:replay-snap"',
'local function own_story_metadata',
)
expect(replay).toContain('message.`allow_replay` = 1')
expect(replay).toContain('message.`opened_at` IS NOT NULL')
expect(replay).toContain('message.`replayed_at` IS NULL')
expect(replay.indexOf('affected_rows(result) ~= 1')).toBeLessThan(
replay.indexOf('released_snap('),
)
})
it('atomically protects every database media reference before remote delete', () => {
const guard = block(
media,
'local function is_referenced_by_skypic',
'local function delete_owned_media',
)
expect(guard).toContain('FROM `sky_phone_skypic_messages`')
expect(guard).toContain('FROM `sky_phone_skypic_stories`')
expect(guard).toContain('FROM `sky_phone_skypic_spotlights`')
expect(guard).not.toContain('`expires_at` >')
expect(guard).not.toContain("`status` = 'active'")
const deletion = block(
media,
'local function delete_owned_media',
'RegisterNetEvent("sky_phone:media:delete"',
)
expect(deletion).toContain('return false, "media_in_use"')
expect(deletion).toContain('AND NOT EXISTS (')
expect(deletion).toContain('if affected_rows(result) ~= 1 then')
expect(deletion.indexOf('DELETE FROM `sky_phone_media`')).toBeLessThan(
deletion.indexOf('delete_remote_file(row.remote_id)'),
)
expect(mediaUtils).toContain("'media_in_use'")
expect(fallbackLocales).toMatch(
/media_in_use:\s*'This media is still used by SkyPic and cannot be deleted yet\.'/,
)
expect(englishLocale).toContain(
'media_in_use = "This media is still used by SkyPic and cannot be deleted yet."',
)
expect(germanLocale).toContain(
'media_in_use = "Dieses Medium wird noch von SkyPic verwendet und kann noch nicht gelöscht werden."',
)
})
it('enforces viewer-specific deletion and bounded expiry cleanup', () => {
expect(server).toContain('`sender_deleted_at`')
expect(server).toContain('`recipient_deleted_at`')
expect(server).toContain('SET `deleted_at` = CURRENT_TIMESTAMP(6)')
expect(server).toContain('AND `sender_profile_id` = ?')
expect(server).toContain('Wait(limit("CleanupIntervalSeconds", 45) * 1000)')
expect(config).toContain('StoryLifetimeSeconds = 24 * 60 * 60')
expect(config).toContain('ReplayWindowSeconds = 5 * 60')
expect(config).toContain('TextAfterReadLifetimeSeconds = 24 * 60 * 60')
})
it('gates optional story replies in the message insert itself', () => {
const sendMessage = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:send-message"',
'Bridge.Callbacks.Register("sky_phone:skypic:mark-thread"',
)
expect(sendMessage).toContain('local story_id =')
expect(sendMessage).toContain('story.`profile_id` = ?')
expect(sendMessage).toContain("story.`status` = 'active'")
expect(sendMessage).toContain('story.`expires_at` > CURRENT_TIMESTAMP(6)')
expect(sendMessage).toContain('author.`allow_story_replies` = 1')
expect(sendMessage).toContain("friendship.`status` = 'accepted'")
expect(sendMessage).toContain('NOT EXISTS (')
expect(sendMessage).toContain('story_id and "story_reply" or "message"')
})
it('returns a rich outgoing friend request from add-friend', () => {
const addFriend = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:add-friend"',
'Bridge.Callbacks.Register("sky_phone:skypic:respond-friend"',
)
expect(addFriend).toContain('target.friendshipId = friendship_id')
expect(addFriend).toContain('target.friendshipStatus = "outgoing"')
expect(addFriend).toContain('direction = "outgoing"')
expect(addFriend).toContain('profile = target')
})
it('enforces the friend limit atomically while accepting requests', () => {
const respondFriend = block(
server,
'Bridge.Callbacks.Register("sky_phone:skypic:respond-friend"',
'Bridge.Callbacks.Register("sky_phone:skypic:remove-friend"',
)
expect(respondFriend).toContain(
'profile_a.`friend_count` = profile_a.`friend_count` + 1',
)
expect(respondFriend).toContain(
'profile_b.`friend_count` = profile_b.`friend_count` + 1',
)
expect(respondFriend).toContain('profile_a.`friend_count` < ?')
expect(respondFriend).toContain('profile_b.`friend_count` < ?')
expect(respondFriend).toContain(
'return { success = false, error = "friend_limit_reached" }',
)
})
it('keeps pending requests out of quick-add suggestions', () => {
const profiles = block(
server,
'local function list_profiles',
'local function list_conversations',
)
expect(profiles).toContain(
'filters[#filters + 1] = "friendship.`id` IS NULL"',
)
expect(profiles).not.toContain("friendship.`status` = 'pending'")
})
})
@@ -0,0 +1,196 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const read = (path: string) =>
readFileSync(new URL(path, import.meta.url), 'utf8')
const app = read('./App.vue')
const appAuth = read('./stores/app-auth.ts')
const appIcon = read('./components/AppIcon.vue')
const client = read('../../sky_phone/source/client/main.lua')
const easyShare = read('../../sky_phone/source/server/easyshare.lua')
const manifest = read('../../sky_phone/fxmanifest.lua')
const mockServer = read('../testserver/index.cjs')
const phoneServer = read('../../sky_phone/source/server/phone.lua')
const reservedApps = read('../../sky_phone/source/shared/custom_apps.lua')
const server = read('../../sky_phone/source/server/skypic.lua')
const types = read('./types/skypic.ts')
const callbacks = [
'skypic:bootstrap',
'skypic:create-profile',
'skypic:delete-account',
'skypic:update-profile',
'skypic:search',
'skypic:add-friend',
'skypic:respond-friend',
'skypic:remove-friend',
'skypic:block',
'skypic:send-snap',
'skypic:open-snap',
'skypic:replay-snap',
'skypic:publish-story',
'skypic:stories',
'skypic:view-story',
'skypic:story-viewers',
'skypic:remove-story',
'skypic:spotlight-feed',
'skypic:publish-spotlight',
'skypic:view-spotlight',
'skypic:like-spotlight',
'skypic:spotlight-comments',
'skypic:comment-spotlight',
'skypic:delete-spotlight-comment',
'skypic:remove-spotlight',
'skypic:report-spotlight',
'skypic:thread',
'skypic:send-message',
'skypic:mark-thread',
'skypic:save-message',
'skypic:delete-message',
] as const
function typeBlock(name: string): string {
const start = types.indexOf(`export type ${name} = {`)
const end = types.indexOf(String.fromCharCode(10) + '}', start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return types.slice(start, end)
}
describe('SkyPic cross-runtime integration contract', () => {
it('bridges every canonical callback through client, server, and browser mock', () => {
for (const callback of callbacks) {
expect(client, `missing client callback ${callback}`).toContain(
`"${callback}"`,
)
expect(server, `missing server callback ${callback}`).toContain(
`"sky_phone:${callback}"`,
)
expect(mockServer, `missing browser mock ${callback}`).toContain(
`endpoint === '${callback}'`,
)
}
})
it('loads the server after media and reserves the app for built-in sharing', () => {
const mediaIndex = manifest.indexOf("'source/server/media.lua'")
const skyPicIndex = manifest.indexOf("'source/server/skypic.lua'")
expect(mediaIndex).toBeGreaterThanOrEqual(0)
expect(skyPicIndex).toBeGreaterThan(mediaIndex)
expect(reservedApps).toContain('skypic = true')
expect(easyShare).toContain('skypic = true')
})
it('routes localized device-aware notifications and refreshes live state', () => {
expect(client).toContain(
'RegisterNetEvent("sky_phone:skypic:new", function(data)',
)
expect(client).toContain('locale.Nui.Apps.skypic')
expect(client).toContain(
'notification_text:gsub("{actor}", tostring(data.actor or ""))',
)
expect(client).toContain(
'SendNUIMessage({ type = "skypic:new", data = data })',
)
expect(client).toContain(
'RegisterNetEvent("sky_phone:skypic:changed", function(data)',
)
expect(client).toContain(
'SendNUIMessage({ type = "skypic:changed", data = data })',
)
expect(server).toContain(
'notify_profile(friendship.peer_id, profile, story_id and "story_reply" or "message", nil)',
)
expect(server).toContain('profileId = actor.profile_id')
expect(phoneServer).toContain('required_app_auth')
expect(phoneServer).toContain(
'app_auth.accountEmail ~= device.account_email',
)
expect(phoneServer).toContain('has_required_app_session(device)')
expect(server.match(/}, 'skypic'\)/g)).toHaveLength(3)
expect(app).toContain("event.data?.type === 'skypic:new'")
expect(app).toContain("event.data?.type === 'skypic:changed'")
expect(app).toContain("appId: 'skypic'")
expect(app).toContain('route: skyPicNotificationRoute(data)')
expect(app).toContain("query.set('profileId', data.profileId)")
expect(app).toContain(
"if (data.kind === 'snap' && data.snapId) query.set('snap', data.snapId)",
)
expect(app).toContain(
'preferences: parsePhonePreferences(data.device.settings ?? null)',
)
expect(app).toContain(
'!data.device || data.device.imei === phone.device?.imei',
)
expect(app).toContain('if (phone.isOpen && targetsActiveDevice)')
expect(app).toContain('void refreshSkyPicState(true)')
expect(app).toContain('!targetsActiveDevice || signedInOnActiveDevice')
expect(appIcon).toContain(
"if (props.app.id === 'skypic') return skypic.unreadCount",
)
})
it('scopes badge state to the active unlocked device and account session', () => {
expect(appAuth).toContain("'skypic'")
expect(app).toContain(
"if (!account.email || !appAuth.isSignedIn('skypic'))",
)
expect(app).toContain(
"() => [phone.device?.imei ?? '', account.email] as const",
)
expect(app).toContain(
'if (imei === previousImei && email === previousEmail) return',
)
expect(app).toContain('skypic.resetSession()')
expect(app).toContain("appAuth.signOut('skypic')")
expect(app).toContain('unlockedServicesLoaded.value = false')
expect(app).toContain(
'if (phone.isOpen && !isLocked.value && !setupRequired.value)',
)
})
it('does not let background refreshes abort auth discovery or account deletion', () => {
const refreshBlock = app
.split('async function refreshSkyPicState(refreshThread = false)')[1]
?.split('function queueCompaniesChange')[0]
expect(refreshBlock).toContain(
'if (!skypic.bootstrapPending) skypic.resetSession()',
)
expect(refreshBlock).toContain('if (skypic.accountDeletePending) return')
expect(app).toContain('!skypic.bootstrapPending')
})
it('keeps snap and story secrets out of list payload types', () => {
const snap = typeBlock('SkyPicSnap')
const story = typeBlock('SkyPicStory')
const openedSnap = typeBlock('SkyPicOpenedSnap')
const viewedStory = typeBlock('SkyPicViewedStory')
for (const metadata of [snap, story]) {
expect(metadata).not.toContain('url:')
expect(metadata).not.toContain('caption:')
expect(metadata).not.toContain('textOverlay:')
expect(metadata).not.toContain('overlayColor:')
}
for (const opened of [openedSnap, viewedStory]) {
expect(opened).toContain('url:')
expect(opened).toContain('caption:')
expect(opened).toContain('textOverlay:')
expect(opened).toContain('overlayColor:')
}
expect(viewedStory).toContain('canReply:')
expect(mockServer).toContain('const skyPicSnapContents = new Map(')
expect(mockServer).toContain('const skyPicStoryContents = new Map(')
expect(mockServer).toContain('blockedProfiles: skyPicProfiles')
expect(mockServer).toContain(
'skyPicIncrementOwnScore(recipients.length * mediaItems.length)',
)
expect(mockServer).toContain('.slice(offset, offset + 30)')
expect(mockServer).toContain(
"response.json({ success: false, error: 'story_unavailable' })",
)
})
})
+18
View File
@@ -37,6 +37,24 @@ describe('app auth store', () => {
})
})
it('persists the SkyPic session independently', () => {
const auth = useAppAuthStore()
auth.hydrate(null, 'demo@ifruit.com')
auth.signIn('skypic', 'demo@ifruit.com')
expect(auth.isSignedIn('skypic')).toBe(true)
expect(auth.isSignedIn('feather')).toBe(false)
expect(saveDeviceNamespace).toHaveBeenLastCalledWith('appAuth', {
accountEmail: 'demo@ifruit.com',
signedIn: ['skypic'],
version: 1,
})
auth.signOut('skypic')
expect(auth.isSignedIn('skypic')).toBe(false)
})
it('does not restore sessions belonging to another iFruit account', () => {
const auth = useAppAuthStore()
auth.hydrate(
+2
View File
@@ -7,6 +7,7 @@ export const APP_AUTH_IDS = [
'local-pages',
'feather',
'crewlink',
'skypic',
] as const
export type AppAuthId = (typeof APP_AUTH_IDS)[number]
@@ -23,6 +24,7 @@ function emptySessions(): Record<AppAuthId, boolean> {
'local-pages': false,
feather: false,
crewlink: false,
skypic: false,
}
}
+6 -41
View File
@@ -64,41 +64,6 @@ 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', () => {
@@ -355,7 +320,7 @@ describe('app store', () => {
const apps = useAppStoreStore()
apps.hydrate(null)
mocks.phone.saveDeviceNamespace.mockClear()
const sourceIndex = apps.homeLayout.grid.indexOf('calculator')
const sourceIndex = apps.homeLayout.grid.indexOf('phone')
expect(sourceIndex).toBeGreaterThanOrEqual(0)
expect(
@@ -364,7 +329,7 @@ describe('app store', () => {
HOME_GRID_PAGE_SIZE,
]),
).toBe(true)
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('calculator')
expect(apps.homeLayout.grid[HOME_GRID_PAGE_SIZE]).toBe('phone')
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
})
@@ -421,18 +386,18 @@ describe('app store', () => {
mocks.phone.saveDeviceNamespace.mockClear()
const notesIndex = apps.homeLayout.grid.indexOf('notes')
const settingsIndex = apps.homeLayout.grid.indexOf('settings')
const clockIndex = apps.homeLayout.grid.indexOf('clock')
const folderId = apps.createHomeFolder(
'grid',
notesIndex,
'grid',
settingsIndex,
clockIndex,
'Utilities',
)
expect(folderId).toBeTruthy()
expect(getHomeFolder(apps.homeLayout, folderId!)?.apps).toEqual([
'settings',
'clock',
'notes',
])
const mailIndex = apps.homeLayout.grid.indexOf('mail')
@@ -440,7 +405,7 @@ describe('app store', () => {
apps.moveHomeFolderApp(folderId!, 2, 0)
apps.renameHomeFolder(folderId!, 'Work')
expect(getHomeFolder(apps.homeLayout, folderId!)).toMatchObject({
apps: ['mail', 'notes', 'settings'],
apps: ['mail', 'notes', 'clock'],
name: 'Work',
})
+7 -22
View File
@@ -25,7 +25,6 @@ import {
parseHomeLayout,
reflowHomeGridForWidgetChange,
renameHomeFolder,
removeDockGridDuplicates,
removeHomeApp,
restoreHomeApp,
type HomeArea,
@@ -46,7 +45,7 @@ const pendingInstallations = new WeakMap<
>()
function getDefaultGridIds(): LaunchablePhoneAppId[] {
return PHONE_APPS.filter((app) => app.dockOrder === null)
return [...PHONE_APPS]
.sort((a, b) => a.gridOrder - b.gridOrder)
.map((app) => app.id)
}
@@ -264,16 +263,12 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultGridIds(),
getDefaultDockIds(),
)
const parsedHomeLayout = parseHomeLayout(
this.homeLayout = 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) {
@@ -297,7 +292,6 @@ export const useAppStoreStore = defineStore('app-store', {
this.hydrated = true
if (
protectedHiddenAppIds.length ||
removedDockGridDuplicates ||
removedLegacyDefaults ||
layoutVersion === 2 ||
layoutVersion === 3 ||
@@ -326,8 +320,11 @@ export const useAppStoreStore = defineStore('app-store', {
getDefaultDockIds(),
)
const previous = JSON.stringify(this.homeLayout)
this.homeLayout = removeDockGridDuplicates(
parseHomeLayout(this.homeLayout, defaults, installedIds, false),
this.homeLayout = parseHomeLayout(
this.homeLayout,
defaults,
installedIds,
false,
)
for (const appId of [...this.homeLayout.hidden]) {
@@ -497,18 +494,6 @@ 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
},
+4 -16
View File
@@ -7,11 +7,6 @@ import type {
MusicTrack,
} from '@/types/music'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
registerPhoneMediaElement,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
export type YouTubePlayer = {
destroy: () => void
@@ -53,7 +48,7 @@ declare global {
}
}
const audio = registerPhoneMediaElement(new Audio())
const audio = new Audio()
audio.preload = 'auto'
const YOUTUBE_API_TIMEOUT_MS = 12000
let audioBound = false
@@ -64,11 +59,6 @@ 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 {
@@ -286,7 +276,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
const api = await loadYouTubeApi()
const store = useMusicStore()
if (youtubePlayer) {
youtubePlayer.setVolume(store.volume * getPhoneOutputVolume() * 100)
youtubePlayer.setVolume(store.volume * 100)
youtubePlayer.loadVideoById(videoId)
youtubePlayer.playVideo()
startYoutubeProgress()
@@ -329,9 +319,7 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
},
onReady: (event) => {
youtubePlayer = event.target
event.target.setVolume(
useMusicStore().volume * getPhoneOutputVolume() * 100,
)
event.target.setVolume(useMusicStore().volume * 100)
event.target.playVideo()
startYoutubeProgress()
resolve()
@@ -566,7 +554,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 * getPhoneOutputVolume() * 100)
youtubePlayer?.setVolume(this.volume * 100)
},
stop(): void {
stopActiveMedia()
+236
View File
@@ -209,6 +209,242 @@ describe('phone locale fallback', () => {
)
})
it('keeps the complete SkyPic copy translated with a partial server locale', () => {
const phone = usePhoneStore()
phone.open({ locales: { Apps: { skypic: { name: 'SkyPic' } } } })
const skyPicKeys = [
'name',
'loading',
'navigation',
...['seconds', 'minutes', 'hours', 'days'].map((key) => 'time.' + key),
...[
'title',
'eyebrow',
'heading',
'body',
'displayName',
'displayNamePlaceholder',
'handle',
'handlePlaceholder',
'accountBound',
'create',
'creating',
].map((key) => 'onboarding.' + key),
...['title', 'body', 'eyebrow', 'login', 'loggingIn', 'noAccount'].map(
(key) => 'auth.' + key,
),
...[
'eyebrow',
'title',
'body',
'snap',
'story',
'photo',
'video',
'gallery',
'capturePhoto',
'captureVideo',
'snapHint',
'storyHint',
].map((key) => 'camera.' + key),
...[
'snapTitle',
'storyTitle',
'send',
'addStory',
'caption',
'captionPlaceholder',
'textOverlay',
'textPlaceholder',
'color',
'duration',
'seconds',
'replay',
'replayBody',
'recipients',
'recipientsHint',
'recipientLimit',
'selectedCount',
'noFriends',
'changeMedia',
'sent',
'storyPublished',
].map((key) => 'composer.' + key),
...['camera', 'chats', 'stories', 'friends'].map((key) => 'tabs.' + key),
...[
'title',
'incoming',
'noSnaps',
'conversations',
'noConversations',
'start',
'message',
'threadPlaceholder',
'messageLimit',
'saved',
'save',
'unsave',
'delete',
'sendSnap',
'moreActions',
'attachPhoto',
'takePhoto',
'emoji',
'attachVideo',
'attachmentPreview',
'removeAttachment',
'moveAttachmentEarlier',
'moveAttachmentLater',
'attachmentLimit',
'sendingAttachments',
'photoAttachmentsSent',
'sending',
'failed',
].map((key) => 'chats.' + key),
...[
'newVideo',
'newPhoto',
'replayed',
'opened',
'video',
'photo',
'replay',
].map((key) => 'snaps.' + key),
...[
'title',
'add',
'yours',
'friends',
'emptyTitle',
'emptyBody',
'views',
'viewers',
'noViewers',
'replyPlaceholder',
'replySent',
'replyLimit',
'delete',
'deleted',
'seen',
'unseen',
].map((key) => 'stories.' + key),
...[
'title',
'searchPlaceholder',
'searchResults',
'score',
'requests',
'sentRequests',
'accept',
'decline',
'quickAdd',
'add',
'pending',
'cancelRequest',
'all',
'empty',
'remove',
'block',
'blockedProfiles',
'unblock',
'chat',
'sendSnap',
'respond',
'friends',
'requestSent',
'requestCanceled',
'removed',
'blocked',
'unblocked',
].map((key) => 'friends.' + key),
...[
'title',
'edit',
'save',
'cancel',
'score',
'streaks',
'friends',
'bio',
'bioPlaceholder',
'storyPrivacy',
'privacyFriends',
'privacyEveryone',
'allowStoryReplies',
'allowStoryRepliesBody',
'showInQuickAdd',
'showInQuickAddBody',
'saved',
'account',
'logout',
'logoutTitle',
'logoutBody',
'loggingOut',
'deleteAccount',
'deleteAccountTitle',
'deleteAccountBody',
'deletingAccount',
].map((key) => 'profile.' + key),
...['close', 'timeLeft'].map((key) => 'viewer.' + key),
...[
'friend_request',
'friend_accepted',
'snap',
'message',
'story_reply',
'snap_opened',
'default',
].map((key) => 'notifications.' + key),
...[
'profile_required',
'profile_exists',
'invalid_handle',
'handle_taken',
'invalid_display_name',
'invalid_bio',
'invalid_avatar',
'invalid_avatar_seed',
'invalid_privacy',
'invalid_request',
'profile_not_found',
'blocked',
'friendship_not_found',
'friend_request_exists',
'friend_limit_reached',
'request_limit_reached',
'invalid_recipients',
'invalid_media',
'invalid_media_type',
'invalid_duration',
'invalid_caption',
'invalid_overlay',
'invalid_color',
'snap_unavailable',
'replay_unavailable',
'message_empty',
'message_too_long',
'message_not_found',
'story_limit_reached',
'story_unavailable',
'not_authorized',
'confirmation_required',
'too_many_snaps',
'rate_limited',
'request_timeout',
'request_failed',
'not_authenticated',
'unknown_error',
'default',
].map((key) => 'errors.' + key),
]
for (const key of skyPicKeys) {
const path = 'Apps.skypic.' + key
expect(phone.t(path), path).not.toBe(path)
}
})
it('uses the English Lua payload before the bundled emergency fallback', () => {
const phone = usePhoneStore()
phone.open({
+490 -36
View File
@@ -1643,6 +1643,306 @@ const defaultLocales: LocaleTree = {
default: 'Picstagram could not complete the request.',
},
},
skypic: {
name: 'SkyPic',
loading: 'Loading SkyPic',
navigation: 'SkyPic navigation',
time: {
seconds: '{count}s ago',
minutes: '{count}m ago',
hours: '{count}h ago',
days: '{count}d ago',
},
onboarding: {
title: 'Welcome to SkyPic',
eyebrow: 'Your private camera network',
heading: 'Share the moment',
body: 'Create a profile for private snaps, close-friend stories, Spotlight videos, and quick chats.',
displayName: 'Display name',
displayNamePlaceholder: 'Your name',
handle: 'Handle',
handlePlaceholder: 'your.handle',
accountBound:
'Your SkyPic profile stays linked to this Sky Cloud account.',
create: 'Create profile',
creating: 'Creating...',
},
auth: {
eyebrow: 'Your SkyPic account',
title: 'Welcome back',
body: 'Continue with your iFruit account to access your SkyPic profile, snaps, and chats.',
login: 'Continue to SkyPic',
loggingIn: 'Signing in...',
noAccount: 'New to SkyPic? Create an iFruit account to get started.',
},
camera: {
eyebrow: 'Sky camera',
title: 'Capture',
body: 'Take a photo or video and share it for just a few seconds.',
snap: 'Snap',
story: 'Story',
photo: 'Photo',
video: 'Video',
gallery: 'Open gallery',
capturePhoto: 'Take photo',
captureVideo: 'Record video',
snapHint: 'Send it privately to one or more friends.',
storyHint: 'Share it with your story for 24 hours.',
},
composer: {
snapTitle: 'New snap',
storyTitle: 'New story',
spotlightTitle: 'New Spotlight',
send: 'Send',
addStory: 'Add to story',
publishSpotlight: 'Publish',
caption: 'Caption',
captionPlaceholder: 'Add a caption...',
textOverlay: 'Text overlay',
textPlaceholder: 'Put words on the moment...',
color: 'Text color',
duration: 'View duration',
seconds: '{count} seconds',
replay: 'Allow one replay',
replayBody: 'Recipients may open this snap one additional time.',
recipients: 'Recipients',
recipientsHint: 'Choose one or more friends.',
recipientLimit: 'Choose up to {count} friends.',
selectedCount: '{count} selected',
noFriends: 'Add a friend before sending a snap.',
sponsored: 'Sponsored post',
sponsoredBody: 'Clearly label this Spotlight as advertising.',
adHeadline: 'Ad headline',
adHeadlinePlaceholder: 'What should people discover?',
allowComments: 'Allow comments',
allowCommentsBody: 'People can comment on this Spotlight.',
changeMedia: 'Change photo or video',
sent: 'Snap sent.',
storyPublished: 'Story published.',
spotlightPublished: 'Spotlight published.',
},
tabs: {
camera: 'Camera',
chats: 'Chats',
stories: 'Stories',
friends: 'Friends',
spotlight: 'Spotlight',
},
spotlight: {
title: 'Spotlight',
add: 'Create Spotlight',
empty: 'No Spotlight videos yet',
emptyBody: 'Record the first short video for the SkyPic community.',
videoBy: 'Spotlight video by {name}',
sponsored: 'Sponsored',
viewProfile: 'View creator profile',
like: 'Like',
comments: 'Comments',
views: 'Views',
noComments: 'No comments yet',
commentPlaceholder: 'Write a comment...',
sendComment: 'Send',
commentsDisabled: 'Comments are disabled for this Spotlight.',
deleteComment: 'Delete comment',
delete: 'Delete Spotlight',
deleted: 'Spotlight deleted.',
report: 'Report Spotlight',
reportBody: 'Tell us why this Spotlight should be reviewed.',
submitReport: 'Submit report',
reported: 'Spotlight reported.',
navigation: 'Spotlight navigation',
previous: 'Previous Spotlight',
next: 'Next Spotlight',
reportReasons: {
spam: 'Spam or misleading',
harassment: 'Harassment',
dangerous: 'Dangerous content',
illegal: 'Illegal content',
other: 'Other',
},
},
chats: {
title: 'Chats',
incoming: 'New snaps',
noSnaps: 'No unopened snaps',
conversations: 'Conversations',
noConversations: 'Your conversations will appear here.',
start: 'Start a conversation',
message: 'Message',
threadPlaceholder: 'Write a message...',
messageLimit: 'Messages can contain up to {count} characters.',
saved: 'Saved in chat',
save: 'Save',
unsave: 'Unsave',
delete: 'Delete',
sendSnap: 'Send a snap',
moreActions: 'More actions',
attachPhoto: 'Attach photos',
takePhoto: 'Take photo',
emoji: 'Emoji',
attachVideo: 'Attach video',
attachmentPreview: 'Selected attachments',
removeAttachment: 'Remove attachment {number}',
moveAttachmentEarlier: 'Move attachment {number} earlier',
moveAttachmentLater: 'Move attachment {number} later',
attachmentLimit: 'You can attach up to {count} photos.',
sendingAttachments: 'Sending photos...',
photoAttachmentsSent: 'Photos sent.',
sending: 'Sending...',
failed: 'Not delivered',
},
snaps: {
newVideo: 'New video snap',
newPhoto: 'New photo snap',
replayed: 'Replayed',
opened: 'Opened',
video: 'Video snap',
photo: 'Photo snap',
replay: 'Replay snap',
},
stories: {
title: 'Stories',
add: 'Add story',
yours: 'Your story',
friends: 'Friends',
emptyTitle: 'No stories right now',
emptyBody: 'Stories from your friends will appear here for 24 hours.',
views: '{count} views',
viewers: 'Viewers',
noViewers: 'No views yet',
replyPlaceholder: 'Reply to this story...',
replySent: 'Reply sent.',
replyLimit: 'Replies can contain up to {count} characters.',
delete: 'Delete story',
deleted: 'Story deleted.',
seen: 'Seen',
unseen: 'New',
},
friends: {
title: 'Friends',
searchPlaceholder: 'Search name or @handle',
searchResults: 'Search results',
score: '{count} points',
requests: 'Friend requests',
sentRequests: 'Sent requests',
accept: 'Accept',
decline: 'Decline',
quickAdd: 'Quick Add',
add: 'Add',
pending: 'Pending',
cancelRequest: 'Cancel request',
all: 'Your friends',
empty: 'Add friends to start snapping.',
remove: 'Remove friend',
block: 'Block',
blockedProfiles: 'Blocked profiles',
unblock: 'Unblock',
chat: 'Chat',
sendSnap: 'Send snap',
respond: 'Respond',
friends: 'Friends',
requestSent: 'Friend request sent to {name}.',
requestCanceled: 'Friend request to {name} canceled.',
removed: '{name} was removed from your friends.',
blocked: '{name} was blocked.',
unblocked: '{name} was unblocked.',
},
profile: {
title: 'Profile',
edit: 'Edit profile',
save: 'Save',
cancel: 'Cancel',
score: 'Snap score',
streaks: 'Streaks',
friends: 'Friends',
bio: 'Bio',
bioPlaceholder: 'Tell your friends a little about you...',
storyPrivacy: 'Story privacy',
privacyFriends: 'Friends only',
privacyEveryone: 'Everyone',
allowStoryReplies: 'Allow story replies',
allowStoryRepliesBody: 'Friends can reply to your stories in chat.',
showInQuickAdd: 'Show in Quick Add',
showInQuickAddBody: 'Let other profiles discover you as a suggestion.',
saved: 'Profile updated.',
account: 'Account',
logout: 'Sign out',
logoutTitle: 'Sign out of SkyPic?',
logoutBody:
'You will only be signed out of SkyPic. Your profile, snaps, and chats stay available.',
loggingOut: 'Signing out...',
deleteAccount: 'Delete SkyPic account',
deleteAccountTitle: 'Delete your SkyPic account?',
deleteAccountBody:
'Your SkyPic profile, friends, snaps, stories, Spotlights, comments, and chats will be permanently deleted. Your iFruit account and Photos library stay available.',
deletingAccount: 'Deleting account...',
},
viewer: {
close: 'Close',
timeLeft: '{count}s',
},
notifications: {
friend_request: '{actor} sent you a friend request.',
friend_accepted: '{actor} accepted your friend request.',
snap: '{actor} sent you a snap.',
message: '{actor} sent you a message.',
story_reply: '{actor} replied to your story.',
snap_opened: '{actor} opened your snap.',
default: 'You have new SkyPic activity.',
},
errors: {
profile_required: 'Create your SkyPic profile first.',
profile_exists: 'This account already has a SkyPic profile.',
invalid_handle: 'Use 3-24 letters, numbers, dots, or underscores.',
handle_taken: 'This handle is already taken.',
invalid_display_name: 'Enter a display name.',
invalid_bio: 'Your bio is too long.',
invalid_avatar: 'Choose a valid profile photo.',
invalid_avatar_seed: 'Choose a valid avatar.',
invalid_privacy: 'Choose a valid story privacy setting.',
invalid_request: 'This friend request is invalid.',
profile_not_found: 'This SkyPic profile is unavailable.',
blocked: 'This profile is blocked.',
friendship_not_found: 'This friendship is unavailable.',
friend_request_exists: 'A friend request already exists.',
friend_limit_reached: 'Your friends list is full.',
request_limit_reached: 'Too many open friend requests.',
invalid_recipients: 'Choose at least one current friend.',
invalid_media: 'Choose media from this phone.',
invalid_media_type: 'This media type is not supported.',
invalid_duration: 'Choose a view time from 1 to 10 seconds.',
invalid_caption: 'This caption is too long.',
invalid_overlay: 'This text overlay is too long.',
invalid_color: 'Choose a valid overlay color.',
snap_unavailable: 'This snap is no longer available.',
replay_unavailable: 'This snap cannot be replayed again.',
message_empty: 'Write a message before sending.',
message_too_long: 'Messages can contain at most 2000 characters.',
message_not_found: 'This message is unavailable.',
story_limit_reached: 'Your active story limit is reached.',
story_unavailable: 'This story is no longer available.',
spotlight_unavailable: 'This Spotlight is no longer available.',
spotlight_limit_reached: 'Your active Spotlight limit is reached.',
sponsored_limit_reached:
'Your active sponsored Spotlight limit is reached.',
ads_disabled: 'Sponsored Spotlights are disabled.',
invalid_ad_headline: 'Enter an ad headline with 3 to 80 characters.',
invalid_comment: 'Enter a valid comment.',
comments_disabled: 'Comments are disabled.',
comment_not_found: 'This comment is unavailable.',
invalid_report: 'Choose a valid report reason.',
not_authorized: 'You are not allowed to do that.',
confirmation_required:
'Confirm that you want to delete your SkyPic account.',
too_many_snaps: 'Choose fewer photos or recipients.',
rate_limited: 'Slow down for a moment and try again.',
request_timeout: 'The SkyPic request timed out. Try again.',
request_failed: 'SkyPic could not complete the request.',
not_authenticated: 'Sign in to Sky Cloud first.',
unknown_error: 'SkyPic could not complete the request.',
default: 'SkyPic could not complete the request.',
},
},
feather: {
name: 'Feather',
loading: 'Loading Feather',
@@ -2397,6 +2697,7 @@ const defaultLocales: LocaleTree = {
companies: 'Businesses, jobs and services',
music: 'Songs, playlists and audio',
picstagram: 'Photo sharing and social feed',
skypic: 'Private snaps, stories and close friends',
feather: 'Short posts and city conversations',
fliptok: 'Short videos and trends',
flare: 'Social posts and live moments',
@@ -2432,46 +2733,195 @@ const defaultLocales: LocaleTree = {
'neon-drop': 'Neon block-dropping puzzle',
},
previews: {
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
house: { first: 'Property access', second: 'Residents', third: 'Management' },
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
citywarn: {
first: 'Live alerts',
second: 'Safety zones',
third: 'Incident updates',
},
crypto: {
first: 'Synthetic markets',
second: 'Portfolio',
third: 'Wallet transfers',
},
health: {
first: 'Activity rings',
second: 'Medical ID',
third: 'Health records',
},
'weazel-news': {
first: 'Top stories',
second: 'Local reports',
third: 'Breaking news',
},
companies: {
first: 'Business directory',
second: 'Job requests',
third: 'Services',
},
music: {
first: 'Now playing',
second: 'Playlists',
third: 'Music library',
},
picstagram: {
first: 'Photo feed',
second: 'Stories',
third: 'Profiles',
},
skypic: {
first: 'Private snaps',
second: 'Friend stories',
third: 'Quick chats',
},
feather: {
first: 'Short posts',
second: 'Following feed',
third: 'Conversations',
},
fliptok: {
first: 'Video feed',
second: 'Creator tools',
third: 'Trends',
},
flare: {
first: 'Discover people',
second: 'Matches',
third: 'Live moments',
},
calendar: {
first: 'Upcoming events',
second: 'Day planner',
third: 'Reminders',
},
radio: {
first: 'Live channels',
second: 'Team radio',
third: 'Favorites',
},
'local-pages': {
first: 'Local pages',
second: 'Reviews',
third: 'City discovery',
},
crewlink: {
first: 'Crew roster',
second: 'Shared locations',
third: 'Coordination',
},
phone: {
first: 'Recent calls',
second: 'Contacts',
third: 'Voicemail',
},
messages: {
first: 'Conversations',
second: 'Media sharing',
third: 'Quick replies',
},
darkchat: {
first: 'Private chats',
second: 'Secure groups',
third: 'Invitations',
},
garage: {
first: 'Vehicle list',
second: 'Parking locations',
third: 'Valet',
},
house: {
first: 'Property access',
second: 'Residents',
third: 'Management',
},
map: {
first: 'Live navigation',
second: 'Nearby places',
third: 'Route guidance',
},
skyride: {
first: 'Ride booking',
second: 'Driver tracking',
third: 'Trip history',
},
banking: {
first: 'Account balance',
second: 'Transfers',
third: 'Transactions',
},
billing: {
first: 'Open invoices',
second: 'Payment requests',
third: 'Payment history',
},
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
memos: {
first: 'Voice recordings',
second: 'Playback',
third: 'Favorites',
},
calculator: {
first: 'Basic calculation',
second: 'Scientific tools',
third: 'History',
},
camera: {
first: 'Photo mode',
second: 'Video capture',
third: 'Zoom controls',
},
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
weather: {
first: 'Current weather',
second: 'Hourly forecast',
third: 'Seven-day outlook',
},
photos: {
first: 'Media library',
second: 'Albums',
third: 'Shared media',
},
settings: {
first: 'Device controls',
second: 'Privacy',
third: 'Personalization',
},
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
memory: {
first: 'Matched pairs',
second: 'Best time',
third: 'Card themes',
},
'number-merge': {
first: 'Highest tile',
second: 'Score',
third: 'Strategy grid',
},
minesweeper: {
first: 'Mine counter',
second: 'Best time',
third: 'Difficulty',
},
'tower-stack': {
first: 'Tower height',
second: 'Perfect drops',
third: 'High score',
},
'sky-flappy': {
first: 'Flight score',
second: 'Best run',
third: 'Obstacles',
},
citymarkt: {
first: 'Listings',
second: 'Categories',
third: 'Saved offers',
},
'neon-drop': {
first: 'Lines cleared',
second: 'Level',
third: 'Neon pieces',
},
},
search: {
recommended: 'Recommended',
@@ -4558,6 +5008,8 @@ const defaultLocales: LocaleTree = {
import_url_not_allowed: 'This link is not from the selected website.',
import_url_unavailable: 'The linked media could not be reached.',
import_size_unavailable: 'The website did not provide the media size.',
media_in_use:
'This media is still used by SkyPic and cannot be deleted yet.',
not_found: 'The media item no longer exists.',
profile_photo_required:
'This is the last photo on your Flare profile. Add another profile photo before deleting it.',
@@ -4967,8 +5419,10 @@ const defaultLocales: LocaleTree = {
pause: 'Pause',
phone: 'Phone',
phoneStatus: 'Phone status',
retry: 'Try Again',
reset: 'Reset',
loading: 'Loading',
loadMore: 'Load More',
search: 'Search',
save: 'Save',
send: 'Send',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -36,6 +36,7 @@ export type BuiltinPhoneAppId =
| 'flare'
| 'fliptok'
| 'picstagram'
| 'skypic'
| 'skyride'
| 'feather'
| 'crewlink'
@@ -159,9 +160,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: {
-7
View File
@@ -1,7 +0,0 @@
export type DynamicIslandActivity =
| 'call'
| 'incoming-call'
| 'music'
| 'recording'
| 'stopwatch'
| 'timer'
+265
View File
@@ -0,0 +1,265 @@
import type { MediaType, PhoneMedia } from '@/types/media'
export type SkyPicStoryPrivacy = 'everyone' | 'friends'
export type SkyPicDirection = 'received' | 'sent'
export type SkyPicSnapType = 'snap_photo' | 'snap_video'
export type SkyPicFriendshipStatus =
| 'friends'
| 'incoming'
| 'none'
| 'outgoing'
export type SkyPicProfileSummary = {
avatarSeed: number
avatarUrl: string | null
displayName: string
friendshipId?: string | null
friendshipStatus: SkyPicFriendshipStatus
handle: string
id: string
snapScore: number
}
export type SkyPicProfile = SkyPicProfileSummary & {
allowStoryReplies: boolean
avatarMediaId: number | null
bio: string
friendCount: number
showInQuickAdd: boolean
storyPrivacy: SkyPicStoryPrivacy
}
export type SkyPicCreateProfileInput = {
avatarMediaId?: number
avatarSeed?: number
displayName: string
handle: string
}
export type SkyPicUpdateProfileInput = {
allowStoryReplies: boolean
avatarMediaId?: number | null
avatarSeed?: number
bio: string
displayName: string
handle: string
showInQuickAdd: boolean
storyPrivacy: SkyPicStoryPrivacy
}
export type SkyPicFriend = {
bestStreak: number
createdAt: string
friendshipId: string
profile: SkyPicProfileSummary
streakCount: number
}
export type SkyPicFriendRequest = {
createdAt: string
direction: 'incoming' | 'outgoing'
friendshipId: string
profile: SkyPicProfileSummary
}
export type SkyPicConversationLastItem = {
body?: string
createdAt: string
direction: SkyPicDirection
id: string
openedAt: string | null
type: 'snap_photo' | 'snap_video' | 'text'
}
export type SkyPicConversation = {
bestStreak: number
friendshipId: string
lastItem: SkyPicConversationLastItem | null
profile: SkyPicProfileSummary
streakCount: number
unreadCount: number
}
/** Direct snap lists intentionally contain no media URL or editor contents. */
export type SkyPicSnap = {
allowReplay: boolean
/** Present on successful sends so friendship streak UI can reconcile immediately. */
bestStreak?: number
createdAt: string
direction: SkyPicDirection
durationSeconds: number
expiresAt: string
friendshipId: string
id: string
openedAt: string | null
replayedAt: string | null
sender: SkyPicProfileSummary
/** Present on successful sends so friendship streak UI can reconcile immediately. */
streakCount?: number
type: SkyPicSnapType
}
/** Media and editor contents are released only by open/replay callbacks. */
export type SkyPicOpenedSnap = {
allowReplay: boolean
caption: string
durationSeconds: number
expiresAt: string
id: string
mediaType: MediaType
mimeType: string | null
openedAt: string
overlayColor: string
replayedAt: string | null
textOverlay: string
url: string
}
/** Bootstrap returns story metadata only; URLs are released by view-story. */
export type SkyPicStory = {
author: SkyPicProfileSummary
createdAt: string
durationSeconds: number
expiresAt: string
id: string
isOwner: boolean
seen: boolean
viewCount: number
}
export type SkyPicViewedStory = {
author: SkyPicProfileSummary
canReply: boolean
caption: string
durationSeconds: number
expiresAt: string
id: string
mediaType: MediaType
mimeType: string | null
overlayColor: string
textOverlay: string
url: string
viewedAt: string
}
export type SkyPicStoryViewer = SkyPicProfileSummary & {
viewedAt: string
}
export type SkyPicSpotlightReportReason =
| 'dangerous'
| 'harassment'
| 'illegal'
| 'other'
| 'spam'
/** Spotlight is public content, so its media URL is intentionally feed-visible. */
export type SkyPicSpotlight = {
adHeadline: string
author: SkyPicProfileSummary
caption: string
commentCount: number
commentsEnabled: boolean
createdAt: string
expiresAt: string
id: string
isLiked: boolean
isOwner: boolean
isSponsored: boolean
isViewed: boolean
likeCount: number
mimeType: string | null
overlayColor: string
textOverlay: string
url: string
viewCount: number
}
export type SkyPicSpotlightComment = {
author: SkyPicProfileSummary
body: string
createdAt: string
id: string
isOwner: boolean
spotlightId: string
}
export type SkyPicMessageDeliveryStatus = 'delivered' | 'failed' | 'sending'
export type SkyPicMessage = {
body: string
clientId?: string
createdAt: string
deliveryStatus?: SkyPicMessageDeliveryStatus
direction: SkyPicDirection
friendshipId: string
id: string
readAt: string | null
savedAt: string | null
type: 'text'
}
export type SkyPicThread = {
messages: SkyPicMessage[]
snaps: SkyPicSnap[]
}
export type SkyPicBootstrap = {
blockedProfiles: SkyPicProfileSummary[]
conversations: SkyPicConversation[]
friends: SkyPicFriend[]
inbox: SkyPicSnap[]
profile: SkyPicProfile | null
requests: SkyPicFriendRequest[]
stories: SkyPicStory[]
suggestions: SkyPicProfileSummary[]
unreadCount: number
}
export type SkyPicDraftPurpose = 'snap' | 'spotlight' | 'story'
export type SkyPicMediaDraftContext = {
purpose: SkyPicDraftPurpose
recipientIds: string[]
}
export type SkyPicThreadMediaDraftContext = {
body: string
friendshipId: string
pendingMedia: PhoneMedia[]
}
type SkyPicEditorInput = {
caption: string
durationSeconds: number
overlayColor: string
textOverlay: string
}
type SkyPicSingleMediaInput = {
mediaId: number
mediaIds?: never
mediaType: MediaType
}
type SkyPicMultipleMediaInput = {
mediaId?: never
mediaIds: number[]
mediaType?: never
}
export type SkyPicSendSnapInput = SkyPicEditorInput &
(SkyPicSingleMediaInput | SkyPicMultipleMediaInput) & {
allowReplay: boolean
recipientIds: string[]
}
export type SkyPicPublishStoryInput = SkyPicEditorInput & SkyPicSingleMediaInput
export type SkyPicPublishSpotlightInput = SkyPicEditorInput & {
adHeadline: string
commentsEnabled: boolean
isSponsored: boolean
mediaId: number
mediaType: 'video'
}
-31
View File
@@ -111,18 +111,6 @@
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);
}
@@ -2745,9 +2733,6 @@ 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;
}
@@ -2756,16 +2741,6 @@ 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 {
@@ -2780,10 +2755,6 @@ 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;
@@ -2854,8 +2825,6 @@ 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,30 +28,6 @@ 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')
+15 -36
View File
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import SkyGlass from './SkyGlass.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
@@ -11,7 +9,6 @@ const props = withDefaults(
clear?: boolean
component?: 'a' | 'button'
disabled?: boolean
glass?: boolean
href?: string
iconOnly?: boolean
inline?: boolean
@@ -29,7 +26,6 @@ const props = withDefaults(
clear: false,
component: 'button',
disabled: false,
glass: false,
href: undefined,
iconOnly: false,
inline: false,
@@ -48,23 +44,6 @@ 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 {
@@ -92,25 +71,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="buttonClasses"
: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,
},
]"
@click="handleClick"
>
<slot />
-29
View File
@@ -73,33 +73,4 @@ 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,
)
})
})
+1 -2
View File
@@ -14,7 +14,7 @@ const props = withDefaults(
text?: string
textPosition?: 'after' | 'before'
type?: 'button' | 'reset' | 'submit'
variant?: 'glass' | 'neutral' | 'primary'
variant?: 'neutral' | 'primary'
}>(),
{
ariaLabel: '',
@@ -72,7 +72,6 @@ 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,
+1 -6
View File
@@ -10,10 +10,6 @@ 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(
@@ -60,11 +56,10 @@ 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(
`(?:["']${escapedAppId}["']\\]?|${escapedAppId})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
`(?:["']${appId}["']\\]?|${appId.replace(/-/g, '\\-')})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
),
)
}
+1
View File
@@ -15,6 +15,7 @@ const APP_STORE_PREVIEW_STYLES = {
companies: { accent: '#4a92ff', surface: '#0c1728' },
music: { accent: '#fa3d71', surface: '#240b19' },
picstagram: { accent: '#e54cff', surface: '#210b27' },
skypic: { accent: '#24c7ff', surface: '#070f2b' },
feather: { accent: '#3c9cff', surface: '#091c2d' },
fliptok: { accent: '#24f0d2', surface: '#071d1b' },
flare: { accent: '#ff567f', surface: '#260d18' },
-35
View File
@@ -21,7 +21,6 @@ import {
moveHomeFolderApp,
parseHomeLayout,
reflowHomeGridForWidgetChange,
removeDockGridDuplicates,
removeHomeApp,
renameHomeFolder,
restoreHomeApp,
@@ -79,40 +78,6 @@ 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,37 +505,6 @@ 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,
+4 -49
View File
@@ -5,10 +5,7 @@ import {
createLbPhoneFrameDocument,
createLbPhoneHostSettings,
getLbPhoneCallbackResource,
getLbPhoneStorageKey,
readLbPhoneStorage,
usesLbPhoneHostRuntime,
writeLbPhoneStorage,
} from '@/utils/lbPhoneAppBridge'
import { DEFAULT_PHONE_PREFERENCES } from '@/utils/preferences'
@@ -93,10 +90,9 @@ describe('LB Phone app bridge', () => {
it('injects the LB runtime and asset base before the vendor bundle', () => {
const 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>'
'<!doctype html><html><head><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>',
@@ -113,52 +109,11 @@ 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 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('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,
)
const runtime = /<script>([\s\S]*?)<\/script>/.exec(document)?.[1]
expect(runtime).toBeTruthy()
expect(() => new Function(runtime ?? '')).not.toThrow()
})
})
-140
View File
@@ -4,15 +4,6 @@ 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
@@ -50,7 +41,6 @@ export type LbPhoneHostSettings = {
type LbPhoneFrameDocumentOptions = {
appName: string
localStorage: LbPhoneStorageSnapshot
resourceName: string
settings: LbPhoneHostSettings
ui: string
@@ -70,59 +60,6 @@ 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';
@@ -133,22 +70,7 @@ function applySettings(nextSettings) {
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');
@@ -228,67 +150,6 @@ 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 {
@@ -367,7 +228,6 @@ 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,
})
+1
View File
@@ -158,6 +158,7 @@ describe('media utilities', () => {
expect(mediaErrorKey('profile_photo_required')).toBe(
'profile_photo_required',
)
expect(mediaErrorKey('media_in_use')).toBe('media_in_use')
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
+1
View File
@@ -106,6 +106,7 @@ export function mediaErrorKey(error?: string): string {
'import_url_not_allowed',
'import_url_unavailable',
'import_size_unavailable',
'media_in_use',
'not_found',
'operation_in_progress',
'owner_changed',
-8
View File
@@ -47,12 +47,4 @@ 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>')
})
})
-96
View File
@@ -1,96 +0,0 @@
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)
}
+4
View File
@@ -85,6 +85,10 @@ describe('preferences', () => {
enabled: true,
sounds: true,
})
expect(value.settings.notifications.skypic).toEqual({
enabled: true,
sounds: true,
})
expect(value.settings.phoneScale).toBe(110)
expect(value.settings.screenBrightness).toBe(64)
expect(value.settings.wallpaper).toBe('ember')
+1
View File
@@ -118,6 +118,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
'weazel-news': { enabled: true, sounds: true },
'local-pages': { enabled: true, sounds: true },
picstagram: { enabled: true, sounds: true },
skypic: { enabled: true, sounds: true },
fliptok: { enabled: true, sounds: true },
feather: { enabled: true, sounds: true },
crewlink: { enabled: true, sounds: true },
+1 -2
View File
@@ -32,7 +32,7 @@ describe('phone tones', () => {
}> = []
vi.stubGlobal(
'Audio',
class extends EventTarget {
class {
currentTime = 7
loop = false
pause = pause
@@ -42,7 +42,6 @@ describe('phone tones', () => {
volume = 0
constructor(src: string) {
super()
this.src = src
players.push(this)
}
+2 -3
View File
@@ -1,4 +1,3 @@
import { registerPhoneMediaElement } from '@/utils/phoneAudio'
import type { AlarmSoundId } from '@/utils/alarms'
import type { NotificationSoundId } from '@/utils/preferences'
@@ -496,8 +495,8 @@ export function playPhoneVibration(
kind: PhoneVibrationKind,
loop: boolean,
): () => void {
const player = registerPhoneMediaElement(
new Audio(`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`),
const player = new Audio(
`${import.meta.env.BASE_URL}${VIBRATION_SOUND_PATHS[kind]}`,
)
let stopped = false
player.loop = loop
@@ -48,11 +48,13 @@ 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')
@@ -69,16 +71,6 @@ 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')
})
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,
)
expect(source).toContain('.store-detail__toolbar button:hover')
})
})
+27 -37
View File
@@ -1,11 +1,15 @@
<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'
@@ -122,28 +126,20 @@ function updateActivePreview(): void {
<template>
<section class="store-detail" :style="detailStyle">
<header class="store-detail__toolbar">
<SkyButton
glass
icon-only
rounded
class="store-detail__toolbar-button"
<button
type="button"
:aria-label="phone.t('Common.back')"
@click="emit('back')"
>
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="store-detail__toolbar-button"
</button>
<button
type="button"
:aria-label="phone.t('Apps.appStore.details.share')"
@click="emit('share')"
>
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
</SkyButton>
</button>
</header>
<section class="store-detail__hero">
@@ -216,30 +212,22 @@ function updateActivePreview(): void {
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
<div class="store-detail__preview-navigation">
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
<SkyButton
glass
icon-only
rounded
class="store-detail__preview-control"
<button
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" />
</SkyButton>
<SkyButton
glass
icon-only
rounded
class="store-detail__preview-control"
</button>
<button
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" />
</SkyButton>
</button>
</div>
</header>
<div
@@ -298,7 +286,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;
@@ -306,6 +294,7 @@ 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,
@@ -313,7 +302,7 @@ function updateActivePreview(): void {
transform 100ms ease;
}
.store-detail__toolbar-button:active {
.store-detail__toolbar button:active {
transform: scale(0.94);
}
@@ -492,28 +481,27 @@ function updateActivePreview(): void {
text-align: center;
}
.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);
.store-detail__preview-navigation button {
width: 30px;
height: 30px;
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-control:disabled {
.store-detail__preview-navigation button:disabled {
opacity: 0.34;
}
.store-detail__preview-control:active:not(:disabled) {
.store-detail__preview-navigation button:active:not(:disabled) {
transform: scale(0.92);
}
@@ -532,14 +520,16 @@ 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-control:hover:not(:disabled) {
.store-detail__preview-navigation button:hover:not(:disabled) {
color: var(--sky-app-accent);
background: var(--sky-surface-tint);
transform: translateY(-1px);
}
}
@@ -15,7 +15,7 @@ const appSource = readFileSync(
'utf8',
)
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/nui_events.lua', import.meta.url),
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
const serverSource = readFileSync(
@@ -69,10 +69,7 @@ describe('Banking app Sky UI migration', () => {
expect(serverSource).toContain('kind = "transfer_in"')
expect(serverSource).toContain('currency = Config.Banking.Currency')
expect(clientSource).toContain(
'["sky_phone:banking:changed"] = "banking:changed"',
)
expect(clientSource).toContain(
'SendNUIMessage({ type = nui_type, data = data })',
'SendNUIMessage({ type = "banking:changed", data = data })',
)
expect(appSource).toContain("data?.kind === 'transfer_in'")
expect(appSource).toContain("appId: 'banking'")
@@ -52,8 +52,7 @@ describe('CalculatorApp layout contract', () => {
)
expect(source).toContain('calculator-history__nav-button--edit')
expect(source).toContain('calculator-history__nav-button--close')
expect(source.match(/<SkyButton\s+glass/g)).toHaveLength(2)
expect(source).toContain('--sky-glass: rgb(44 44 46 / 62%);')
expect(source).toContain('background: rgb(255 255 255 / 9%);')
expect(source).toContain('-webkit-backdrop-filter: none;')
expect(source).toContain('backdrop-filter: none;')
expect(source).toContain('min-width: 106px;')
@@ -63,7 +62,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\)[^}]*transform: none;[^}]*filter: brightness\(1\.08\);/s,
/\.calculator-history__nav-button:hover:not\(:disabled\)[^}]*background: rgb\(255 255 255 \/ 15%\);[^}]*transform: none;[^}]*filter: none;/s,
)
expect(source).not.toContain('class="calculator-history__edit"')
expect(source).not.toContain('class="calculator-history__close"')
+6 -6
View File
@@ -378,7 +378,6 @@ watch([() => calculator.display, expression], async () => {
>
<template #left>
<SkyButton
glass
class="calculator-history__nav-button calculator-history__nav-button--edit"
inline
rounded
@@ -398,7 +397,6 @@ watch([() => calculator.display, expression], async () => {
</template>
<template #right>
<SkyButton
glass
class="calculator-history__nav-button calculator-history__nav-button--close"
icon-only
rounded
@@ -723,15 +721,16 @@ 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) {
filter: brightness(0.94);
background: rgb(255 255 255 / 14%);
filter: none;
}
.calculator-history__navbar :deep(.calculator-history__nav-button--edit) {
@@ -758,8 +757,9 @@ 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: brightness(1.08);
filter: none;
}
}
@@ -31,14 +31,6 @@ 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"')
+51 -42
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { SkyAppPage, SkyButton, SkyFab, SkyGlass } from '@/ui'
import { SkyFab, SkyAppPage } from '@/ui'
import {
ArrowLeft,
Images,
@@ -421,11 +421,13 @@ 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()
})
@@ -484,24 +486,22 @@ onBeforeUnmount(() => {
<header class="camera-topbar">
<div class="camera-topbar-actions">
<sky-fab
<button
v-if="requestedMessageMedia"
component="button"
class="camera-control camera-picker-back"
class="camera-picker-back"
type="button"
variant="glass"
:aria-label="phone.t('Common.back')"
@click="cancelMediaSelection"
>
<template #icon><ArrowLeft :size="20" /></template>
</sky-fab>
<ArrowLeft :size="20" />
</button>
<sky-fab
v-else
component="button"
type="button"
class="camera-control"
:class="{ 'camera-control--flash-active': flashEnabled }"
variant="glass"
variant="neutral"
:aria-label="phone.t('Apps.camera.flash')"
@click="toggleFlash"
>
@@ -516,7 +516,7 @@ onBeforeUnmount(() => {
type="button"
class="camera-control"
:class="{ 'camera-control--danger': !microphoneEnabled }"
variant="glass"
variant="neutral"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
@@ -543,10 +543,8 @@ onBeforeUnmount(() => {
<span v-else-if="pendingCount" class="camera-upload-pill">
{{ phone.t('Apps.camera.uploading', { count: String(pendingCount) }) }}
</span>
<SkyButton
<button
v-else
glass
rounded
class="camera-focus-pill camera-lock-control"
:class="{ 'camera-lock-control--active': cameraLocked }"
type="button"
@@ -563,12 +561,12 @@ onBeforeUnmount(() => {
<LockKeyhole v-if="cameraLocked" :size="12" />
<LockOpen v-else :size="12" />
<kbd>{{ phone.t('Apps.camera.spaceKey') }}</kbd>
</SkyButton>
</button>
<sky-fab
component="button"
type="button"
class="camera-control"
variant="glass"
variant="neutral"
:disabled="recording || savingVideo"
:aria-label="
phone.t(
@@ -590,11 +588,9 @@ onBeforeUnmount(() => {
<div class="camera-zoom-control">
<div class="camera-zoom-row">
<SkyButton
<button
v-for="zoom in zoomLevels"
:key="zoom"
glass
rounded
class="camera-zoom-pill"
:class="{ active: Math.abs(selectedZoom - zoom) < 0.03 }"
type="button"
@@ -603,14 +599,13 @@ onBeforeUnmount(() => {
@click="setZoom(zoom)"
>
{{ zoom }}x
</SkyButton>
</button>
</div>
</div>
<footer class="camera-controls">
<div class="camera-capture-row">
<sky-glass
component="button"
<button
class="camera-latest"
type="button"
:aria-label="phone.t('Apps.camera.openGallery')"
@@ -630,7 +625,7 @@ onBeforeUnmount(() => {
/>
<Video v-else-if="latestMedia" :size="22" />
<Images v-else :size="22" />
</sky-glass>
</button>
<button
class="camera-shutter"
@@ -655,7 +650,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="camera-control camera-selfie"
variant="glass"
variant="neutral"
:aria-label="phone.t('Apps.camera.flip')"
@click="toggleFacing"
>
@@ -804,9 +799,8 @@ onBeforeUnmount(() => {
height: 44px;
}
.camera-control {
--sky-glass: rgb(28 28 30 / 58%);
--sky-hairline: rgb(255 255 255 / 16%);
color: rgb(255 255 255 / 92%) !important;
--sky-glass-solid: rgb(28 28 30 / 80%);
color: rgb(255 255 255 / 86%);
}
.camera-control--flash-active {
color: #ffd60a !important;
@@ -817,6 +811,13 @@ 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;
@@ -830,7 +831,7 @@ onBeforeUnmount(() => {
.camera-page--landscape .camera-latest svg {
transform: rotate(90deg);
}
.camera-focus-pill:not(.sky-button--glass),
.camera-focus-pill,
.camera-upload-pill {
min-width: 0;
padding: 7px 10px;
@@ -852,7 +853,7 @@ onBeforeUnmount(() => {
}
.camera-lock-control {
min-height: 44px;
padding: 7px 10px;
border: 0;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -901,20 +902,27 @@ onBeforeUnmount(() => {
z-index: 4;
bottom: 196px;
left: 50%;
width: auto;
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);
transform: translateX(-50%);
}
.camera-zoom-row {
display: flex;
justify-content: space-between;
gap: 8px;
gap: 6px;
}
.camera-zoom-pill {
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
width: 30px;
height: 26px;
padding: 0;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: #fff;
font-size: 10px;
text-align: center;
@@ -925,6 +933,9 @@ 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 {
@@ -945,17 +956,15 @@ 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: var(--sky-glass);
background: #111b;
color: #fff;
display: grid;
place-items: center;
box-shadow: var(--sky-shadow-glass);
}
.camera-selfie {
justify-self: end;
@@ -26,12 +26,6 @@ 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)",
@@ -149,9 +143,7 @@ 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,
)
@@ -201,7 +193,9 @@ 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,
)
+25 -10
View File
@@ -2174,7 +2174,6 @@ onMounted(async () => {
component="button"
type="button"
class="feather-compose-fab"
variant="glass"
:aria-label="t('newPost')"
@click="openComposer()"
>
@@ -2332,8 +2331,7 @@ onMounted(async () => {
/>
<template v-if="mediaPreview.items.length > 1">
<SkyButton
glass
icon-only
clear
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--left"
:aria-label="t('previousImage')"
@@ -2342,8 +2340,7 @@ onMounted(async () => {
<ChevronLeft :size="20" :stroke-width="2.8" />
</SkyButton>
<SkyButton
glass
icon-only
clear
rounded
class="feather-media-preview__arrow feather-media-preview__arrow--right"
:aria-label="t('nextImage')"
@@ -2392,15 +2389,22 @@ 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: 44px !important;
min-width: 44px;
height: 44px;
min-height: 44px;
width: 34px !important;
min-width: 34px;
height: 34px;
min-height: 34px;
border: 1.5px solid rgb(255 255 255 / 58%);
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 {
@@ -4482,6 +4486,7 @@ onMounted(async () => {
place-items: center;
}
.feather-compose-fab {
--sky-app-accent: #58a6ff;
position: absolute;
z-index: 12;
right: 14px;
@@ -4489,14 +4494,23 @@ onMounted(async () => {
width: 46px;
height: 46px;
min-width: 46px;
color: var(--feather-blue);
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%);
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;
@@ -5401,6 +5415,7 @@ 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/nui_server_bridge.lua', import.meta.url),
new URL('../../../../sky_phone/source/client/main.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).toMatch(/flare\s*=\s*\[\[[^\]]*delete-profile/)
expect(clientSource).toContain('"flare:delete-profile"')
expect(serverSource).toContain(
'Bridge.Callbacks.Register("sky_phone:flare:delete-profile"',
)
+7 -9
View File
@@ -2037,17 +2037,14 @@ onBeforeUnmount(() => {
role="dialog"
aria-modal="true"
>
<SkyButton
glass
icon-only
rounded
<button
type="button"
class="flare-match-reveal__close"
:aria-label="phone.t('Common.close')"
@click="matchReveal = null"
>
<X />
</SkyButton>
</button>
<Flame class="flare-match-reveal__flame" fill="currentColor" />
<h2>{{ phone.t('Apps.flare.itsAMatch') }}</h2>
<p>
@@ -3422,13 +3419,14 @@ onBeforeUnmount(() => {
position: absolute;
top: 24px;
right: 18px;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
color: #fff;
background: rgb(0 0 0 / 18%);
}
.flare-match-reveal__flame {
width: 58px;
+12 -27
View File
@@ -77,10 +77,6 @@ import {
SkyToggle,
} from '@/ui'
import { nuiCall } from '@/utils/nui'
import {
getPhoneOutputVolume,
subscribePhoneOutputVolume,
} from '@/utils/phoneAudio'
type Tab = 'feed' | 'discover' | 'create' | 'activity' | 'profile'
type AuthMode = 'login' | 'register'
@@ -198,8 +194,6 @@ let flipTokYoutubePlayer: YouTubePlayer | null = null
let flipTokYoutubeApi: YouTubeApi | null = null
let flipTokYoutubeOwner = ''
let flipTokYoutubeVideoId = ''
let flipTokYoutubeVolume = 0
let removePhoneOutputVolumeListener: (() => void) | undefined
let observer: IntersectionObserver | null = null
let videoClickTimer: number | null = null
let likePulseTimer: number | null = null
@@ -792,7 +786,6 @@ async function playFlipTokYoutube(
seconds = 0,
): Promise<void> {
flipTokYoutubeOwner = owner
flipTokYoutubeVolume = volume
try {
const api = await loadYouTubeApi()
flipTokYoutubeApi = api
@@ -801,7 +794,7 @@ async function playFlipTokYoutube(
flipTokYoutubeVideoId = videoId
flipTokYoutubePlayer.loadVideoById(videoId)
}
flipTokYoutubePlayer.setVolume(volume * getPhoneOutputVolume())
flipTokYoutubePlayer.setVolume(volume)
flipTokYoutubePlayer.seekTo(Math.max(0, seconds), true)
flipTokYoutubePlayer.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -842,7 +835,7 @@ async function playFlipTokYoutube(
},
onReady: (event) => {
flipTokYoutubePlayer = event.target
event.target.setVolume(volume * getPhoneOutputVolume())
event.target.setVolume(volume)
event.target.seekTo(Math.max(0, seconds), true)
event.target.playVideo()
if (owner === 'composer') customMusicLoadFailed.value = false
@@ -1634,10 +1627,7 @@ watch(originalVolume, (value) => {
watch(musicVolume, (value) => {
if (composerMusic.value) composerMusic.value.volume = value / 100
if (flipTokYoutubeOwner === 'composer') {
flipTokYoutubeVolume = value
flipTokYoutubePlayer?.setVolume(value * getPhoneOutputVolume())
}
if (flipTokYoutubeOwner === 'composer') flipTokYoutubePlayer?.setVolume(value)
})
watch(
@@ -1665,9 +1655,6 @@ watch(
)
onMounted(async () => {
removePhoneOutputVolumeListener = subscribePhoneOutputVolume((volume) => {
flipTokYoutubePlayer?.setVolume(flipTokYoutubeVolume * volume)
})
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
'fliptok:profile-avatar',
)
@@ -1744,7 +1731,6 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
removePhoneOutputVolumeListener?.()
observer?.disconnect()
if (videoClickTimer !== null) window.clearTimeout(videoClickTimer)
if (likePulseTimer !== null) window.clearTimeout(likePulseTimer)
@@ -2422,10 +2408,7 @@ onBeforeUnmount(() => {
alt=""
/>
<template v-if="selectedMediaItems.length > 1">
<SkyButton
glass
icon-only
rounded
<button
type="button"
class="compose-photo-preview__arrow compose-photo-preview__arrow--previous"
:disabled="composerPhotoIndex === 0"
@@ -2433,11 +2416,8 @@ onBeforeUnmount(() => {
@click="moveComposerPhoto(-1)"
>
<ChevronLeft />
</SkyButton>
<SkyButton
glass
icon-only
rounded
</button>
<button
type="button"
class="compose-photo-preview__arrow compose-photo-preview__arrow--next"
:disabled="composerPhotoIndex === selectedMediaItems.length - 1"
@@ -2445,7 +2425,7 @@ onBeforeUnmount(() => {
@click="moveComposerPhoto(1)"
>
<ChevronRight />
</SkyButton>
</button>
<span class="compose-photo-preview__count">
{{ composerPhotoIndex + 1 }} / {{ selectedMediaItems.length }}
</span>
@@ -5892,7 +5872,12 @@ onBeforeUnmount(() => {
padding: 0;
display: grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 22%);
border-radius: 50%;
background: rgb(14 14 16 / 58%);
color: #fff;
box-shadow: 0 4px 14px rgb(0 0 0 / 24%);
backdrop-filter: blur(10px);
pointer-events: auto;
}
@@ -57,21 +57,6 @@ describe('GalleryApp import action', () => {
)
})
it('uses the shared app tab bar for gallery filters', () => {
expect(source).toContain('<SkyTabBar')
expect(source).toContain('class="gallery-filter-tabbar"')
expect(source).toContain('icons')
expect(source).toContain('<Images :size="21" />')
expect(source).toContain('<Image :size="21" />')
expect(source).toContain('<Video :size="21" />')
expect(source).toContain('<SkyTabButton')
expect(source).toContain(':active="filter === \'all\'"')
expect(source).toContain(':active="filter === \'photo\'"')
expect(source).toContain(':active="filter === \'video\'"')
expect(source).not.toContain('gallery-filter-navbar')
expect(source).not.toContain('<sky-segmented')
})
it('opens an accessible sort menu from the large header', () => {
expect(headerActions).toContain('<ListFilter')
expect(headerActions).toContain('aria-haspopup="menu"')
+35 -32
View File
@@ -9,6 +9,8 @@ import {
SkyNavbarBackLink,
SkyAppPage,
SkySpinner,
SkySegmented,
SkySegmentedButton,
SkyNotification,
} from '@/ui'
import {
@@ -17,14 +19,11 @@ import {
Download,
Globe2,
Heart,
Image,
Images,
Link2,
ListFilter,
Play,
Share2,
Trash2,
Video,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -36,8 +35,6 @@ import {
SkyButton,
SkyDropdown,
SkyNavbar,
SkyTabBar,
SkyTabButton,
SkyToolbar,
SkyToolbarPane,
} from '@/ui'
@@ -71,6 +68,11 @@ const developmentParameters = isDevelopment
const developmentApiEnabled = Boolean(developmentParameters?.has('apiPort'))
const developmentGalleryState =
developmentParameters?.get('galleryMock') ?? null
const filterItems = [
{ id: 'all', label: 'all' },
{ id: 'photo', label: 'photos' },
{ id: 'video', label: 'videos' },
] as const
const phone = usePhoneStore()
const easyShare = useEasyShareStore()
const messageMedia = useMessageMediaStore()
@@ -1376,35 +1378,28 @@ onBeforeUnmount(() => {
</div>
</div>
<SkyTabBar
<sky-navbar
v-if="!requestedMessageMedia && !selectionMode"
icons
labels
class="gallery-filter-tabbar"
:label="phone.t('Apps.photos.name')"
component="nav"
class="gallery-filter-navbar"
:aria-label="phone.t('Apps.photos.name')"
>
<SkyTabButton
:active="filter === 'all'"
:label="phone.t('Apps.photos.filters.all')"
@click="filter = 'all'"
>
<template #icon><Images :size="21" /></template>
</SkyTabButton>
<SkyTabButton
:active="filter === 'photo'"
:label="phone.t('Apps.photos.filters.photos')"
@click="filter = 'photo'"
>
<template #icon><Image :size="21" /></template>
</SkyTabButton>
<SkyTabButton
:active="filter === 'video'"
:label="phone.t('Apps.photos.filters.videos')"
@click="filter = 'video'"
>
<template #icon><Video :size="21" /></template>
</SkyTabButton>
</SkyTabBar>
<template #subnavbar>
<sky-segmented strong rounded :data-active-filter="filter">
<sky-segmented-button
v-for="item in filterItems"
:key="item.id"
large
:active="filter === item.id"
:class="filter === item.id ? 'text-white' : 'text-[#8e8e93]'"
:aria-pressed="filter === item.id"
@click="filter = item.id"
>
{{ phone.t(`Apps.photos.filters.${item.label}`) }}
</sky-segmented-button>
</sky-segmented>
</template>
</sky-navbar>
<SkyToolbar
v-if="selectionMode"
@@ -1699,6 +1694,14 @@ onBeforeUnmount(() => {
flex-direction: column;
overflow-y: auto;
}
.gallery-filter-navbar {
position: absolute !important;
top: auto !important;
bottom: 24px;
}
.gallery-filter-navbar :deep(> div:nth-child(-n + 2)) {
display: none;
}
.gallery-grid {
position: relative;
display: grid;
@@ -19,7 +19,7 @@ const migrationSource = readFileSync(
'utf8',
)
const clientSource = readFileSync(
new URL('../../../../sky_phone/source/client/nui_server_bridge.lua', import.meta.url),
new URL('../../../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
@@ -112,7 +112,7 @@ describe('MailApp Sky UI contract', () => {
expect(toolbar).toContain('{{ activeFilterSummary }}')
expect(toolbar).toContain('<sky-searchbar')
expect(toolbar).toContain('@update:model-value="updateSearch"')
expect(toolbar).toContain('variant="glass"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('@click="beginCompose()"')
expect(source).not.toContain('class="mail-search"')
expect(source).not.toContain('class="mail-compose-fab"')
@@ -121,11 +121,6 @@ describe('MailApp Sky UI contract', () => {
)
})
it('uses liquid glass for mailbox, filter and compose floating actions', () => {
expect(source.match(/variant="glass"/g)).toHaveLength(5)
expect(source).not.toContain('variant="neutral"')
})
it('offers multiple filter criteria in one scrolling Sky UI modal', () => {
const filterStart = source.indexOf('class="mail-modal mail-filter-modal"')
const filterEnd = source.indexOf('</sky-sheet>', filterStart)
@@ -300,10 +295,7 @@ describe('Mail custom mailbox server contract', () => {
'mail:delete-mailbox',
'mail:move',
]) {
const callback = endpoint.slice('mail:'.length)
expect(clientSource).toMatch(
new RegExp(`mail\\s*=\\s*\\[\\[[^\\]]*(?:^|\\s)${callback}(?:\\s|\\]\\])`),
)
expect(clientSource).toContain(`"${endpoint}"`)
}
})
})
+5 -5
View File
@@ -1246,7 +1246,7 @@ onBeforeUnmount(() => {
v-if="editingMailboxes"
component="button"
type="button"
variant="glass"
variant="neutral"
:text="phone.t('Apps.mail.newMailbox')"
:aria-label="phone.t('Apps.mail.newMailbox')"
@click="beginMailboxCreate"
@@ -1257,7 +1257,7 @@ onBeforeUnmount(() => {
v-else
component="button"
type="button"
variant="glass"
variant="neutral"
:aria-label="phone.t('Apps.mail.compose')"
@click="beginCompose()"
>
@@ -1426,7 +1426,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="mail-filter-fab mail-filter-fab--active"
variant="glass"
variant="neutral"
:disabled="!canFilterFolder"
:aria-label="filterButtonLabel"
:aria-pressed="true"
@@ -1448,7 +1448,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="mail-filter-fab"
variant="glass"
variant="neutral"
:disabled="!canFilterFolder"
:aria-label="filterButtonLabel"
:aria-pressed="false"
@@ -1467,7 +1467,7 @@ onBeforeUnmount(() => {
<sky-fab
component="button"
type="button"
variant="glass"
variant="neutral"
:aria-label="phone.t('Apps.mail.compose')"
@click="beginCompose()"
>
@@ -15,17 +15,6 @@ describe('MapApp interaction contract', () => {
expect(source).not.toContain('<EasyShareSheet')
})
it('uses translucent liquid glass for every floating map control', () => {
const controlsStart = source.indexOf('class="map-controls"')
const controlsEnd = source.indexOf('</nav>', controlsStart)
const controls = source.slice(controlsStart, controlsEnd)
expect(controls.match(/variant="glass"/g)).toHaveLength(4)
expect(controls).not.toContain('variant="neutral"')
expect(controls).not.toContain('variant="primary"')
expect(source).toContain('--sky-glass: rgb(247 247 248 / 72%);')
})
it('keeps zoom and panning inside scale-aware map bounds', () => {
expect(source).toContain('minimumCoverZoom(metrics, baseMinZoom)')
expect(source).toContain('clampMapPan(nextPan, nextZoom, metrics)')
+7 -9
View File
@@ -667,7 +667,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--share"
variant="glass"
variant="primary"
:aria-label="phone.t('Apps.easyShare.name')"
@click="shareCurrentLocation"
>
@@ -679,7 +679,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control"
variant="glass"
variant="neutral"
:aria-label="`${phone.t('Apps.map.switchStyle')}: ${phone.t(`Apps.map.styles.${mapStyle}`)}`"
@click="cycleMapStyle"
>
@@ -691,7 +691,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--marker"
variant="glass"
variant="neutral"
:disabled="placingMarker"
:aria-label="phone.t('Apps.map.addMarker')"
@click="startMarkerPlacement"
@@ -704,7 +704,7 @@ onBeforeUnmount(() => {
component="button"
type="button"
class="map-control map-control--location"
variant="glass"
variant="neutral"
:disabled="locating"
:aria-label="phone.t('Apps.map.currentLocation')"
@click="loadCurrentLocation(true)"
@@ -1015,16 +1015,14 @@ onBeforeUnmount(() => {
}
.map-control {
--sky-glass: rgb(247 247 248 / 72%);
--sky-hairline: rgb(0 0 0 / 16%);
--sky-glass-solid: rgb(247 247 248 / 92%);
color: #151515;
}
.map-control--share {
color: #007aff;
color: #fff;
}
.sky-app-page--dark .map-control {
--sky-glass: rgb(44 44 46 / 62%);
--sky-hairline: rgb(255 255 255 / 16%);
--sky-glass-solid: rgb(44 44 46 / 88%);
color: #fff;
}
+9 -11
View File
@@ -24,7 +24,6 @@ import type {
MemoryDifficulty,
} from '@/features/games/memory/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const memory = useMemoryStore()
@@ -136,7 +135,7 @@ onBeforeUnmount(() => {
</div>
<div class="memory-header__actions">
<Sparkles :size="23" aria-hidden="true" />
<SkyButton glass icon-only rounded
<button
type="button"
:aria-label="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
:title="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
@@ -144,7 +143,7 @@ onBeforeUnmount(() => {
>
<Volume2 v-if="memory.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</SkyButton>
</button>
</div>
</header>
@@ -183,7 +182,7 @@ onBeforeUnmount(() => {
<section v-else class="memory-game">
<div class="memory-stats">
<SkyButton glass icon-only rounded
<button
type="button"
class="memory-menu-button"
:aria-label="phone.t('Apps.memory.backToMenu')"
@@ -191,7 +190,7 @@ onBeforeUnmount(() => {
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.6" aria-hidden="true" />
</SkyButton>
</button>
<div>
<span>{{ phone.t('Apps.memory.time') }}</span>
<strong>{{ formatTime(memory.elapsedMs) }}</strong>
@@ -317,7 +316,7 @@ onBeforeUnmount(() => {
}
.memory-header__actions > svg,
.memory-header__actions button:not(.sky-button--glass) {
.memory-header__actions button {
box-sizing: content-box;
padding: 9px;
border: 0;
@@ -326,8 +325,6 @@ onBeforeUnmount(() => {
background: rgb(255 255 255 / 54%);
}
.memory-header__actions .sky-button--glass { color: #7658c7; }
.memory-header__actions button {
display: grid;
place-items: center;
@@ -444,16 +441,17 @@ onBeforeUnmount(() => {
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
.memory-stats button:not(.sky-button--glass) { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats .memory-menu-button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
justify-self: start;
padding: 0;
color: #7052bf;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 48%);
cursor: pointer;
}
@@ -1,29 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./MemosApp.vue', import.meta.url), 'utf8')
describe('MemosApp layout', () => {
it('uses the shared horizontal page gutter in list and detail views', () => {
expect(source).toContain(
'<SkyScrollArea padded class="memos-page__content">',
)
expect(source).toContain(
'<SkyScrollArea padded class="memo-detail-scroll">',
)
})
it('optically centers playback speed labels without moving the controls', () => {
expect(source).toContain('<span class="memo-speed-label">')
expect(source).toContain('.memo-speed-label {')
expect(source).toContain('transform: translateY(1px)')
})
it('centers the skip duration inside a dedicated icon frame', () => {
expect(source).toContain('<span class="memo-skip-icon" aria-hidden="true">')
expect(source).toContain('<RotateCcw :size="24" />')
expect(source).toContain('<RotateCw :size="24" />')
expect(source).toContain('.memo-skip-icon small {')
expect(source).toContain('place-items: center')
})
})
+9 -32
View File
@@ -457,7 +457,7 @@ onBeforeUnmount(() => {
:title="phone.t('Apps.memos.name')"
/>
<SkyScrollArea padded class="memos-page__content">
<SkyScrollArea class="memos-page__content">
<div v-if="memos.loading" class="memos-loading">
<SkySpinner :label="phone.t('Common.loading')" :size="24" />
</div>
@@ -581,7 +581,7 @@ onBeforeUnmount(() => {
</template>
</SkyNavbar>
<SkyScrollArea padded class="memo-detail-scroll">
<SkyScrollArea class="memo-detail-scroll">
<SkyGlass class="memo-fields-glass" :highlight="false">
<SkyList nested :dividers="false">
<SkyField
@@ -648,10 +648,8 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.memos.skipBack')"
@click="skipPlayback(-15)"
>
<span class="memo-skip-icon" aria-hidden="true">
<RotateCcw :size="24" />
<small>15</small>
</span>
<RotateCcw :size="22" aria-hidden="true" />
<small>15</small>
</SkyButton>
<SkyButton
rounded
@@ -680,10 +678,8 @@ onBeforeUnmount(() => {
:aria-label="phone.t('Apps.memos.skipForward')"
@click="skipPlayback(15)"
>
<span class="memo-skip-icon" aria-hidden="true">
<RotateCw :size="24" />
<small>15</small>
</span>
<RotateCw :size="22" aria-hidden="true" />
<small>15</small>
</SkyButton>
</SkyGlass>
@@ -703,7 +699,7 @@ onBeforeUnmount(() => {
:active="playbackRate === rate"
@click="setPlaybackRate(rate)"
>
<span class="memo-speed-label">{{ rate }}×</span>
{{ rate }}×
</SkySegmentedButton>
</SkySegmented>
</SkyGlass>
@@ -1066,25 +1062,10 @@ onBeforeUnmount(() => {
padding: 0;
}
.memo-skip-icon {
position: relative;
width: 24px;
height: 24px;
display: grid;
flex: none;
place-items: center;
}
.memo-skip-icon small {
.memo-player-control small {
position: absolute;
inset: 0;
display: grid;
padding-top: 1px;
font-size: 7px;
font-size: 8px;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
place-items: center;
}
.memo-player-control--main {
@@ -1109,10 +1090,6 @@ onBeforeUnmount(() => {
background: transparent;
}
.memo-speed-label {
transform: translateY(1px);
}
.memo-delete-action {
min-height: 48px;
gap: 8px;
@@ -90,7 +90,7 @@ describe('MessagesApp Sky UI contract', () => {
expect(searchbarStart).toBeGreaterThan(-1)
expect(fabStart).toBeGreaterThan(searchbarStart)
expect(toolbar).toContain('v-model="search"')
expect(toolbar).toContain('variant="glass"')
expect(toolbar).toContain('variant="neutral"')
expect(toolbar).toContain('@click="beginCompose"')
expect(toolbar).toContain('<SquarePen :size="21" />')
expect(inbox).not.toContain('messages-sky-compose-navigation')
@@ -186,13 +186,6 @@ describe('MessagesApp Sky UI contract', () => {
expect(source).toContain(
'aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`',
)
expect(source).toContain('const gifColumns = computed')
expect(source).toContain('class="messages-gif-grid"')
expect(source).toContain('class="messages-gif-column"')
expect(source).toContain('class="messages-gif-result"')
expect(styles).toMatch(
/\.messages-media-picker__gifs--masonry \.messages-gif-result img\s*\{[^}]*object-fit:\s*cover/s,
)
})
it('opens contact sharing in a draggable Sky UI bottom sheet', () => {
+14 -39
View File
@@ -126,19 +126,6 @@ const gifLoading = ref(false)
const gifError = ref<string | null>(null)
const gifHasMore = ref(true)
const gifNextOffset = ref(0)
const gifColumns = computed<[GifSearchResult[], GifSearchResult[]]>(() => {
const columns: [GifSearchResult[], GifSearchResult[]] = [[], []]
const columnHeights = [0, 0]
for (const gif of gifResults.value) {
const columnIndex = columnHeights[0] <= columnHeights[1] ? 0 : 1
columns[columnIndex].push(gif)
columnHeights[columnIndex] +=
Math.max(1, gif.height) / Math.max(1, gif.width)
}
return columns
})
const recording = ref(false)
const recordingStarting = ref(false)
const recordingElapsedMs = ref(0)
@@ -1248,7 +1235,7 @@ onBeforeUnmount(() => {
:clear-label="phone.t('Common.clear')"
/>
<SkyFab
variant="glass"
variant="neutral"
:aria-label="phone.t('Apps.messages.compose')"
@click="beginCompose"
>
@@ -1727,10 +1714,7 @@ onBeforeUnmount(() => {
{{ phone.t('Apps.messages.noContactsToShare') }}
</p>
</SkyList>
<div
v-else
class="messages-media-picker__gifs messages-media-picker__gifs--masonry"
>
<div v-else class="messages-media-picker__gifs">
<SkySearchbar
v-model="gifQuery"
class="messages-gif-search"
@@ -1740,27 +1724,18 @@ onBeforeUnmount(() => {
@input="queueGifSearch"
@clear="queueGifSearch"
/>
<div v-if="gifResults.length" class="messages-gif-grid">
<div
v-for="(column, columnIndex) in gifColumns"
:key="columnIndex"
class="messages-gif-column"
>
<button
v-for="gif in column"
:key="gif.id"
type="button"
class="messages-gif-result"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@click="sendAttachment('gif', gif.url)"
>
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
</button>
</div>
</div>
<button
v-for="gif in gifResults"
:key="gif.id"
type="button"
:aria-label="gif.title"
:style="{
aspectRatio: `${Math.max(1, gif.width)} / ${Math.max(1, gif.height)}`,
}"
@click="sendAttachment('gif', gif.url)"
>
<img :src="gif.previewUrl" :alt="gif.title" loading="lazy" />
</button>
<button
v-if="gifResults.length && gifHasMore && !gifLoading"
type="button"
+8 -12
View File
@@ -23,7 +23,6 @@ import type {
MinesweeperDifficulty,
} from '@/features/games/minesweeper/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const minesweeper = useMinesweeperStore()
@@ -209,7 +208,7 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
</div>
<SkyButton glass icon-only rounded
<button
type="button"
:aria-label="
phone.t(
@@ -222,7 +221,7 @@ onBeforeUnmount(() => {
>
<Volume2 v-if="minesweeper.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</SkyButton>
</button>
</header>
<section v-if="minesweeper.menuOpen" class="minesweeper-menu">
@@ -276,7 +275,7 @@ onBeforeUnmount(() => {
}"
>
<div class="minesweeper-toolbar">
<SkyButton glass icon-only rounded
<button
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.backToMenu')"
@@ -284,7 +283,7 @@ onBeforeUnmount(() => {
@click.stop="minesweeper.showMenu()"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</SkyButton>
</button>
<div>
<span>{{ phone.t('Apps.minesweeper.mines') }}</span>
<strong>{{ minesRemaining }}</strong>
@@ -293,14 +292,14 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.minesweeper.time') }}</span>
<strong>{{ formatTime(minesweeper.elapsedMs) }}</strong>
</div>
<SkyButton glass icon-only rounded
<button
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.restart')"
@click="restart"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</SkyButton>
</button>
</div>
<div
@@ -439,8 +438,8 @@ onBeforeUnmount(() => {
.minesweeper-header span { display: block; color: #59878a; font-size: 9px; font-weight: 800; letter-spacing: 1.1px; text-transform: uppercase; }
.minesweeper-header h1 { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.7px; }
.minesweeper-header button:not(.sky-button--glass),
.minesweeper-toolbar__icon:not(.sky-button--glass) {
.minesweeper-header button,
.minesweeper-toolbar__icon {
width: 36px;
height: 36px;
display: grid;
@@ -453,9 +452,6 @@ onBeforeUnmount(() => {
box-shadow: 0 4px 10px rgb(23 73 75 / 8%);
}
.minesweeper-header .sky-button--glass { color: #246871; }
.minesweeper-toolbar .sky-button--glass { --sky-touch-target: 32px; width: 32px; height: 32px; color: #246871; }
.minesweeper-menu {
height: calc(100% - 55px);
display: flex;
+11 -23
View File
@@ -25,7 +25,6 @@ import type {
NeonDropPieceKind,
} from '@/features/games/neon-drop/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
type RenderCell = {
active: boolean
@@ -207,10 +206,7 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
</div>
<SkyButton
glass
icon-only
rounded
<button
type="button"
:aria-label="
phone.t(
@@ -223,7 +219,7 @@ onBeforeUnmount(() => {
v-else
:size="18"
/>
</SkyButton>
</button>
</header>
<section v-if="neon.menuOpen" class="neon-menu">
@@ -278,16 +274,13 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="neon-game">
<div class="neon-toolbar">
<SkyButton
glass
icon-only
rounded
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.backToMenu')"
@click="neon.showMenu()"
>
<ChevronLeft :size="19" />
</SkyButton>
</button>
<div>
<span>{{ phone.t('Apps.neonDrop.score') }}</span
><strong>{{ game.score }}</strong>
@@ -296,10 +289,7 @@ onBeforeUnmount(() => {
<span>{{ phone.t('Apps.neonDrop.lines') }}</span
><strong>{{ game.lines }}</strong>
</div>
<SkyButton
glass
icon-only
rounded
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.pause')"
@click="togglePause"
@@ -309,7 +299,7 @@ onBeforeUnmount(() => {
:size="16"
fill="currentColor"
/><Play v-else :size="16" fill="currentColor" />
</SkyButton>
</button>
</div>
<div class="neon-play-area">
@@ -413,8 +403,8 @@ onBeforeUnmount(() => {
font-size: 32px;
line-height: 1;
}
.neon-header button:not(.sky-button--glass),
.neon-toolbar button:not(.sky-button--glass) {
.neon-header button,
.neon-toolbar button {
width: 35px;
height: 35px;
display: grid;
@@ -425,10 +415,6 @@ onBeforeUnmount(() => {
color: #fff;
background: #ffffff0d;
}
.neon-header .sky-button--glass,
.neon-toolbar .sky-button--glass {
color: #fff;
}
.neon-menu {
height: calc(100% - 54px);
display: flex;
@@ -628,9 +614,11 @@ onBeforeUnmount(() => {
line-height: 20px;
}
.neon-toolbar button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.neon-play-area {
position: absolute;
@@ -10,11 +10,10 @@ const menuSource = source.slice(
source.indexOf('<SkyActionSheet'),
source.indexOf('</SkyActionSheet>') + '</SkyActionSheet>'.length,
)
const listStart = source.search(/<sky-app-page\r?\n\s+v-if="!editorOpened"/)
const listSource = source.slice(
listStart,
source.indexOf('<sky-app-page v-else'),
const listStart = source.search(
/<sky-app-page\r?\n\s+v-if="!editorOpened"/,
)
const listSource = source.slice(listStart, source.indexOf('<sky-app-page v-else'))
describe('NotesApp list controls', () => {
it('places the Sky searchbar and create action together at the bottom', () => {
@@ -28,7 +27,7 @@ describe('NotesApp list controls', () => {
expect(composerSource).toContain('<SkySearchbar')
expect(composerSource).toContain('v-model="searchQuery"')
expect(composerSource).toContain('<SkyFab')
expect(composerSource).toContain('variant="glass"')
expect(composerSource).toContain('variant="neutral"')
expect(composerSource).toContain('@click="createNote"')
expect(composerSource).not.toContain('notes-search')
expect(composerSource).not.toContain('notes-create-fab')
@@ -38,20 +37,6 @@ describe('NotesApp list controls', () => {
})
})
describe('NotesApp headers', () => {
it('keeps list and editor headers at the shared app height', () => {
expect(source).toContain('class="notes-list-navbar"')
expect(source).toContain(
'.notes-list-navbar.sky-navbar--large.sky-navbar--no-navigation',
)
expect(source).toContain(
'padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3))',
)
expect(source).toContain('class="notes-editor-page !pb-0"')
expect(source).not.toContain('notes-editor-page !pt-[44px]')
})
})
describe('NotesApp more menu', () => {
it('uses the shared Feather-style action sheet', () => {
expect(menuSource).toContain(
+2 -13
View File
@@ -206,7 +206,6 @@ function shareNote(): void {
:aria-label="phone.t('Apps.notes.name')"
>
<sky-navbar
class="notes-list-navbar"
variant="large"
transparent
:title="phone.t('Apps.notes.name')"
@@ -274,7 +273,7 @@ function shareNote(): void {
/>
<SkyFab
:aria-label="phone.t('Apps.notes.newNote')"
variant="glass"
variant="neutral"
@click="createNote"
>
<template #icon>
@@ -306,7 +305,7 @@ function shareNote(): void {
</SkyDialog>
</sky-app-page>
<sky-app-page v-else class="notes-editor-page !pb-0">
<sky-app-page v-else class="notes-editor-page !pt-[44px] !pb-0">
<sky-navbar :title="phone.t('Apps.notes.note')">
<template #left>
<sky-navbar-back-link
@@ -387,16 +386,6 @@ function shareNote(): void {
overflow: hidden;
}
:deep(.notes-list-navbar.sky-navbar--large) {
min-height: calc(
var(--sky-navbar-safe-area-top) + var(--sky-navbar-large-title-height)
);
}
:deep(.notes-list-navbar.sky-navbar--large.sky-navbar--no-navigation) {
padding-top: calc(var(--sky-navbar-safe-area-top) + var(--sky-space-3));
}
.notes-delete-confirm {
color: var(--sky-danger);
background: var(--sky-danger-soft);
+8 -12
View File
@@ -14,7 +14,6 @@ import type {
NumberMergeTile,
} from '@/features/games/number-merge/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const numberMerge = useNumberMergeStore()
@@ -136,7 +135,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<span>{{ phone.t('Apps.numberMerge.eyebrow') }}</span>
<h1>{{ phone.t('Apps.numberMerge.name') }}</h1>
</div>
<SkyButton glass icon-only rounded
<button
type="button"
:aria-label="
phone.t(
@@ -156,7 +155,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
>
<Volume2 v-if="numberMerge.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</SkyButton>
</button>
</header>
<section v-if="numberMerge.menuOpen" class="number-merge-menu">
@@ -211,7 +210,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<section v-else-if="game" class="number-merge-game">
<div class="number-merge-toolbar">
<SkyButton glass icon-only rounded
<button
type="button"
class="number-merge-toolbar__icon"
:aria-label="phone.t('Apps.numberMerge.backToMenu')"
@@ -219,7 +218,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
@click="numberMerge.showMenu"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</SkyButton>
</button>
<div>
<span>{{ phone.t('Apps.numberMerge.score') }}</span>
<strong>{{ formatScore(game.score) }}</strong>
@@ -228,7 +227,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
<span>{{ phone.t('Apps.numberMerge.best') }}</span>
<strong>{{ formatScore(numberMerge.bestScore) }}</strong>
</div>
<SkyButton glass icon-only rounded
<button
type="button"
class="number-merge-toolbar__icon number-merge-toolbar__restart"
:aria-label="phone.t('Apps.numberMerge.newGame')"
@@ -236,7 +235,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
@click="requestNewGame"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</SkyButton>
</button>
</div>
<div
@@ -374,8 +373,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
overflow-wrap: anywhere;
}
.number-merge-header button:not(.sky-button--glass),
.number-merge-toolbar__icon:not(.sky-button--glass) {
.number-merge-header button,
.number-merge-toolbar__icon {
width: 36px;
height: 36px;
display: grid;
@@ -389,9 +388,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
cursor: pointer;
}
.number-merge-header .sky-button--glass { color: #784c3c; }
.number-merge-toolbar .sky-button--glass { --sky-touch-target: 32px; width: 32px; height: 32px; color: #784c3c; }
.number-merge-menu {
height: calc(100% - 55px);
min-height: 0;
@@ -16,45 +16,7 @@ describe('PhoneApp EasyShare contract', () => {
it('uses the shared full-width Sky tab bar for phone sections', () => {
expect(source).toContain('<sky-tab-bar')
expect(source).toContain('<sky-tab-button')
})
it('uses shared interactive liquid glass surfaces for phone controls', () => {
expect(source).toMatch(
/<sky-button\s+glass\s+rounded\s+class="phone-detail-header-button phone-detail-back"/,
)
expect(source).toMatch(
/<sky-button\s+glass\s+v-for="action in contactProfileActions"[\s\S]*?icon-only[\s\S]*?class="phone-profile-action"/,
)
expect(source).toContain(
'width: var(--phone-profile-action-size) !important;',
)
expect(source).toContain(
'height: var(--phone-profile-action-size) !important;',
)
expect(source).toMatch(
/<sky-glass\s+v-for="key in keypadKeys"[\s\S]*?component="button"[\s\S]*?class="phone-keypad-key"/,
)
expect(source).toContain('class="phone-contacts-add"')
expect(source).toContain('class="phone-recents-search"')
const recentsFilter = source.slice(
source.indexOf('<sky-segmented'),
source.indexOf('</sky-segmented>') + '</sky-segmented>'.length,
)
expect(recentsFilter).toContain('class="phone-recents-filter"')
expect(recentsFilter).toContain('navigation')
expect(recentsFilter).toContain('strong')
expect(recentsFilter.match(/<sky-segmented-button/g)).toHaveLength(2)
expect(source).toContain(
'background: var(--sky-tabbar-highlight-background);',
)
expect(source).toContain(
'grid-template-columns: 52px minmax(0, 1fr) auto 44px;',
)
expect(source).not.toMatch(
/#(?:007aff|0a84ff|195287|22527d|25458e|2a468f|2f4a98|4b92d1|55aaff|5b91c2|64a8ff|68adff)/i,
)
expect(source).not.toContain('rgba(10, 132, 255')
expect(source).not.toContain('<sky-segmented')
})
it('opens contact deep links only after contacts bootstrap and consumes the query', () => {
File diff suppressed because it is too large Load Diff
+22 -38
View File
@@ -1062,20 +1062,14 @@ onBeforeUnmount(() => {
selectedPost.media.length
}}</span
>
<SkyButton
glass
icon-only
rounded
<button
class="ps-carousel-button ps-carousel-button--left"
:disabled="(carouselIndexes[selectedPost.id] ?? 0) === 0"
@click="moveCarousel(selectedPost, -1)"
>
<ChevronLeft />
</SkyButton>
<SkyButton
glass
icon-only
rounded
</button>
<button
class="ps-carousel-button ps-carousel-button--right"
:disabled="
(carouselIndexes[selectedPost.id] ?? 0) ===
@@ -1084,7 +1078,7 @@ onBeforeUnmount(() => {
@click="moveCarousel(selectedPost, 1)"
>
<ChevronRight />
</SkyButton>
</button>
<div class="ps-dots">
<span
v-for="(_, index) in selectedPost.media"
@@ -1277,20 +1271,14 @@ onBeforeUnmount(() => {
post.media.length
}}</span
>
<SkyButton
glass
icon-only
rounded
<button
class="ps-carousel-button ps-carousel-button--left"
:disabled="(carouselIndexes[post.id] ?? 0) === 0"
@click="moveCarousel(post, -1)"
>
<ChevronLeft />
</SkyButton>
<SkyButton
glass
icon-only
rounded
</button>
<button
class="ps-carousel-button ps-carousel-button--right"
:disabled="
(carouselIndexes[post.id] ?? 0) === post.media.length - 1
@@ -1298,7 +1286,7 @@ onBeforeUnmount(() => {
@click="moveCarousel(post, 1)"
>
<ChevronRight />
</SkyButton>
</button>
<div class="ps-dots">
<span
v-for="(_, index) in post.media"
@@ -1499,28 +1487,22 @@ onBeforeUnmount(() => {
</button>
</div>
<template v-if="selectedMedia.length > 1">
<SkyButton
glass
icon-only
rounded
<button
class="ps-selection-arrow ps-selection-arrow--left"
:aria-label="t('previousPhoto')"
:disabled="composePreviewIndex === 0"
@click="moveComposePreview(-1)"
>
<ChevronLeft />
</SkyButton>
<SkyButton
glass
icon-only
rounded
</button>
<button
class="ps-selection-arrow ps-selection-arrow--right"
:aria-label="t('nextPhoto')"
:disabled="composePreviewIndex === selectedMedia.length - 1"
@click="moveComposePreview(1)"
>
<ChevronRight />
</SkyButton>
</button>
<span class="ps-selection-counter">
{{ composePreviewIndex + 1 }}/{{ selectedMedia.length }}
</span>
@@ -2876,11 +2858,12 @@ button {
top: 50%;
display: grid;
place-items: center;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
background: rgba(0, 0, 0, 0.58);
color: white;
transform: translateY(-50%);
}
@@ -3208,11 +3191,12 @@ button {
top: 50%;
display: grid;
place-items: center;
width: 44px;
min-width: 44px;
height: 44px;
min-height: 44px;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.62);
color: white;
transform: translateY(-50%);
}
+6 -7
View File
@@ -9,7 +9,6 @@ import SkyFlappyBird from '@/features/games/sky-flappy/SkyFlappyBird.vue'
import { useSkyFlappyStore } from '@/features/games/sky-flappy/store'
import type { SkyFlappyDesign, SkyFlappyObstacle } from '@/features/games/sky-flappy/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const flappy = useSkyFlappyStore()
@@ -117,9 +116,9 @@ onBeforeUnmount(() => {
>
<header v-if="flappy.menuOpen" class="flappy-header">
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
</SkyButton>
</button>
</header>
<section v-if="flappy.menuOpen" class="flappy-menu">
@@ -142,10 +141,10 @@ onBeforeUnmount(() => {
<section v-else-if="game" class="flappy-game">
<div class="flappy-toolbar">
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></SkyButton>
<button type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></button>
<div><span>{{ phone.t('Apps.skyFlappy.score') }}</span><strong>{{ game.score }}</strong></div>
<div><span>{{ phone.t('Apps.skyFlappy.best') }}</span><strong>{{ flappy.highScore }}</strong></div>
<SkyButton glass icon-only rounded type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></SkyButton>
<button type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></button>
</div>
<button type="button" class="flappy-stage" :class="{ 'flappy-stage--crashed': game.status === 'over' && !gameOverVisible }" :aria-label="phone.t('Apps.skyFlappy.flap')" @pointerdown.stop.prevent="flap">
@@ -170,7 +169,7 @@ onBeforeUnmount(() => {
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
.flappy-app--playing { padding:0; }
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 28px #223c6d35,0 18px 32px #101b3b66}.flappy-menu__hero::before{position:absolute;z-index:0;top:22px;left:25px;width:39px;height:39px;border-radius:50%;background:#ffe8a7cc;box-shadow:0 0 18px #fff1b46b;content:""}.flappy-menu__hero::after{position:absolute;z-index:1;bottom:30px;left:-12px;width:68px;height:18px;border-radius:999px;background:#ffffff3d;box-shadow:27px -9px 0 3px #ffffff36,57px 1px 0 -2px #ffffff2c;content:""}.flappy-menu__tower{position:absolute;z-index:3;right:10px;width:42px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 30%),var(--tower));box-shadow:inset 7px 0 0 #ffffff22,inset -6px 0 9px #0003}.flappy-menu__tower::after{position:absolute;left:-8px;width:58px;height:22px;border:3px solid color-mix(in srgb,var(--tower),black 22%);border-radius:7px;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 36%),var(--tower));box-shadow:inset 0 4px 0 #ffffff26;content:""}.flappy-menu__tower--top{top:0;height:61px;border-radius:0 0 5px 5px}.flappy-menu__tower--top::after{bottom:-11px}.flappy-menu__tower--bottom{bottom:0;height:67px;border-radius:5px 5px 0 0}.flappy-menu__tower--bottom::after{top:-11px}.flappy-menu__trail{position:absolute;z-index:2;top:102px;left:11px;width:46px;height:4px;border-radius:99px;background:#edfbffb8;box-shadow:13px 12px 0 -1px #e9faff8c,6px -12px 0 -1px #e9faff73;animation:flappy-trail 1s ease-in-out infinite}.sky-bird{position:absolute;z-index:4;width:66px;height:46px;overflow:visible;filter:drop-shadow(0 4px 5px #071d3e66)}.sky-bird :deep(path){stroke-linecap:round;stroke-linejoin:round}.sky-bird :deep(.sky-flappy-bird__far-wing){fill:#2d7188;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__far-wing path:last-child){fill:none;stroke:#8ec6ce;stroke-width:1.4;opacity:.65}.sky-bird :deep(.sky-flappy-bird__tail){fill:#285e78;stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__body){fill:url(#sky-bird-body);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__belly){fill:#b7e5df;opacity:.84}.sky-bird :deep(.sky-flappy-bird__neck){fill:#377f98}.sky-bird :deep(.sky-flappy-bird__wing){transform-box:view-box;transform-origin:52px 38px}.sky-bird :deep(.sky-flappy-bird__wing-shape){fill:url(#sky-bird-wing);stroke:#123f57;stroke-width:2}.sky-bird :deep(.sky-flappy-bird__feather){fill:none;stroke:#d4f0ed;stroke-width:1.5;opacity:.78}.sky-bird :deep(.sky-flappy-bird__face){fill:#d8f0e9}.sky-bird :deep(.sky-flappy-bird__eye-ring){fill:#f5fbf7}.sky-bird :deep(.sky-flappy-bird__eye){fill:#102d3d}.sky-bird :deep(.sky-flappy-bird__beak){fill:#f2a84b;stroke:#6f4727;stroke-width:1.5}.sky-bird--hero{top:72px;left:42px;width:100px;height:69px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.sky-bird--hero :deep(.sky-flappy-bird__wing){animation:flappy-bird-glide 1.1s ease-in-out infinite}.sky-bird--hero :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-glide 1.1s ease-in-out infinite}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-secondary{width:100%;min-height:43px;display:flex;align-items:center;justify-content:center;gap:7px;border-radius:14px;font-size:11px;font-weight:850}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
.flappy-game{position:absolute;inset:0}.flappy-toolbar{position:absolute;z-index:10;top:48px;right:14px;left:14px;height:42px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px;padding:4px 6px;border:1px solid #ffffff2b;border-radius:22px;background:#263c6da8;box-shadow:0 8px 24px #10193455;backdrop-filter:blur(14px)}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-toolbar button{width:34px;height:34px;border:0;border-radius:50%;box-shadow:none}.flappy-stage{position:absolute;inset:0;width:100%;height:100%;display:block;overflow:hidden;padding:0;border:0;border-radius:0;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55;touch-action:manipulation}.flappy-clouds{position:absolute;z-index:1;inset:0;overflow:hidden;pointer-events:none}.flappy-clouds i{--cloud-scale:1;--cloud-opacity:.34;--cloud-duration:18s;--cloud-delay:0s;position:absolute;left:100%;width:70px;height:18px;border-radius:999px;background:linear-gradient(180deg,#ffffffd9,#eaf7ff9c);box-shadow:0 8px 16px #24376518;opacity:var(--cloud-opacity);animation:cloud-drift var(--cloud-duration) linear var(--cloud-delay) infinite;will-change:transform}.flappy-clouds i::before{position:absolute;bottom:4px;left:12px;width:29px;height:29px;border-radius:50%;background:#f8fcffe6;box-shadow:22px -8px 0 4px #f7fcff,40px 1px 0 -2px #eef9ff;content:""}.flappy-clouds i::after{position:absolute;right:8px;bottom:-3px;left:8px;height:8px;border-radius:50%;background:#bcdff477;filter:blur(4px);content:""}.flappy-clouds i:nth-child(1){--cloud-scale:.7;--cloud-opacity:.3;--cloud-duration:20s;--cloud-delay:-4s;top:9%}.flappy-clouds i:nth-child(2){--cloud-scale:1.05;--cloud-opacity:.4;--cloud-duration:15s;--cloud-delay:-11s;top:22%}.flappy-clouds i:nth-child(3){--cloud-scale:.52;--cloud-opacity:.25;--cloud-duration:23s;--cloud-delay:-17s;top:38%}.flappy-clouds i:nth-child(4){--cloud-scale:.88;--cloud-opacity:.36;--cloud-duration:17s;--cloud-delay:-7s;top:53%}.flappy-clouds i:nth-child(5){--cloud-scale:1.18;--cloud-opacity:.42;--cloud-duration:14s;--cloud-delay:-2s;top:68%}.flappy-clouds i:nth-child(6){--cloud-scale:.62;--cloud-opacity:.27;--cloud-duration:21s;--cloud-delay:-14s;top:78%}.flappy-clouds i:nth-child(7){--cloud-scale:.96;--cloud-opacity:.34;--cloud-duration:16s;--cloud-delay:-9s;top:86%}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-bird--player{left:23%;animation:flappy-wing .24s ease-out}.sky-bird--player :deep(.sky-flappy-bird__wing){animation:flappy-bird-flap .24s cubic-bezier(.2,.75,.35,1)}.sky-bird--player :deep(.sky-flappy-bird__far-wing){transform-box:view-box;transform-origin:50px 38px;animation:flappy-bird-far-flap .24s cubic-bezier(.2,.75,.35,1)}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{position:absolute;z-index:6;right:45px;bottom:27px;left:45px;margin:0;padding:7px 10px;border-radius:999px;background:#263c6d91;backdrop-filter:blur(10px);text-align:center;pointer-events:none}.flappy-overlay{position:absolute;z-index:12;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
.flappy-menu__tower{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff38,inset -6px 0 9px #0004,0 0 18px var(--tower-glow)}
@@ -191,7 +190,7 @@ onBeforeUnmount(() => {
.flappy-toolbar strong{display:block;font-size:19px;line-height:20px}
.flappy-toolbar{top:66px;right:18px;left:18px;height:42px;grid-template-columns:32px 1fr 1fr 32px;gap:4px;padding:4px;border-radius:21px;box-sizing:border-box}
.flappy-toolbar div{height:32px;display:grid;grid-template-rows:10px 20px;align-content:center;justify-items:center}
.flappy-toolbar button{--sky-touch-target:32px;width:32px;height:32px}
.flappy-toolbar button{width:32px;height:32px}
.flappy-clouds{top:110px}
.flappy-stage{border:0;box-shadow:inset 0 0 35px #15244c38}
.flappy-obstacle span{border:2px solid var(--tower-dark);background:linear-gradient(90deg,var(--tower-light),var(--tower),var(--tower-dark));box-shadow:inset 7px 0 0 #ffffff42,inset -6px 0 8px #0004,0 0 18px var(--tower-glow)}
@@ -0,0 +1,445 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const viewSource = readFileSync(
new URL('./SkyPicApp.vue', import.meta.url),
'utf8',
)
const storeSource = readFileSync(
new URL('../../stores/skypic.ts', import.meta.url),
'utf8',
)
const typeSource = readFileSync(
new URL('../../types/skypic.ts', import.meta.url),
'utf8',
)
describe('SkyPic frontend contract', () => {
it('uses Sky UI and exposes the camera-first five-tab shell', () => {
expect(viewSource).toContain("from '@/ui'")
expect(viewSource).not.toContain("from 'konsta/vue'")
expect(viewSource).toContain("const activeTab = ref<Tab>('camera')")
expect(viewSource).toContain(
"type Tab = 'camera' | 'chats' | 'friends' | 'spotlight' | 'stories'",
)
expect(viewSource).toContain("activeTab === 'camera'")
expect(viewSource).toContain("activeTab === 'chats'")
expect(viewSource).toContain("activeTab === 'stories'")
expect(viewSource).toContain("activeTab === 'friends'")
expect(viewSource).toContain("activeTab === 'spotlight'")
expect(viewSource.match(/<SkyTabButton/g)).toHaveLength(5)
})
it('keeps every bottom tab and app surface theme-monochrome', () => {
expect(viewSource).toContain('phone.isDarkMode')
expect(viewSource).toContain("'skypic-app--player-dark': phone.isDarkMode")
expect(viewSource).toContain(
"'skypic-app--player-light': !phone.isDarkMode",
)
expect(viewSource).toContain(
'class="sp-tab sp-tab--camera sp-tab--monochrome"',
)
expect(viewSource.match(/class="sp-tab sp-tab--monochrome"/g)).toHaveLength(
4,
)
expect(viewSource).not.toContain('color: var(--sp-camera-blue) !important')
expect(viewSource).toContain('--sp-tab-monochrome: #000000')
expect(viewSource).toContain('--sp-tab-monochrome: #ffffff')
expect(viewSource).toContain('color: var(--sp-tab-monochrome) !important')
const cameraCss = viewSource
.split('.sp-camera-screen {')[1]
?.split('.sp-camera-header')[0]
expect(cameraCss).toBeTruthy()
expect(cameraCss).toContain('background: #000')
expect(cameraCss).not.toContain('255, 91, 189')
expect(viewSource).toContain('rgba(18, 18, 18, 0.92)')
expect(viewSource).not.toContain('var(--sp-cyan)')
})
it('uses the reference-led Sky UI hierarchy with Spotlight and no camera filters', () => {
expect(viewSource.match(/class="sp-camera-header__actions"/g)).toHaveLength(
2,
)
expect(viewSource.match(/class="sp-camera-header__glass"/g)).toHaveLength(3)
expect(viewSource).toContain('class="sp-camera-viewfinder"')
expect(viewSource).toContain('class="sp-camera-dock"')
const cameraMarkup = viewSource
.split('v-if="activeTab === \'camera\'"')[1]
?.split('<template v-else-if="activeTab === \'chats\'">')[0]
expect(cameraMarkup).toContain('<SkyGlass')
expect(cameraMarkup).toContain('class="sp-shutter"')
expect(viewSource).toContain('class="sp-chat-list"')
expect(viewSource).toContain('class="sp-chat-row')
expect(viewSource).toContain('class="sp-story-rail"')
expect(viewSource).toContain('class="sp-story-grid"')
expect(viewSource).toContain('class="sp-discovery-grid"')
expect(viewSource).toContain('width: calc(100% - (var(--sky-space-3) * 2))')
expect(viewSource).toContain('class="sp-bottom-nav"')
expect(viewSource).not.toContain('sp-camera-filter')
expect(viewSource).toContain('class="sp-spotlight-player"')
expect(
viewSource.match(/class="sp-spotlight-header__actions/g),
).toHaveLength(2)
expect(
viewSource.match(/class="sp-spotlight-header__glass"/g),
).toHaveLength(3)
expect(viewSource).toContain(
'height: calc(var(--sky-safe-area-top) + var(--sky-navbar-height))',
)
expect(viewSource).toContain(
'padding: var(--sky-safe-area-top) var(--sky-page-gutter)',
)
expect(viewSource).toContain('min-height: var(--sky-navbar-height)')
expect(viewSource).toContain("t('spotlight.sponsored')")
expect(viewSource).toContain('store.publishSpotlight')
expect(viewSource).not.toContain('href=')
})
it('builds story discovery only from safe metadata until view-story releases media', () => {
const storyFeedBlock = viewSource
.split('<section class="sp-story-feed">')[1]
?.split('</section>')[0]
expect(viewSource).toContain('const friendStoryRail = computed(')
expect(storyFeedBlock).toBeTruthy()
expect(storyFeedBlock).toContain('v-for="story in store.stories"')
expect(storyFeedBlock).toContain('story.author.avatarUrl')
expect(storyFeedBlock).toContain('story.durationSeconds')
expect(storyFeedBlock).not.toContain('story.url')
expect(storyFeedBlock).not.toContain('store.viewedStory.url')
})
it('renders every visible streak as an accessible Flame icon', () => {
expect(viewSource.match(/<Flame/g)?.length).toBeGreaterThanOrEqual(4)
expect(viewSource).toContain("t('profile.streaks') +")
expect(viewSource).toContain('class="sp-navbar-streak"')
expect(viewSource).toContain('class="sp-profile-streak-stat"')
expect(viewSource).not.toContain('🔥')
})
it('keeps existing Snap and Story viewers full-bleed with ordered overlays', () => {
expect(viewSource).toContain(
'class="sp-media-viewer sp-media-viewer--snap"',
)
expect(viewSource).toContain(
'class="sp-media-viewer sp-media-viewer--story"',
)
expect(viewSource).toContain('object-fit: cover')
expect(viewSource).toContain('.sp-media-viewer::before')
expect(viewSource).toContain('.sp-media-viewer::after')
})
it('uses the shared Camera and Gallery media handoff with a bounded draft', () => {
expect(viewSource).toContain('mediaPicker.begin(')
expect(viewSource).toContain("'skypic-draft'")
expect(viewSource).toContain('`/apps/skypic?compose=${purpose}`')
expect(viewSource).toContain(
"mediaPicker.consumeMany<SkyPicMediaDraftContext>('skypic-draft')",
)
expect(viewSource).toContain('path: `/apps/${source}`')
expect(viewSource).toContain('query: { mediaAttachment: mediaType }')
expect(viewSource).toContain("type MediaSource = 'camera' | 'photos'")
})
it('supports a bounded multi-photo thread handoff with preview controls', () => {
expect(viewSource).toContain('const MAX_THREAD_ATTACHMENTS = 10')
expect(viewSource).toContain("'skypic-thread-media'")
expect(viewSource).toContain(
'mediaPicker.consumeMany<SkyPicThreadMediaDraftContext>',
)
expect(viewSource).toContain('pendingThreadMedia')
expect(viewSource).toContain('mediaIds: queuedMedia.map')
expect(viewSource).toContain('removeThreadMedia(media.id)')
expect(viewSource).toContain('moveThreadMedia(index, -1)')
expect(viewSource).toContain('moveThreadMedia(index, 1)')
expect(viewSource).toContain("openThreadMedia('photos', 'photo')")
expect(viewSource).toContain("openThreadMedia('camera', 'photo')")
expect(viewSource).toContain("openThreadMedia('photos', 'video')")
expect(viewSource).toContain('openThreadEmojiPicker')
expect(typeSource).toContain('SkyPicThreadMediaDraftContext')
expect(typeSource).toContain('mediaIds: number[]')
})
it('aligns chat ownership and keeps the story reply action inside the viewer', () => {
expect(viewSource).toContain(
`:type="entry.value.direction === 'sent' ? 'sent' : 'received'"`,
)
const threadLayoutCss = viewSource
.split('.sp-thread :deep(.sky-messages) {')[1]
?.split('.sp-thread :deep(.sky-message__footer)')[0]
expect(threadLayoutCss).toContain('display: flex')
expect(threadLayoutCss).toContain('flex-direction: column')
const sentSnapCss = viewSource
.split('.sp-thread-snap--sent {')[1]
?.split('.sp-thread-snap > span')[0]
expect(sentSnapCss).toContain('align-self: flex-end')
const storyReplyCss = viewSource
.split('.sp-story-reply {')[1]
?.split('.sp-media-viewer__owner-actions button')[0]
expect(storyReplyCss).toContain('width: auto')
expect(storyReplyCss).toContain('right: var(--sky-page-gutter)')
expect(storyReplyCss).toContain('left: var(--sky-page-gutter)')
})
it('uses app-local authentication and exposes safe account controls', () => {
expect(viewSource).toContain('useAppAuthStore()')
expect(viewSource).toContain("appAuth.isSignedIn('skypic')")
expect(viewSource).toContain("appAuth.signIn('skypic', account.email)")
expect(viewSource).toContain("appAuth.signOut('skypic')")
expect(viewSource).toContain('<AppProfileAuth')
expect(viewSource).toContain('moving-mode-highlight')
expect(viewSource).toContain(':login-mode-label=')
expect(viewSource).toContain(':register-mode-label=')
expect(viewSource).toContain('<AccountLogoutDialog')
expect(viewSource).toContain('app-id="skypic"')
expect(viewSource).toContain('deleteSkyPicAccount')
expect(viewSource).toContain('store.resetSession()')
expect(storeSource).toContain("'skypic:delete-account'")
expect(storeSource).toContain('confirmed: true')
})
it('uses the production SkyPic handle rule for login and registration', () => {
expect(viewSource).toContain('isValidSkyPicHandle(authHandle.value)')
expect(viewSource).toContain(
'const handle = normalizeSkyPicHandle(onboarding.handle)',
)
expect(storeSource).toContain('/^[a-z0-9._]+$/')
})
it('reacts to confirmed remote account deletion with a clean register state', () => {
expect(viewSource).toContain('() => store.profileAbsentRevision')
const resetBlock = viewSource
.split('function resetAccountUiState(accountExists: boolean): void {')[1]
?.split('function handleLoggedOut')[0]
expect(resetBlock).toContain(
"authMode.value = accountExists ? 'login' : 'register'",
)
expect(resetBlock).toContain('deleteAccountDialogOpen.value = false')
expect(resetBlock).toContain('profileEditing.value = false')
expect(resetBlock).toContain('pendingThreadMedia.value = []')
expect(resetBlock).toContain('storyViewerSheetOpen.value = false')
expect(resetBlock).toContain("storyViewerSheetStoryId.value = ''")
expect(resetBlock).toContain("storyReply.value = ''")
expect(resetBlock).toContain('storyNavigationRequest += 1')
expect(resetBlock).toContain('clearStoryTimer()')
expect(resetBlock).toContain('store.clearViewedStory()')
expect(resetBlock).toContain("activeTab.value = 'camera'")
})
it('clears private SkyPic state while the local app session is signed out', () => {
const logoutBlock = viewSource
.split('function handleLoggedOut(): void {')[1]
?.split('async function deleteSkyPicAccount')[0]
const bootstrapBlock = viewSource
.split('async function bootstrapApp(): Promise<void> {')[1]
?.split('onMounted(')[0]
expect(logoutBlock).toContain('store.resetSession()')
expect(logoutBlock).not.toContain('bootstrapApp()')
expect(bootstrapBlock).toContain("!appAuth.isSignedIn('skypic')")
expect(bootstrapBlock).toContain('store.resetSession()')
expect(viewSource).toContain(
'hasSkyPicAccount.value = Boolean(store.profile)',
)
})
it('renders local registration guidance without bootstrapping an absent Sky account', () => {
const bootstrapBlock = viewSource
.split('async function bootstrapApp(): Promise<void> {')[1]
?.split('onMounted(')[0]
const noAccountGuard = bootstrapBlock?.indexOf('if (!account.email)') ?? -1
const serverBootstrap = bootstrapBlock?.indexOf(
'const loaded = await store.bootstrap()',
)
expect(noAccountGuard).toBeGreaterThanOrEqual(0)
expect(noAccountGuard).toBeLessThan(serverBootstrap ?? -1)
expect(bootstrapBlock).toContain('resetAccountUiState(false)')
expect(bootstrapBlock).toContain('bootstrapped.value = true')
})
it('keeps profile editing parse-safe and composer inputs canonical', () => {
expect(viewSource).toContain('function toggleProfileEditor(): void')
expect(viewSource.match(/@click="toggleProfileEditor"/g)).toHaveLength(3)
expect(viewSource).toContain('const MAX_CAPTION_LENGTH = 160')
expect(viewSource).toContain(':maxlength="MAX_CAPTION_LENGTH"')
expect(viewSource).toContain('.slice(0, MAX_CAPTION_LENGTH)')
expect(viewSource).not.toContain('maxlength="240"')
expect(viewSource).toContain('const MAX_TEXT_OVERLAY_LENGTH = 160')
expect(viewSource).toContain(':maxlength="MAX_TEXT_OVERLAY_LENGTH"')
expect(storeSource).toContain('MAX_TEXT_OVERLAY_CHARACTERS = 160')
expect(viewSource).toContain(
'avatarSeed: Math.floor(Math.random() * 360) + 1',
)
})
it('keeps direct and story secrets behind explicit release callbacks', () => {
const snapBlock = typeSource.match(
/export type SkyPicSnap = \{([\s\S]*?)\n\}/,
)?.[1]
const openedSnapBlock = typeSource.match(
/export type SkyPicOpenedSnap = \{([\s\S]*?)\n\}/,
)?.[1]
const storyBlock = typeSource.match(
/export type SkyPicStory = \{([\s\S]*?)\n\}/,
)?.[1]
const viewedStoryBlock = typeSource.match(
/export type SkyPicViewedStory = \{([\s\S]*?)\n\}/,
)?.[1]
expect(snapBlock).toBeTruthy()
expect(snapBlock).not.toMatch(/\burl\b|caption|textOverlay|overlayColor/)
expect(openedSnapBlock).toMatch(/\burl: string\b/)
expect(openedSnapBlock).toMatch(/caption: string/)
expect(openedSnapBlock).toMatch(/textOverlay: string/)
expect(openedSnapBlock).toMatch(/overlayColor: string/)
expect(storyBlock).not.toMatch(/\burl\b|caption|textOverlay|overlayColor/)
expect(viewedStoryBlock).toMatch(/\burl: string\b/)
expect(storeSource).toContain("'skypic:open-snap'")
expect(storeSource).toContain("'skypic:replay-snap'")
expect(storeSource).toContain("'skypic:view-story'")
expect(viewSource).toContain('store.clearOpenedSnap()')
expect(viewSource).toContain('store.clearViewedStory()')
})
it('implements all agreed callback names exactly', () => {
const callbacks = [
'skypic:bootstrap',
'skypic:create-profile',
'skypic:delete-account',
'skypic:update-profile',
'skypic:search',
'skypic:add-friend',
'skypic:respond-friend',
'skypic:remove-friend',
'skypic:block',
'skypic:send-snap',
'skypic:open-snap',
'skypic:replay-snap',
'skypic:publish-story',
'skypic:stories',
'skypic:view-story',
'skypic:story-viewers',
'skypic:remove-story',
'skypic:thread',
'skypic:send-message',
'skypic:mark-thread',
'skypic:save-message',
'skypic:delete-message',
]
callbacks.forEach((callback) =>
expect(storeSource).toContain("'" + callback + "'"),
)
})
it('reacts to query-only notification and media-return deep links', () => {
expect(viewSource).toContain('() => route.query')
expect(viewSource).toContain('{ deep: true }')
expect(viewSource).toContain('query.compose')
expect(viewSource).toContain('query.profileId')
expect(viewSource).toContain('query.friendship')
expect(viewSource).toContain('query.snap')
expect(viewSource).toContain('query.story')
expect(viewSource).toContain('conversationForProfile(profileId)')
expect(viewSource).toContain(
'if (!requestedFriendship && store.activeFriendshipId)',
)
expect(viewSource).toContain("chatBody.value = ''")
})
it('only opens incoming direct snaps', () => {
const canOpenBlock = viewSource.match(
/function snapCanOpen\(snap: SkyPicSnap\): boolean \{([\s\S]*?)\n\}/,
)?.[1]
expect(canOpenBlock).toContain("snap.direction === 'received'")
expect(canOpenBlock).toContain('snap.allowReplay')
})
it('implements server-authorized story replies', () => {
expect(typeSource).toContain('canReply: boolean')
expect(viewSource).toContain('store.viewedStory.canReply')
expect(viewSource).toContain('stories.replyPlaceholder')
expect(viewSource).toContain(
'store.sendMessage(friendshipId, body, story.id)',
)
expect(viewSource).toContain('@focus="pauseStoryCountdown"')
expect(viewSource).toContain('@blur="resumeStoryCountdown"')
expect(storeSource).toContain('...(storyId ? { storyId } : {})')
})
it('renders outgoing requests with a cancellable relationship action', () => {
expect(viewSource).toContain('store.outgoingRequests')
expect(viewSource).toContain('cancelFriendRequest(request)')
expect(viewSource).toContain("'friends.cancelRequest'")
expect(storeSource).toContain("'skypic:remove-friend'")
})
it('has timer cleanup, conditional scroll owners and accessible controls', () => {
expect(viewSource).toContain('beginCountdown(')
expect(viewSource).toContain('clearSnapTimer()')
expect(viewSource).toContain('clearStoryTimer()')
expect(viewSource).toContain('onBeforeUnmount(')
expect(viewSource).toContain('<SkyScrollArea')
expect(viewSource).toContain('with-tabbar')
expect(viewSource).toContain('var(--sky-touch-target)')
expect(viewSource).toContain(':focus-visible')
expect(viewSource).toContain('@media (prefers-reduced-motion: reduce)')
expect(viewSource).toContain('aria-modal="true"')
})
it('starts viewer timers only after media readiness and closes sheets safely', () => {
expect(viewSource).toContain(
"prepareViewerMedia('snap', snap.durationSeconds)",
)
expect(viewSource).toContain(
"prepareViewerMedia('story', story.durationSeconds)",
)
expect(viewSource).toContain("handleViewerMediaReady('snap')")
expect(viewSource).toContain("handleViewerMediaReady('story')")
expect(viewSource).toContain("handleViewerVideoCanPlay('snap', $event)")
expect(viewSource).toContain("handleViewerVideoCanPlay('story', $event)")
expect(viewSource).toContain('await video.play()')
expect(viewSource).toContain('mediaLoading.value = true')
expect(viewSource).toContain('mediaError.value = true')
expect(viewSource).toContain('pauseStoryCountdown()')
expect(viewSource).toContain('storyViewerSheetOpen.value = false')
})
it('guards stale tab, story and viewer-sheet requests', () => {
expect(viewSource).toContain(
'navigationRequest !== threadNavigationRequest',
)
expect(viewSource).toContain('activeTab.value !== next')
expect(viewSource).toContain(
'if (!appMounted || store.storyViewing) return',
)
expect(viewSource).toContain('const requestId = ++storyNavigationRequest')
expect(viewSource).toContain('const requestId = ++storyViewerRequest')
expect(viewSource).toContain('store.viewedStory?.id === story.id')
expect(storeSource).toContain("error: 'story_view_in_progress'")
expect(storeSource).toContain('requestId !== storyRequest')
expect(storeSource).toContain('requestId !== storyViewersRequest')
expect(viewSource).toContain('if (!appMounted) return')
expect(viewSource).toContain('threadNavigationRequest += 1')
expect(viewSource).toContain('store.closeThread()')
})
it('bounds discovery search and clears failed results', () => {
expect(viewSource).toContain('const MAX_SEARCH_CHARACTERS = 64')
expect(viewSource).toContain("slice(0, MAX_SEARCH_CHARACTERS).join('')")
expect(viewSource).toContain('@update:model-value="updateSearchQuery"')
expect(viewSource).toContain('notify(errorText(store.error ?? undefined))')
expect(storeSource).toContain('searchResults.value = []')
})
it('deduplicates concurrent account bootstraps', () => {
expect(storeSource).toContain(
'bootstrapInFlight?.session === requestSession',
)
expect(storeSource).toContain('return bootstrapInFlight.promise')
expect(storeSource).toContain(
'bootstrapInFlight = { promise, session: requestSession, token }',
)
})
})
File diff suppressed because it is too large Load Diff

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