diff --git a/.gitignore b/.gitignore index 423b058..5a0cd38 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ build/ out/ *.tsbuildinfo static/app.js +examples/svelte-tile/tile.js # Coverage / test output coverage/ diff --git a/README.md b/README.md index a9b3238..f271380 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,30 @@ Two local adaptations (the stock pieces assume `https://` on port 443): which is why the desktop build uses the CEF backend (`"backend": "cef"` in `deno.json`) rather than the platform webview. +## Tiles protocols: the data console + +Blastile implements the host side of the +[tp-data tiles protocol](https://dasl.ing/tp-data.html): tiles import +`/.well-known/web-tiles/data.js`, call `listen()`, and exchange +structured-cloned payloads with the host through the shuttle's +`tiles-protocol-up-*`/`tiles-protocol-down-*` relay. + +Every tile gets a **data console** — the *Data* button on a tile card (it +auto-opens the preview, since data flows through the rendered tile), or the +tray at the bottom of a tile window. It logs everything the tile sends +(`⬆ tile`), lets you send it JSON payloads (`⬇ host`, ⌘⏎ to send), and notes +protocol events like the ready signal. The host plumbing lives in +`src/ui/data-protocol.ts`; `globalThis.blastile.{app,dataHub}` is exposed for +poking at it from the devtools. + +`examples/hello-tile` speaks the protocol: it echoes back whatever you send it +and reports pokes as they happen. + +Note: npm's `@dasl/tile-server@2.0.0` predates tiles-protocols, so +`vendor/web-tiles/` carries `shuttle.js` + `data.js` from dasl-tiles HEAD and +Blastile serves those two in front of the stock router — see +`vendor/web-tiles/README.md` for when to drop it. + ## Running it Requires Deno ≥ 2.9 (for `deno desktop`). @@ -62,9 +86,24 @@ deno task dev # dev without a window: serves http://127.0.0.1:4179 deno task build # compile dist/Blastile.app ``` -There's a sample tile in `examples/hello-tile/` — add it with -**Watch tile dir…** and start editing. For an AT-hosted example, try -Minesweeper: +There are sample tiles in `examples/` — add them with **Watch tile dir…**: + +- `hello-tile/` — minimal tile; echoes tp-data payloads and reports pokes. +- `theme-tile/` — a little site whose whole theme (colours, fonts, radius) + is CSS custom properties driven live over tp-data. It tells you what it + accepts when it connects (with a paste-ready example payload), applies + whatever tokens you send, shows the received payload and current theme in + the page, and answers with `{ ok, applied, ignored, theme }`. +- `svelte-tile/` — a blank canvas with a built-in Svelte 5 engine. Send it + `{ "source": "", "props": {…} }` (or a bare source + string) and it compiles and mounts it live, answering with the rendered + text or the compile error. Runes and legacy syntax both work. Build its + engine bundle first with `deno task build:examples` — the tile embeds the + Svelte compiler + runtime (tiles have no network), compiles in-page, and + loads the emitted module through blob: URLs, which the web-tiles CSP + permits. It sends you a starter component when it connects. + +For an AT-hosted example, try Minesweeper: ``` at://did:plc:izttpdp3l6vss5crelt5kcux/ing.dasl.masl/3mcjwwoqjqs2v @@ -76,6 +115,7 @@ at://did:plc:izttpdp3l6vss5crelt5kcux/ing.dasl.masl/3mcjwwoqjqs2v main.ts Deno.serve on 127.0.0.1 (desktop-assigned port) ├── src/server/api.ts static UI, /api/* + WebSocket events, OAuth callback ├── src/server/tileserver.ts express + @dasl/tile-server on *.localhost: +│ (+ vendored tiles-protocols runtime from vendor/) ├── src/server/tiledir.ts tile dir → MASL manifest / .tile CAR (atile-compatible) ├── src/server/watcher.ts Deno.watchFs → debounced tile-changed events ├── src/server/oauth.ts @atcute/oauth-node-client loopback client, diff --git a/deno.json b/deno.json index 15bbb36..d5e76f9 100644 --- a/deno.json +++ b/deno.json @@ -12,9 +12,13 @@ }, "tasks": { "build:ui": "deno bundle --platform browser --output static/app.js src/ui/app.ts", + "build:examples": "deno bundle --platform browser --minify --output examples/svelte-tile/tile.js examples/_src/svelte-tile.ts", "dev": "deno task build:ui && deno run -A --watch main.ts", - "desktop": "deno task build:ui && deno desktop --hmr -A --include static main.ts", - "build": "deno task build:ui && deno desktop -A --include static --output dist/Blastile.app main.ts" + "desktop": "deno task build:ui && deno desktop --hmr -A --include static --include vendor main.ts", + "build": "deno task build:ui && deno desktop -A --include static --include vendor --output dist/Blastile.app main.ts" + }, + "lint": { + "exclude": ["vendor/", "examples/", "static/app.js"] }, "compilerOptions": { "lib": ["deno.ns", "dom", "dom.iterable", "dom.asynciterable"] diff --git a/deno.lock b/deno.lock index 964ef13..2d18be9 100644 --- a/deno.lock +++ b/deno.lock @@ -20,7 +20,8 @@ "npm:file-type@^22.0.1": "22.0.1", "npm:lit@^3.3.3": "3.3.3", "npm:mime@^4.1.0": "4.1.0", - "npm:refrakt@1": "1.0.0" + "npm:refrakt@1": "1.0.0", + "npm:svelte@5": "5.56.4" }, "jsr": { "@std/internal@1.0.14": { @@ -194,6 +195,33 @@ "tmp-promise" ] }, + "@jridgewell/gen-mapping@0.3.13": { + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.31": { + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, "@lit-labs/signals@0.3.0": { "integrity": "sha512-PfB5FLtorVfkhwTWXOPNT/rvrVdSDY8siwPyR17ztrViRbMqxy1TQBcHqJoA4303QT8yXPGxYtGKJGj7fEfEwg==", "dependencies": [ @@ -213,6 +241,12 @@ "@standard-schema/spec@1.1.0": { "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, + "@sveltejs/acorn-typescript@1.0.11_acorn@8.17.0": { + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dependencies": [ + "acorn" + ] + }, "@tokenizer/inflate@0.4.1": { "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "dependencies": [ @@ -236,6 +270,9 @@ "@types/node" ] }, + "@types/estree@1.0.9": { + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" + }, "@types/express-serve-static-core@5.1.2": { "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", "dependencies": [ @@ -291,6 +328,16 @@ "negotiator" ] }, + "acorn@8.17.0": { + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "bin": true + }, + "aria-query@5.3.1": { + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==" + }, + "axobject-query@4.1.0": { + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" + }, "body-parser@2.3.0": { "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dependencies": [ @@ -322,6 +369,9 @@ "get-intrinsic" ] }, + "clsx@2.1.1": { + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, "content-disposition@1.1.0": { "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" }, @@ -349,6 +399,9 @@ "depd@2.0.0": { "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, + "devalue@5.8.1": { + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==" + }, "dunder-proto@1.0.1": { "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dependencies": [ @@ -381,6 +434,12 @@ "esm-env@1.2.2": { "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" }, + "esrap@2.2.13": { + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, "etag@1.8.1": { "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, @@ -511,6 +570,12 @@ "is-promise@4.0.0": { "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, + "is-reference@3.0.3": { + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dependencies": [ + "@types/estree" + ] + }, "isarray@1.0.0": { "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, @@ -551,6 +616,15 @@ "lit-html" ] }, + "locate-character@3.0.0": { + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" + }, + "magic-string@0.30.21": { + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, "math-intrinsics@1.1.0": { "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" }, @@ -758,6 +832,27 @@ "@tokenizer/token" ] }, + "svelte@5.56.4": { + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dependencies": [ + "@jridgewell/remapping", + "@jridgewell/sourcemap-codec", + "@sveltejs/acorn-typescript", + "@types/estree", + "@types/trusted-types", + "acorn", + "aria-query", + "axobject-query", + "clsx", + "devalue", + "esm-env", + "esrap", + "is-reference", + "locate-character", + "magic-string", + "zimmerframe" + ] + }, "tmp-promise@3.0.3": { "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", "dependencies": [ @@ -809,6 +904,9 @@ }, "wrappy@1.0.2": { "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "zimmerframe@1.1.4": { + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" } }, "workspace": { diff --git a/examples/_src/process-shim.ts b/examples/_src/process-shim.ts new file mode 100644 index 0000000..c92ee2b --- /dev/null +++ b/examples/_src/process-shim.ts @@ -0,0 +1,3 @@ +// Bundled tooling sometimes probes process.env; give it a stub in the tile. +(globalThis as { process?: unknown }).process ??= { env: { NODE_ENV: 'production' } }; +export {}; diff --git a/examples/_src/svelte-tile.ts b/examples/_src/svelte-tile.ts new file mode 100644 index 0000000..f346e99 --- /dev/null +++ b/examples/_src/svelte-tile.ts @@ -0,0 +1,188 @@ +// Svelte Playground tile: a blank canvas with a built-in Svelte 5 engine. +// +// Tiles cannot touch the network, so the Svelte compiler AND runtime are +// bundled into this file (deno task build:examples). Source arrives over the +// tp-data protocol; we compile it client-side, rewrite the emitted bare +// `svelte/*` imports to blob-URL shim modules that re-export the bundled +// runtime (the web-tiles CSP allows blob: scripts), import the blob, and +// mount the component. +// +// Payloads: a string of Svelte source, or { source, props? }. The tile +// answers { ok, text? , warnings? } or { ok: false, error }. + +import './process-shim.ts'; +import { compile, VERSION as SVELTE_VERSION } from 'npm:svelte@^5.0.0/compiler'; +import * as svelteMain from 'npm:svelte@^5.0.0'; +import * as svelteStore from 'npm:svelte@^5.0.0/store'; +import * as svelteTransition from 'npm:svelte@^5.0.0/transition'; +import * as svelteEasing from 'npm:svelte@^5.0.0/easing'; +import * as svelteMotion from 'npm:svelte@^5.0.0/motion'; +import * as internalClient from 'npm:svelte@^5.0.0/internal/client'; +import 'npm:svelte@^5.0.0/internal/disclose-version'; + +const libs: Record> = { + 'svelte': svelteMain as unknown as Record, + 'svelte/store': svelteStore as unknown as Record, + 'svelte/transition': svelteTransition as unknown as Record, + 'svelte/easing': svelteEasing as unknown as Record, + 'svelte/motion': svelteMotion as unknown as Record, + 'svelte/internal/client': internalClient as unknown as Record, +}; + +(globalThis as Record).__svelteTile = { + libs, + legacy: () => import('npm:svelte@^5.0.0/internal/flags/legacy'), +}; + +// --- blob-URL shim modules for the specifiers compiled output imports + +const shimCache = new Map(); + +function shimFor(spec: string): string | undefined { + const cached = shimCache.get(spec); + if (cached) return cached; + let src: string | undefined; + if (spec === 'svelte/internal/disclose-version') { + src = '// version already disclosed by the engine bundle\n'; + } else if (spec === 'svelte/internal/flags/legacy') { + src = 'await globalThis.__svelteTile.legacy();\n'; + } else if (libs[spec]) { + // export via alias strings: runtime export names include reserved words + // (svelte/internal/client exports one literally called `await`) + const lines = [`const m = globalThis.__svelteTile.libs[${JSON.stringify(spec)}];`]; + const aliases: string[] = []; + Object.keys(libs[spec]).forEach((key, i) => { + if (key === 'default') { + lines.push('export default m.default;'); + return; + } + lines.push(`const _${i} = m[${JSON.stringify(key)}];`); + aliases.push(`_${i} as ${JSON.stringify(key)}`); + }); + if (aliases.length) lines.push(`export { ${aliases.join(', ')} };`); + src = lines.join('\n'); + } + if (src === undefined) return undefined; + const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' })); + shimCache.set(spec, url); + return url; +} + +function rewriteImports(code: string): { code: string; unsupported: string[] } { + const unsupported = new Set(); + const rewritten = code.replace( + /(['"])(svelte(?:\/[\w/-]+)?)\1/g, + (match, quote: string, spec: string) => { + const url = shimFor(spec); + if (!url) { + unsupported.add(spec); + return match; + } + return `${quote}${url}${quote}`; + }, + ); + return { code: rewritten, unsupported: [...unsupported] }; +} + +// --- the stage + +const stage = document.getElementById('stage')!; +const hint = document.getElementById('hint')!; +const statusBar = document.getElementById('status')!; +const errorPre = document.getElementById('error') as HTMLPreElement; + +const VERSION = SVELTE_VERSION; +statusBar.textContent = `Svelte ${VERSION} engine · waiting for source`; + +let instance: Record | null = null; +let run = 0; + +interface RunResult { + ok: boolean; + error?: string; + warnings?: string[]; + text?: string; +} + +async function runSource(source: string, props?: Record): Promise { + const seq = ++run; + try { + const compiled = compile(source, { + filename: 'Playground.svelte', + generate: 'client', + css: 'injected', + }); + const warnings = compiled.warnings.map((w) => w.message); + const { code, unsupported } = rewriteImports(compiled.js.code); + if (unsupported.length) { + throw new Error(`imports not available in this tile: ${unsupported.join(', ')}`); + } + const blobUrl = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })); + let mod: { default?: unknown }; + try { + mod = await import(blobUrl); + } finally { + URL.revokeObjectURL(blobUrl); + } + if (seq !== run) return { ok: false, error: 'superseded by newer source' }; + if (typeof mod.default !== 'function') throw new Error('compiled module has no component export'); + if (instance) svelteMain.unmount(instance); + stage.replaceChildren(); + hint.remove(); + instance = svelteMain.mount(mod.default as Parameters[0], { + target: stage, + props: props ?? {}, + }) as Record; + await svelteMain.tick(); + errorPre.hidden = true; + statusBar.textContent = `Svelte ${VERSION} engine · mounted (${source.length} chars)` + + (warnings.length ? ` · ${warnings.length} warning(s)` : ''); + return { + ok: true, + warnings: warnings.length ? warnings : undefined, + text: (stage.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 500), + }; + } catch (err) { + const message = (err as Error).message ?? String(err); + errorPre.textContent = message; + errorPre.hidden = false; + statusBar.textContent = `Svelte ${VERSION} engine · compile/run failed`; + return { ok: false, error: message }; + } +} + +// --- tp-data wiring (dynamic path so the bundler leaves it alone) + +const EXAMPLE = ` + +

Hello from inside the tile

+ + +`; + +const protocolPath = '/.well-known/web-tiles/data.js'; +try { + const data = await import(protocolPath); + data.addDataHandler(async (payload: unknown) => { + const source = typeof payload === 'string' + ? payload + : (payload as { source?: string })?.source; + const props = (payload as { props?: Record })?.props; + if (!source) { + data.sendData({ ok: false, error: 'send a string of Svelte source, or { source, props? }' }); + return; + } + data.sendData(await runSource(source, props)); + }); + data.listen(); + data.sendData({ hello: 'svelte-tile', svelte: VERSION, example: { source: EXAMPLE } }); +} catch { + statusBar.textContent = `Svelte ${VERSION} engine · tp-data not available in this host`; +} diff --git a/examples/hello-tile/index.html b/examples/hello-tile/index.html index 71edd6a..807bcbf 100644 --- a/examples/hello-tile/index.html +++ b/examples/hello-tile/index.html @@ -10,8 +10,15 @@

⬢ Hello from a tile

This content is served from an isolated *.localhost origin.

Edit any file in examples/hello-tile/ and watch me reload.

- 0 pokes +

+ 0 pokes + +

+ - + diff --git a/examples/hello-tile/style.css b/examples/hello-tile/style.css index f65b824..d8002a4 100644 --- a/examples/hello-tile/style.css +++ b/examples/hello-tile/style.css @@ -28,3 +28,27 @@ button { color: #101430; cursor: pointer; } +.proto { + font-size: 0.8rem; + opacity: 0.7; +} +.received { + margin-top: 0.8rem; + text-align: left; + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 0.6rem 0.9rem; +} +.received h2 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0 0 0.3rem; + opacity: 0.7; +} +.received pre { + margin: 0; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/examples/hello-tile/tile.js b/examples/hello-tile/tile.js index a2c981a..3147ff5 100644 --- a/examples/hello-tile/tile.js +++ b/examples/hello-tile/tile.js @@ -1,4 +1,23 @@ let pokes = 0; +let sendData; + document.getElementById('poke').addEventListener('click', () => { document.getElementById('count').textContent = String(++pokes); + // tell the host about it, if it speaks tp-data + sendData?.({ poked: pokes, at: new Date().toISOString() }); }); + +// tp-data tiles protocol: receive payloads from the host, echo them back +try { + const data = await import('/.well-known/web-tiles/data.js'); + sendData = data.sendData; + data.addDataHandler((payload) => { + document.getElementById('received').hidden = false; + document.getElementById('payload').textContent = JSON.stringify(payload, null, 2); + data.sendData({ echo: payload, pokes }); + }); + data.listen(); + document.getElementById('proto').textContent = '· tp-data connected'; +} catch { + document.getElementById('proto').textContent = '· no tp-data here'; +} diff --git a/examples/svelte-tile/index.html b/examples/svelte-tile/index.html new file mode 100644 index 0000000..0a7c669 --- /dev/null +++ b/examples/svelte-tile/index.html @@ -0,0 +1,24 @@ + + + + + Svelte Playground + + + +
+
+
⬢▲
+

Blank canvas, Svelte engine idling.

+

+ Send Svelte source through the data console — a bare string or + {"source": "…", "props": {…}}. The tile sends back a + ready-made example when it connects. +

+
+
+ +
booting engine…
+ + + diff --git a/examples/svelte-tile/manifest.json b/examples/svelte-tile/manifest.json new file mode 100644 index 0000000..55544b2 --- /dev/null +++ b/examples/svelte-tile/manifest.json @@ -0,0 +1,5 @@ +{ + "name": "Svelte Playground", + "description": "A blank canvas with a built-in Svelte 5 engine: send it Svelte source over tp-data and it compiles and mounts it live.", + "sizing": { "width": 720, "height": 520 } +} diff --git a/examples/svelte-tile/style.css b/examples/svelte-tile/style.css new file mode 100644 index 0000000..afddb61 --- /dev/null +++ b/examples/svelte-tile/style.css @@ -0,0 +1,64 @@ +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; +} + +body { + display: flex; + flex-direction: column; + font-family: system-ui, sans-serif; + background: #fdfdfc; + color: #1c1c22; +} + +#stage { + flex: 1; + overflow: auto; + padding: 1rem; +} + +#hint { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #8a8a96; + text-align: center; +} +#hint .mark { + font-size: 2rem; + opacity: 0.5; +} +#hint p { margin: 0.3rem 0; } +#hint .how { + font-size: 0.85rem; + max-width: 30rem; +} +#hint code { + background: #ececf1; + border-radius: 4px; + padding: 0.05rem 0.3rem; +} + +#error { + margin: 0; + padding: 0.6rem 1rem; + background: #2a1215; + color: #ff9b90; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-word; + max-height: 8rem; + overflow: auto; +} + +#status { + padding: 0.35rem 1rem; + font-size: 0.72rem; + color: #8a8a96; + border-top: 1px solid #e4e4ea; + background: #f6f6f8; +} diff --git a/examples/theme-tile/index.html b/examples/theme-tile/index.html new file mode 100644 index 0000000..d01f7af --- /dev/null +++ b/examples/theme-tile/index.html @@ -0,0 +1,38 @@ + + + + + Themeable Site + + + +
+ + +
+
+
+

This whole page is themed by its host

+

+ Every colour, font, and corner radius on this page comes from CSS custom + properties. Send theme tokens through the tp-data console and they + apply live. +

+ + +
+
+

Accent

Buttons, links, and highlights pick up accent and accent-contrast.

+

Surfaces

Cards and panels sit on surface over background, with border edges.

+

Type

font and heading-font swap typefaces; radius rounds everything.

+
+
+

Current theme

+
+

Last payload

+
— nothing received yet —
+
+
+ + + diff --git a/examples/theme-tile/manifest.json b/examples/theme-tile/manifest.json new file mode 100644 index 0000000..11d7492 --- /dev/null +++ b/examples/theme-tile/manifest.json @@ -0,0 +1,5 @@ +{ + "name": "Themeable Site", + "description": "A little site whose theme is driven live by tp-data payloads — send it colour/font/radius tokens and watch it restyle itself.", + "sizing": { "width": 720, "height": 560 } +} diff --git a/examples/theme-tile/style.css b/examples/theme-tile/style.css new file mode 100644 index 0000000..e93c651 --- /dev/null +++ b/examples/theme-tile/style.css @@ -0,0 +1,140 @@ +:root { + --background: #14141a; + --surface: #1e1e26; + --text: #ececf4; + --muted: #9a9aa8; + --accent: #e4573d; + --accent-contrast: #ffffff; + --border: #32323e; + --radius: 10px; + --font: system-ui, sans-serif; + --heading-font: var(--font); +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--background); + color: var(--text); + font-family: var(--font); + font-size: 15px; + transition: background 0.35s ease, color 0.35s ease; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.7rem 1.2rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.logo { + font-family: var(--heading-font); + font-weight: 800; + color: var(--accent); +} +nav { + display: flex; + gap: 1rem; +} +nav a { + color: var(--muted); + cursor: pointer; +} +nav a:hover { color: var(--accent); } + +main { padding: 1.2rem; } + +.hero h1 { + font-family: var(--heading-font); + font-size: 1.5rem; + margin: 0.2rem 0 0.6rem; +} +.hero p { color: var(--muted); max-width: 46rem; } + +button { + font: inherit; + background: var(--accent); + color: var(--accent-contrast); + border: none; + border-radius: var(--radius); + padding: 0.45rem 1.1rem; + cursor: pointer; + transition: filter 0.15s ease; +} +button:hover { filter: brightness(1.12); } + +.flash { + margin-left: 0.7rem; + color: var(--accent); + font-size: 0.85rem; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.8rem; + margin: 1.2rem 0; +} +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.8rem 1rem; + transition: background 0.35s ease; +} +.card h2 { + font-family: var(--heading-font); + font-size: 0.95rem; + margin: 0 0 0.4rem; + color: var(--accent); +} +.card p { margin: 0; color: var(--muted); font-size: 0.85rem; } +code { + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-radius: 4px; + padding: 0.05rem 0.3rem; + font-size: 0.8em; +} + +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.8rem 1rem; +} +.panel h2 { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 0.2rem 0 0.5rem; +} +#tokens { + width: 100%; + border-collapse: collapse; + font-family: ui-monospace, monospace; + font-size: 0.8rem; + margin-bottom: 0.8rem; +} +#tokens td { + border-bottom: 1px solid var(--border); + padding: 0.25rem 0.5rem 0.25rem 0; +} +#tokens td.swatch span { + display: inline-block; + width: 1rem; + height: 1rem; + border-radius: 3px; + border: 1px solid var(--border); + vertical-align: -0.2rem; +} +#payload { + margin: 0; + font-size: 0.78rem; + white-space: pre-wrap; + word-break: break-all; + color: var(--muted); +} diff --git a/examples/theme-tile/tile.js b/examples/theme-tile/tile.js new file mode 100644 index 0000000..c4d9e63 --- /dev/null +++ b/examples/theme-tile/tile.js @@ -0,0 +1,113 @@ +// Themeable Site — tp-data drives the theme. +// +// Send a payload of theme tokens (flat, or under a `theme` key). Recognised +// tokens map straight onto CSS custom properties; everything else is +// reported back as ignored. Send `null` for a token to reset it. + +const TOKENS = [ + 'background', + 'surface', + 'text', + 'muted', + 'accent', + 'accent-contrast', + 'border', + 'radius', + 'font', + 'heading-font', +]; +const COLORISH = /^(background|surface|text|muted|accent|accent-contrast|border)$/; + +const rootStyle = document.documentElement.style; +const tokensTable = document.getElementById('tokens'); +const payloadPre = document.getElementById('payload'); +const flash = document.getElementById('flash'); + +function currentTheme() { + const styles = getComputedStyle(document.documentElement); + return Object.fromEntries(TOKENS.map((t) => [t, styles.getPropertyValue(`--${t}`).trim()])); +} + +function renderTokens() { + const theme = currentTheme(); + tokensTable.replaceChildren(...TOKENS.map((token) => { + const tr = document.createElement('tr'); + const name = document.createElement('td'); + name.textContent = token; + const value = document.createElement('td'); + value.textContent = theme[token]; + const swatch = document.createElement('td'); + swatch.className = 'swatch'; + if (COLORISH.test(token)) { + const chip = document.createElement('span'); + chip.style.background = theme[token]; + swatch.append(chip); + } + tr.append(name, value, swatch); + return tr; + })); +} + +function applyTheme(payload) { + const theme = payload && typeof payload === 'object' && payload.theme && typeof payload.theme === 'object' + ? payload.theme + : payload; + const applied = []; + const ignored = []; + if (!theme || typeof theme !== 'object') return { applied, ignored: [String(theme)] }; + for (const [key, value] of Object.entries(theme)) { + if (!TOKENS.includes(key)) { + ignored.push(key); + } else if (value === null) { + rootStyle.removeProperty(`--${key}`); + applied.push(key); + } else if (typeof value === 'string') { + rootStyle.setProperty(`--${key}`, value); + applied.push(key); + } else { + ignored.push(key); + } + } + return { applied, ignored }; +} + +renderTokens(); +document.getElementById('cta').addEventListener('click', () => { + sendData?.({ clicked: 'the very themeable button', theme: currentTheme() }); +}); + +let sendData; +try { + const data = await import('/.well-known/web-tiles/data.js'); + sendData = data.sendData; + data.addDataHandler((payload) => { + payloadPre.textContent = JSON.stringify(payload, null, 2); + const { applied, ignored } = applyTheme(payload); + renderTokens(); + if (applied.length) { + flash.hidden = false; + setTimeout(() => (flash.hidden = true), 1500); + } + data.sendData({ ok: applied.length > 0, applied, ignored, theme: currentTheme() }); + }); + data.listen(); + // hand the host a ready-made example to paste into the console + data.sendData({ + hello: 'theme-tile', + accepts: TOKENS, + example: { + background: '#f4f1ea', + surface: '#ffffff', + text: '#22221c', + muted: '#6e6a5e', + accent: '#2a6e4e', + 'accent-contrast': '#ffffff', + border: '#ddd8ca', + radius: '2px', + font: 'Georgia, serif', + 'heading-font': 'Futura, Avenir, sans-serif', + }, + }); +} catch { + payloadPre.textContent = 'tp-data is not available in this host'; +} diff --git a/src/server/tileserver.ts b/src/server/tileserver.ts index 01784e5..bf1679e 100644 --- a/src/server/tileserver.ts +++ b/src/server/tileserver.ts @@ -2,7 +2,14 @@ // shuttle/worker runtime on *.localhost origins. The stock router's // load. redirect rebuilds URLs without a port, which only works on :80, // so we mint the random subdomain ourselves (port included) in front of it. +// +// We also serve vendor/web-tiles/{shuttle.js,data.js} (dasl-tiles HEAD) ahead +// of the stock router: npm tile-server 2.0.0 predates tiles-protocols, and +// those two files carry the protocol forwarding + the tp-data module. Drop +// the vendored mount once a tile-server release ships them. +import { Buffer } from 'node:buffer'; +import { join, toFileUrl } from '@std/path'; // @ts-types="npm:@types/express@^5.0.6" import express from 'express'; import { createTileLoadingRouter } from '@dasl/tile-server'; @@ -13,6 +20,37 @@ export function randomSubdomain(length = 20): string { return [...bytes].map((b) => LETTERS[b % LETTERS.length]).join(''); } +// Same headers the stock router sets on the web-tiles runtime assets. +const TILE_HEADERS: Record = { + 'service-worker-allowed': '/', + 'origin-agent-cluster': '?1', + 'referrer-policy': 'no-referrer', + 'permissions-policy': 'interest-cohort=(), browsing-topics=()', + 'cross-origin-resource-policy': 'cross-origin', + 'cross-origin-opener-policy': 'same-origin', + 'x-content-type-options': 'nosniff', + 'x-dns-prefetch-control': 'off', + 'content-security-policy': [ + `default-src 'self' blob: data:`, + `script-src 'self' blob: data: 'unsafe-inline' 'wasm-unsafe-eval'`, + `script-src-attr 'none'`, + `style-src 'self' blob: data: 'unsafe-inline'`, + `form-src 'self'`, + `manifest-src 'none'`, + `object-src 'none'`, + `base-uri 'none'`, + 'sandbox allow-downloads allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts', + ].join('; '), + 'tk': 'N', + 'x-robots-tag': 'noai, noimageai', +}; + +const VENDORED = new Set(['shuttle.js', 'data.js']); +const vendorRoots = [ + toFileUrl(join(Deno.cwd(), 'vendor', 'web-tiles')) + '/', + new URL('../../vendor/web-tiles/', import.meta.url), +]; + export function startTileServer(): Promise { const app = express(); app.set('trust proxy', 'loopback'); @@ -24,6 +62,22 @@ export function startTileServer(): Promise { } next(); }); + app.use('/.well-known/web-tiles', async (req, res, next) => { + const name = req.path.replace(/^\//, ''); + if (!VENDORED.has(name)) return next(); + for (const root of vendorRoots) { + try { + const bytes = await Deno.readFile(new URL(name, root)); + res.set(TILE_HEADERS); + res.type('text/javascript'); + res.end(Buffer.from(bytes)); + return; + } catch { + // try the next root + } + } + next(); + }); app.use(createTileLoadingRouter('localhost')); return new Promise((resolve, reject) => { // Express 5 calls the listen callback with the error on failure. diff --git a/src/ui/app.ts b/src/ui/app.ts index 1e146aa..d73c0a5 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -8,8 +8,10 @@ import { effect } from 'refrakt'; import { app } from './store.ts'; import { connectEvents } from './api.ts'; import { mothership } from './mothership.ts'; +import { dataHub } from './data-protocol.ts'; import './tile-card.ts'; import './tile-frame.ts'; +import './data-console.ts'; connectEvents({ onEvent: (event) => app.send({ type: 'server-event', event }), @@ -17,6 +19,19 @@ connectEvents({ onClose: () => app.send({ type: 'ws-close' }), }); +dataHub.init((ev) => { + app.send({ + type: 'data-log', + id: ev.tileId, + entry: ev.kind === 'ready' + ? { dir: 'info', text: 'tile signaled ready — listen()', at: new Date().toLocaleTimeString() } + : { dir: 'up', payload: ev.payload, at: new Date().toLocaleTimeString() }, + }); +}); + +// Handy in the devtools, and what the headless smoke harness drives. +(globalThis as unknown as Record).blastile = { app, dataHub }; + // The mothership can only mint .localhost origins once it knows the // embedded tile server's port. effect(() => { @@ -56,13 +71,16 @@ if (new URLSearchParams(location.search).has('smoke')) { export class BlastileApp extends SignalWatcher(LitElement) { static override properties = { dragging: { state: true }, + consoleOpen: { state: true }, }; - // `declare`d so it stays a Lit reactive accessor (a class field shadows it) + // `declare`d so they stay Lit reactive accessors (class fields shadow them) declare dragging: boolean; + declare consoleOpen: boolean; constructor() { super(); this.dragging = false; + this.consoleOpen = false; } static override styles = css` @@ -187,6 +205,18 @@ export class BlastileApp extends SignalWatcher(LitElement) { } .viewer tile-frame { flex: 1; + min-height: 0; + } + .viewer .tray { + display: flex; + justify-content: flex-end; + padding: 0.2rem 0.6rem; + border-top: 1px solid var(--border, #34343e); + background: var(--header-bg, #1c1c22); + } + .viewer .tray button { + font-size: 0.72rem; + padding: 0.15rem 0.6rem; } `; @@ -259,6 +289,12 @@ export class BlastileApp extends SignalWatcher(LitElement) { return html`
+
+ +
+ ${this.consoleOpen ? html`` : nothing}
`; } diff --git a/src/ui/data-console.ts b/src/ui/data-console.ts new file mode 100644 index 0000000..0b44025 --- /dev/null +++ b/src/ui/data-console.ts @@ -0,0 +1,183 @@ +// The tp-data console: a small tray to send payloads to a rendered tile and +// watch what it sends back (plus protocol events like `listen()`). + +import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; +import { SignalWatcher } from '@lit-labs/signals'; +import { app, type DataLogEntry } from './store.ts'; +import type { TileEntry } from '../types.ts'; + +const DIR_GLYPH: Record = { + up: '⬆ tile', + down: '⬇ host', + info: 'ℹ', +}; + +function show(payload: unknown): string { + try { + return JSON.stringify(payload); + } catch { + return String(payload); + } +} + +export class DataConsole extends SignalWatcher(LitElement) { + static override properties = { + tile: { attribute: false }, + parseError: { state: true }, + }; + declare tile: TileEntry; + declare parseError: string | null; + + constructor() { + super(); + this.parseError = null; + } + + static override styles = css` + :host { + display: flex; + flex-direction: column; + font-size: 0.8rem; + background: var(--console-bg, #101014); + color: var(--console-fg, #d6d6e0); + border-top: 1px solid var(--border, #34343e); + max-height: 260px; + } + .log { + flex: 1; + overflow-y: auto; + padding: 0.4rem 0.6rem; + font-family: ui-monospace, monospace; + display: flex; + flex-direction: column; + gap: 0.15rem; + min-height: 72px; + } + .entry { + display: flex; + gap: 0.5rem; + align-items: baseline; + } + .entry .at { + color: #6a6a78; + font-size: 0.7rem; + white-space: nowrap; + } + .entry .dir { + white-space: nowrap; + font-size: 0.7rem; + width: 3.6rem; + } + .entry.up .dir { color: #7ee2a8; } + .entry.down .dir { color: #8ab4ff; } + .entry.info .dir { color: #d8b04a; } + .entry .body { + word-break: break-all; + white-space: pre-wrap; + } + .empty { + color: #6a6a78; + padding: 0.2rem 0; + } + form { + display: flex; + gap: 0.4rem; + padding: 0.45rem 0.6rem; + border-top: 1px solid #26262e; + align-items: flex-start; + } + textarea { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 0.78rem; + background: #17171d; + color: inherit; + border: 1px solid #30303a; + border-radius: 5px; + padding: 0.35rem 0.5rem; + resize: vertical; + min-height: 2.1rem; + height: 2.1rem; + } + button { + font: inherit; + font-size: 0.78rem; + padding: 0.3rem 0.7rem; + border-radius: 5px; + border: 1px solid #3a3a46; + background: #22222a; + color: inherit; + cursor: pointer; + } + button:hover { background: #2c2c36; } + .error { + color: #ff8a80; + padding: 0 0.6rem 0.4rem; + font-family: ui-monospace, monospace; + } + `; + + override render() { + const log = app.get().dataConsole[this.tile?.id] ?? []; + return html` +
+ ${log.length ? log.map((entry) => html` +
+ ${entry.at} + ${DIR_GLYPH[entry.dir]} + ${entry.payload !== undefined ? show(entry.payload) : entry.text} +
`) : html` +
+ No data yet. Payloads the tile sends with sendData() show up here; + send it JSON below (it needs addDataHandler() + listen()). +
`} +
+ ${this.parseError ? html`
${this.parseError}
` : nothing} +
+ + + +
+ `; + } + + protected override updated(changed: PropertyValues) { + super.updated(changed); + const log = this.renderRoot.querySelector('.log'); + if (log) log.scrollTop = log.scrollHeight; + } + + #onKeydown = (ev: KeyboardEvent) => { + if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) { + ev.preventDefault(); + this.#send(); + } + }; + + #onSend = (ev: SubmitEvent) => { + ev.preventDefault(); + this.#send(); + }; + + #send() { + const textarea = this.renderRoot.querySelector('textarea')!; + const raw = textarea.value.trim(); + if (!raw) return; + let payload: unknown; + try { + payload = JSON.parse(raw); + } catch (err) { + this.parseError = `Not JSON: ${(err as Error).message}`; + return; + } + this.parseError = null; + app.send({ type: 'data-send', id: this.tile.id, payload }); + textarea.value = ''; + } +} + +customElements.define('data-console', DataConsole); diff --git a/src/ui/data-protocol.ts b/src/ui/data-protocol.ts new file mode 100644 index 0000000..73c1308 --- /dev/null +++ b/src/ui/data-protocol.ts @@ -0,0 +1,57 @@ +// Host side of the tp-data tiles protocol +// (https://dasl.ing/tp-data.html): the shuttle forwards +// `tiles-protocol-up-data-*` messages from the tile up to this window, and +// relays `tiles-protocol-down-data-payload` from us to the tile. Payloads are +// whatever structured clone carries. + +const UP_PREFIX = 'tiles-protocol-up-data-'; +const UP_READY = `${UP_PREFIX}ready`; +const UP_PAYLOAD = `${UP_PREFIX}payload`; +const DOWN_PAYLOAD = 'tiles-protocol-down-data-payload'; + +export type DataProtocolEvent = + | { tileId: string; kind: 'ready' } + | { tileId: string; kind: 'payload'; payload: unknown }; + +class DataProtocolHub { + #frames = new Map(); + #onEvent: ((ev: DataProtocolEvent) => void) | undefined; + + init(onEvent: (ev: DataProtocolEvent) => void) { + this.#onEvent = onEvent; + globalThis.addEventListener('message', (ev: MessageEvent) => { + const action = ev.data?.action; + if (typeof action !== 'string' || !action.startsWith(UP_PREFIX)) return; + const tileId = this.#tileIdFor(ev.source); + if (!tileId) return; + if (action === UP_READY) this.#onEvent?.({ tileId, kind: 'ready' }); + else if (action === UP_PAYLOAD) this.#onEvent?.({ tileId, kind: 'payload', payload: ev.data.payload }); + }); + } + + #tileIdFor(source: MessageEventSource | null): string | undefined { + for (const [id, ifr] of this.#frames) { + if (ifr.contentWindow && ifr.contentWindow === source) return id; + } + return undefined; + } + + /** Called by tile-frame whenever it mounts a fresh shuttle iframe. */ + register(tileId: string, iframe: HTMLIFrameElement) { + this.#frames.set(tileId, iframe); + } + + unregister(tileId: string, iframe?: HTMLIFrameElement) { + if (!iframe || this.#frames.get(tileId) === iframe) this.#frames.delete(tileId); + } + + /** Send a payload down to the tile; false if it isn't rendered right now. */ + send(tileId: string, payload: unknown): boolean { + const win = this.#frames.get(tileId)?.contentWindow; + if (!win) return false; + win.postMessage({ action: DOWN_PAYLOAD, payload }, '*'); + return true; + } +} + +export const dataHub = new DataProtocolHub(); diff --git a/src/ui/store.ts b/src/ui/store.ts index 4bf2d61..26f39d7 100644 --- a/src/ui/store.ts +++ b/src/ui/store.ts @@ -5,6 +5,17 @@ import { store, tx, type Tx } from 'refrakt'; import type { ServerEvent, SessionInfo, TileEntry } from '../types.ts'; import { post } from './api.ts'; +import { dataHub } from './data-protocol.ts'; + +export interface DataLogEntry { + /** up = tile → host, down = host → tile, info = protocol events/errors */ + dir: 'up' | 'down' | 'info'; + payload?: unknown; + text?: string; + at: string; +} + +const DATA_LOG_CAP = 200; export interface Model { connected: boolean; @@ -16,6 +27,8 @@ export interface Model { versions: Record; /** Publish progress message per tile while a publish is running. */ publishing: Record; + /** tp-data console log per tile. */ + dataConsole: Record; loginPending: boolean; error: string | null; } @@ -34,7 +47,10 @@ export type Action = | { type: 'remove-tile'; id: string } | { type: 'open-window'; id: string; width?: number; height?: number } | { type: 'publish'; id: string } - | { type: 'tile-name'; id: string; name: string }; + | { type: 'tile-name'; id: string; name: string } + | { type: 'data-log'; id: string; entry: DataLogEntry } + | { type: 'data-send'; id: string; payload: unknown } + | { type: 'data-clear'; id: string }; const initial: Model = { connected: false, @@ -44,10 +60,18 @@ const initial: Model = { tiles: [], versions: {}, publishing: {}, + dataConsole: {}, loginPending: false, error: null, }; +function appendLog(state: Model, id: string, entry: DataLogEntry): Model { + const log = [...(state.dataConsole[id] ?? []), entry].slice(-DATA_LOG_CAP); + return { ...state, dataConsole: { ...state.dataConsole, [id]: log } }; +} + +const now = () => new Date().toLocaleTimeString(); + // A fire-and-forget backend call whose effects come back over the WebSocket; // only failures produce a follow-up action. function call(fn: () => Promise) { @@ -168,6 +192,24 @@ function update(state: Model, action: Action): Tx { ); case 'tile-name': return tx(state, call(() => post(`/api/tiles/${action.id}/meta`, { name: action.name }))); + case 'data-log': + return tx(appendLog(state, action.id, action.entry)); + case 'data-send': { + // Send + log in one transaction: the hub needs the tile's iframe mounted. + if (dataHub.send(action.id, action.payload)) { + return tx(appendLog(state, action.id, { dir: 'down', payload: action.payload, at: now() })); + } + return tx(appendLog(state, action.id, { + dir: 'info', + text: 'tile is not rendered — open its preview first', + at: now(), + })); + } + case 'data-clear': { + const dataConsole = { ...state.dataConsole }; + delete dataConsole[action.id]; + return tx({ ...state, dataConsole }); + } default: return tx(state); } diff --git a/src/ui/tile-card.ts b/src/ui/tile-card.ts index e3a8610..facd3aa 100644 --- a/src/ui/tile-card.ts +++ b/src/ui/tile-card.ts @@ -4,6 +4,7 @@ import { app } from './store.ts'; import type { TileEntry } from '../types.ts'; import type { MaslSizing } from '@dasl/tile-lexicon'; import './tile-frame.ts'; +import './data-console.ts'; const KIND_LABEL: Record = { car: 'CAR file', @@ -15,16 +16,19 @@ export class TileCard extends SignalWatcher(LitElement) { static override properties = { tile: { attribute: false }, showing: { state: true }, + showingConsole: { state: true }, }; // `declare`d so they stay Lit reactive accessors (class fields shadow them) declare tile: TileEntry; declare showing: boolean; + declare showingConsole: boolean; #sizing?: MaslSizing; constructor() { super(); this.showing = false; + this.showingConsole = false; } static override styles = css` @@ -122,6 +126,9 @@ export class TileCard extends SignalWatcher(LitElement) { + ${tile.kind === 'watch' ? html`