@shipvo/sim 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps.mjs +138 -0
- package/browser-app.mjs +83 -0
- package/frame.d.mts +5 -2
- package/frame.mjs +69 -13
- package/guide.mjs +44 -0
- package/lumi.mjs +97 -0
- package/nav.mjs +98 -0
- package/network-profiles.mjs +84 -0
- package/package.json +15 -1
- package/phone-browser.mjs +187 -0
- package/server.mjs +271 -26
- package/settings-actions.mjs +28 -0
- package/settings.mjs +95 -0
- package/shade-actions.mjs +52 -0
- package/shade.mjs +141 -0
- package/update.mjs +126 -0
- package/ws-lite.mjs +40 -0
package/apps.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Installed apps on the simulated phone: state, the phone.json profile, and the Store screen. Pure: no DOM, no timers.
|
|
2
|
+
// The server uses these rules for the MCP tools install_app, uninstall_app and switch_app. The frame page uses the same
|
|
3
|
+
// functions (serialised into the page) to draw the home screen icons and the Store. Our own artwork: no vendor marks.
|
|
4
|
+
import { SETTINGS, settingPatch } from "./settings.mjs";
|
|
5
|
+
|
|
6
|
+
/** Typed error: unknown_app, already_installed, not_installed or bad_profile. */
|
|
7
|
+
export class AppsError extends Error { constructor(code, message) { super(message); this.code = code; } }
|
|
8
|
+
|
|
9
|
+
/** The apps the Store lists when there is no network or no marketplace. Used by tests and offline runs. */
|
|
10
|
+
export const STORE_FIXTURE = [
|
|
11
|
+
{ id: "tide", name: "Tide & Tackle", url: "fixture://tide", version: "1.0.0" },
|
|
12
|
+
{ id: "notes", name: "Pocket Notes", url: "fixture://notes", version: "0.3.1" },
|
|
13
|
+
{ id: "weather", name: "Harbour Weather", url: "fixture://weather", version: "2.1.0" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const text = (v, n) => String(v ?? "").replace(/[<>"'\\]/g, "").slice(0, n);
|
|
17
|
+
const entry = (a) => ({ id: text(a.id, 60), name: text(a.name || a.id, 40), url: String(a.url ?? ""), version: text(a.version ?? "0.0.0", 20) });
|
|
18
|
+
|
|
19
|
+
/** Phone state for apps. `installed_apps` is a list of { id, name, url, version }; `current_app` is an id or null (the app the phone was started with). */
|
|
20
|
+
export function createApps(profile = {}) {
|
|
21
|
+
return { installed_apps: [], current_app: null, store: STORE_FIXTURE.map(entry), settings: {}, log: [], ...profile };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a phone.json profile: { "apps": [{id,name,url?,version?}], "current_app"?: id, "settings"?: { dark_mode: true, ... }, "store"?: [...] }.
|
|
26
|
+
* The same profile always gives the same phone. Throws AppsError("bad_profile") with the reason. Contacts and photos come later.
|
|
27
|
+
*/
|
|
28
|
+
export function loadProfile(json) {
|
|
29
|
+
let p = json;
|
|
30
|
+
if (typeof json === "string") { try { p = JSON.parse(json); } catch { throw new AppsError("bad_profile", "phone.json is not valid JSON"); } }
|
|
31
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) throw new AppsError("bad_profile", "phone.json must be an object");
|
|
32
|
+
const apps = p.apps ?? [];
|
|
33
|
+
if (!Array.isArray(apps)) throw new AppsError("bad_profile", "'apps' must be a list");
|
|
34
|
+
const st = createApps();
|
|
35
|
+
if (p.store !== undefined) { if (!Array.isArray(p.store)) throw new AppsError("bad_profile", "'store' must be a list"); st.store = p.store.map((a) => entry(a)); }
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const a of apps) {
|
|
38
|
+
if (!a || typeof a.id !== "string" || !/^[a-z0-9-]+$/.test(a.id)) throw new AppsError("bad_profile", "every app needs an id of lowercase letters, digits and dashes");
|
|
39
|
+
if (seen.has(a.id)) throw new AppsError("bad_profile", `app '${a.id}' is listed twice`);
|
|
40
|
+
seen.add(a.id); st.installed_apps.push(entry(a));
|
|
41
|
+
}
|
|
42
|
+
if (p.current_app != null) { if (!seen.has(p.current_app)) throw new AppsError("bad_profile", `current_app '${p.current_app}' is not in 'apps'`); st.current_app = p.current_app; }
|
|
43
|
+
if (p.settings !== undefined) {
|
|
44
|
+
if (!p.settings || typeof p.settings !== "object") throw new AppsError("bad_profile", "'settings' must be an object");
|
|
45
|
+
for (const [k, v] of Object.entries(p.settings)) { try { settingPatch(k, v); } catch (e) { throw new AppsError("bad_profile", `settings: ${e.message}`); } st.settings[k] = v; }
|
|
46
|
+
}
|
|
47
|
+
return st;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The device patch that applies a profile's settings (the same for every run). */
|
|
51
|
+
export function profilePatch(st) {
|
|
52
|
+
return Object.assign({}, ...Object.entries(st.settings).map(([k, v]) => settingPatch(k, v)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const note = (st, name, id) => { st.log.push({ at: Date.now(), name, app: id, source: "simulated" }); };
|
|
56
|
+
|
|
57
|
+
/** Install an app from the catalog (default: the phone's Store list). Throws unknown_app or already_installed. */
|
|
58
|
+
export function installApp(st, id, catalog = st.store) {
|
|
59
|
+
const found = (catalog ?? []).find((a) => a.id === id);
|
|
60
|
+
if (!found) throw new AppsError("unknown_app", `no app '${id}' in the Store. Apps: ${(catalog ?? []).map((a) => a.id).join(", ") || "none"}`);
|
|
61
|
+
if (st.installed_apps.some((a) => a.id === id)) throw new AppsError("already_installed", `'${id}' is already installed`);
|
|
62
|
+
st.installed_apps.push(entry(found)); note(st, "install_app", id); return st;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Remove an app. If it was in front, the phone goes home and current_app is empty. Throws not_installed. */
|
|
66
|
+
export function uninstallApp(st, id, nav) {
|
|
67
|
+
if (!st.installed_apps.some((a) => a.id === id)) throw new AppsError("not_installed", `'${id}' is not installed`);
|
|
68
|
+
st.installed_apps = st.installed_apps.filter((a) => a.id !== id);
|
|
69
|
+
if (st.current_app === id) { st.current_app = null; if (nav) nav.where = "home"; }
|
|
70
|
+
note(st, "uninstall_app", id); return st;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Open an installed app (tap its icon). Throws not_installed. */
|
|
74
|
+
export function switchApp(st, id, nav) {
|
|
75
|
+
if (!st.installed_apps.some((a) => a.id === id)) throw new AppsError("not_installed", `'${id}' is not installed`);
|
|
76
|
+
st.current_app = id; if (nav) nav.where = "app"; note(st, "switch_app", id); return st;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Plain text for an agent: the installed apps, then the Store. */
|
|
80
|
+
export function describeApps(st) {
|
|
81
|
+
const l = ["Installed apps"];
|
|
82
|
+
if (!st.installed_apps.length) l.push("- none");
|
|
83
|
+
for (const a of st.installed_apps) l.push(`- button "${a.name}" (${a.id} ${a.version}${st.current_app === a.id ? ", open" : ""}: switch_app opens it)`);
|
|
84
|
+
l.push("Store");
|
|
85
|
+
for (const a of st.store) l.push(st.installed_apps.some((i) => i.id === a.id) ? `- "${a.name}" (${a.id}): installed` : `- button "Install ${a.name}" (${a.id}: install_app installs it)`);
|
|
86
|
+
return l.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
90
|
+
/** Home screen icons for installed apps. */
|
|
91
|
+
export function installedIconsHtml(st) {
|
|
92
|
+
return st.installed_apps.map((a) => `<button type="button" class="sf-icon sf-appicon" data-part="installed-app" data-app="${esc(a.id)}" aria-label="${esc(a.name)}"><b>${esc(a.name.slice(0, 1).toUpperCase())}</b><span>${esc(a.name)}</span></button>`).join("");
|
|
93
|
+
}
|
|
94
|
+
/** Store rows: one button per app that is not installed yet. */
|
|
95
|
+
export function storeRowsHtml(st) {
|
|
96
|
+
return st.store.map((a) => {
|
|
97
|
+
const have = st.installed_apps.some((i) => i.id === a.id);
|
|
98
|
+
return `<div class="sf-store-row" data-app="${esc(a.id)}"><span class="sf-store-name">${esc(a.name)} <small>${esc(a.version)}</small></span>${have ? `<span class="sf-store-have">Installed</span>` : `<button type="button" data-part="store-install" data-app="${esc(a.id)}" aria-label="Install ${esc(a.name)}">Install</button>`}</div>`;
|
|
99
|
+
}).join("");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The Store icon for the home screen and the Store screen. Hidden until opened. */
|
|
103
|
+
export function storeIconHtml() {
|
|
104
|
+
return `<button type="button" class="sf-icon" data-part="store-icon" aria-label="Store" style="left:220px"><b>▤</b><span>Store</span></button>`;
|
|
105
|
+
}
|
|
106
|
+
export function storeScreenHtml() {
|
|
107
|
+
return `<div class="sf-settings sf-store" data-part="store-screen" role="dialog" aria-label="Store" tabindex="-1" hidden><h2 class="sf-sh">Store</h2><div data-part="store-rows" role="list" aria-label="Apps"></div><button type="button" class="sf-close" data-part="store-close">Close</button></div>`;
|
|
108
|
+
}
|
|
109
|
+
export const storeCss = [
|
|
110
|
+
`.sf-store{position:absolute;inset:0;z-index:8;overflow:auto;padding:70px 20px 40px;box-sizing:border-box;background:#0b1320;color:#fff;font-family:system-ui,sans-serif}[data-theme="light"] .sf-store{background:#f4f7fb;color:#111}`,
|
|
111
|
+
`.sf-store[hidden]{display:none}.sf-store-row{display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid #8883;font:600 15px system-ui,sans-serif}.sf-store-row small{opacity:.6;font-weight:400}`,
|
|
112
|
+
`.sf-store-row button,.sf-store .sf-close{border:0;border-radius:999px;padding:12px 18px;min-height:44px;background:#f4a261;color:#102030;font:600 14px system-ui,sans-serif;cursor:pointer}.sf-store-have{color:#8a94a6;font-size:13px}[data-theme="light"] .sf-store-have{color:#4a5568}.sf-store .sf-close{margin-top:18px;background:#8883;color:inherit}`,
|
|
113
|
+
`.sf-apps{position:absolute;left:28px;right:28px;top:180px;display:flex;flex-wrap:wrap;gap:20px 24px}.sf-apps .sf-icon{position:static}`,
|
|
114
|
+
].join("");
|
|
115
|
+
|
|
116
|
+
/** Client script: draws installed icons and the Store in the frame page. `seed` is a phone.json object. */
|
|
117
|
+
export function appsClientScript(iframeId = "app", seed = {}) {
|
|
118
|
+
return `(function(){
|
|
119
|
+
var AppsError=function(c,m){var e=new Error(m);e.code=c;return e},STORE_FIXTURE=${JSON.stringify(STORE_FIXTURE)};
|
|
120
|
+
var entry=${entry.toString()},text=${text.toString()},esc=${esc.toString()},note=${note.toString()},installApp=${installApp.toString()},switchApp=${switchApp.toString()},installedIconsHtml=${installedIconsHtml.toString()},storeRowsHtml=${storeRowsHtml.toString()},createApps=${createApps.toString()};
|
|
121
|
+
var loadProfile=${loadProfile.toString()},settingPatch=function(){};
|
|
122
|
+
var r=document.querySelector('[data-part="phone-body"]');if(!r)return;
|
|
123
|
+
var q=function(p){return r.querySelector('[data-part="'+p+'"]')},sc=q("store-screen"),home=q("home-screen");if(!sc||!home)return;
|
|
124
|
+
var st;try{st=loadProfile(${JSON.stringify(seed)})}catch(x){st=createApps()}
|
|
125
|
+
st.settings={};
|
|
126
|
+
var box=document.createElement("div");box.className="sf-apps";box.setAttribute("data-part","installed-apps");home.appendChild(box);
|
|
127
|
+
function drawHome(){box.innerHTML=installedIconsHtml(st)}
|
|
128
|
+
function drawStore(){q("store-rows").innerHTML=storeRowsHtml(st)}
|
|
129
|
+
function open(on){sc.hidden=!on;if(on){drawStore();sc.focus({preventScroll:true})}}
|
|
130
|
+
drawHome();
|
|
131
|
+
r.addEventListener("click",function(e){var t=e.target;if(!t.closest)return;
|
|
132
|
+
if(t.closest('[data-part="store-icon"]'))return open(true);
|
|
133
|
+
if(t.closest('[data-part="store-close"]'))return open(false);
|
|
134
|
+
var i=t.closest('[data-part="store-install"]');if(i){try{installApp(st,i.getAttribute("data-app"))}catch(x){}drawStore();drawHome();return}
|
|
135
|
+
var a=t.closest('[data-part="installed-app"]');if(a){try{switchApp(st,a.getAttribute("data-app"))}catch(x){}window.postMessage({type:"pn:nav",where:"app"},"*")}});
|
|
136
|
+
document.addEventListener("keydown",function(e){if(e.key==="Escape"&&!sc.hidden)open(false)});
|
|
137
|
+
})();`;
|
|
138
|
+
}
|
package/browser-app.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The Browser app of Shipvo Phone, drawn inside the phone body. Android style: controls on top, like a Chrome tab bar.
|
|
2
|
+
// iPhone style: address pill on top, toolbar at the bottom. The page below is the live view of the phone's Chromium:
|
|
3
|
+
// frames come over a WebSocket, taps, swipes and typing go back as input. Our own artwork, no vendor icons.
|
|
4
|
+
import { renderFrame } from "./frame.mjs";
|
|
5
|
+
|
|
6
|
+
const I = {
|
|
7
|
+
back: '<path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
8
|
+
forward: '<path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
9
|
+
reload: '<path d="M19 12a7 7 0 1 1-2.2-5.1M19 4v4.5h-4.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
10
|
+
};
|
|
11
|
+
const btn = (part, label, body) => `<button type="button" class="ba-btn" data-part="${part}" aria-label="${label}"><svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">${body}</svg></button>`;
|
|
12
|
+
const tabsBtn = '<button type="button" class="ba-btn ba-tabs" data-part="tabs" aria-label="Tabs"><span data-part="tab-count">1</span></button>';
|
|
13
|
+
const url = '<form class="ba-url" data-part="address-form"><input data-part="address" aria-label="Address" inputmode="url" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="Search or type a URL"></form>';
|
|
14
|
+
|
|
15
|
+
/** The markup and CSS of the Browser app for one skin. */
|
|
16
|
+
export function renderBrowserApp(skin = "android") {
|
|
17
|
+
const nav = btn("back", "Back", I.back) + btn("forward", "Forward", I.forward) + btn("reload", "Reload", I.reload);
|
|
18
|
+
const ios = skin === "ios";
|
|
19
|
+
const html = `<div class="ba" data-part="browser-app" data-skin="${skin}">`
|
|
20
|
+
+ (ios ? `<div class="ba-top">${url}</div>` : `<div class="ba-top">${nav}${url}${tabsBtn}</div>`)
|
|
21
|
+
+ `<div class="ba-view" tabindex="0" data-part="browser-view"><img data-part="screen" alt="Web page" draggable="false"><p class="ba-empty" data-part="empty">No page open</p><p class="ba-toast" data-part="toast" role="status" hidden></p></div>`
|
|
22
|
+
+ (ios ? `<div class="ba-bottom">${nav}${tabsBtn}</div>` : "")
|
|
23
|
+
+ `<div class="ba-sheet" data-part="tab-list" hidden><ul></ul><button type="button" data-part="new-tab">New tab</button></div></div>`;
|
|
24
|
+
const css = [
|
|
25
|
+
`.ba{--ba-bg:#fff;--ba-fg:#111;--ba-bar:${ios ? "#f2f2f7" : "#f1f3f4"};--ba-field:${ios ? "#e3e3e8" : "#fff"};--ba-icon:${ios ? "#0a64d6" : "#444"};--ba-line:#d0d0d5;--ba-muted:#595959;--ba-card:#eee;--ba-sheet:#fffe;--ba-focus:#0a64d6;--ba-toast:#222;--ba-toast-fg:#fff}`,
|
|
26
|
+
`@media (prefers-color-scheme:dark){.ba{--ba-bg:#121212;--ba-fg:#f1f1f1;--ba-bar:${ios ? "#1c1c1e" : "#202124"};--ba-field:${ios ? "#2c2c2e" : "#303134"};--ba-icon:${ios ? "#5eb0ff" : "#d0d0d0"};--ba-line:#3a3a3c;--ba-muted:#b0b0b0;--ba-card:#2a2a2c;--ba-sheet:#121212ee;--ba-focus:#8ab4f8;--ba-toast:#f1f1f1;--ba-toast-fg:#111}}`,
|
|
27
|
+
`.ba{position:absolute;inset:0;display:flex;flex-direction:column;background:var(--ba-bg);color:var(--ba-fg);font:14px system-ui,sans-serif}`,
|
|
28
|
+
`.ba button:focus-visible,.ba input:focus-visible,.ba-view:focus-visible{outline:3px solid var(--ba-focus);outline-offset:-3px}`,
|
|
29
|
+
`.ba-top,.ba-bottom{flex:none;display:flex;align-items:center;gap:4px;padding:${ios ? "6px 16px" : "6px 8px"};background:var(--ba-bar)}`,
|
|
30
|
+
`.ba-bottom{justify-content:space-around;padding:6px 10px 4px;border-top:1px solid var(--ba-line)}`,
|
|
31
|
+
`.ba-btn{flex:none;width:40px;height:40px;border:0;border-radius:${ios ? "10px" : "50%"};background:transparent;color:var(--ba-icon);display:flex;align-items:center;justify-content:center;cursor:pointer}`,
|
|
32
|
+
`.ba-tabs span{display:block;min-width:20px;padding:1px 3px;box-sizing:border-box;border:2px solid currentColor;border-radius:${ios ? "5px" : "6px"};font:700 12px system-ui,sans-serif;text-align:center}`,
|
|
33
|
+
`.ba-url{flex:1;min-width:0}.ba-url input{width:100%;box-sizing:border-box;height:38px;border:0;border-radius:${ios ? "10px" : "19px"};padding:0 14px;background:var(--ba-field);font:15px system-ui,sans-serif;color:var(--ba-fg);${ios ? "text-align:center" : ""}}`,
|
|
34
|
+
// The page keeps its own aspect ratio: contain letterboxes it, and pointer positions are mapped against the drawn image.
|
|
35
|
+
`.ba-view{flex:1;min-height:0;position:relative;background:var(--ba-bg);overflow:hidden}.ba-view img{display:block;width:100%;height:100%;object-fit:contain;touch-action:none;user-select:none;-webkit-user-drag:none}.ba-view img:not([src]){display:none}`,
|
|
36
|
+
`.ba-empty{position:absolute;inset:0;margin:0;display:flex;align-items:center;justify-content:center;color:var(--ba-muted)}.ba-view.has-frame .ba-empty{display:none}`,
|
|
37
|
+
`.ba-toast{position:absolute;left:12px;right:12px;bottom:12px;margin:0;padding:10px 14px;border-radius:12px;background:var(--ba-toast);color:var(--ba-toast-fg)}.ba-toast[hidden],.ba-sheet[hidden]{display:none}`,
|
|
38
|
+
`.ba-sheet{position:absolute;inset:0;background:var(--ba-sheet);padding:12px;overflow:auto}.ba-sheet ul{list-style:none;margin:0 0 10px;padding:0}.ba-sheet li{display:flex;gap:8px;align-items:center;padding:2px 2px 2px 10px;margin-bottom:6px;border-radius:12px;background:var(--ba-card)}.ba-sheet li[aria-current=true]{outline:2px solid var(--ba-focus)}`,
|
|
39
|
+
`.ba-sheet button{min-height:44px;border:0;border-radius:10px;background:none;color:inherit;font:inherit;cursor:pointer}.ba-sheet li .pick{flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ba-sheet li .close{min-width:44px;font-size:22px}.ba-sheet>button{padding:0 14px;background:var(--ba-card)}`,
|
|
40
|
+
].join("\n");
|
|
41
|
+
return { html, css };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Browser script for the page that holds the app. `session` names the sim session whose Chromium it shows. */
|
|
45
|
+
export function browserAppScript(session) {
|
|
46
|
+
return `(function(){
|
|
47
|
+
var $=function(p){return document.querySelector('[data-part="'+p+'"]')},img=$("screen"),view=$("browser-view"),vw=393,vh=851,ws,st={tabs:[]};
|
|
48
|
+
function send(m){if(ws&&ws.readyState===1)ws.send(JSON.stringify(m))}
|
|
49
|
+
function toast(t){var e=$("toast");e.textContent=t;e.hidden=false;clearTimeout(toast.t);toast.t=setTimeout(function(){e.hidden=true},3500)}
|
|
50
|
+
function draw(){var a=$("address");if(document.activeElement!==a){var t=st.tabs.filter(function(x){return x.active})[0];a.value=st.url&&st.url!=="about:blank"?st.url:""}
|
|
51
|
+
$("tab-count").textContent=st.tabs.length||1;
|
|
52
|
+
var l=$("tab-list").querySelector("ul");l.innerHTML="";st.tabs.forEach(function(t){var li=document.createElement("li");if(t.active)li.setAttribute("aria-current","true");
|
|
53
|
+
var b=document.createElement("button");b.className="pick";b.textContent=t.title||t.url||"New tab";b.onclick=function(){send({type:"tab",index:t.index});$("tab-list").hidden=true};
|
|
54
|
+
var x=document.createElement("button");x.className="close";x.textContent="\\u00d7";x.setAttribute("aria-label","Close tab");x.onclick=function(){send({type:"closetab",index:t.index})};li.append(b,x);l.append(li)})}
|
|
55
|
+
function connect(){ws=new WebSocket((location.protocol==="https:"?"wss://":"ws://")+location.host+"/browser/ws?session="+encodeURIComponent(${JSON.stringify(session)}));
|
|
56
|
+
ws.onmessage=function(e){var m=JSON.parse(e.data);
|
|
57
|
+
if(m.type==="frame"){vw=m.width||vw;vh=m.height||vh;img.src="data:image/jpeg;base64,"+m.data;view.classList.add("has-frame")}
|
|
58
|
+
else if(m.type==="state"){st=m;draw()}
|
|
59
|
+
else if(m.type==="browser"){toast("Opened in Browser: "+m.url)}
|
|
60
|
+
else if(m.type==="app"){toast("Opened in the app: "+m.url);window.dispatchEvent(new CustomEvent("shipvo:open-app",{detail:m.url}))}
|
|
61
|
+
else if(m.type==="error")toast(m.message)};
|
|
62
|
+
ws.onclose=function(){setTimeout(connect,1000)}}
|
|
63
|
+
connect();
|
|
64
|
+
function pt(e){var r=img.getBoundingClientRect(),k=Math.min(r.width/vw,r.height/vh),ox=r.left+(r.width-vw*k)/2,oy=r.top+(r.height-vh*k)/2;
|
|
65
|
+
return{x:Math.max(0,Math.min(vw,(e.clientX-ox)/k)),y:Math.max(0,Math.min(vh,(e.clientY-oy)/k))}}
|
|
66
|
+
var down=null;
|
|
67
|
+
img.addEventListener("pointerdown",function(e){down=pt(e);view.focus();e.preventDefault()});
|
|
68
|
+
img.addEventListener("pointerup",function(e){if(!down)return;var u=pt(e),d=Math.hypot(u.x-down.x,u.y-down.y);
|
|
69
|
+
send(d<10?{type:"tap",x:down.x,y:down.y}:{type:"swipe",x1:down.x,y1:down.y,x2:u.x,y2:u.y});down=null});
|
|
70
|
+
view.addEventListener("keydown",function(e){if(e.key==="Enter"||e.key==="Backspace"||e.key==="Tab"){e.preventDefault();send({type:"key",key:e.key})}
|
|
71
|
+
else if(e.key.length===1&&!e.ctrlKey&&!e.metaKey){e.preventDefault();send({type:"text",text:e.key})}});
|
|
72
|
+
$("back").onclick=function(){send({type:"back"})};$("forward").onclick=function(){send({type:"forward"})};$("reload").onclick=function(){send({type:"reload"})};
|
|
73
|
+
$("tabs").onclick=function(){var t=$("tab-list");t.hidden=!t.hidden};$("new-tab").onclick=function(){send({type:"newtab"});$("tab-list").hidden=true};
|
|
74
|
+
$("address-form").onsubmit=function(e){e.preventDefault();var v=$("address").value.trim();if(!v)return;if(!/^[a-z][a-z0-9+.-]*:/i.test(v))v="https://"+v;send({type:"open",url:v});$("address").blur()};
|
|
75
|
+
})();`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A full page: the phone body in the given skin with the Browser app in it. */
|
|
79
|
+
export function browserAppPage(skin, session) {
|
|
80
|
+
const app = renderBrowserApp(skin), frame = renderFrame({ skin, screen: app.html });
|
|
81
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Browser (simulated ${skin === "ios" ? "iPhone" : "Android"})</title>
|
|
82
|
+
<style>html,body{margin:0;height:100%;background:#e8eaed;overflow:hidden}body{display:flex;align-items:center;justify-content:center}${frame.css}\n${app.css}</style></head><body>${frame.html}<script>${browserAppScript(session).replace(/<\/script/gi, "<\\/script")}</script></body></html>`;
|
|
83
|
+
}
|
package/frame.d.mts
CHANGED
|
@@ -11,7 +11,7 @@ export type FrameStatus = {
|
|
|
11
11
|
/** Level 0 to 100, or an object. Default 100. */
|
|
12
12
|
battery?: number | { level?: number; charging?: boolean };
|
|
13
13
|
};
|
|
14
|
-
export type FrameOptions = { skin?: FrameSkin; dark?: boolean; status?: FrameStatus; /** HTML for the screen area. */ screen?: string };
|
|
14
|
+
export type FrameOptions = { skin?: FrameSkin; dark?: boolean; status?: FrameStatus; /** HTML for the screen area. */ screen?: string; /** Draw Lumi beside the cutout. Default: on, off in CI; SHIPVO_LUMI=off|on overrides. */ lumi?: boolean; /** Also draw Lumi's first-run guide. Needs lumi. Default: off. */ guide?: boolean };
|
|
15
15
|
export type Frame = {
|
|
16
16
|
skin: FrameSkin;
|
|
17
17
|
/** Screen size in CSS pixels. */
|
|
@@ -34,4 +34,7 @@ export function isDarkBackground(color: string): boolean;
|
|
|
34
34
|
/** The clock text and icons markup, for redrawing the status bar. */
|
|
35
35
|
export function statusParts(status?: FrameStatus): { time: string; icons: string };
|
|
36
36
|
/** Browser script that keeps the status bar live from the app's `pn:status` events. */
|
|
37
|
-
export function statusClientScript(iframeId?: string, skin?: FrameSkin): string;
|
|
37
|
+
export function statusClientScript(iframeId?: string, skin?: FrameSkin, phone?: object): string;
|
|
38
|
+
|
|
39
|
+
export type LumiState = "idle" | "working" | "happy" | "worried";
|
|
40
|
+
export type LumiEvent = { kind: "check-passed" | "flow-done" | "page-error" | "crash" | "action-failed" | "check-failed"; name?: string; error?: unknown; detail?: unknown };
|
package/frame.mjs
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
// Scaling to fit the window is done by the page with a CSS transform on the element this markup sits in.
|
|
4
4
|
|
|
5
5
|
import { lockScreenHtml, bootScreenHtml, offScreenHtml } from "./lifecycle.mjs";
|
|
6
|
+
import { shadeHtml, shadeCss, shadeClientScript } from "./shade.mjs";
|
|
7
|
+
import { settingsHtml, settingsIconHtml, settingsCss, settingsClientScript } from "./settings.mjs";
|
|
8
|
+
import { storeIconHtml, storeScreenHtml, storeCss, appsClientScript } from "./apps.mjs";
|
|
9
|
+
import { updateBannerHtml, updateCss, updateClientScript } from "./update.mjs";
|
|
10
|
+
import { lumiHtml, lumiCaptionHtml, lumiCss, lumiClientScript, lumiDefault } from "./lumi.mjs";
|
|
11
|
+
import { guideHtml, guideCss, guideClientScript } from "./guide.mjs";
|
|
12
|
+
import { homeScreenHtml, recentsHtml, navButtonsHtml, navScreensCss, classifySwipe, setVisibilityInPage } from "./nav.mjs";
|
|
6
13
|
|
|
7
14
|
export const SKINS = {
|
|
8
15
|
android: { label: "Android", width: 412, height: 915, radius: 36, bezel: 10, status: 36, bar: 24, pad: 22, indicator: 108, part: "punch-hole", bottom: "gesture-bar" },
|
|
@@ -30,9 +37,9 @@ export function normalizeStatus(status = {}) {
|
|
|
30
37
|
/** Maps the simulated device ({ online, airplane, battery } from @shipvo/device) and a clock to a status object. Network off shows no signal and no wifi. */
|
|
31
38
|
export function statusFromDevice(device = {}, now = new Date()) {
|
|
32
39
|
const d = device && typeof device === "object" ? device : {};
|
|
33
|
-
const online = d.online !== false && d.airplane !== true;
|
|
40
|
+
const online = d.online !== false && d.airplane !== true && (d.wifi !== false || d.mobileData !== false);
|
|
34
41
|
const h = now.getHours() % 12 || 12, m = String(now.getMinutes()).padStart(2, "0");
|
|
35
|
-
return { time: `${h}:${m}`, signal: online ? 4 : 0, wifi: online, airplane: d.airplane === true, battery: d.battery };
|
|
42
|
+
return { time: `${h}:${m}`, signal: online && d.mobileData !== false ? 4 : 0, wifi: online && d.wifi !== false, airplane: d.airplane === true, battery: d.battery };
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/** True when a CSS colour (#rgb, #rrggbb, rgb(), rgba()) is dark, so the status text should be light. Unknown or transparent colours count as light. */
|
|
@@ -57,7 +64,7 @@ export function statusParts(status) {
|
|
|
57
64
|
* airplane or battery change), redraws the status bar, ticks the clock each minute and flips the text colour from the app's top background.
|
|
58
65
|
* `iframeId` is the app iframe. It expects the markup from renderFrame and the functions below inlined by this script.
|
|
59
66
|
*/
|
|
60
|
-
export function statusClientScript(iframeId = "app", skin = "android") {
|
|
67
|
+
export function statusClientScript(iframeId = "app", skin = "android", phone = {}) {
|
|
61
68
|
return `(function(){
|
|
62
69
|
var statusParts=${statusParts.toString()},normalizeStatus=${normalizeStatus.toString()},icons=${icons.toString()},battery=${battery.toString()},svg=${svg.toString()},isDarkBackground=${isDarkBackground.toString()},statusFromDevice=${statusFromDevice.toString()};
|
|
63
70
|
var clean=${clean.toString()},clamp=${clamp.toString()};
|
|
@@ -70,8 +77,8 @@ while(e){var c=w.getComputedStyle(e).backgroundColor;if(c&&c!=="transparent"&&!/
|
|
|
70
77
|
function theme(w){var r=root();if(!r)return;var c=topColor(w),dark=isDarkBackground(c);
|
|
71
78
|
r.setAttribute("data-theme",dark?"dark":"light");r.style.setProperty("--sf-status-fg",dark?"#fff":"#111");r.style.setProperty("--sf-status-bg",c)}
|
|
72
79
|
function hook(){var f=document.getElementById(${JSON.stringify(iframeId)});var w=f&&f.contentWindow;if(!w)return;
|
|
73
|
-
if(w.__pnDevice&&w.__pnDevice.status)dev=w.__pnDevice.status();draw();theme(w);
|
|
74
|
-
if(!w.__shipvoStatusHooked){w.__shipvoStatusHooked=true;w.addEventListener("pn:status",function(e){dev=e.detail;draw();theme(w)})}}
|
|
80
|
+
if(w.__pnDevice&&w.__pnDevice.status)dev=Object.assign({},w.__pnDevice.status(),w.__pnDevice.settings?w.__pnDevice.settings():{});draw();theme(w);
|
|
81
|
+
if(!w.__shipvoStatusHooked){w.__shipvoStatusHooked=true;w.addEventListener("pn:status",function(e){dev=Object.assign({},dev,e.detail);draw();theme(w)});w.addEventListener("pn:settings",function(e){dev=Object.assign({},dev,e.detail);draw()})}}
|
|
75
82
|
var f=document.getElementById(${JSON.stringify(iframeId)});if(f){f.addEventListener("load",hook);hook()}
|
|
76
83
|
(function buttons(){var r=root();if(!r)return;var q=function(p){return r.querySelector('[data-part="'+p+'"]')},vol=50,hold,volT,bootT;
|
|
77
84
|
if(!q("power-button")||!q("lock-screen")||!q("power-menu"))return;
|
|
@@ -99,15 +106,53 @@ if(e.detail===0&&e.shiftKey){shown("power-menu")?closeMenu():openMenu();return}
|
|
|
99
106
|
if(shown("power-menu")){closeMenu();return}
|
|
100
107
|
if(shown("lock-screen")){return}
|
|
101
108
|
lockNow()});
|
|
102
|
-
function bump(d){vol=Math.max(0,Math.min(100,vol+d));q("volume-level").textContent=vol;show(q("volume-overlay"),true);clearTimeout(volT);volT=setTimeout(function(){show(q("volume-overlay"),false)},1500)}
|
|
109
|
+
function bump(d){vol=Math.max(0,Math.min(100,vol+d));q("volume-level").textContent=vol;q("volume-bar").firstChild.style.width=vol+"%";show(q("volume-overlay"),true);clearTimeout(volT);volT=setTimeout(function(){show(q("volume-overlay"),false)},1500)}
|
|
103
110
|
q("volume-up-button").addEventListener("click",function(){bump(10)});q("volume-down-button").addEventListener("click",function(){bump(-10)});
|
|
104
111
|
q("off-screen").addEventListener("click",powerOn);q("off-screen").addEventListener("keydown",function(e){if(e.key==="Enter"||e.key===" "){e.preventDefault();powerOn()}});
|
|
105
112
|
r.addEventListener("keydown",function(e){if(e.key==="Escape")closeMenu()});
|
|
106
113
|
document.addEventListener("pointerdown",function(e){if(shown("power-menu")&&!q("power-menu").contains(e.target)&&e.target!==pw)closeMenu()});
|
|
107
114
|
q("power-menu").addEventListener("click",function(e){var a=e.target.getAttribute&&e.target.getAttribute("data-act");if(!a)return;show(q("power-menu"),false);
|
|
108
115
|
if(a==="cancel"){pw.focus();return}
|
|
116
|
+
if(a==="navstyle")return;
|
|
109
117
|
if(a==="restart"){show(q("boot-screen"),true);var f=document.getElementById(${JSON.stringify(iframeId)});if(f&&f.contentWindow)f.contentWindow.location.reload();clearTimeout(bootT);bootT=setTimeout(function(){show(q("boot-screen"),false)},1200)}
|
|
110
118
|
else{show(q("lock-screen"),false);show(q("off-screen"),true);pw.focus()}})})();
|
|
119
|
+
${shadeClientScript(iframeId, skin)}
|
|
120
|
+
${settingsClientScript(iframeId)}
|
|
121
|
+
${appsClientScript(iframeId, phone)}
|
|
122
|
+
${updateClientScript()}
|
|
123
|
+
${lumiClientScript()}
|
|
124
|
+
${guideClientScript()}
|
|
125
|
+
(function nav(){var r=root();if(!r)return;var q=function(p){return r.querySelector('[data-part="'+p+'"]')};
|
|
126
|
+
if(!q("home-screen")||!q("recents"))return;
|
|
127
|
+
var classifySwipe=${classifySwipe.toString()},setVisibilityInPage=${setVisibilityInPage.toString()};
|
|
128
|
+
var where="app",style="gesture",skin=${JSON.stringify(skin)};
|
|
129
|
+
function show(el,on){if(el)el.hidden=!on}
|
|
130
|
+
function appWin(){var f=document.getElementById(${JSON.stringify(iframeId)});return f&&f.contentWindow}
|
|
131
|
+
function fire(state){var w=appWin();if(!w)return;try{w.eval("("+setVisibilityInPage.toString()+")("+JSON.stringify(state)+")")}catch(x){}}
|
|
132
|
+
function go(next){var was=where;where=next;show(q("home-screen"),next==="home");show(q("recents"),next==="recents");r.setAttribute("data-where",next);
|
|
133
|
+
if(was==="app"&&next!=="app")fire("background");if(was!=="app"&&next==="app")fire("active");
|
|
134
|
+
if(was!==next){try{window.parent.postMessage({type:"pn:nav-state",where:next},"*")}catch(x){}}}
|
|
135
|
+
window.addEventListener("message",function(e){var d=e.data;if(d&&d.type==="pn:nav"&&(d.where==="app"||d.where==="home"||d.where==="recents"))go(d.where)});
|
|
136
|
+
var af=document.getElementById(${JSON.stringify(iframeId)});if(af)af.addEventListener("load",function(){if(where!=="app")fire("background")});
|
|
137
|
+
function home(){go("home")}function recents(){go("recents")}
|
|
138
|
+
function back(){if(where!=="app")return go("app");var w=appWin();if(!w)return;var b=w.document.querySelector('[aria-label="Back"],[role=button][aria-label="Back"]');if(b)b.click()}
|
|
139
|
+
function kill(){var w=appWin();go("home");if(w)w.location.reload()}
|
|
140
|
+
function setStyle(st){style=st;r.setAttribute("data-nav",st);show(q("nav-buttons"),st==="buttons");var t=q("nav-style-toggle");if(t)t.textContent=st==="buttons"?"Gesture navigation":"3-button navigation"}
|
|
141
|
+
q("app-icon").addEventListener("click",function(){go("app")});
|
|
142
|
+
q("recents-open").addEventListener("click",function(){go("app")});
|
|
143
|
+
q("recents-close").addEventListener("click",kill);
|
|
144
|
+
var cd=q("recents-card"),cy=null;cd.addEventListener("pointerdown",function(e){cy=e.clientY});cd.addEventListener("pointerup",function(e){if(cy!=null&&cy-e.clientY>80)kill();cy=null});
|
|
145
|
+
if(q("nav-back")){q("nav-back").addEventListener("click",back);q("nav-home").addEventListener("click",home);q("nav-recents").addEventListener("click",recents)}
|
|
146
|
+
var tg=q("nav-style-toggle");if(tg)tg.addEventListener("click",function(){setStyle(style==="buttons"?"gesture":"buttons")});
|
|
147
|
+
function swipe(el,edge){var x0=null,y0,t0;
|
|
148
|
+
el.addEventListener("pointerdown",function(e){if(e.target.closest&&e.target.closest("button"))return;x0=e.clientX;y0=e.clientY;t0=Date.now();try{el.setPointerCapture(e.pointerId)}catch(x){}});
|
|
149
|
+
el.addEventListener("pointercancel",function(){x0=null});
|
|
150
|
+
el.addEventListener("pointerup",function(e){if(x0==null)return;var a=classifySwipe({skin:skin,fromEdge:edge,dx:e.clientX-x0,dy:e.clientY-y0,ms:Date.now()-t0});x0=null;
|
|
151
|
+
if(a==="home")home();else if(a==="recents")recents();else if(a==="back")back()})}
|
|
152
|
+
swipe(q("edge-swipe"),"left");
|
|
153
|
+
var bar=r.querySelector(".sf-bar");if(bar&&!(skin==="android"&&style==="buttons"))swipe(bar,"bottom");
|
|
154
|
+
document.addEventListener("keydown",function(e){if(e.key==="Escape"&&where!=="app")go("app")});
|
|
155
|
+
})();
|
|
111
156
|
(function tick(){draw();var n=new Date();setTimeout(tick,(60-n.getSeconds())*1000-n.getMilliseconds()+50)})();
|
|
112
157
|
// The app can change its top colour (a theme switch) without any event we can hear, so look again every 2 seconds. Skipped while the page is hidden.
|
|
113
158
|
setInterval(function(){if(document.hidden)return;var f=document.getElementById(${JSON.stringify(iframeId)});if(f&&f.contentWindow)theme(f.contentWindow)},2000);
|
|
@@ -141,9 +186,11 @@ function battery(b) {
|
|
|
141
186
|
* - dark: true gives light status text on a dark bar, false gives dark text on a light bar.
|
|
142
187
|
* - status: { time, signal 0-4, wifi, airplane, battery (0-100, or { level, charging }) }.
|
|
143
188
|
* - screen: optional HTML placed in the screen area (the page puts its iframe there).
|
|
189
|
+
* - lumi: draw Lumi beside the cutout (outside the app's screen). Default: on, but off in CI; SHIPVO_LUMI=off hides it anywhere.
|
|
190
|
+
* - guide: also draw Lumi's first-run guide (3 short steps, skippable, shown once per browser). Needs lumi. Default: off.
|
|
144
191
|
* Returns { skin, width, height, outerWidth, outerHeight, html, css }. width and height are the screen size.
|
|
145
192
|
*/
|
|
146
|
-
export function renderFrame({ skin = "android", dark = false, status, screen = "" } = {}) {
|
|
193
|
+
export function renderFrame({ skin = "android", dark = false, status, screen = "", lumi = lumiDefault(), guide = false } = {}) {
|
|
147
194
|
const k = SKINS[skin];
|
|
148
195
|
if (!k) throw new Error(`Unknown skin "${skin}". Use "android" or "ios".`);
|
|
149
196
|
const st = normalizeStatus(status);
|
|
@@ -152,11 +199,11 @@ export function renderFrame({ skin = "android", dark = false, status, screen = "
|
|
|
152
199
|
? `<span class="sf-cut sf-island" data-part="dynamic-island"></span>`
|
|
153
200
|
: `<span class="sf-cut sf-hole" data-part="punch-hole"></span>`;
|
|
154
201
|
const html = `<div class="sf-body" data-skin="${skin}" data-theme="${dark ? "dark" : "light"}" data-part="phone-body">`
|
|
155
|
-
+ `<div class="sf-screen"><div class="sf-status" data-part="status-bar"><span class="sf-time" data-part="time">${st.time}</span>${shape}<span class="sf-icons">${icons(st)}</span></div>`
|
|
156
|
-
+ `<div class="sf-content">${screen}</div>`
|
|
157
|
-
+ lockScreenHtml(st.time) + bootScreenHtml() + offScreenHtml()
|
|
158
|
-
+ `<div class="sf-vol" data-part="volume-overlay" role="status" aria-live="polite" hidden><span data-part="volume-level">50</span></div><div class="sf-menu" data-part="power-menu" role="menu" aria-label="Power menu" hidden><button role="menuitem" data-act="restart">Restart</button><button role="menuitem" data-act="poweroff">Power off</button
|
|
159
|
-
+ `<div class="sf-bar" data-part="${k.bottom}"><i></i
|
|
202
|
+
+ `<div class="sf-screen"><div class="sf-status" data-part="status-bar"><span class="sf-time" data-part="time">${st.time}</span>${shape}${lumi ? lumiHtml() : ""}<span class="sf-icons">${icons(st)}</span></div>`
|
|
203
|
+
+ `<div class="sf-content">${screen}</div>` + (lumi ? lumiCaptionHtml() : "") + updateBannerHtml()
|
|
204
|
+
+ shadeHtml(skin) + `<span class="sf-edge" data-part="edge-swipe"></span>` + homeScreenHtml().replace(/<\/div>$/, settingsIconHtml() + storeIconHtml() + "</div>") + settingsHtml() + storeScreenHtml() + recentsHtml() + lockScreenHtml(st.time) + bootScreenHtml() + offScreenHtml() + (lumi && guide ? guideHtml() : "")
|
|
205
|
+
+ `<div class="sf-vol" data-part="volume-overlay" role="status" aria-live="polite" hidden><span data-part="volume-level">50</span><i class="sf-vbar" data-part="volume-bar"><b style="width:50%"></b></i></div><div class="sf-menu" data-part="power-menu" role="menu" aria-label="Power menu" hidden><button role="menuitem" data-act="restart">Restart</button><button role="menuitem" data-act="poweroff">Power off</button>${skin === "android" ? `<button role="menuitem" data-act="navstyle" data-part="nav-style-toggle">3-button navigation</button>` : ""}<button role="menuitem" data-act="cancel">Cancel</button></div>`
|
|
206
|
+
+ `<div class="sf-bar" data-part="${k.bottom}"><i></i>${skin === "android" ? navButtonsHtml() : ""}</div></div>`
|
|
160
207
|
+ `<button class="sf-btn sf-power" data-part="power-button" aria-label="Power"></button><button class="sf-btn sf-volup" data-part="volume-up-button" aria-label="Volume up"></button><button class="sf-btn sf-voldown" data-part="volume-down-button" aria-label="Volume down"></button>`
|
|
161
208
|
+ `<span class="sf-label" data-part="simulated-label">Simulated</span></div>`;
|
|
162
209
|
const css = [
|
|
@@ -176,8 +223,17 @@ export function renderFrame({ skin = "android", dark = false, status, screen = "
|
|
|
176
223
|
`.sf-lock,.sf-boot,.sf-off,.sf-menu,.sf-vol{position:absolute;z-index:5}.sf-lock[hidden],.sf-boot[hidden],.sf-off[hidden],.sf-menu[hidden],.sf-vol[hidden]{display:none}.sf-off{inset:0;z-index:7;background:#000;cursor:pointer}`,
|
|
177
224
|
`.sf-lock{inset:0;background:#10151c;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;font-family:system-ui,sans-serif}.sf-lock-time{font-size:64px;font-weight:300}.sf-lock-hint{font-size:14px;opacity:.7}.sf-lock button{margin-top:8px;padding:10px 28px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff;font:600 15px system-ui,sans-serif;cursor:pointer}`,
|
|
178
225
|
`.sf-boot{inset:0;z-index:6;background:#000;color:#fff;display:flex;align-items:center;justify-content:center;font:700 34px system-ui,sans-serif;letter-spacing:.04em}`,
|
|
179
|
-
`.sf-vol{top:80px;right:12px;padding:8px 14px;border-radius:14px;background:#222d;color:#fff;font:600 14px system-ui,sans-serif}`,
|
|
226
|
+
`.sf-vbar{display:block;width:90px;height:4px;margin-top:6px;border-radius:2px;background:#fff4}.sf-vbar b{display:block;height:100%;border-radius:2px;background:#fff}.sf-vol{top:80px;right:12px;padding:8px 14px;border-radius:14px;background:#222d;color:#fff;font:600 14px system-ui,sans-serif}`,
|
|
180
227
|
`.sf-menu{left:50%;top:50%;transform:translate(-50%,-50%);display:flex;flex-direction:column;gap:8px;padding:14px;border-radius:16px;background:#222;box-shadow:0 8px 30px #0008}.sf-menu button{padding:10px 24px;border:0;border-radius:10px;background:#3a3d42;color:#fff;font:600 15px system-ui,sans-serif}`,
|
|
228
|
+
...(lumi ? [lumiCss(k)] : []),
|
|
229
|
+
...(lumi && guide ? [guideCss] : []),
|
|
230
|
+
shadeCss,
|
|
231
|
+
settingsCss,
|
|
232
|
+
storeCss,
|
|
233
|
+
updateCss,
|
|
234
|
+
`.sf-edge{position:absolute;left:0;top:${k.status}px;bottom:${k.bar}px;width:14px;z-index:3}.sf-home[hidden],.sf-recents[hidden],.sf-navbtns[hidden]{display:none}`,
|
|
235
|
+
navScreensCss(),
|
|
236
|
+
`.sf-bar{position:relative}.sf-navbtns{position:absolute;inset:0;display:flex;justify-content:space-around;align-items:center;background:var(--sf-bg)}.sf-navbtns button{border:0;background:none;color:var(--sf-fg);font-size:16px;padding:2px 24px;cursor:pointer}.sf-body[data-nav="buttons"] .sf-bar i{display:none}`,
|
|
181
237
|
`.sf-label{position:absolute;left:0;right:0;bottom:3px;text-align:center;font:600 9px system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:#9aa0a6}`,
|
|
182
238
|
].join("\n");
|
|
183
239
|
return { skin, width: k.width, height: k.height, outerWidth, outerHeight, html, css };
|
package/guide.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Lumi's first-run guide on the Shipvo Phone: three short steps (home screen, the Store, connecting an assistant).
|
|
2
|
+
// It can be skipped, and once it is finished or skipped it never shows again on this browser. It sits in the phone frame
|
|
3
|
+
// over the bottom of the screen, never in the app's own page, so the simulator's screenshots and screen tree do not see it.
|
|
4
|
+
// It is drawn only when Lumi is drawn and the page asks for it (`guide: true`).
|
|
5
|
+
|
|
6
|
+
export const GUIDE_KEY = "shipvo.lumi.guide";
|
|
7
|
+
|
|
8
|
+
export const GUIDE_STEPS = [
|
|
9
|
+
{ title: "Your home screen", text: "Go home to see your apps. Go home at any time to come back." },
|
|
10
|
+
{ title: "The Store", text: "The Store icon on the home screen installs more apps on this phone." },
|
|
11
|
+
{ title: "Connect an assistant", text: "To let an AI assistant drive this phone, run: claude mcp add shipvo -- npx -y @shipvo/mcp" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** Markup for the guide. Hidden until the page script finds that this browser has not seen it. */
|
|
15
|
+
export function guideHtml() {
|
|
16
|
+
const steps = GUIDE_STEPS.map((s, i) => `<div class="sf-guide-step" data-step="${i + 1}"${i ? " hidden" : ""}><b>${s.title}</b><p>${s.text.replace(/&/g, "&").replace(/</g, "<")}</p></div>`).join("");
|
|
17
|
+
return `<div class="sf-guide" data-part="lumi-guide" role="dialog" aria-live="polite" aria-label="Welcome to Shipvo Phone" hidden>`
|
|
18
|
+
+ `<span class="sf-guide-count" data-part="guide-count">1 of ${GUIDE_STEPS.length}</span>${steps}`
|
|
19
|
+
+ `<div class="sf-guide-actions"><button type="button" data-part="guide-skip">Skip</button><button type="button" data-part="guide-next">Next</button></div></div>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const guideCss = [
|
|
23
|
+
`.sf-guide{position:absolute;left:12px;right:12px;bottom:40px;z-index:4;box-sizing:border-box;padding:12px 14px;border-radius:16px;background:#10151c;color:#fff;border:1px solid #7fd6f5aa;box-shadow:0 0 14px #7fd6f555,0 6px 20px #0008;font:14px/1.4 system-ui,sans-serif}`,
|
|
24
|
+
`.sf-guide[hidden],.sf-guide [hidden]{display:none}`,
|
|
25
|
+
`.sf-guide p{margin:4px 0 0;overflow-wrap:anywhere}.sf-guide-count{float:right;font-size:11px;opacity:.7}`,
|
|
26
|
+
`.sf-guide-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:10px}`,
|
|
27
|
+
`.sf-guide button{padding:6px 16px;border:1px solid #fff6;border-radius:999px;background:transparent;color:#fff;font:600 13px system-ui,sans-serif;cursor:pointer}`,
|
|
28
|
+
`.sf-guide [data-part=guide-next]{background:#7fd6f5;border-color:#7fd6f5;color:#10151c}.sf-guide button:focus-visible{outline:3px solid #4da3ff;outline-offset:2px}`,
|
|
29
|
+
].join("\n");
|
|
30
|
+
|
|
31
|
+
/** Browser script. Shows the guide on first launch, steps through it, and remembers a finish or a skip in localStorage. */
|
|
32
|
+
export function guideClientScript() {
|
|
33
|
+
return `(function(){var K=${JSON.stringify(GUIDE_KEY)},g=document.querySelector('[data-part="lumi-guide"]');if(!g)return;
|
|
34
|
+
function seen(){try{return localStorage.getItem(K)==="done"}catch(x){return false}}
|
|
35
|
+
function save(){try{localStorage.setItem(K,"done")}catch(x){}}
|
|
36
|
+
var s=g.querySelectorAll(".sf-guide-step"),i=0,c=g.querySelector('[data-part="guide-count"]'),n=g.querySelector('[data-part="guide-next"]');
|
|
37
|
+
function lumi(st){if(window.shipvoLumi)window.shipvoLumi.set(st)}
|
|
38
|
+
function draw(){for(var j=0;j<s.length;j++)s[j].hidden=j!==i;c.textContent=(i+1)+" of "+s.length;n.textContent=i===s.length-1?"Done":"Next"}
|
|
39
|
+
function close(happy){save();g.hidden=true;lumi(happy?"happy":"idle")}
|
|
40
|
+
g.querySelector('[data-part="guide-skip"]').addEventListener("click",function(){close(false)});
|
|
41
|
+
n.addEventListener("click",function(){if(i>=s.length-1)return close(true);i++;draw()});
|
|
42
|
+
document.addEventListener("keydown",function(e){if(e.key==="Escape"&&!g.hidden)close(false)});
|
|
43
|
+
if(seen())return;draw();g.hidden=false})();`;
|
|
44
|
+
}
|
package/lumi.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Lumi, the glowing orb, drawn in the phone frame beside the camera cutout. Our own artwork.
|
|
2
|
+
// Lumi lives in the frame's status bar, never in the app's screen: it is not inside `.sf-content`, it is hidden from
|
|
3
|
+
// assistive tech and the labelled screen tree, and it takes no pointer input. The simulator's own page (screens, screenshots,
|
|
4
|
+
// recordings for agents) never draws it, so agents and tests see the same app with Lumi on or off.
|
|
5
|
+
|
|
6
|
+
export const LUMI_STATES = ["idle", "working", "happy", "worried"];
|
|
7
|
+
|
|
8
|
+
/** True when Lumi is drawn by default. SHIPVO_LUMI=off|0|false|no hides it anywhere; on|1|true|yes shows it. With no setting it is off in CI and on otherwise. */
|
|
9
|
+
export function lumiDefault(env = typeof process !== "undefined" ? process.env : {}) {
|
|
10
|
+
const v = String(env.SHIPVO_LUMI ?? "").trim().toLowerCase();
|
|
11
|
+
if (["off", "0", "false", "no"].includes(v)) return false;
|
|
12
|
+
if (["on", "1", "true", "yes"].includes(v)) return true;
|
|
13
|
+
const ci = String(env.CI ?? "").trim().toLowerCase();
|
|
14
|
+
return !(ci && ci !== "0" && ci !== "false");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const FACE = {
|
|
18
|
+
idle: `<path d="M8.6 15.4q2.4 1.6 4.8 0" fill="none" stroke="#10151c" stroke-width="1.3" stroke-linecap="round"/>`,
|
|
19
|
+
working: `<ellipse cx="11" cy="15.6" rx="1.3" ry="1.1" fill="#10151c"/>`,
|
|
20
|
+
happy: `<path d="M8 14.6q3 3.6 6 0z" fill="#10151c"/>`,
|
|
21
|
+
worried: `<path d="M8.8 16.6q2.2-1.8 4.4 0" fill="none" stroke="#10151c" stroke-width="1.3" stroke-linecap="round"/><path d="M6.6 8.2l3 .8M15.4 8.2l-3 .8" stroke="#10151c" stroke-width="1" stroke-linecap="round"/>`,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What Lumi shows for a result. `event` is { kind, error?, name?, detail? }:
|
|
26
|
+
* check-passed and flow-done give happy; page-error, crash, action-failed and check-failed give worried.
|
|
27
|
+
* The reason is one line taken from the real error or result (first line, spaces collapsed, at most 90 characters).
|
|
28
|
+
* Anything else, or an unusable event, returns null and Lumi stays as it is. Pure and self-contained, so the page can run it.
|
|
29
|
+
*/
|
|
30
|
+
export function lumiReaction(event) {
|
|
31
|
+
if (!event || typeof event !== "object") return null;
|
|
32
|
+
const one = (v) => {
|
|
33
|
+
const t = String(v && v.message ? v.message : v == null ? "" : v).split(/\r?\n/).map((x) => x.replace(/\s+/g, " ").trim()).find(Boolean) || "";
|
|
34
|
+
return t.length > 90 ? t.slice(0, 89) + "…" : t;
|
|
35
|
+
};
|
|
36
|
+
const name = one(event.name), err = one(event.error), detail = one(event.detail);
|
|
37
|
+
const why = (fallback) => err || detail || fallback;
|
|
38
|
+
switch (event.kind) {
|
|
39
|
+
case "check-passed": return { state: "happy", reason: name ? "Check passed: " + name : "Check passed" };
|
|
40
|
+
case "flow-done": return { state: "happy", reason: name ? "Flow finished: " + name : "Flow finished" };
|
|
41
|
+
case "page-error": return { state: "worried", reason: "Page error: " + why("the page threw an error") };
|
|
42
|
+
case "crash": return { state: "worried", reason: "Crash: " + why("the app stopped") };
|
|
43
|
+
case "action-failed": return { state: "worried", reason: (name ? "Action failed (" + name + "): " : "Action failed: ") + why("no reason given") };
|
|
44
|
+
case "check-failed": return { state: "worried", reason: (name ? "Check failed (" + name + "): " : "Check failed: ") + why("no reason given") };
|
|
45
|
+
default: return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Markup for Lumi. `state` is one of idle, working, happy, worried. */
|
|
50
|
+
export function lumiHtml(state = "idle") {
|
|
51
|
+
const st = LUMI_STATES.includes(state) ? state : "idle";
|
|
52
|
+
const faces = LUMI_STATES.map((s) => `<g data-face="${s}"${s === st ? "" : " hidden"}>${FACE[s]}</g>`).join("");
|
|
53
|
+
return `<span class="sf-lumi" data-part="lumi" data-state="${st}" aria-hidden="true" inert>`
|
|
54
|
+
+ `<svg viewBox="0 0 22 22" width="22" height="22" focusable="false"><defs><radialGradient id="sf-lumi-g" cx="40%" cy="35%" r="70%"><stop offset="0" stop-color="#e9fbff"/><stop offset=".6" stop-color="#7fd6f5"/><stop offset="1" stop-color="#3b9ad9"/></radialGradient></defs>`
|
|
55
|
+
+ `<circle cx="11" cy="11" r="10" fill="url(#sf-lumi-g)"/>`
|
|
56
|
+
+ `<g class="sf-lumi-eyes"><ellipse cx="7.8" cy="11" rx="1.4" ry="1.9" fill="#10151c"/><ellipse cx="14.2" cy="11" rx="1.4" ry="1.9" fill="#10151c"/></g>${faces}</svg></span>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The caption pill Lumi shows for the agent's current step. Hidden until a step is shown. Outside the app's screen tree like Lumi. */
|
|
60
|
+
export const lumiCaptionHtml = () => `<span class="sf-lumi-cap" data-part="lumi-caption" aria-hidden="true" inert hidden></span>`;
|
|
61
|
+
|
|
62
|
+
/** Style for Lumi. `top` and `offset` place it beside the cutout. Motion stops for people who ask for less of it. */
|
|
63
|
+
export const lumiCss = (k) => [
|
|
64
|
+
// The glow must never paint over the app's screen below the status bar.
|
|
65
|
+
`.sf-status{overflow:hidden}`,
|
|
66
|
+
`.sf-lumi{position:absolute;left:50%;top:${Math.round((k.status - 22) / 2)}px;margin-left:${k.part === "dynamic-island" ? 66 : 20}px;width:22px;height:22px;pointer-events:none;z-index:3;animation:sf-lumi-float 3.6s ease-in-out infinite}`,
|
|
67
|
+
`.sf-lumi svg{display:block;border-radius:50%;box-shadow:0 0 6px 1px #7fd6f5aa;animation:sf-lumi-glow 2.8s ease-in-out infinite}`,
|
|
68
|
+
`.sf-lumi-eyes{transform-box:fill-box;transform-origin:center;animation:sf-lumi-blink 4.5s infinite}`,
|
|
69
|
+
`.sf-lumi [hidden]{display:none}`,
|
|
70
|
+
`.sf-lumi[data-state=working] svg{animation-duration:1.1s}.sf-lumi[data-state=working]{animation-duration:1.6s}`,
|
|
71
|
+
`.sf-lumi[data-state=happy] svg{box-shadow:0 0 9px 3px #ffe08aaa}.sf-lumi[data-state=worried] svg{box-shadow:0 0 7px 2px #ff9a8aaa}`,
|
|
72
|
+
`.sf-lumi-cap{position:absolute;left:50%;top:${k.status + 6}px;transform:translateX(-50%);max-width:80%;box-sizing:border-box;padding:4px 12px;border-radius:14px;background:#10151ce6;color:#fff;font:600 12px system-ui,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;z-index:4}.sf-lumi-cap[hidden]{display:none}`,
|
|
73
|
+
`@keyframes sf-lumi-float{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}`,
|
|
74
|
+
`@keyframes sf-lumi-glow{0%,100%{opacity:.88}50%{opacity:1;filter:brightness(1.12)}}`,
|
|
75
|
+
`@keyframes sf-lumi-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.1)}}`,
|
|
76
|
+
`@media (prefers-reduced-motion:reduce){.sf-lumi,.sf-lumi svg,.sf-lumi-eyes{animation:none}}`,
|
|
77
|
+
].join("\n");
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Browser script. `window.shipvoLumi.set(state, reason?)` changes Lumi's state; `react(event)` picks the state and reason from a result (see lumiReaction).
|
|
81
|
+
* A `{ type: "pn:lumi", state, reason? }` or `{ type: "pn:lumi", event }` message does the same. The reason sits in Lumi's `data-reason` and `title`
|
|
82
|
+
* and is cleared when the state has no reason.
|
|
83
|
+
* `window.shipvoLumi.say(text, state, holdMs)` (or `{ type: "pn:lumi", state, caption, hold }`) also shows a short caption.
|
|
84
|
+
* With `holdMs` > 0 the caption goes away and Lumi goes back to idle after that long. Touches only Lumi and its caption.
|
|
85
|
+
*/
|
|
86
|
+
export function lumiClientScript() {
|
|
87
|
+
return `(function(){var S=${JSON.stringify(LUMI_STATES)},T=0;var lumiReaction=${lumiReaction.toString()};
|
|
88
|
+
function set(state,reason){if(S.indexOf(state)<0)return false;var l=document.querySelector('[data-part="lumi"]');if(!l)return false;l.setAttribute("data-state",state);
|
|
89
|
+
if(typeof reason==="string"&&reason){l.setAttribute("data-reason",reason);l.setAttribute("title",reason)}else{l.removeAttribute("data-reason");l.removeAttribute("title")}
|
|
90
|
+
var g=l.querySelectorAll("[data-face]");for(var i=0;i<g.length;i++)g[i].hidden=g[i].getAttribute("data-face")!==state;return true}
|
|
91
|
+
function react(ev){var r=lumiReaction(ev);if(!r)return null;return set(r.state,r.reason)?r:null}
|
|
92
|
+
function say(text,state,hold){clearTimeout(T);var c=document.querySelector('[data-part="lumi-caption"]');var ok=set(state||"working");
|
|
93
|
+
if(c){var t=String(text==null?"":text).replace(/\\s+/g," ").trim().slice(0,80);c.textContent=t;c.hidden=!t}
|
|
94
|
+
if(hold>0)T=setTimeout(function(){if(c){c.hidden=true;c.textContent=""}set("idle")},hold);return ok}
|
|
95
|
+
if(typeof window==="undefined")return;window.shipvoLumi={set:set,react:react,say:say,states:S};
|
|
96
|
+
window.addEventListener("message",function(e){var d=e.data;if(!d||d.type!=="pn:lumi")return;if(d.event)react(d.event);else if(typeof d.caption==="string")say(d.caption,d.state,Number(d.hold)||0);else set(d.state,d.reason)})})();`;
|
|
97
|
+
}
|