diff --git a/easel/phone/README.md b/easel/phone/README.md new file mode 100644 index 0000000000..2c137a80dc --- /dev/null +++ b/easel/phone/README.md @@ -0,0 +1,64 @@ +# Aesel on a phone + +The hosted half of Aesel with a touch interface. It is not a port: the agent +loop is `easel/src/ac-server.mjs`, loaded unmodified, and publishing is +`easel/src/publish.mjs`, also unmodified. This directory is only the shell +around them. + +## Why this shape + +`easel/docs/native-shell.md` concluded that iOS should be its own project with +"the agent loop rewritten in Swift." Reading `ac-server.mjs` closely argues +against that. Its entire Node surface is `EventEmitter`, three `fs` calls, +`path.join`, `randomUUID` and `process.env` — and `piece`, `token`, `fetch` and +`site` are already constructor-injected. Every one of those is a few lines of +shim, so a Swift rewrite would buy nothing and guarantee the phone and the +desktop drift apart. `shim/` answers the imports instead, through an import map +in `index.html`. + +The one thing that genuinely could not be shimmed is `validatePieceSource`, +which shells out to `node --check`. `shim/revisions.mjs` explains what replaced +it and what that costs. + +## Running it + +```sh +node easel/phone/serve.mjs --port 8770 --token +``` + +Serves the repository so the bridge's own paths resolve — `/easel/src/*.mjs` and +`/easel/context/*.md` are fetched at the very paths `bundledContext()` builds. +Only those three trees are reachable; the root is the repository, so the fence +matters. + +`--token` exposes `/dev-token`, which hands the page the access token out of +`~/.ac-token` so a phone on the same network can sign in before any Apple +plumbing exists. Private addresses only, and off by default. It is a bearer +token: do not run it on a network you do not own. + +Open the printed LAN address on the phone. Without `--token`, the page asks for +a pasted token instead. + +## What it does + +Type a sentence; the bridge writes the whole piece; the write handler publishes +it under your `@handle`; the preview reloads. The piece is live on +`aesthetic.computer` while you are still making it, which is the thing this +client is actually for. + +Caveats worth knowing before judging output: the server allowlists three cheap +models, so this is a deliberately weaker agent than desktop Aesel, and the +allowance is the same daily one `/api/ask` spends. + +## Where it is going + +This page is the content of the iOS app, not a detour. The Swift shell replaces +three things and nothing else: + +| Browser now | iOS later | +|---|---| +| `/dev-token` or a pasted token | `ASWebAuthenticationSession`, then `window.aeselAdoptToken(token)` | +| `localStorage` | the app's Documents container, via `vfs.setWriteHandler` | +| an ` +

Your piece appears here, live, as it is written.

+ + +
    + +
    + + + +
    + + +
    +

    Aesel

    +

    Make art and software, on your phone.

    +
    +

    Checking for a session…

    +
    +
    + + + + diff --git a/easel/phone/serve.mjs b/easel/phone/serve.mjs new file mode 100644 index 0000000000..ca5edc6296 --- /dev/null +++ b/easel/phone/serve.mjs @@ -0,0 +1,126 @@ +// A static server for the phone client, rooted at the repository so the page +// can import `easel/src/*.mjs` and fetch `easel/context/*.md` at the very paths +// the bridge builds internally. Nothing is copied or bundled — the phone runs +// the same files the desktop does. +// +// node easel/phone/serve.mjs [--port 8770] [--token] +// +// `--token` mints a dev endpoint that hands the page the access token out of +// ~/.ac-token, so a phone on the LAN can sign in before the real +// ASWebAuthenticationSession flow exists. It is off by default and refuses any +// request that is not from a private address, because it is a bearer token. + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { existsSync, readFileSync } from "node:fs"; +import { join, normalize, extname } from "node:path"; +import { homedir, networkInterfaces } from "node:os"; + +const ROOT = new URL("../..", import.meta.url).pathname.replace(/\/$/, ""); + +const args = process.argv.slice(2); +const flag = (name, fallback) => { + const at = args.indexOf(`--${name}`); + if (at === -1) return fallback; + const value = args[at + 1]; + return value && !value.startsWith("--") ? value : true; +}; + +const PORT = Number(flag("port", 8770)); +const SHARE_TOKEN = Boolean(flag("token", false)); + +const TYPES = { + ".html": "text/html; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".md": "text/markdown; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +// Only these trees are reachable. The server sits on the repository root so the +// unmodified bridge resolves, which is exactly the kind of convenience that +// serves a vault by accident if it is not fenced. +const ALLOWED = ["/easel/phone/", "/easel/src/", "/easel/context/"]; + +function isPrivate(address = "") { + const host = address.replace(/^::ffff:/, ""); + return ( + host === "127.0.0.1" || + host === "::1" || + host.startsWith("10.") || + host.startsWith("192.168.") || + host.startsWith("100.") || // tailnet + /^172\.(1[6-9]|2\d|3[01])\./.test(host) + ); +} + +function localAddresses() { + const out = []; + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family === "IPv4" && !entry.internal) out.push(entry.address); + } + } + return out; +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url, `http://${request.headers.host ?? "localhost"}`); + let path = decodeURIComponent(url.pathname); + if (path === "/") path = "/easel/phone/index.html"; + + if (path === "/dev-token") { + if (!SHARE_TOKEN) return send(response, 404, "text/plain", "dev token sharing is off"); + if (!isPrivate(request.socket.remoteAddress)) { + return send(response, 403, "text/plain", "refused: not a private address"); + } + const file = join(homedir(), ".ac-token"); + if (!existsSync(file)) return send(response, 404, "text/plain", "no ~/.ac-token — run ac-login"); + try { + const record = JSON.parse(readFileSync(file, "utf8")); + if (!record.access_token) throw new Error("no access_token in ~/.ac-token"); + return send( + response, + 200, + TYPES[".json"], + JSON.stringify({ access_token: record.access_token }), + ); + } catch (error) { + return send(response, 500, "text/plain", error.message); + } + } + + const safe = normalize(path); + if (!ALLOWED.some((prefix) => safe.startsWith(prefix))) { + return send(response, 403, "text/plain", "forbidden"); + } + + try { + const body = await readFile(join(ROOT, safe)); + send(response, 200, TYPES[extname(safe)] ?? "application/octet-stream", body); + } catch { + send(response, 404, "text/plain", `not found: ${safe}`); + } +}); + +function send(response, status, type, body) { + response.writeHead(status, { + "Content-Type": type, + "Cache-Control": "no-store", + // The page calls aesthetic.computer, not the other way round, so this is + // only here so a phone can be pointed at a laptop without a proxy. + "Access-Control-Allow-Origin": "*", + }); + response.end(body); +} + +server.listen(PORT, "0.0.0.0", () => { + console.log(`aesel phone → http://localhost:${PORT}/`); + for (const address of localAddresses()) console.log(` → http://${address}:${PORT}/`); + if (SHARE_TOKEN) console.log(" /dev-token is live (private addresses only)"); +}); diff --git a/easel/phone/shim/absent.mjs b/easel/phone/shim/absent.mjs new file mode 100644 index 0000000000..f8eee33f4f --- /dev/null +++ b/easel/phone/shim/absent.mjs @@ -0,0 +1,22 @@ +// Stubs for the Node modules that `publish.mjs` and its import chain pull in at +// module scope but only call from code paths a phone never takes — `spawn` for +// the desktop syntax check, `createServer` for the loopback OAuth listener, +// `homedir` for the default ~/.ac-token location. +// +// They throw when called rather than returning something plausible. A stub that +// quietly answers is how you get a session that looks signed in and is not. + +const absent = (name) => () => { + throw new Error(`${name}() is not available in the phone client`); +}; + +export const spawn = absent("spawn"); +export const execFile = absent("execFile"); +export const execFileSync = absent("execFileSync"); +export const createServer = absent("createServer"); +export const homedir = () => "/home"; +export const tmpdir = () => "/tmp"; +export const networkInterfaces = () => ({}); +export const platform = () => "webview"; + +export default { spawn, execFile, execFileSync, createServer, homedir, tmpdir, platform }; diff --git a/easel/phone/shim/crypto.mjs b/easel/phone/shim/crypto.mjs new file mode 100644 index 0000000000..4e2beb71ac --- /dev/null +++ b/easel/phone/shim/crypto.mjs @@ -0,0 +1,38 @@ +// `randomUUID`, which browsers already have — but only in a secure context. +// Safari over plain http on the LAN is not one, so this falls back rather than +// letting a thread id be the thing that breaks a phone test. + +export function randomUUID() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + const bytes = new Uint8Array(16); + (globalThis.crypto ?? { getRandomValues: fill }).getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function fill(array) { + for (let i = 0; i < array.length; i += 1) array[i] = Math.floor(Math.random() * 256); + return array; +} + +// `ac-session.mjs` is evaluated because `publish.mjs` imports two constants +// from it, so its imports must resolve even though the phone never runs the +// desktop PKCE flow. randomBytes is real (getRandomValues is everywhere); +// createHash is not, because SubtleCrypto is async and nothing here awaits it. +export function randomBytes(size) { + const bytes = new Uint8Array(size); + globalThis.crypto.getRandomValues(bytes); + bytes.toString = (encoding) => + encoding === "hex" + ? [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("") + : btoa(String.fromCharCode(...bytes)); + return bytes; +} + +export function createHash() { + throw new Error("createHash() is not available in the phone client — use SubtleCrypto"); +} + +export default { randomUUID, randomBytes, createHash }; diff --git a/easel/phone/shim/events.mjs b/easel/phone/shim/events.mjs new file mode 100644 index 0000000000..d7d343c98e --- /dev/null +++ b/easel/phone/shim/events.mjs @@ -0,0 +1,57 @@ +// The slice of `node:events` that `ac-server.mjs` actually uses. +// +// It extends EventEmitter and calls `emit`. Nothing subscribes twice, nothing +// removes a listener mid-emit, and there is no `once`. So this is the whole of +// it — a Map of arrays — rather than a port of Node's class, which carries +// captureRejections, error-event throwing and max-listener warnings that would +// be dead weight in a webview. + +export class EventEmitter { + #listeners = new Map(); + + on(name, fn) { + const list = this.#listeners.get(name); + if (list) list.push(fn); + else this.#listeners.set(name, [fn]); + return this; + } + + off(name, fn) { + const list = this.#listeners.get(name); + if (!list) return this; + const at = list.indexOf(fn); + if (at !== -1) list.splice(at, 1); + return this; + } + + once(name, fn) { + const wrapped = (...args) => { + this.off(name, wrapped); + fn(...args); + }; + return this.on(name, wrapped); + } + + // Copied before iterating: a listener that unsubscribes itself must not + // shorten the list we are walking. + emit(name, ...args) { + const list = this.#listeners.get(name); + if (!list?.length) return false; + for (const fn of [...list]) { + try { + fn(...args); + } catch (error) { + console.error(`[easel] listener for ${name} threw`, error); + } + } + return true; + } + + removeAllListeners(name) { + if (name === undefined) this.#listeners.clear(); + else this.#listeners.delete(name); + return this; + } +} + +export default { EventEmitter }; diff --git a/easel/phone/shim/fs.mjs b/easel/phone/shim/fs.mjs new file mode 100644 index 0000000000..9095d7201f --- /dev/null +++ b/easel/phone/shim/fs.mjs @@ -0,0 +1,81 @@ +// A virtual filesystem with exactly three operations, because that is all the +// `ac` bridge performs: it reads four guides out of `easel/context`, reads the +// piece before a turn, and writes the piece after one. +// +// Everything lives in a Map keyed by URL pathname. Two things fill it: +// `preload()` fetches the guides from the server at boot, and the host (the +// page, and later Swift) mounts the piece. Writes go to `onWrite`, which is +// where persistence actually happens — localStorage in the browser, the app's +// Documents container on iOS. Keeping that a callback rather than a hard-coded +// backend is the seam the native shell will use without touching this file. + +const files = new Map(); + +let onWrite = () => {}; + +export function mount(path, contents) { + files.set(path, contents); +} + +export function unmount(path) { + files.delete(path); +} + +export function setWriteHandler(fn) { + onWrite = typeof fn === "function" ? fn : () => {}; +} + +export function snapshot() { + return new Map(files); +} + +// The guides are fetched under the same pathnames `bundledContext()` builds, so +// a miss here is a genuinely missing file rather than a path mismatch. It skips +// rather than throws for the same reason the real `bundledContext()` does: a +// guide that fails to load should cost the model context, not the session. +export async function preload(paths) { + const missing = []; + await Promise.all( + paths.map(async (path) => { + try { + const response = await fetch(path); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + files.set(path, await response.text()); + } catch (error) { + missing.push(`${path} (${error.message})`); + } + }), + ); + return missing; +} + +export function existsSync(path) { + return files.has(path); +} + +export function readFileSync(path) { + const contents = files.get(path); + if (contents === undefined) { + const error = new Error(`ENOENT: no such file or directory, open '${path}'`); + error.code = "ENOENT"; + throw error; + } + return contents; +} + +export function writeFileSync(path, contents) { + files.set(path, contents); + onWrite(path, contents); +} + +// Imported by `ac-session.mjs`, never reached: the phone does not keep a +// ~/.ac-token to create, delete or watch. +export function mkdirSync() {} +export function unlinkSync(path) { + files.delete(path); +} +export function watch() { + return { close() {} }; +} + +export default { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, watch }; diff --git a/easel/phone/shim/globals.js b/easel/phone/shim/globals.js new file mode 100644 index 0000000000..cbbb28bf72 --- /dev/null +++ b/easel/phone/shim/globals.js @@ -0,0 +1,22 @@ +// A classic script, loaded before any module, because `process` is referenced +// at module scope in `ac-server.mjs` (`process.env.EASEL_SITE`) and an import +// map cannot supply a bare global. +// +// Deliberately tiny and deliberately not `process`-shaped beyond what is read: +// an `env` to look in, a `cwd()` that answers with the piece root, and a +// `platform` that says what this really is. Anything else should fail loudly +// rather than quietly pretend to be Node. +globalThis.process = globalThis.process || { + env: {}, + argv: ["aesel", "phone"], + platform: "webview", + version: "", + cwd: () => "/piece", +}; + +// `publish.mjs` measures the upload with `Buffer.byteLength`. That is the only +// Buffer call anywhere in the shared path, and TextEncoder already answers it +// correctly for UTF-8 — which is the encoding every piece is stored in. +globalThis.Buffer = globalThis.Buffer || { + byteLength: (value) => new TextEncoder().encode(String(value)).length, +}; diff --git a/easel/phone/shim/path.mjs b/easel/phone/shim/path.mjs new file mode 100644 index 0000000000..4e51862b75 --- /dev/null +++ b/easel/phone/shim/path.mjs @@ -0,0 +1,59 @@ +// POSIX `join` and `dirname` over the three call sites in the `ac` bridge. +// +// The paths this sees are not really filesystem paths — they are the URL +// pathnames the import map hands back, so `/easel/src/ac-server.mjs` rather +// than `/Users/jas/…`. That is deliberate: it makes the virtual filesystem's +// keys the same strings the page can fetch, so the guides load from the server +// under exactly the path `bundledContext()` asks for. + +export function join(...parts) { + const joined = parts.filter((part) => part !== "" && part != null).join("/"); + return normalize(joined); +} + +export function normalize(path) { + const absolute = path.startsWith("/"); + const out = []; + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + // A leading `..` on a relative path has to survive; on an absolute one it + // is meaningless and Node drops it too. + if (out.length && out.at(-1) !== "..") out.pop(); + else if (!absolute) out.push(".."); + continue; + } + out.push(segment); + } + const body = out.join("/"); + if (absolute) return `/${body}`; + return body || "."; +} + +export function dirname(path) { + const at = path.lastIndexOf("/"); + if (at === -1) return "."; + if (at === 0) return "/"; + return path.slice(0, at); +} + +export function basename(path) { + return path.slice(path.lastIndexOf("/") + 1); +} + +export function extname(path) { + const name = basename(path); + const at = name.lastIndexOf("."); + return at <= 0 ? "" : name.slice(at); +} + +export function resolve(...parts) { + let out = ""; + for (const part of parts) { + if (!part) continue; + out = part.startsWith("/") ? part : out ? `${out}/${part}` : part; + } + return normalize(out.startsWith("/") ? out : `/${out}`); +} + +export default { join, normalize, dirname, basename, extname, resolve }; diff --git a/easel/phone/shim/revisions.mjs b/easel/phone/shim/revisions.mjs new file mode 100644 index 0000000000..87de538e51 --- /dev/null +++ b/easel/phone/shim/revisions.mjs @@ -0,0 +1,211 @@ +// `validatePieceSource` without a subprocess. +// +// The real one in `easel/src/revisions.mjs` spawns `node --input-type=module +// --check` and lets V8 be the judge. A webview has no subprocess and no +// non-executing module parser — `import()` and `new Function` both compile *and* +// run, and the rule that file states plainly ("user code must never execute in +// Aesel") is the whole reason it shells out rather than importing. +// +// So this is a scanner, not a parser, and it is weaker on purpose. Be clear +// about the trade: it will not catch `let let = 1` or a bad destructuring +// pattern. What it does catch is the failure this check exists for — a stream +// that stopped mid-file, leaving an unclosed brace, string or template. That is +// what a truncated `write_piece` looks like, and shipping it would blank the +// preview. Everything subtler is caught a second later by the runtime, which +// reports into the transcript the same way it already does on desktop. +// +// If this ever needs to be strict, the honest fix is a real parser (acorn is +// ~120 KB) or a `--check` endpoint on the server — not a cleverer regex. + +const OPENERS = { "(": ")", "[": "]", "{": "}" }; +const CLOSERS = { ")": "(", "]": "[", "}": "{" }; + +// A `/` starts a regex rather than a division when the last meaningful thing +// before it cannot end an expression. Approximate, and that is fine: guessing +// wrong costs a false report on code that divides by a bracketed expression, +// which is rare in a piece and still only refuses one revision. +function regexCanFollow(previous) { + if (!previous) return true; + if (/[)\]]/.test(previous)) return false; + if (/[A-Za-z0-9_$]/.test(previous)) return false; + return true; +} + +function scan(source) { + const stack = []; + // Template literals nest: `${ `inner` }` is legal, so depth has to be a stack + // too, tracking whether each `{` we meet belongs to a template substitution. + const templates = []; + let previous = ""; + let i = 0; + + while (i < source.length) { + const char = source[i]; + const next = source[i + 1]; + + if (char === "/" && next === "/") { + const end = source.indexOf("\n", i); + i = end === -1 ? source.length : end; + continue; + } + + if (char === "/" && next === "*") { + const end = source.indexOf("*/", i + 2); + if (end === -1) return "a block comment is never closed"; + i = end + 2; + continue; + } + + if (char === '"' || char === "'") { + const failure = skipQuoted(source, i, char); + if (typeof failure === "string") return failure; + i = failure; + previous = char; + continue; + } + + if (char === "`") { + templates.push(stack.length); + const moved = skipTemplate(source, i, stack, templates); + if (typeof moved === "string") return moved; + i = moved; + previous = "`"; + continue; + } + + if (char === "/" && regexCanFollow(previous)) { + const moved = skipRegex(source, i); + if (moved !== null) { + i = moved; + previous = "/"; + continue; + } + } + + if (OPENERS[char]) { + stack.push(char); + } else if (CLOSERS[char]) { + const open = stack.pop(); + if (open !== CLOSERS[char]) { + return open === undefined + ? `a stray \`${char}\` closes something that was never opened` + : `a \`${open}\` is closed by \`${char}\``; + } + } + + if (!/\s/.test(char)) previous = char; + i += 1; + } + + if (stack.length) { + const open = stack.at(-1); + return `a \`${open}\` is never closed — the file looks cut off`; + } + return null; +} + +function skipQuoted(source, start, quote) { + let i = start + 1; + while (i < source.length) { + const char = source[i]; + if (char === "\\") { + i += 2; + continue; + } + if (char === "\n") return `a ${quote === '"' ? "double" : "single"}-quoted string runs past the end of its line`; + if (char === quote) return i + 1; + i += 1; + } + return "a string is never closed — the file looks cut off"; +} + +// Walks a template literal, recursing through `${ … }` so brackets inside a +// substitution are balanced against the same stack the rest of the file uses. +function skipTemplate(source, start, stack, templates) { + let i = start + 1; + while (i < source.length) { + const char = source[i]; + if (char === "\\") { + i += 2; + continue; + } + if (char === "`") { + templates.pop(); + return i + 1; + } + if (char === "$" && source[i + 1] === "{") { + let depth = 1; + i += 2; + while (i < source.length && depth > 0) { + const inner = source[i]; + if (inner === "\\") { + i += 2; + continue; + } + if (inner === "`") { + const moved = skipTemplate(source, i, stack, templates); + if (typeof moved === "string") return moved; + i = moved; + continue; + } + if (inner === '"' || inner === "'") { + const moved = skipQuoted(source, i, inner); + if (typeof moved === "string") return moved; + i = moved; + continue; + } + if (inner === "{") depth += 1; + else if (inner === "}") depth -= 1; + i += 1; + } + if (depth > 0) return "a `${` substitution is never closed — the file looks cut off"; + continue; + } + i += 1; + } + return "a template literal is never closed — the file looks cut off"; +} + +// Returns the index after the regex, or null when this `/` was division after +// all (an unterminated one on a single line is the tell). +function skipRegex(source, start) { + let i = start + 1; + let inClass = false; + while (i < source.length) { + const char = source[i]; + if (char === "\\") { + i += 2; + continue; + } + if (char === "\n") return null; + if (char === "[") inClass = true; + else if (char === "]") inClass = false; + else if (char === "/" && !inClass) { + i += 1; + while (i < source.length && /[a-z]/.test(source[i])) i += 1; + return i; + } + i += 1; + } + return null; +} + +export async function validatePieceSource(source, file) { + if (typeof source !== "string" || !source.trim()) throw new Error("The piece is empty."); + // Matches the original: other runtimes keep their own loader validation. + if (!String(file).endsWith(".mjs")) return; + const failure = scan(source); + if (failure) { + throw new Error(`Incomplete or invalid JavaScript; previous preview kept. ${failure}.`); + } +} + +// The desktop module also exports `PieceRevisions`, a local snapshot store under +// ~/.local/share/easel/history. The phone keeps its history somewhere else, so +// this is a stub that fails loudly rather than a half-implementation that looks +// like it is saving and is not. +export class PieceRevisions { + constructor() { + throw new Error("PieceRevisions is not available in the phone client."); + } +} diff --git a/easel/phone/shim/url.mjs b/easel/phone/shim/url.mjs new file mode 100644 index 0000000000..fa4dcf864f --- /dev/null +++ b/easel/phone/shim/url.mjs @@ -0,0 +1,22 @@ +// `fileURLToPath` over http(s) URLs. +// +// `ac-server.mjs` calls it on `import.meta.url` to find its own directory. In a +// webview that URL is `https://host/easel/src/ac-server.mjs`, so the useful +// answer is the pathname — which keeps every derived path fetchable, the same +// convention the fs shim relies on. + +export function fileURLToPath(url) { + const text = String(url); + if (text.startsWith("file://")) return new URL(text).pathname; + try { + return new URL(text).pathname; + } catch { + return text; + } +} + +export function pathToFileURL(path) { + return new URL(path, globalThis.location?.href ?? "file:///"); +} + +export default { fileURLToPath, pathToFileURL }; diff --git a/easel/phone/style.css b/easel/phone/style.css new file mode 100644 index 0000000000..32e888cb8b --- /dev/null +++ b/easel/phone/style.css @@ -0,0 +1,273 @@ +/* Phone-first, and only phone for now. The desktop already has a client. */ + +:root { + --bg: #463264; + --bg-deep: #241d35; + --ink: #efe9ff; + --dim: #b3a4cf; + --rule: #5e4881; + --you: #ff4d88; + --ac: #b58cff; + --edit: #6ee7a8; + --bad: #ff7a7a; + --gap: 12px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + overscroll-behavior: none; +} + +body { + background: var(--bg); + color: var(--ink); + font: 15px/1.45 ui-monospace, "SF Mono", Menlo, monospace; + -webkit-text-size-adjust: 100%; +} + +/* The app is a three-row column: preview, transcript, composer. Only the + transcript scrolls, so the preview never leaves and the input never hides. */ +#app { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + height: 100dvh; +} + +#app[hidden] { + display: none; +} + +#bar { + display: flex; + align-items: center; + gap: var(--gap); + padding: calc(env(safe-area-inset-top) + 8px) var(--gap) 8px; + border-bottom: 1px solid var(--rule); +} + +#piece { + flex: 1; + min-width: 0; + padding: 0; + border: 0; + background: none; + color: var(--ink); + font: inherit; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#status { + flex: none; + font-size: 12px; + color: var(--dim); +} + +#status[data-state="working"] { + color: var(--ac); +} + +#status[data-state="failed"] { + color: var(--bad); +} + +#status[data-state="live"] { + color: var(--edit); +} + +/* 4:3 keeps a piece's default frame honest without eating the transcript. */ +#stage { + position: relative; + aspect-ratio: 4 / 3; + background: var(--bg-deep); + border-bottom: 1px solid var(--rule); +} + +#preview { + width: 100%; + height: 100%; + border: 0; + display: block; +} + +#preview:not([src]) { + visibility: hidden; +} + +#stage-empty { + position: absolute; + inset: 0; + margin: 0; + display: grid; + place-content: center; + padding: 0 24px; + text-align: center; + color: var(--dim); + font-size: 13px; +} + +#stage.has-piece #stage-empty { + display: none; +} + +#log { + margin: 0; + padding: var(--gap); + list-style: none; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +#log li { + margin: 0 0 10px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +#log .tag { + display: inline-block; + min-width: 3.2em; + color: var(--dim); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +#log .you .tag { + color: var(--you); +} + +#log .ac .tag { + color: var(--ac); +} + +#log .edit .tag { + color: var(--edit); +} + +#log .bad { + color: var(--bad); +} + +#log .note { + color: var(--dim); + font-size: 13px; +} + +#composer { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 8px var(--gap) calc(env(safe-area-inset-bottom) + 8px); + border-top: 1px solid var(--rule); + background: var(--bg); +} + +#input { + flex: 1; + min-width: 0; + max-height: 40vh; + padding: 10px 12px; + border: 1px solid var(--rule); + border-radius: 10px; + background: var(--bg-deep); + color: var(--ink); + font: inherit; + resize: none; + /* 16px or iOS zooms the page on focus. */ + font-size: 16px; +} + +#input:focus { + outline: 2px solid var(--ac); + outline-offset: -1px; +} + +#composer button { + flex: none; + width: 44px; + height: 44px; + border: 0; + border-radius: 10px; + background: var(--you); + color: #fff; + font-size: 20px; + line-height: 1; +} + +#composer button:disabled { + opacity: 0.4; +} + +#stop { + background: var(--rule); +} + +#gate { + display: grid; + align-content: center; + gap: 16px; + min-height: 100dvh; + padding: 24px; + text-align: center; +} + +#gate[hidden] { + display: none; +} + +#gate h1 { + margin: 0; + font-size: 34px; + letter-spacing: 0.02em; +} + +.tagline { + margin: 0; + color: var(--dim); +} + +#gate-body { + display: grid; + gap: 12px; + justify-items: center; +} + +#gate input { + width: min(420px, 100%); + padding: 12px; + border: 1px solid var(--rule); + border-radius: 10px; + background: var(--bg-deep); + color: var(--ink); + font: inherit; + font-size: 16px; +} + +#gate button { + padding: 12px 20px; + border: 0; + border-radius: 10px; + background: var(--you); + color: #fff; + font: inherit; + font-size: 16px; +} + +#gate-note { + margin: 0; + color: var(--dim); + font-size: 13px; +} + +#gate-note.bad { + color: var(--bad); +}