From 323bb89a3bba452dc7da47e9ae7fa6b4be04ba6c Mon Sep 17 00:00:00 2001 From: Robin Berjon Date: Sun, 30 Aug 2026 23:34:39 +0300 Subject: [PATCH] ohA --- deno.json | 7 +- deno.lock | 4 + docs/WEB-APP.md | 236 +++++++ lexicons/space/ziran/home.json | 19 + lexicons/space/ziran/permissions.json | 16 + main.ts | 50 +- src/components/zn-base-window.ts | 154 ++++- src/components/zn-connections.ts | 726 ++++++++++++++------ src/components/zn-doc-window.ts | 13 + src/lib/zfs.ts | 106 +++ src/server/api.ts | 60 ++ src/server/atprofiles.ts | 95 +++ src/server/atsession.ts | 66 +- src/server/deeplink.test.ts | 17 + src/server/store.ts | 40 +- src/server/tilefile.ts | 11 +- src/server/windows.ts | 13 +- src/state/backend.ts | 27 +- src/state/desk.ts | 82 ++- src/sync/protocol.ts | 2 + src/sync/provider.ts | 9 + src/sync/spaces/client.ts | 4 +- src/sync/spaces/dpop.ts | 59 ++ src/sync/spaces/provider.test.ts | 50 +- src/sync/spaces/provider.ts | 151 ++++- src/sync/spaces/registry.ts | 15 +- src/sync/spaces/scope.ts | 7 + src/sync/spaces/scopes.test.ts | 14 +- src/sync/spaces/webstore.test.ts | 136 ++++ src/types.ts | 21 + src/web/runtime-core.ts | 15 + static/app.js | 932 ++++++++++++++++++++------ tools/web-core-smoke.ts | 93 +++ tools/web-proof.ts | 78 +++ website/site/d/index.html | 117 ++++ 35 files changed, 2968 insertions(+), 477 deletions(-) create mode 100644 docs/WEB-APP.md create mode 100644 lexicons/space/ziran/home.json create mode 100644 src/lib/zfs.ts create mode 100644 src/server/atprofiles.ts create mode 100644 src/sync/spaces/dpop.ts create mode 100644 src/sync/spaces/webstore.test.ts create mode 100644 src/web/runtime-core.ts create mode 100644 tools/web-core-smoke.ts create mode 100644 tools/web-proof.ts create mode 100644 website/site/d/index.html diff --git a/deno.json b/deno.json index 9dcfd88..ccc352a 100644 --- a/deno.json +++ b/deno.json @@ -16,11 +16,12 @@ "build:icons": "deno run -A tools/make-icons.ts", "dev": "deno task build:ui && deno run -A --watch main.ts", "relay": "deno run -A relay/main.ts", - "test:spaces": "deno run -A src/sync/spaces/scopes.test.ts && deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/session-fetch.test.ts && deno run -A src/sync/spaces/provider.test.ts", + "test:spaces": "deno run -A src/sync/spaces/scopes.test.ts && deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/session-fetch.test.ts && deno run -A src/sync/spaces/provider.test.ts && deno run -A src/sync/spaces/webstore.test.ts", "devnet:spaces": "bash tools/spaces-devnet.sh", "lex:publish": "deno run -A tools/spaces-publish-lexicons.ts", + "check:web": "deno run -A tools/web-core-smoke.ts", "test:tiles": "deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts", - "test:sync": "deno run -A src/sync/spaces/scopes.test.ts && deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/session-fetch.test.ts && deno run -A src/sync/spaces/provider.test.ts && deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts && deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts && deno run -A src/sync/reconnect.test.ts && deno run -A src/sync/restart-invite.test.ts && deno run -A src/sync/signedout.test.ts && deno run -A src/server/keeper.test.ts && deno run -A src/server/deeplink.test.ts", + "test:sync": "deno run -A src/sync/spaces/scopes.test.ts && deno run -A src/sync/spaces/client.test.ts && deno run -A src/sync/spaces/session-fetch.test.ts && deno run -A src/sync/spaces/provider.test.ts && deno run -A src/sync/spaces/webstore.test.ts && deno run -A src/sync/tiles-logic.test.ts && deno run -A src/sync/richtext-crdt.test.ts && deno run -A src/sync/richtext-editing.test.ts && deno run -A relay/test.ts && deno run -A src/sync/mls.test.ts && deno run -A src/sync/mls-transport.test.ts && deno run -A src/sync/atproto.test.ts && deno run -A src/sync/e2e.test.ts && deno run -A src/sync/newcomer.test.ts && deno run -A src/sync/reconnect.test.ts && deno run -A src/sync/restart-invite.test.ts && deno run -A src/sync/signedout.test.ts && deno run -A src/server/keeper.test.ts && deno run -A src/server/deeplink.test.ts", "desktop": "deno task build:ui && deno desktop --hmr -A --include static --include vendor --include tiles --include build main.ts", "build": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-mac-1024.png --output dist/Ziran.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran.app", "build:mac": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-mac-1024.png --target aarch64-apple-darwin --output dist/Ziran-arm64.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran-arm64.app", @@ -77,6 +78,8 @@ "@noble/curves/secp256k1.js": "npm:@noble/curves@^2.2.0/secp256k1.js", "@scure/base": "npm:@scure/base@^2.0.0", "@atproto/space": "npm:@atproto/space@0.0.0-spaces-alpha-20260818163953", + "@atproto/crypto": "npm:@atproto/crypto@0.0.0-spaces-alpha-20260818163953", + "@atproto/lex-data": "npm:@atproto/lex-data@^0.1.7", "@atproto/oauth-scopes": "npm:@atproto/oauth-scopes@0.0.0-spaces-alpha-20260818163953", "@atproto/jwk-jose": "npm:@atproto/jwk-jose@^0.2.4", "@atproto/oauth-client-node": "npm:@atproto/oauth-client-node@0.0.0-spaces-alpha-20260818163953", diff --git a/deno.lock b/deno.lock index 50123e0..8f00e47 100644 --- a/deno.lock +++ b/deno.lock @@ -8,8 +8,10 @@ "npm:@atcute/cbor@^2.3.5": "2.3.5_@atcute+cid@2.4.2", "npm:@atcute/cid@^2.4.2": "2.4.2", "npm:@atproto/api@0.17": "0.17.7", + "npm:@atproto/crypto@0.0.0-spaces-alpha-20260818163953": "0.0.0-spaces-alpha-20260818163953", "npm:@atproto/jwk-jose@0.2.4": "0.2.4", "npm:@atproto/jwk-jose@~0.2.4": "0.2.4", + "npm:@atproto/lex-data@~0.1.7": "0.1.7", "npm:@atproto/oauth-client-node@0.0.0-spaces-alpha-20260818163953": "0.0.0-spaces-alpha-20260818163953", "npm:@atproto/oauth-scopes@0.0.0-spaces-alpha-20260818163953": "0.0.0-spaces-alpha-20260818163953", "npm:@atproto/space@0.0.0-spaces-alpha-20260818163953": "0.0.0-spaces-alpha-20260818163953", @@ -1347,7 +1349,9 @@ "npm:@atcute/cbor@^2.3.5", "npm:@atcute/cid@^2.4.2", "npm:@atproto/api@0.17", + "npm:@atproto/crypto@0.0.0-spaces-alpha-20260818163953", "npm:@atproto/jwk-jose@~0.2.4", + "npm:@atproto/lex-data@~0.1.7", "npm:@atproto/oauth-client-node@0.0.0-spaces-alpha-20260818163953", "npm:@atproto/oauth-scopes@0.0.0-spaces-alpha-20260818163953", "npm:@atproto/space@0.0.0-spaces-alpha-20260818163953", diff --git a/docs/WEB-APP.md b/docs/WEB-APP.md new file mode 100644 index 0000000..0426520 --- /dev/null +++ b/docs/WEB-APP.md @@ -0,0 +1,236 @@ +# Ziran on the Web — app.ziran.space + +A plan, for review. The web version is the same medium in a browser: sign in +with your AT identity, and your documents are there — because they live in +your PDS, not on any machine of ours. Nothing in this plan puts a Ziran +server in the critical path: **app.ziran.space is static hosting**, exactly +like the website. The app runs entirely in the browser and talks only to the +person's own PDS (and the spaces they're in). Credible exit survives the +move to the web. + +## The shape + +``` +app.ziran.space static shell: the Ziran UI (ribbon, tabs, panels) + └─ SharedWorker the runtime: store + SpacesProvider + tile plumbing + └─ caches only Cache API + IndexedDB — disposable; never authority +load.ziran.space @dasl/tile-server (sandboxed, fresh origin per load) + └─ .load.… wildcard TLS (sm, proven); shuttle/worker runtime +pds.ziran.space etc. the person's PDS — THE storage, sync, identity +ziran.space/d/#… share links; gains an "Open in the web app" path +``` + +### What replaces "Local" + +There is no local disk, so the desktop's "Local Device" becomes the **home +space**: `at://{did}/space/space.ziran.home/self` — a new space type, skey +`self` (guessable from the DID alone, like the inbox), member-list policy +with exactly one member. Every document you make on the web lives there by +default: checkpointed + op-synced through the same provider as any shared +doc, which means **your documents follow your identity** — sign in anywhere, +they're all present. Sharing = adding the doc to a shared space, exactly the +go-live picker of today. + +Why a new type rather than reusing `space.ziran.space`: the core type +declares `key: tid`, and a home is semantically different — always one per +identity, always yours alone, never invitable. The permission set gains one +entry (`space.ziran.home`, same collections); scope string unchanged; +`SCOPE_VERSION` bumps once. + +The desktop keeps its local files through v1 and joins the home space in +Phase F — the storage model below is what makes that a small delta. + +### The home space is the disk + +The browser holds **no authority at all**. Durable truth for every web +document — content, ops, checkpoints, models — lives in the home space (or +the shared space it was moved to), which is what makes it follow your +identity across devices and lets it serve as the sync point the desktop can +join later. What stays in the browser is strictly disposable or per-device: + +| In the browser | Why it's allowed to be | Where | +|---|---|---| +| OAuth session | inherently per-device | IndexedDB (oauth-client) | +| sync cursors, acked revs | per-device by nature; reconstructible from any checkpoint frontier (the adoption path already does this) | IndexedDB | +| materialized docs, blobs | pure cache; rebuilt from checkpoint + op replay | Cache API / memory | + +Losing the whole browser profile loses nothing but cursor positions. There +is no OPFS layer and no "local-only" tier on the web — creating a document +IS registering it in the home space (records + go-live checkpoint), and the +op flush (~300 ms) keeps the space current between the rare checkpoints, so +closing the tab never strands unsynced work. Two consequences, named: + +- **Every web document writes ops to the PDS, solo included** — the traffic + and the not-E2EE posture that shared docs already have extend to + everything made on the web. Rate margins are the known ones (3 pts/write, + batching load-bearing); the self-hosted PDS can raise them. +- **Cold opens replay** checkpoint + op tail; the Cache API makes every + subsequent open incremental from the cached materialization, and the web + client checkpoints on the same thresholds as any member, keeping tails + short for everyone. + +### Identity + +app.ziran.space is a **real OAuth client** (hosted +`oauth-client-metadata.json` — no more loopback rules), requesting the exact +same scope string from `scope.ts`. `@atproto/oauth-client-browser` (alpha +pin, same family as the node client we already run) handles PAR, DPoP, and +session storage in IndexedDB. Signed out, the app is a sign-in page and +nothing else — there is no meaningful signed-out web Ziran, since the PDS +*is* the storage. + +### The runtime: one codebase, two hosts + +The server core is already close to portable: `SpacesProvider`, `SpaceClient`, +`causal`, `checkpoint`, and the tile engine are injectable-fetch TypeScript. +The plan extracts their host dependencies behind two small seams: + +- **Doc-store seam**: the handful of `Deno.readFile/writeTextFile/mkdir/ + remove` calls in tilefile/registry/checkpoint move behind a `ZiranStore` + interface — the Deno impl is today's behavior unchanged; the web impl is + in-memory + Cache API, because on the web the durable store is the home + space itself (below). +- **Host seam**: the existing `SpacesHost` interface already carries + broadcast/openPath/isOpen — the web shell implements it. + +The runtime runs in a **SharedWorker**: one instance across every +app.ziran.space tab — the browser-native form of the single-instance rule we +just enforced on the desktop (one OAuth session store, one poller, one +registry; no refresh-token fights between tabs). Pages talk to it over a +message port with a thin RPC that mirrors `backend.ts`'s surface plus the +state-snapshot broadcasts that ride the WebSocket today, so `desk.ts` and +every `zn-*` component work unmodified above a swapped `backend` +implementation. + +(Fallback for browsers with weak SharedWorker support: run the runtime +in-page with a Web Lock as the single-instance guard. Same seams either way.) + +### The tile loader: load.ziran.space + +This is not new machinery: **it is the stack the desktop already runs**, +deployed to its natural habitat. Today `src/server/tileserver.ts` fronts +`@dasl/tile-server`'s `createTileLoadingRouter` on `*.localhost` origins, +serving the `@dasl/tile-loader` shuttle/worker runtime (vendored at +dasl-tiles HEAD), and the UI loads every doc through `TileMothership` +(`src/ui/mothership.ts` → `mothership.loadTile(bundleUrl(id))`). The web +version relocates, not reinvents: + +- **`load.ziran.space` = `@dasl/tile-server` as a small sm node service** — + it serves only the fixed runtime assets under the hardened headers and + redirects each load onto a fresh random `.load.ziran.space` origin + (its stock behavior; it never sees tile content). Wildcard DNS + + on-demand TLS is the same machinery already proven for + `*.pds.ziran.space`. +- **The app keeps `TileMothership`**, pointed at the hosted load domain + instead of `*.localhost:port`, plus one new class: a Ziran + `TilePathLoader` (the `MemoryPathLoader`/`CARPathLoader` shape) that + answers resource requests from the worker runtime's materialized doc — + CID-verified exactly as the desktop tileserver verifies today. +- `tp-data`, `tp-sync`, and `tp-mode` already ride postMessage through the + shuttle — they cross this boundary as they are, and ziran's `store.js` + (the Tenet loadData/putData contract) needs no change. +- Once the tiles-protocols commit ships in a tile-server release, the + vendored shuttle/data.js get deleted on both hosts at once. + +### The shell: subtabs under the ribbon + +No OS windows. The black ribbon stays permanent at the top and gains a **tab +strip**: a Home tab (the Documents + Models columns of the base window) and +one subtab per open document. A subtab's toolbar carries the doc title, +mode switcher, and the connections chip — the same components, re-parented. +No splits, merges, or tear-offs in v1 (those are desktop luxuries; a browser +tab per doc is available for free by deep-linking `#doc=`). + +- **Open Tile** → a file picker (`` / File System Access + where present). Importing registers the tile in the home space (records + + blobs + checkpoint) and opens a subtab — import IS the upload. +- **Export** → assemble the `.tile` in the worker and hand it down as a + download. A web document is never trapped. +- **Models**: starters ship as static assets of app.ziran.space; "Save as + model" stores into the home space, so your models roam too. Uninstall + tombstones as on desktop. +- **Share links**: `ziran.space/d/#…` gains an "Open in the web app" action + → `app.ziran.space/#ref=…&doc=…` → adopt-and-open, membership permitting. + The desktop deep link keeps priority when installed. + +### Sync + +Unchanged in substance: the same provider polls the same endpoints with the +same cadences, from the worker. Two web-specific notes: + +- **CORS**: every PDS in play must allow browser calls from + app.ziran.space. The spaces-alpha PDS answers `*` (verified when the + account page shipped); anything stricter surfaces in the panel as the + classified error it is. +- **Lifetime**: polling runs only while a tab is open — same honesty as the + desktop ("Ziran keeps trying while it's running"). No push, no service + worker background sync games in v1. + +## What this costs, honestly + +- Safari is the risk browser: SharedWorker returned in 16.4 — the in-page + fallback (Web Locks as the single-instance guard) exists for a reason, + and Phase A ends with a proof-of-life matrix on the three engines. With + no OPFS in the design, the storage story needs nothing exotic from any of + them. +- Two new sm services: `app.ziran.space` (static) and `load.ziran.space` + (the stock tile-server router, node type + wildcard) — deploys are + `sm deploy`; the load service holds no state and no secrets. +- The OAuth alpha packages must run in-browser; they are built for it, but + the pin is the same alpha train as everything else — one more thing that + moves under us until Spaces stabilizes. +- One `SCOPE_VERSION` bump (home space entry in the permission set) — every + signed-in desktop user re-consents once. + +## Phases + +- **A. Extraction + proof of life — code side DONE (2026-08-30).** The + `zfs` seam (`src/lib/zfs.ts`: Deno impl = today, `MemoryFs` = the web + cache tier; browser-safe defaults) carries tilefile + registry + provider; + `webstore.test.ts` proves the full lifecycle diskless — share → adopt → + ops → wipe the profile → re-materialize from the space alone; `deno task + check:web` bundles `src/web/runtime-core.ts` for the browser platform + (417 modules), asserts no Deno globals beyond the seam's guarded desktop + branch, and executes the built artifact through a checkpoint round-trip. + Desktop suites stayed green throughout. **Remaining (human):** open + `deno run -A tools/web-proof.ts` → http://127.0.0.1:4187 in each browser + that matters — it runs the bundle in-browser and reports SharedWorker / + Web Locks / Cache API availability (the terminal cannot see a real + browser from here). +- **B. Identity + home space.** Hosted OAuth client metadata; browser + sign-in; home-space auto-create on first sign-in; docs list reading the + home space. *Protocol groundwork DONE (2026-08-30): `space.ziran.home` + lexicon + permission-set entry (authority `self` — the matcher-verified + guard: no grant whatsoever on anyone else's home) published live; + `ZIRAN_HOME_TYPE`/`homeRef()` in scope.ts; `SCOPE_VERSION` = 6.* +- **C. Loader.** load.ziran.space deployed (stock @dasl/tile-server + sm + wildcard); the Ziran `TilePathLoader` feeding TileMothership from the + worker runtime; the three starter tiles running isolated. Exit: richtext + editing + persistence via the home space, two browsers side by side, + live. +- **D. Shell.** Ribbon + subtabs; connections panel, invitations strip, + presence — the components as they are, over the RPC backend. Import/ + export. `/d/` page gains the web path. +- **E. Ship.** sm services; smoke suite (browser harness driving two + identities end to end); website "use it in the browser" entry; alpha + caveats page (Safari matrix, no offline background sync). + +- **F. Desktop ↔ home-space unification (after v1 ships).** The desktop + gains "sync to my home space": a local doc registers in the home space + through the very same share() path (space = home, no invites), making the + local file one more materialization of a space-resident doc — the same + role the web cache plays. Opt-in per doc first, default later if it + earns it. Exit: make a doc on the desktop, open it at app.ziran.space, + and back, nothing special-cased. + +Order of A–C is deliberate: the risky unknowns (worker runtime, cacheless +storage, origin isolation) all die in the first two phases while the +desktop keeps shipping. + +## Deliberately not in v1 + +Signed-out scratch mode; splits/tear-offs; offline-first service-worker +boot of the shell (nice, later); push; E2EE (tracked in SYNC-SPACES.md +already). Desktop↔home-space unification graduated from "not in v1" to +Phase F: the home-space-as-disk storage model is what makes it a small +delta instead of a project. diff --git a/lexicons/space/ziran/home.json b/lexicons/space/ziran/home.json new file mode 100644 index 0000000..2a393ef --- /dev/null +++ b/lexicons/space/ziran/home.json @@ -0,0 +1,19 @@ +{ + "lexicon": 1, + "id": "space.ziran.home", + "defs": { + "main": { + "type": "space", + "key": "literal:self", + "name": "Ziran Home", + "description": "This account's own document space: every Ziran document that isn't shared lives here, following the identity across devices and browsers. Always at skey `self`, always member-list policy with exactly one member. Sharing a document means adding it to a shared space instead.", + "collections": [ + "space.ziran.info", + "space.ziran.doc", + "space.ziran.op", + "space.ziran.checkpoint", + "space.ziran.presence" + ] + } + } +} diff --git a/lexicons/space/ziran/permissions.json b/lexicons/space/ziran/permissions.json index db6dd74..71c4608 100644 --- a/lexicons/space/ziran/permissions.json +++ b/lexicons/space/ziran/permissions.json @@ -23,6 +23,22 @@ "action": ["read", "create", "update", "delete"], "manage": ["create", "update", "delete"] }, + { + "type": "permission", + "resource": "space", + "spaceType": "space.ziran.home", + "authority": "self", + "skey": "self", + "collection": [ + "space.ziran.info", + "space.ziran.doc", + "space.ziran.op", + "space.ziran.checkpoint", + "space.ziran.presence" + ], + "action": ["read", "create", "update", "delete"], + "manage": ["create", "update", "delete"] + }, { "type": "permission", "resource": "space", diff --git a/main.ts b/main.ts index 7750993..05ebf08 100644 --- a/main.ts +++ b/main.ts @@ -25,16 +25,50 @@ if (handOffArgs.length && (await handOffToRunningInstance(handOffArgs))) { Deno.exit(0); } -async function handOffToRunningInstance(args: string[]): Promise { - let key: string; - let port: number; +// A plain double-launch (no arguments) must not boot a second copy over the +// same data dir either: both copies would share one OAuth session store, and +// refresh tokens are single-use — the two instances take turns consuming +// each other's, until the library gives up and deletes the session ("the +// session was deleted by another process", in the person's face). Raise the +// running instance and bow out. Dev harnesses that genuinely want several +// instances point ZIRAN_DATA_DIR elsewhere, or set +// ZIRAN_ALLOW_SECOND_INSTANCE=1. +if ( + !handOffArgs.length && Deno.env.get('ZIRAN_ALLOW_SECOND_INSTANCE') !== '1' && + (await raiseRunningInstance()) +) { + console.log('Ziran is already running — raised its window instead of starting a second copy.'); + Deno.exit(0); +} + +async function instanceAddress(): Promise<{ key: string; port: number } | null> { try { - key = Deno.env.get('ZIRAN_KEY') ?? (await Deno.readTextFile(apiKeyFile())).trim(); - port = Number((await Deno.readTextFile(appPortFile())).trim()); + const key = Deno.env.get('ZIRAN_KEY') ?? (await Deno.readTextFile(apiKeyFile())).trim(); + const port = Number((await Deno.readTextFile(appPortFile())).trim()); + if (!Number.isInteger(port) || port <= 0) return null; + return { key, port }; } catch { - return false; // first run: nothing to hand off to + return null; // first run: nothing is running } - if (!Number.isInteger(port) || port <= 0) return false; +} + +async function raiseRunningInstance(): Promise { + const addr = await instanceAddress(); + if (!addr) return false; + const res = await fetch(`http://127.0.0.1:${addr.port}/api/raise`, { + method: 'POST', + headers: { 'x-ziran-key': addr.key }, + signal: AbortSignal.timeout(3_000), + }).catch(() => null); + if (!res) return false; + await res.body?.cancel(); + return res.ok; // 401 = not our instance (stale port file): boot normally +} + +async function handOffToRunningInstance(args: string[]): Promise { + const addr = await instanceAddress(); + if (!addr) return false; + const { key, port } = addr; let reached = false; for (const arg of args) { const res = await fetch(`http://127.0.0.1:${port}/api/deeplink`, { @@ -123,7 +157,7 @@ function makeSpacesProvider(): SpacesProvider { displayName: at.displayName ?? undefined, hasAvatar: Boolean(at.avatar), } - : { signedIn: false, staleScope: at.staleScope }, + : { signedIn: false, staleScope: at.staleScope, sessionLost: at.sessionLost }, devIdentity: () => envAuth, handle: () => envHandle ?? at.handle ?? undefined, }); diff --git a/src/components/zn-base-window.ts b/src/components/zn-base-window.ts index d542b2f..fdf14e2 100644 --- a/src/components/zn-base-window.ts +++ b/src/components/zn-base-window.ts @@ -17,12 +17,18 @@ export class ZnBaseWindow extends LitElement { dropping: { state: true }, menuRecent: { state: true }, docSort: { state: true }, + confirmModel: { state: true }, + menuConfirmDelete: { state: true }, }; declare private query: string; declare private dropping: boolean; declare private menuRecent: RecentEntry | undefined; declare private docSort: 'recent' | 'location'; + /** Model id whose Remove button is one click from firing. */ + declare private confirmModel: string | undefined; + /** Row-menu delete is armed (one more click deletes for real). */ + declare private menuConfirmDelete: boolean; constructor() { super(); @@ -30,6 +36,8 @@ export class ZnBaseWindow extends LitElement { this.dropping = false; this.menuRecent = undefined; this.docSort = 'recent'; + this.confirmModel = undefined; + this.menuConfirmDelete = false; } private onStore = () => this.requestUpdate(); @@ -147,6 +155,9 @@ export class ZnBaseWindow extends LitElement { .open-btn:hover { border-color: var(--on-ink-muted); } + .open-btn:active { + background: oklch(1 0 0 / 0.12); + } .open-btn:focus-visible { outline: 2px solid var(--signal-strong); outline-offset: 2px; @@ -280,6 +291,10 @@ export class ZnBaseWindow extends LitElement { cursor: pointer; justify-self: start; } + .menu button.signin-go:active { + filter: brightness(1.25); + transform: translateY(0.5px); + } .menu button.signin-go:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; @@ -355,6 +370,11 @@ export class ZnBaseWindow extends LitElement { .invite-dismiss:hover { border-color: var(--ink-muted); } + .invite-accept:active, + .invite-dismiss:active { + transform: translateY(0.5px); + filter: brightness(0.94); + } .invite-accept:focus-visible, .invite-dismiss:focus-visible { outline: 2px solid var(--signal); @@ -473,6 +493,9 @@ export class ZnBaseWindow extends LitElement { .row:hover { background: var(--panel); } + .row:active { + background: var(--panel-deep, var(--line-soft)); + } .row:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; @@ -548,10 +571,16 @@ export class ZnBaseWindow extends LitElement { .menu button:hover { background: var(--panel); } + .menu button:active { + background: var(--panel-deep, var(--line-soft)); + } .menu button:focus-visible { outline: 2px solid var(--signal); outline-offset: -2px; } + .menu button.menu-danger { + color: var(--danger); + } .menu hr { border: none; border-top: 1px solid var(--line-soft); @@ -566,9 +595,9 @@ export class ZnBaseWindow extends LitElement { /* model rows */ .model { display: grid; - grid-template-columns: auto 1fr; + grid-template-columns: auto 1fr auto; column-gap: var(--sp-3); - align-items: start; + align-items: center; width: 100%; border: 1px solid var(--line-soft); background: var(--surface); @@ -586,6 +615,9 @@ export class ZnBaseWindow extends LitElement { .model:hover { border-color: var(--ink-muted); } + .model:active { + background: var(--panel); + } .model:focus-visible { outline: 2px solid var(--signal); outline-offset: 1px; @@ -604,6 +636,50 @@ export class ZnBaseWindow extends LitElement { margin-top: 1px; text-wrap: pretty; } + .model-main { + min-width: 0; + } + .model-acts { + display: inline-flex; + gap: var(--sp-1); + opacity: 0; + transition: opacity var(--t-fast) var(--ease-out); + } + .model:hover .model-acts, + .model:focus-within .model-acts { + opacity: 1; + } + .model-act { + font: inherit; + font-size: var(--text-xs); + font-weight: var(--w-medium); + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-full); + padding: 3px 10px; + cursor: pointer; + transition: border-color var(--t-fast) var(--ease-out), background var(--t-fast) var(--ease-out); + } + .model-act:hover { + border-color: var(--ink-muted); + } + .model-act:active { + background: var(--panel); + border-color: var(--ink); + transform: translateY(0.5px); + } + .model-act:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .model-act.quiet { + color: var(--ink-muted); + } + .model-act.quiet:hover, + .model-act.confirm { + color: var(--danger); + } .empty { padding: var(--sp-4) var(--sp-2); } @@ -752,7 +828,6 @@ export class ZnBaseWindow extends LitElement {

Models

-

Pick one to start a new document. A model carries the editor, not the content.

${models.length ? models.map((m) => this.renderModel(m, m === enterModel)) @@ -891,7 +966,12 @@ export class ZnBaseWindow extends LitElement { }} > - ${desk.sync?.at?.staleScope + ${desk.sync?.at?.sessionLost + ? html`` + : desk.sync?.at?.staleScope ? html`
`; } diff --git a/src/components/zn-connections.ts b/src/components/zn-connections.ts index ec7b059..cae6205 100644 --- a/src/components/zn-connections.ts +++ b/src/components/zn-connections.ts @@ -1,20 +1,23 @@ import { css, html, LitElement, nothing } from 'lit'; import { baseStyles } from '../styles/shared.ts'; -import type { DocInfo } from '../types.ts'; +import type { AtProfile, DocInfo, SyncMember } from '../types.ts'; import { desk } from '../state/desk.ts'; -import { docIconUrl, formatSize } from '../state/backend.ts'; +import { backend, docIconUrl, keyed, modelIconUrl } from '../state/backend.ts'; import { icons } from './icons.ts'; /** The connections chip: folded, it shows the document's own icon plus its - verified/damaged mark. Expanded, it is the tile panel: address - fingerprint, size, provenance, save-as-model — and an honest note that - sync and publishing arrive with the network layer. */ + verified/damaged mark. Expanded, it is the document panel: model, the + people in the doc, the way in for new ones — and nothing that needs a + paragraph to justify itself. */ export class ZnConnections extends LitElement { static override properties = { doc: { attribute: false }, open: { state: true }, lastInvite: { state: true }, shareSpaceRef: { state: true }, + suggestions: { state: true }, + confirmRemove: { state: true }, + busy: { state: true }, }; declare doc: DocInfo; @@ -22,23 +25,40 @@ export class ZnConnections extends LitElement { declare private lastInvite: { link: string; pending: boolean } | undefined; /** Spaces go-live picker: '' = a new space (name asked inline). */ declare private shareSpaceRef: string; + /** Handle typeahead results for the invite field. */ + declare private suggestions: AtProfile[]; + /** DID whose Remove button is one click from firing. */ + declare private confirmRemove: string | undefined; + /** An action is in flight; controls disable instead of double-firing. */ + declare private busy: boolean; constructor() { super(); this.open = false; this.lastInvite = undefined; this.shareSpaceRef = ''; + this.suggestions = []; + this.confirmRemove = undefined; + this.busy = false; } private onStore = () => this.requestUpdate(); + /** Clicks inside a tile's iframe never reach this document, so the + popover's light-dismiss cannot see them — but the window does lose + focus. Treat that as the click-outside it is. */ + private onWindowBlur = () => { + if (this.open) this.panel.hidePopover(); + }; override connectedCallback() { super.connectedCallback(); desk.addEventListener('change', this.onStore); + window.addEventListener('blur', this.onWindowBlur); } override disconnectedCallback() { desk.removeEventListener('change', this.onStore); + window.removeEventListener('blur', this.onWindowBlur); super.disconnectedCallback(); } @@ -104,7 +124,7 @@ export class ZnConnections extends LitElement { } .panel { position: fixed; - width: 324px; + width: 340px; margin: 0; padding: 0; /* Short windows are common (a game tile is small). Without this the @@ -120,8 +140,8 @@ export class ZnConnections extends LitElement { color: var(--ink); font-size: var(--text-sm); /* The window's title bar sets user-select: none and this popover - lives inside it. Everything here — paths, fingerprints, links, - errors — exists to be read and copied. */ + lives inside it. Everything here — paths, links, errors — exists + to be read and copied. */ user-select: text; -webkit-user-select: text; } @@ -129,9 +149,9 @@ export class ZnConnections extends LitElement { background: transparent; } section { - padding: var(--sp-3) var(--sp-4); + padding: var(--sp-4); display: grid; - gap: var(--sp-2); + gap: var(--sp-3); min-width: 0; } /* Grid items default to min-width auto, which lets a long path force @@ -142,19 +162,14 @@ export class ZnConnections extends LitElement { section + section { border-top: 1px solid var(--line-soft); } - h3 { - margin: 0; - font-size: var(--text-xs); - font-weight: var(--w-semibold); - letter-spacing: 0.07em; - text-transform: uppercase; - color: var(--ink-muted); + section.quiet { + padding-block: var(--sp-3); } .row { display: flex; align-items: center; gap: var(--sp-2); - min-height: 24px; + min-height: 28px; } .row .grow { flex: 1; @@ -196,12 +211,6 @@ export class ZnConnections extends LitElement { details.detail[open] summary { margin-bottom: var(--sp-2); } - .ok { - color: var(--ink-muted); - display: inline-flex; - align-items: center; - gap: 4px; - } .muted { color: var(--ink-muted); } @@ -218,36 +227,121 @@ export class ZnConnections extends LitElement { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-full); - padding: 3px 12px; + padding: 5px 14px; cursor: pointer; - transition: border-color var(--t-fast) var(--ease-out); + transition: border-color var(--t-fast) var(--ease-out), background var(--t-fast) var(--ease-out); white-space: nowrap; } button.action:hover { border-color: var(--ink-muted); } + button.action:active { + background: var(--panel-deep, var(--panel)); + border-color: var(--ink); + transform: translateY(0.5px); + } + button.action:disabled { + opacity: 0.55; + cursor: default; + transform: none; + } button.action:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; } + button.action.danger { + color: var(--danger); + } + button.action.danger:active { + background: oklch(0.96 0.02 20); + } + .model-row { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + font: inherit; + text-align: left; + color: var(--ink); + background: none; + border: none; + border-radius: var(--r-sm); + padding: 4px 6px; + margin: -4px -6px; + cursor: pointer; + transition: background var(--t-fast) var(--ease-out); + } + .model-row:hover { + background: var(--panel); + } + .model-row:active { + background: var(--panel-deep, var(--panel)); + } + .model-row:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .model-row img, + .model-row .tile-fallback { + width: 20px; + height: 20px; + border-radius: 5px; + display: block; + flex: none; + } + .model-row .tile-fallback { + display: inline-grid; + place-items: center; + color: var(--ink-muted); + border: 1px solid var(--line-soft); + } ul.members { list-style: none; margin: 0; padding: 0; display: grid; - gap: 2px; + gap: var(--sp-1); } ul.members li { display: flex; align-items: center; gap: var(--sp-2); - min-height: 24px; + min-height: 32px; min-width: 0; } + .avatar { + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; + flex: none; + display: block; + background: var(--panel); + } + .avatar.fallback { + display: grid; + place-items: center; + background: var(--signal); + color: #fff; + font-size: 0.7rem; + font-weight: var(--w-semibold); + line-height: 1; + } + li.pending .avatar, + li.pending .member-name { + opacity: 0.6; + } .member-name { + flex: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-weight: var(--w-medium); + } + .member-name .you { + color: var(--ink-muted); + font-weight: var(--w-regular); } .presence { width: 7px; @@ -256,21 +350,76 @@ export class ZnConnections extends LitElement { background: var(--signal); flex: none; } - /* Known member, not currently around (Spaces presence aged out). */ .presence.away { background: var(--line); } + .role-label { + color: var(--ink-muted); + flex: none; + } + select.role { + font: inherit; + font-size: var(--text-xs); + border: 1px solid var(--line); + border-radius: var(--r-full); + background: var(--surface); + color: var(--ink-muted); + padding: 2px 6px; + flex: none; + cursor: pointer; + } + select.role:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .kick { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--ink-muted); + cursor: pointer; + flex: none; + font: inherit; + font-size: var(--text-xs); + } + .kick:hover { + background: var(--panel); + color: var(--danger); + } + .kick:active { + background: var(--panel-deep, var(--panel)); + } + .kick.confirm { + color: var(--danger); + font-weight: var(--w-semibold); + width: auto; + padding: 0 8px; + } + .kick:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } form.invite { display: flex; gap: var(--sp-2); min-width: 0; } + .suggest-wrap { + position: relative; + flex: 1; + min-width: 0; + display: flex; + } form.invite input { flex: 1; min-width: 0; font: inherit; font-size: var(--text-sm); - padding: 3px 10px; + padding: 5px 12px; border: 1px solid var(--line); border-radius: var(--r-full); background: var(--surface); @@ -291,12 +440,63 @@ export class ZnConnections extends LitElement { border-radius: var(--r-full); background: var(--surface); color: var(--ink); - padding: 3px 6px; + padding: 5px 8px; + } + .suggest { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 3; + margin: 0; + padding: var(--sp-1); + list-style: none; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-pop); + display: grid; + gap: 1px; + max-height: 220px; + overflow-y: auto; + } + .suggest button { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + border: none; + background: none; + font: inherit; + font-size: var(--text-sm); + text-align: left; + border-radius: var(--r-sm); + padding: 5px 8px; + cursor: pointer; + color: var(--ink); + min-width: 0; + } + .suggest button:hover, + .suggest button:focus-visible { + background: var(--panel); + outline: none; + } + .suggest .s-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .suggest .s-handle { + color: var(--ink-muted); + font-size: var(--text-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .row.share { background: var(--soft, oklch(0.965 0.004 352)); border-radius: var(--r-sm); - padding: 4px 8px; + padding: 5px 8px; } `, ]; @@ -306,8 +506,8 @@ export class ZnConnections extends LitElement { const iconUrl = docIconUrl(d); const live = desk.syncDoc(d.id); const isLive = Boolean(live) && live!.live !== false; - const label = `Tile details for ${d.name}: ${d.damaged ? 'failed verification' : 'verified'}` + - (d.modelName ? `, made from the ${d.modelName} model` : '') + + const label = `Document panel for ${d.name}` + + (d.damaged ? ': failed verification' : '') + (live ? isLive ? `, live with ${live.members.length} ${live.members.length === 1 ? 'member' : 'members'}` @@ -327,36 +527,62 @@ export class ZnConnections extends LitElement { ${isLive ? html`` : nothing} ${d.damaged ? icons.warning : icons.check} - -

- Going live keeps the document here and end-to-end encrypted — the relay only ever - carries ciphertext. You choose who joins, and as what. -

`; } if (sync.provider === 'spaces') { - const spaceName = sync.spaces?.find((sp) => sp.ref === s.space)?.name || 'its space'; const open = s.roles?.filter((r) => r.invitable) ?? []; + const link = this.lastInvite?.link ?? s.link; return html` -
    - ${s.members.map((m) => html` -
  • - - ${m.handle ?? m.did} - - ${m.did === sync.identity.did ? `${m.role} · you` : m.role} - -
  • - `)} -
-

- Live in “${spaceName}” through your PDS. Everyone in this space sees every - document in it, and your other signed-in devices sync on their own. -

+ ${this.renderMembers(s.members, s, sync.identity.did)} ${open.length ? html`
- + + setTimeout(() => (this.suggestions = []), 150)} + /> + ${this.renderSuggestions()} + - +
- ${this.renderLastSpacesInvite()} + ${this.lastInvite?.pending + ? html`

They can’t receive invitations yet — send them the link below; it works the moment they sign in.

` + : nothing} ` - : html`

Only the space owner can invite people to this space.

`} + : html`

Only the space owner can invite people.

`} + ${link + ? html` + + ` + : nothing} `; } if (s.live === false) { return html`

- This document is shared, but its live session isn’t running right now — the - relay refused it, or it was shared under a different identity. Your edits stay - safe here; Ziran retries on every reconnect. + This document is shared, but its live session isn’t running right now. Your + edits stay safe here; Ziran retries on every reconnect.

@@ -443,62 +666,195 @@ export class ZnConnections extends LitElement {
`; } - // The document's own roles — not a list this panel invents. `null` means - // the runtime never told us (a window older than the app): say that, - // rather than claiming every seat is taken. - const label = (name: string) => - s.roles?.find((r) => r.name === name)?.label ?? - name.charAt(0).toUpperCase() + name.slice(1); + // Relay branch (parked provider): unchanged behavior, quieter copy. const open = s.roles ? s.roles.filter((r) => r.invitable && !r.full) : null; return html` -
    - ${s.members.map((m) => html` -
  • - - ${m.handle ?? m.did} - - ${m.did === sync.identity.did ? `${label(m.role)} · you` : label(m.role)} - -
  • - `)} -
+ ${this.renderMembers(s.members, s, sync.identity.did)} ${open === null - ? html`

- Reopen Ziran to invite people: this window is older than the app and - doesn’t know what roles this document offers. -

` + ? html`

Reopen Ziran to invite people — this window is older than the app.

` : open.length ? html`
- + + setTimeout(() => (this.suggestions = []), 150)} + /> + ${this.renderSuggestions()} + - +
- ${this.renderLastInvite()} + ${this.lastInvite + ? html` + + ` + : nothing} ` - : html`

- Every seat this document offers is taken, so there is no one left to invite. -

`} + : html`

Every seat this document offers is taken.

`} + `; + } + + /** The people: avatar + name, presence, role (editable by the doc's + author), and removal (by the space owner). Invitees who haven't joined + yet show dimmed — an invitation is a member-shaped promise. */ + private renderMembers(members: SyncMember[], s: NonNullable>, myDid: string) { + const roleLabel = (name: string) => + s.roles?.find((r) => r.name === name)?.label ?? name.charAt(0).toUpperCase() + name.slice(1); + return html` +
    + ${members.map((m) => { + const me = m.did === myDid; + const profile = desk.profile(m.did); + const name = me + ? (desk.sync?.at?.displayName ?? desk.sync?.at?.handle ?? 'You') + : profile?.displayName ?? profile?.handle ?? m.handle ?? `${m.did.slice(0, 14)}…`; + const canAssign = s.mine === true && !me && !m.pending; + const canRemove = s.owner === true && !me; + const confirming = this.confirmRemove === m.did; + return html` +
  • + ${this.renderMemberAvatar(profile, name, me)} + + ${name}${me ? html` · you` : nothing} + + ${m.pending + ? html`invited · ${roleLabel(m.role)}` + : html` + + ${canAssign + ? html` + + ` + : html`${roleLabel(m.role)}`} + `} + ${canRemove + ? html` + + ` + : nothing} +
  • + `; + })} +
+ `; + } + + private renderMemberAvatar(profile: AtProfile | undefined, name: string, me: boolean) { + // Own avatar is served same-origin and needs the key; others come + // straight from their PDS/CDN. + const src = me && desk.sync?.at?.hasAvatar ? keyed('/api/at/avatar') : profile?.avatar; + if (src) return html``; + return html``; + } + + private kick(did: string) { + if (this.confirmRemove !== did) { + this.confirmRemove = did; + return; + } + this.confirmRemove = undefined; + desk.removeMember(this.doc.id, did); + } + + /* ——— handle typeahead ——— */ + + #searchTimer: number | undefined; + private onInviteInput(e: Event) { + const q = (e.target as HTMLInputElement).value; + if (this.#searchTimer !== undefined) clearTimeout(this.#searchTimer); + if (q.trim().length < 2 || q.trim().startsWith('did:')) { + this.suggestions = []; + return; + } + this.#searchTimer = setTimeout(async () => { + const actors = await backend.atSearch(q).catch(() => []); + // The field may have moved on while the network answered. + const input = this.renderRoot.querySelector('form.invite input[name="did"]'); + if (input && input.value === q) this.suggestions = actors; + }, 250) as unknown as number; + } + + private onInviteKey(e: KeyboardEvent) { + if (e.key === 'Escape' && this.suggestions.length) { + e.stopPropagation(); + this.suggestions = []; + return; + } + // Enter always submits, whether or not suggestions are showing — the + // typed handle resolves server-side either way. + if (e.key === 'Enter') { + e.preventDefault(); + this.suggestions = []; + (e.target as HTMLInputElement).form?.requestSubmit(); + } + } + + private renderSuggestions() { + if (!this.suggestions.length) return nothing; + return html` +
    + ${this.suggestions.map((a) => html` +
  • + +
  • + `)} +
`; } + private pickSuggestion(a: AtProfile) { + const input = this.renderRoot.querySelector('form.invite input[name="did"]'); + if (input) input.value = a.handle ?? a.did; + this.suggestions = []; + input?.focus(); + } + /** Going live into a space (Spaces provider): pick one of yours, or name a new one. Membership is per-space, so the copy says exactly that. */ private renderGoLiveSpaces() { const spaces = desk.sync?.spaces ?? []; const wantsNew = this.shareSpaceRef === ''; return html` -
- Only on this machine -
(this.busy = false)); }} >