From df71b9563ae6fd2cd72e329b3a419130ecb98146 Mon Sep 17 00:00:00 2001 From: Robin Berjon Date: Fri, 17 Jul 2026 22:37:58 +0200 Subject: [PATCH] basics --- .gitignore | 1 + CLAUDE.md | 8 + DESIGN.md | 68 + PRODUCT.md | 44 + deno.json | 50 + deno.lock | 686 +++++ docs/ARCHITECTURE.md | 125 + main.ts | 82 + package.json | 5 + src/components/icons.ts | 39 + src/components/zn-app.ts | 139 + src/components/zn-base-window.ts | 644 +++++ src/components/zn-connections.ts | 287 +++ src/components/zn-doc-canvas.ts | 181 ++ src/components/zn-doc-window.ts | 672 +++++ src/main.ts | 1 + src/server/api.ts | 249 ++ src/server/dialogs.ts | 119 + src/server/paths.ts | 33 + src/server/register.ts | 96 + src/server/store.ts | 583 +++++ src/server/tilefile.ts | 305 +++ src/server/tileserver.ts | 91 + src/server/windows.ts | 198 ++ src/state/backend.ts | 104 + src/state/desk.ts | 272 ++ src/styles/shared.ts | 15 + src/types.ts | 79 + src/ui/data-protocol.ts | 77 + src/ui/mothership.ts | 28 + static/app.js | 4106 ++++++++++++++++++++++++++++++ static/base.css | 42 + static/index.html | 19 + static/tokens.css | 88 + tiles/built/checklist.tile | Bin 0 -> 4852 bytes tiles/built/note.tile | Bin 0 -> 2704 bytes tiles/checklist/icon.svg | 5 + tiles/checklist/index.html | 119 + tiles/checklist/manifest.json | 12 + tiles/note/icon.svg | 5 + tiles/note/index.html | 71 + tiles/note/manifest.json | 12 + tools/build-tiles.ts | 41 + tools/contrast.mjs | 77 + tools/mac-bundle.ts | 75 + tools/make-icons.ts | 101 + tools/tile-selftest.ts | 68 + vendor/web-tiles/README.md | 13 + vendor/web-tiles/data.js | 31 + vendor/web-tiles/shuttle.js | 133 + vendor/web-tiles/store.js | 39 + 51 files changed, 10338 insertions(+) create mode 100644 CLAUDE.md create mode 100644 DESIGN.md create mode 100644 PRODUCT.md create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 docs/ARCHITECTURE.md create mode 100644 main.ts create mode 100644 package.json create mode 100644 src/components/icons.ts create mode 100644 src/components/zn-app.ts create mode 100644 src/components/zn-base-window.ts create mode 100644 src/components/zn-connections.ts create mode 100644 src/components/zn-doc-canvas.ts create mode 100644 src/components/zn-doc-window.ts create mode 100644 src/main.ts create mode 100644 src/server/api.ts create mode 100644 src/server/dialogs.ts create mode 100644 src/server/paths.ts create mode 100644 src/server/register.ts create mode 100644 src/server/store.ts create mode 100644 src/server/tilefile.ts create mode 100644 src/server/tileserver.ts create mode 100644 src/server/windows.ts create mode 100644 src/state/backend.ts create mode 100644 src/state/desk.ts create mode 100644 src/styles/shared.ts create mode 100644 src/types.ts create mode 100644 src/ui/data-protocol.ts create mode 100644 src/ui/mothership.ts create mode 100644 static/app.js create mode 100644 static/base.css create mode 100644 static/index.html create mode 100644 static/tokens.css create mode 100644 tiles/built/checklist.tile create mode 100644 tiles/built/note.tile create mode 100644 tiles/checklist/icon.svg create mode 100644 tiles/checklist/index.html create mode 100644 tiles/checklist/manifest.json create mode 100644 tiles/note/icon.svg create mode 100644 tiles/note/index.html create mode 100644 tiles/note/manifest.json create mode 100644 tools/build-tiles.ts create mode 100644 tools/contrast.mjs create mode 100644 tools/mac-bundle.ts create mode 100644 tools/make-icons.ts create mode 100644 tools/tile-selftest.ts create mode 100644 vendor/web-tiles/README.md create mode 100644 vendor/web-tiles/data.js create mode 100644 vendor/web-tiles/shuttle.js create mode 100644 vendor/web-tiles/store.js diff --git a/.gitignore b/.gitignore index e46e550..7092072 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ Thumbs.db .idea/ .vscode/ *.swp +.claude/ .impeccable/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9ddd187 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# Ziran + +Ziran is a local-first document environment built on self-editing DASL tiles, shipped as a Deno desktop app on web technology. It aims to replace the fragmented document stack (PDF, Word, Docs, Excel, Sheets, PowerPoint, Keynote) with one medium. + +## Design Context + +- **PRODUCT.md** — strategic context: register (product), platform (web), users, positioning, brand personality, anti-references, design principles, accessibility bar (WCAG 2.2 AA + accessible output). Read it before designing or building any UI. +- **DESIGN.md** — visual system (currently a seed): light-first surfaces, one hot-pink signal accent (≤10% of any screen), single sans UI type, flat-by-default elevation. Re-run `/impeccable document` once real code exists to capture actual tokens. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..fe849d3 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,68 @@ + +--- +name: Ziran +description: A local-first document environment built on self-editing DASL tiles. +--- + +# Design System: Ziran + +## Overview + +**Creative North Star: "The Sunlit Workbench"** + +Ziran is a bright, calm working surface where the user's document is always the protagonist. The interface has the crisp, keyboard-first precision of Linear and the pro-tool confidence of Figma, but it lives in daylight: light surfaces, generous clarity, nothing murky, nothing dark. Against that quiet field, one hot signal — a saturated pink — marks what is alive: the primary action, the current selection, the syncing presence of another person. The tool recedes; the signal is unmissable; the document is everything. + +This system explicitly rejects the feel of Adobe Acrobat — heavy, hostile, rent-seeking document software — along with Office ribbon maximalism, Google-suite blandness, Notion-style gray-on-white SaaS minimalism, and retro computing nostalgia. Ziran should read as next-generation, liberating, and reliable: familiar enough to trust at first sight, with the ambition showing in the details. + +**Key Characteristics:** +- Light-first: bright, airy surfaces; never a dark theme by default. +- One hot signal: a saturated pink accent, rare and purposeful. +- The document is the interface; chrome earns its place or disappears. +- Keyboard-first speed with responsive, sub-250ms feedback. +- Precise, dependable, never lossy — reliability as a visual feeling. + +## Colors + +Strategy: restrained neutrals with one punchy accent — louder than Linear allows itself, but never covering more than a sliver of the screen. + +**The Hot Signal Rule.** The pink accent appears on at most 10% of any screen: primary actions, active selection, live/sync state. Its rarity is its power. The moment pink becomes decoration, it stops meaning anything. + +**The Unbranded Document Rule.** The user's document never wears ziran's brand. Accent color lives in the chrome; the document canvas stays neutral and belongs entirely to its author. + +**The Home Base Rule.** The base window alone wears the ink banner with the accent Z mark — it is the one dark object on the desk, which is what makes it home. Document windows stay light so documents remain the brightest things on the desk. + +### Primary +- **Hot Signal Pink** (anchor: oklch(64.7% 0.263 352)): the brand accent. Exact working values [to be resolved during implementation]. At this lightness and chroma the fill glows perceptually, so text-carrying fills (buttons, pills, badges) resolve to chroma ≤ 0.23 with white text; the full-chroma anchor is reserved for thin strokes, glyphs, and small non-text marks. + +### Neutral +- **Daylight surfaces** (direction: pure white background, oklch(1 0 0), with a second slightly-inked neutral layer for sidebars and panels): exact ramp [to be resolved during implementation]. No hidden warm tint; the mood lives in the accent and typography, not the surface. +- **Ink** (direction: near-black body text at ≥7:1 contrast on white): [to be resolved during implementation]. + +## Typography + +**UI Font:** single sans — one well-tuned technical-humanist family carries all UI: headings, labels, buttons, data. [Font to be chosen at implementation.] + +**Character:** crisp and self-effacing. The UI type is an instrument, not a voice; documents carry their own typography, and the app never competes with them. + +Direction for implementation: fixed rem scale (no fluid clamp in product UI), tight ratio around 1.125–1.2 between steps, prose capped at 65–75ch, denser runs permitted in tables and lists. + +## Elevation + +Flat by default. Depth is conveyed by the second neutral layer (panels and sidebars sit tonally apart from the canvas), and soft shadow appears only on transient surfaces — menus, command palette, dialogs — as a response to state, never at rest. Motion is responsive: every interaction acknowledges the user within 150–250ms, with no choreography and a reduced-motion alternative for everything that moves. + +## Do's and Don'ts + +### Do: +- **Do** keep surfaces light and bright; ziran does not ship dark-first. +- **Do** use white text on pink fills (Helmholtz-Kohlrausch: saturated mid-luminance fills need white, even where dark technically passes). +- **Do** hold the pink to ≤10% of any screen, reserved for action, selection, and live state. +- **Do** give every interactive component its full state set: default, hover, focus, active, disabled, loading, error. +- **Do** meet WCAG 2.2 AA in the app and treat accessible document output as a first-class requirement. + +### Don't: +- **Don't** feel like Adobe Acrobat: heavy, hostile, rent-seeking document software. +- **Don't** reproduce Office ribbon maximalism — toolbars everywhere, chrome crowding the document. +- **Don't** drift into Google-suite blandness: generic Material chrome, competent but interchangeable. +- **Don't** default to Notion-style SaaS minimalism: gray-on-white, emoji icons, the interchangeable startup look. +- **Don't** touch retro computing nostalgia: terminal aesthetics, pixel fonts, deliberately old-looking. +- **Don't** put the brand pink inside the user's document canvas. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..2b7b2d4 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,44 @@ +# Product + +## Register + +product + +## Platform + +web + +## Users + +People who live in documents. They read and manage large collections — PDFs and the like — and they produce new documents: text, spreadsheets, presentations. They are working, not browsing; on any given screen they are either reading and organizing a collection, or authoring inside a document. Ziran ships first as a Deno desktop app built on web technology; a web version may follow. + +## Product Purpose + +Ziran is a document environment built on DASL tiles: a unified, safe document format in which each document carries the code to edit itself. Tiles are local-first and travel without a cloud. A tile saved without its data becomes a model — a template from which new, similar documents are created. With CRDT support, the same document can be edited live by several people at once. Documents can be published to the Atmosphere as tiles. Success means ziran replaces the fragmented document stack — PDF, Word, Google Docs, Excel, Google Sheets, PowerPoint, Keynote, and more — with one medium. + +## Positioning + +One safe, self-editing document medium — local-first, verifiable, publishable — instead of a shelf of rented apps. + +## Brand Personality + +Next-generation, liberating, reliable. In order: confident and quietly radical; calm, natural, and unobtrusive; precise, fast, and professional. Familiar enough to trust at first sight, with the ambition showing in the details — the tool recedes and the document is everything. Closest existing reference: Linear, for its crisp, keyboard-first professionalism, but with stronger accents than Linear allows itself. + +## Anti-references + +- Office ribbon maximalism: toolbars everywhere, every feature visible at once, chrome crowding the document. +- Google-suite blandness: generic Material chrome; competent, interchangeable, no point of view. +- Notion-style SaaS minimalism: gray-on-white, emoji icons, the interchangeable startup look. +- Retro computing nostalgia: terminal aesthetics, pixel fonts, deliberately old-looking. + +## Design Principles + +1. The document is the interface. Chrome earns its place or disappears; on any screen the user's document, not ziran, is the protagonist. +2. Familiar outside, radical inside. Every surface must be immediately trustworthy to someone arriving from Word or Docs, while the new capabilities — self-editing tiles, models, offline sync, publishing — reveal themselves in the details. +3. Liberation is legible. Local-first, ownership, and portability should be felt in the interface — documents that visibly live on your machine, travel as files, and never depend on a cloud — not just claimed in marketing. +4. One medium, many shapes. Text, tables, and slides are expressions of one document model, and the interface should make that unity perceptible rather than reproducing five app silos inside one window. +5. Reliability is a feeling. Fast, precise, never lossy: every interaction reinforces that documents here are safe — safer than the formats ziran replaces. + +## Accessibility & Inclusion + +WCAG 2.2 AA for the application UI: contrast, full keyboard support, reduced motion, focus management. Accessible output is a first-class requirement in its own right: documents ziran produces carry real semantics and are screen-reader-grade artifacts, not visual approximations. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..66ab063 --- /dev/null +++ b/deno.json @@ -0,0 +1,50 @@ +{ + "name": "ziran", + "version": "0.1.0", + "exports": "./main.ts", + "nodeModulesDir": "auto", + "desktop": { + "app": { + "name": "Ziran", + "identifier": "com.berjon.ziran" + }, + "backend": "cef" + }, + "tasks": { + "build:ui": "deno bundle --platform browser --output static/app.js src/main.ts", + "build:tiles": "deno run -A tools/build-tiles.ts", + "build:icons": "deno run -A tools/make-icons.ts", + "dev": "deno task build:ui && deno run -A --watch main.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-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-1024.png --target aarch64-apple-darwin --output dist/Ziran-arm64.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran-arm64.app", + "build:mac-intel": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-1024.png --target x86_64-apple-darwin --output dist/Ziran-x64.app main.ts && deno run -A tools/mac-bundle.ts dist/Ziran-x64.app", + "build:win": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon.ico --target x86_64-pc-windows-msvc --output dist/Ziran.msi main.ts", + "build:linux": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-1024.png --target x86_64-unknown-linux-gnu --output dist/Ziran.AppImage main.ts", + "build:linux-arm": "deno task build:ui && deno desktop -A --include static --include vendor --include tiles --include build --icon build/icon-1024.png --target aarch64-unknown-linux-gnu --output dist/Ziran-arm64.AppImage main.ts", + "build:all": "deno task build:mac && deno task build:mac-intel && deno task build:win && deno task build:linux && deno task build:linux-arm" + }, + "lint": { + "exclude": ["vendor/", "static/app.js", "dist/", "node_modules/", ".claude/", ".pi/"] + }, + "compilerOptions": { + "lib": ["deno.ns", "dom", "dom.iterable", "dom.asynciterable"], + "experimentalDecorators": true, + "useDefineForClassFields": false + }, + "imports": { + "@std/path": "jsr:@std/path@^1.1.0", + "lit": "npm:lit@^3.3.3", + "lit/": "npm:/lit@^3.3.3/", + "express": "npm:express@^5.2.1", + "@dasl/tile-server": "npm:@dasl/tile-server@^2.0.0", + "@dasl/tile-loader": "npm:@dasl/tile-loader@^2.0.0", + "@dasl/tile-loader/car": "npm:/@dasl/tile-loader@^2.0.0/car", + "@dasl/tile-lexicon": "npm:@dasl/tile-lexicon@^2.0.0", + "@atcute/cid": "npm:@atcute/cid@^2.4.2", + "@atcute/cbor": "npm:@atcute/cbor@^2.3.5", + "@atcute/car": "npm:@atcute/car@^6.0.2", + "file-type": "npm:file-type@^22.0.1", + "mime": "npm:mime@^4.1.0" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..8bb38c1 --- /dev/null +++ b/deno.lock @@ -0,0 +1,686 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.1.0": "1.1.6", + "npm:@atcute/car@^6.0.2": "6.0.2_@atcute+cbor@2.3.5__@atcute+cid@2.4.2_@atcute+cid@2.4.2", + "npm:@atcute/cbor@^2.3.5": "2.3.5_@atcute+cid@2.4.2", + "npm:@atcute/cid@^2.4.2": "2.4.2", + "npm:@dasl/tile-lexicon@2": "2.0.0", + "npm:@dasl/tile-loader@2": "2.0.0_@atcute+cbor@2.3.5__@atcute+cid@2.4.2", + "npm:@dasl/tile-server@2": "2.0.0", + "npm:@types/express@^5.0.6": "5.0.6", + "npm:express@^5.2.1": "5.2.1", + "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" + }, + "jsr": { + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "@atcute/car@6.0.2_@atcute+cbor@2.3.5__@atcute+cid@2.4.2_@atcute+cid@2.4.2": { + "integrity": "sha512-6AaLjO0zrFD8R/aK7jwrqHEmLzfVilu/5pv4LAcUjxIYfBrw+nThV2FVLPtA4Jt59tIhjp5tQDP+9sx1eqQp+A==", + "dependencies": [ + "@atcute/cbor", + "@atcute/cid", + "@atcute/uint8array", + "@atcute/varint" + ] + }, + "@atcute/cbor@2.3.5_@atcute+cid@2.4.2": { + "integrity": "sha512-NvgeibMqtfeD6/4NMTnBwhjyB/F945UdnzeZMEsGb2eXSalOBzutgNmpnBPTj5FiN6Sl9VYhE3qDQfyox+P8aw==", + "dependencies": [ + "@atcute/cid", + "@atcute/multibase", + "@atcute/uint8array" + ] + }, + "@atcute/cid@2.4.2": { + "integrity": "sha512-Uy48yfyo/hPQXF+XMXWIGomF6v8IvX5ErjozXMV7rlfU3EV7PSVX3J7plJXV6MRC3iI1z3PgTZTS7V0drCQVVw==", + "dependencies": [ + "@atcute/multibase", + "@atcute/uint8array" + ] + }, + "@atcute/multibase@1.2.4": { + "integrity": "sha512-WeX12hvFZEim6C+cyv7Eqd93w6DzubNWQGmTFBghjsEuXvMe4HbBCYvsti0OUnbA5qLBPlsTyssQUJeLlHCzIw==", + "dependencies": [ + "@atcute/uint8array" + ] + }, + "@atcute/uint8array@1.1.4": { + "integrity": "sha512-rSW5AFVCIN4ooH7vEZB+J60+uWjn5fRBQAQL58qHLiDm8+xDPmHfEU5GfYOJuuD+7UBj8KKiQugIzohFn8/xPw==" + }, + "@atcute/varint@2.0.1": { + "integrity": "sha512-reTtgQ1VPGVRcaTohKVb1tUbpvE7MpguoNS0gjhsxDWFY3t1yqWWvwKK6cJodAmePML36JombvgFwfsblir9Jg==" + }, + "@borewit/text-codec@0.2.2": { + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==" + }, + "@dasl/tile-lexicon@2.0.0": { + "integrity": "sha512-MeRgdv6MUBC5Bd6p8hZ19bRxLURbsSK1dJ6BekzehOWHfhJiIlSztBkFaQRsycRtHBVDATB4KVagb4pkWkUF0Q==" + }, + "@dasl/tile-loader@2.0.0_@atcute+cbor@2.3.5__@atcute+cid@2.4.2": { + "integrity": "sha512-P9M/GUDhN37vXIrB8E4JpbY2BoLRcJevJKAKYUrAZN3/v0dpP1FhYbWPzTeoyn0iT5iHNKqiaqQVspPqZfPL/A==", + "dependencies": [ + "@atcute/car", + "@atcute/cid", + "@dasl/tile-lexicon", + "jszip", + "mime", + "smol-toml" + ] + }, + "@dasl/tile-server@2.0.0": { + "integrity": "sha512-C9kh5sbS8WWNwJP4Q0h3SSzKX8GU1A7FWL84M0Q9Ol5ielw2/C0F6tHjH4ZsJbPgxdffwq9gxTpR5mNmWj8O4g==", + "dependencies": [ + "express", + "nanoid" + ], + "bin": true + }, + "@lit-labs/ssr-dom-shim@1.6.0": { + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==" + }, + "@lit/reactive-element@2.1.2": { + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "dependencies": [ + "@lit-labs/ssr-dom-shim" + ] + }, + "@tokenizer/inflate@0.4.1": { + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dependencies": [ + "debug", + "token-types" + ] + }, + "@tokenizer/token@0.3.0": { + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" + }, + "@types/body-parser@1.19.6": { + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dependencies": [ + "@types/connect", + "@types/node" + ] + }, + "@types/connect@3.4.38": { + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": [ + "@types/node" + ] + }, + "@types/express-serve-static-core@5.1.2": { + "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", + "dependencies": [ + "@types/node", + "@types/qs", + "@types/range-parser", + "@types/send" + ] + }, + "@types/express@5.0.6": { + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dependencies": [ + "@types/body-parser", + "@types/express-serve-static-core", + "@types/serve-static" + ] + }, + "@types/http-errors@2.0.5": { + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==" + }, + "@types/node@26.1.1": { + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dependencies": [ + "undici-types" + ] + }, + "@types/qs@6.15.1": { + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==" + }, + "@types/range-parser@1.2.7": { + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "@types/send@1.2.1": { + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dependencies": [ + "@types/node" + ] + }, + "@types/serve-static@2.2.0": { + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dependencies": [ + "@types/http-errors", + "@types/node" + ] + }, + "@types/trusted-types@2.0.7": { + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, + "accepts@2.0.0": { + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dependencies": [ + "mime-types", + "negotiator" + ] + }, + "body-parser@2.3.0": { + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dependencies": [ + "bytes", + "content-type@2.0.0", + "debug", + "http-errors", + "iconv-lite", + "on-finished", + "qs", + "raw-body", + "type-is" + ] + }, + "bytes@3.1.2": { + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "call-bind-apply-helpers@1.0.2": { + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": [ + "es-errors", + "function-bind" + ] + }, + "call-bound@1.0.4": { + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": [ + "call-bind-apply-helpers", + "get-intrinsic" + ] + }, + "content-disposition@1.1.0": { + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" + }, + "content-type@1.0.5": { + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "content-type@2.0.0": { + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==" + }, + "cookie-signature@1.2.2": { + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, + "cookie@0.7.2": { + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "core-util-is@1.0.3": { + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "debug@4.4.3": { + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": [ + "ms" + ] + }, + "depd@2.0.0": { + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "dunder-proto@1.0.1": { + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": [ + "call-bind-apply-helpers", + "es-errors", + "gopd" + ] + }, + "ee-first@1.1.1": { + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "encodeurl@2.0.0": { + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" + }, + "es-define-property@1.0.1": { + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors@1.3.0": { + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms@1.1.2": { + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": [ + "es-errors" + ] + }, + "escape-html@1.0.3": { + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "etag@1.8.1": { + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "express@5.2.1": { + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dependencies": [ + "accepts", + "body-parser", + "content-disposition", + "content-type@1.0.5", + "cookie", + "cookie-signature", + "debug", + "depd", + "encodeurl", + "escape-html", + "etag", + "finalhandler", + "fresh", + "http-errors", + "merge-descriptors", + "mime-types", + "on-finished", + "once", + "parseurl", + "proxy-addr", + "qs", + "range-parser", + "router", + "send", + "serve-static", + "statuses", + "type-is", + "vary" + ] + }, + "file-type@22.0.1": { + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "dependencies": [ + "@tokenizer/inflate", + "strtok3", + "token-types", + "uint8array-extras" + ] + }, + "finalhandler@2.1.1": { + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dependencies": [ + "debug", + "encodeurl", + "escape-html", + "on-finished", + "parseurl", + "statuses" + ] + }, + "forwarded@0.2.0": { + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh@2.0.0": { + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, + "function-bind@1.1.2": { + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic@1.3.0": { + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": [ + "call-bind-apply-helpers", + "es-define-property", + "es-errors", + "es-object-atoms", + "function-bind", + "get-proto", + "gopd", + "has-symbols", + "hasown", + "math-intrinsics" + ] + }, + "get-proto@1.0.1": { + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": [ + "dunder-proto", + "es-object-atoms" + ] + }, + "gopd@1.2.0": { + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "has-symbols@1.1.0": { + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "hasown@2.0.4": { + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": [ + "function-bind" + ] + }, + "http-errors@2.0.1": { + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": [ + "depd", + "inherits", + "setprototypeof", + "statuses", + "toidentifier" + ] + }, + "iconv-lite@0.7.3": { + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dependencies": [ + "safer-buffer" + ] + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "immediate@3.0.6": { + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ipaddr.js@1.9.1": { + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-promise@4.0.0": { + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, + "isarray@1.0.0": { + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "jszip@3.10.1": { + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": [ + "lie", + "pako", + "readable-stream", + "setimmediate" + ] + }, + "lie@3.3.0": { + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": [ + "immediate" + ] + }, + "lit-element@4.2.2": { + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "dependencies": [ + "@lit-labs/ssr-dom-shim", + "@lit/reactive-element", + "lit-html" + ] + }, + "lit-html@3.3.3": { + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "dependencies": [ + "@types/trusted-types" + ] + }, + "lit@3.3.3": { + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "dependencies": [ + "@lit/reactive-element", + "lit-element", + "lit-html" + ] + }, + "math-intrinsics@1.1.0": { + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "media-typer@1.1.0": { + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "merge-descriptors@2.0.0": { + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" + }, + "mime-db@1.54.0": { + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types@3.0.2": { + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": [ + "mime-db" + ] + }, + "mime@4.1.0": { + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "bin": true + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "nanoid@5.1.16": { + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "bin": true + }, + "negotiator@1.0.0": { + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + }, + "object-inspect@1.13.4": { + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" + }, + "on-finished@2.4.1": { + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": [ + "ee-first" + ] + }, + "once@1.4.0": { + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": [ + "wrappy" + ] + }, + "pako@1.0.11": { + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "parseurl@1.3.3": { + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-to-regexp@8.4.2": { + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" + }, + "process-nextick-args@2.0.1": { + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "proxy-addr@2.0.7": { + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": [ + "forwarded", + "ipaddr.js" + ] + }, + "qs@6.15.3": { + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": [ + "es-define-property", + "side-channel" + ] + }, + "range-parser@1.3.0": { + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==" + }, + "raw-body@3.0.2": { + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dependencies": [ + "bytes", + "http-errors", + "iconv-lite", + "unpipe" + ] + }, + "readable-stream@2.3.8": { + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": [ + "core-util-is", + "inherits", + "isarray", + "process-nextick-args", + "safe-buffer", + "string_decoder", + "util-deprecate" + ] + }, + "router@2.2.0": { + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": [ + "debug", + "depd", + "is-promise", + "parseurl", + "path-to-regexp" + ] + }, + "safe-buffer@5.1.2": { + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send@1.2.1": { + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": [ + "debug", + "encodeurl", + "escape-html", + "etag", + "fresh", + "http-errors", + "mime-types", + "ms", + "on-finished", + "range-parser", + "statuses" + ] + }, + "serve-static@2.2.1": { + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dependencies": [ + "encodeurl", + "escape-html", + "parseurl", + "send" + ] + }, + "setimmediate@1.0.5": { + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, + "setprototypeof@1.2.0": { + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "side-channel-list@1.0.1": { + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": [ + "es-errors", + "object-inspect" + ] + }, + "side-channel-map@1.0.1": { + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect" + ] + }, + "side-channel-weakmap@1.0.2": { + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": [ + "call-bound", + "es-errors", + "get-intrinsic", + "object-inspect", + "side-channel-map" + ] + }, + "side-channel@1.1.1": { + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": [ + "es-errors", + "object-inspect", + "side-channel-list", + "side-channel-map", + "side-channel-weakmap" + ] + }, + "smol-toml@1.7.0": { + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==" + }, + "statuses@2.0.2": { + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" + }, + "string_decoder@1.1.1": { + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": [ + "safe-buffer" + ] + }, + "strtok3@10.3.5": { + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dependencies": [ + "@tokenizer/token" + ] + }, + "toidentifier@1.0.1": { + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "token-types@6.1.2": { + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dependencies": [ + "@borewit/text-codec", + "@tokenizer/token", + "ieee754" + ] + }, + "type-is@2.1.0": { + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dependencies": [ + "content-type@2.0.0", + "media-typer", + "mime-types" + ] + }, + "uint8array-extras@1.5.0": { + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==" + }, + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, + "unpipe@1.0.0": { + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "vary@1.1.2": { + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "wrappy@1.0.2": { + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/path@^1.1.0", + "npm:@atcute/car@^6.0.2", + "npm:@atcute/cbor@^2.3.5", + "npm:@atcute/cid@^2.4.2", + "npm:@dasl/tile-lexicon@2", + "npm:@dasl/tile-loader@2", + "npm:@dasl/tile-server@2", + "npm:express@^5.2.1", + "npm:file-type@^22.0.1", + "npm:lit@^3.3.3", + "npm:mime@^4.1.0" + ] + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..64c9f34 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,125 @@ +# Ziran — Desktop App Architecture + +Ziran is a Deno desktop app (CEF backend, Deno ≥ 2.9) following Blastile's shell +patterns and Tenet's tile-management patterns. One process: a Deno backend owns +all state and serves HTTP + WebSocket on 127.0.0.1; the CEF window loads the UI +from it; tiles render in isolated `*.localhost` iframes. + +## Shell (from Blastile) + +- `main.ts` binds `Deno.serve` FIRST (under `deno desktop`, to the + runtime-assigned `DENO_SERVE_ADDRESS`; else `127.0.0.1:4180`), then deletes + `DENO_SERVE_ADDRESS` before the express-based tile server starts — the + node-compat listener honors that env var and would steal the address. +- Main window via `Deno.BrowserWindow` (the desk shell). No Deno Desktop + bindings are ever registered: the bindings proxy leaks into cross-origin tile + frames, so all IPC is HTTP (`/api/*`) + one WebSocket (`/api/events`). +- CEF (not the platform webview) because tiles need `*.localhost` secure + contexts for their service workers. +- Plain `deno task dev` runs the same app in a normal browser. + +## Tiles (format from Blastile, engine ported from Tenet) + +A `.tile` is a CARv1: varint-prefixed dCBOR MASL manifest (with `version: 1`, +`roots: []`), then blocks of `varint(36+len) + CID bytes + resource bytes`. +Resource CIDs are CIDv1 / raw 0x55 / sha2-256. `resources[path].src` is a CID +link (`{$link}` / Tag 42 with the 0x00 identity prefix — handled by +`@atcute/cbor`); `icons[].src` is a plain path into resources. The `/` resource +is the tile's `index.html`; the tile IS its own editor. + +`src/server/tilefile.ts` (TS port of Tenet's `car.rs` against Blastile's byte +format, built on `@atcute/{car,cbor,cid}` + `@dasl/tile-writer`): + +- `parseTile(path)` → manifest + CID→offset index; **verifies** every block's + CID against its bytes (ziran adds the integrity check both references skip — + a mismatch marks the document damaged, feeding the UI's calm failure state). +- `readResource(path)` / `readStorage(name)` — lazy block reads. +- `writeTileData(name, bytes)` — upsert `/.well-known/web-tiles-storage/` + and atomically rebuild (tmp + rename in the same dir). +- `writeModelTile(src, dest)` — Tenet semantics: require `model.id`; hoist + `model.*` over top-level name/description/icons; strip all + `/.well-known/web-tiles-storage/*` resources and any blocks only they + referenced; keep the `model` field so instances stay model-backed. +- `safeModelStem(id)` — slug + first 8 hex of sha256(id); models upsert by id. + +## Storage (from Tenet) + +App data dir (`src/server/paths.ts`): macOS +`~/Library/Application Support/com.berjon.ziran/`, Linux +`$XDG_DATA_HOME/com.berjon.ziran/`, Windows `%APPDATA%\com.berjon.ziran\`. + +``` +com.berjon.ziran/ +├── documents/ # app-managed documents (create-from-model copies here) +├── models/ # -.tile — standalone stripped model tiles +├── imports/ # drag-and-dropped .tile files +├── recents.json # [{path, lastOpened, name, hasIcon, modelName}] +├── session.json # desk layout: windows/panes/tabs by doc id + base position +``` + +Creating from a model = copy the model tile into `documents/` (no dialog; +local-first means it exists and is recoverable from Recent immediately). +External tiles can be opened via picker (macOS `osascript`, else manual path) +or drag-and-drop import. Starter models ship in `tiles/` and seed `models/` on +first run. + +## Rendering & self-storage + +`@dasl/tile-server` + vendored `web-tiles/{shuttle,data}.js` serve the tile +runtime; a `LocalTileMothership` mints a random `<20 letters>.localhost:` +origin per render; `CARTileLoader` fetches `/api/docs/:id/bundle.tile`. Tiles +persist through `/.well-known/web-tiles/store.js` (`loadData`/`putData`, +Tenet's contract) implemented over tp-data postMessage → host → +`GET/PUT /api/docs/:id/data/:name` → `writeTileData`. + +## UI — real OS windows + +Every page is one real OS window (`Deno.BrowserWindow`; `window.open` popups +in browser mode). `/` is the base window; `/?win=` is a document window. +The **server owns window layout** (`windows: DocWindowRec[]` — panes, tabs, +split ratio) alongside documents; every window mirrors state over its own WS +connection (`/api/events?win=`) and forwards actions as window ops +(`POST /api/windows/:id/op`). When a window's last socket drops and no reload +reclaims it within 2s, its record is closed and unreferenced docs released; +conversely, when a window's record empties (merge, last tab closed), the page +calls `window.close()`. + +Tabs and vertical splits live *inside* each document window. Cross-window +combination is menu-driven (merge into / tile into / move tab to / tear off — +tear-off spawns a new OS window); there is no cross-window drag, because real +OS windows offer no drop orchestration. Session restore reopens last session's +windows server-side at boot (desktop mode only), including geometry: each +window page reports its own bounds (screenX/outerWidth — the desktop API has +no bounds events) to `POST /api/windows/:id/bounds`, persisted in +session.json; restores pass bounds as constructor options and the page +re-applies them via moveTo/resizeTo. The base window centres itself when no +stored bounds exist. + +## OS integration + +App + document icons are generated by `tools/make-icons.ts` (headless-Chrome +rasterization of the brand SVGs; Windows .ico is a PNG-embedded single-image +wrapper). `.tile` / `application/web-tile` association: on macOS, +`tools/mac-bundle.ts` post-processes the built .app (CFBundleDocumentTypes + +UTExportedTypeDeclarations for UTI `ing.dasl.tile`, document icns via +sips/iconutil, lsregister) — wired into the `build`/`build:mac*` tasks; on +Windows/Linux, `src/server/register.ts` registers at first run (HKCU registry +/ XDG mime + desktop entry). File paths passed on the command line open at +boot. Known gap: macOS Finder open-events (Apple Events) don't reach +`Deno.args`; opening a .tile from Finder while ziran runs needs runtime +support Deno desktop doesn't expose yet. `deno task build:all` chains every +platform target. + +Documents and models carry real tile metadata — name, icon (served from the +tile's own resources via `/api/docs/:id/icon`, surfaced in recents, models, +window bars, and tabs), model provenance, verified/damaged state. +`zn-doc-canvas` is the tile frame (shuttle iframe) with skeleton/damaged +states wired to real load/verify. + +**Deferred (stubs kept honest in UI):** live sync (CRDT), Atmosphere publish, +accounts/OAuth. The connections panel shows tile facts (CID, verified, model) +and disables the rest with "arrives with sync". + +Build: `deno bundle --platform browser` → `static/app.js`; CSS linked from +`static/index.html`. Vite/package.json remain for browser-only UI iteration +and node tooling (contrast checks), but the app's toolchain is Deno. diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..5064812 --- /dev/null +++ b/main.ts @@ -0,0 +1,82 @@ +// Ziran — a local-first document environment built on self-editing DASL tiles. +// +// Under `deno desktop`, Deno.serve() auto-binds to the runtime-assigned +// 127.0.0.1 port (DENO_SERVE_ADDRESS) and the CEF window opens on it. Run +// with plain `deno run -A main.ts` and it binds 127.0.0.1:4180 for a regular +// browser instead. + +import { createHandler } from './src/server/api.ts'; +import { ZiranStore } from './src/server/store.ts'; +import { startTileServer } from './src/server/tileserver.ts'; +import { hasDesktopWindows, openDocWindow, setupMainWindow } from './src/server/windows.ts'; + +const store = new ZiranStore(); +await store.load(); +store.desktop = hasDesktopWindows(); +if (store.desktop) store.openOsWindow = openDocWindow; + +const handler = createHandler(store); + +// Bind the main server before ANY other listener: under deno desktop the +// runtime probes a free port for DENO_SERVE_ADDRESS and releases it, so a +// listener started earlier can be handed that exact port by the OS and +// Deno.serve() then dies with AddrInUse. +const server = Deno.env.get('DENO_SERVE_ADDRESS') + ? Deno.serve(handler) + : Deno.serve( + { hostname: '127.0.0.1', port: Number(Deno.env.get('ZIRAN_PORT') ?? 4180) }, + handler, + ); +const port = (server.addr as Deno.NetAddr).port; +store.appPort = port; +console.log(`ziran on http://127.0.0.1:${port}/`); + +// Deno's node-compat listeners honor DENO_SERVE_ADDRESS too; without this the +// express tile server would try to bind the exact same address. +Deno.env.delete('DENO_SERVE_ADDRESS'); + +store.tileServerPort = await startTileServer(); +store.broadcast(); +console.log(`tile server on http://load.localhost:${store.tileServerPort}/`); + +setupMainWindow({ + onClosed: (winId) => { + if (winId !== 'main') store.closeWindow(winId); + }, + onBounds: (winId, b) => store.updateBounds(winId === 'main' ? 'base' : winId, b), + onOpenTile: async () => { + const { pickOpen } = await import('./src/server/dialogs.ts'); + const picked = await pickOpen(); + if (!('path' in picked) || !picked.path) return; + try { + const info = await store.openPath(picked.path); + const shown = store.windows.some((w) => w.panes.some((p) => p.tabs.includes(info.id))); + if (!shown) store.createWindow([info.id]); + } catch (err) { + console.warn('could not open picked tile:', (err as Error).message); + } + }, +}); +// Reopen last session's document windows (desktop mode only; in a browser, +// popup rules make silent restores hostile). +await store.restoreSession(); + +// First-run OS integration (Windows/Linux; macOS is baked into the bundle). +if (store.desktop) { + const { registerFileTypes } = await import('./src/server/register.ts'); + registerFileTypes(); +} + +// Files passed on the command line (file associations invoke us this way on +// Windows/Linux; macOS open-events are a known gap — see ARCHITECTURE.md). +for (const arg of Deno.args) { + if (!arg.endsWith('.tile')) continue; + try { + const info = await store.openPath(await Deno.realPath(arg)); + if (!store.windows.some((w) => w.panes.some((p) => p.tabs.includes(info.id)))) { + store.createWindow([info.id]); + } + } catch (err) { + console.warn(`could not open ${arg}:`, (err as Error).message); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3069741 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "ziran", + "private": true, + "description": "Node-side dev tooling only (contrast checks, screenshot harness); the app itself is Deno — see deno.json." +} diff --git a/src/components/icons.ts b/src/components/icons.ts new file mode 100644 index 0000000..a2929bf --- /dev/null +++ b/src/components/icons.ts @@ -0,0 +1,39 @@ +import { svg, type TemplateResult } from 'lit'; + +/** One coherent stroke set: 16×16, 1.5 stroke, round caps. Decorative by + default (aria-hidden); give the *container* the accessible name. */ +const wrap = (body: TemplateResult) => svg` + `; + +export const icons = { + text: wrap(svg``), + sheet: wrap(svg``), + deck: wrap(svg``), + model: wrap(svg``), + search: wrap(svg``), + close: wrap(svg``), + plus: wrap(svg``), + dots: wrap(svg``), + publish: wrap(svg``), + lock: wrap(svg``), + warning: wrap(svg``), + check: wrap(svg``), + drive: wrap(svg``), + laptop: wrap(svg``), + people: wrap(svg``), + link: wrap(svg``), + tile: wrap(svg``), +} as const; + +export type IconName = keyof typeof icons; + +/** The brand mark: a heavy standalone Z, no container. Rounded joins come + from stroking the silhouette with itself. */ +export const zmark = svg` + `; + diff --git a/src/components/zn-app.ts b/src/components/zn-app.ts new file mode 100644 index 0000000..8635901 --- /dev/null +++ b/src/components/zn-app.ts @@ -0,0 +1,139 @@ +import { css, html, LitElement } from 'lit'; +import { baseStyles } from '../styles/shared.ts'; +import { desk } from '../state/desk.ts'; +import { dataHub } from '../ui/data-protocol.ts'; +import './zn-base-window.ts'; +import './zn-doc-window.ts'; +import type { ZnBaseWindow } from './zn-base-window.ts'; + +/** Router: every page is one real OS window. `?win=` renders that + document window; otherwise this is the base window. Also hosts toasts. */ +export class ZnApp extends LitElement { + static override properties = { + toasts: { state: true }, + }; + + declare private toasts: Array<{ id: number; text: string }>; + + constructor() { + super(); + this.toasts = []; + } + + private toastSeq = 0; + private toastTimers = new Map(); + private onChange = () => this.requestUpdate(); + private onToast = (e: Event) => this.pushToast((e as CustomEvent).detail); + private onKey = (e: KeyboardEvent) => { + if (desk.winId === 'base' && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + (this.renderRoot.querySelector('zn-base-window') as ZnBaseWindow)?.focusCommand(); + } + }; + + static override styles = [ + baseStyles, + css` + :host { + display: block; + height: 100dvh; + background: var(--surface); + } + .toasts { + position: fixed; + bottom: var(--sp-5); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-2); + z-index: var(--z-toast); + pointer-events: none; + } + .toast { + background: var(--ink); + color: var(--surface); + font-size: var(--text-sm); + font-weight: var(--w-medium); + padding: 9px 18px; + border-radius: var(--r-full); + box-shadow: var(--shadow-pop); + animation: toast-in var(--t-slow) var(--ease-out); + pointer-events: auto; + } + @keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + } + `, + ]; + + override connectedCallback() { + super.connectedCallback(); + desk.addEventListener('change', this.onChange); + desk.addEventListener('toast', this.onToast); + window.addEventListener('keydown', this.onKey); + dataHub.init(); + desk.init(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + desk.removeEventListener('change', this.onChange); + desk.removeEventListener('toast', this.onToast); + window.removeEventListener('keydown', this.onKey); + } + + override render() { + return html` + ${desk.winId === 'base' + ? html`` + : html``} +
+ ${this.toasts.map( + (t) => html`
this.holdToast(t.id)} + @mouseleave=${() => this.armToast(t.id, 1600)} + > + ${t.text} +
`, + )} +
+ `; + } + + private pushToast(text: string) { + const id = ++this.toastSeq; + this.toasts = [...this.toasts.slice(-2), { id, text }]; + this.armToast(id, 3400 + Math.max(0, text.length - 40) * 25); + } + + private armToast(id: number, ms: number) { + this.holdToast(id); + this.toastTimers.set( + id, + setTimeout(() => { + this.toastTimers.delete(id); + this.toasts = this.toasts.filter((t) => t.id !== id); + }, ms) as unknown as number, + ); + } + + private holdToast(id: number) { + const timer = this.toastTimers.get(id); + if (timer !== undefined) clearTimeout(timer); + this.toastTimers.delete(id); + } +} + +customElements.define('zn-app', ZnApp); + +declare global { + interface HTMLElementTagNameMap { + 'zn-app': ZnApp; + } +} diff --git a/src/components/zn-base-window.ts b/src/components/zn-base-window.ts new file mode 100644 index 0000000..8abc35c --- /dev/null +++ b/src/components/zn-base-window.ts @@ -0,0 +1,644 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { baseStyles } from '../styles/shared.ts'; +import { desk } from '../state/desk.ts'; +import { modelIconUrl, recentIconUrl, relativeTime } from '../state/backend.ts'; +import type { ModelEntry, RecentEntry } from '../types.ts'; +import { icons, zmark } from './icons.ts'; + + +/** Ziran's home base: command on top, history on the left, models on the + right. Everything here is a route into an open document. */ +export class ZnBaseWindow extends LitElement { + static override properties = { + query: { state: true }, + dropping: { state: true }, + menuRecent: { state: true }, + }; + + declare private query: string; + declare private dropping: boolean; + declare private menuRecent: RecentEntry | undefined; + + constructor() { + super(); + this.query = ''; + this.dropping = false; + this.menuRecent = undefined; + } + + private onStore = () => this.requestUpdate(); + + override connectedCallback() { + super.connectedCallback(); + desk.addEventListener('change', this.onStore); + } + + override disconnectedCallback() { + desk.removeEventListener('change', this.onStore); + super.disconnectedCallback(); + } + + static override styles = [ + baseStyles, + css` + :host { + display: block; + height: 100dvh; + } + .base { + height: 100%; + display: flex; + flex-direction: column; + background: var(--surface); + overflow: hidden; + } + .base.dropping { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + /* The ink banner: only the home base wears it. */ + header { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-4) var(--sp-5); + background: var(--ink); + color: var(--on-ink); + user-select: none; + } + .mark { + display: inline-grid; + place-items: center; + color: var(--signal-strong); + flex: none; + } + .wordmark { + font-size: var(--text-md); + font-weight: var(--w-semibold); + letter-spacing: 0.01em; + color: var(--on-ink); + } + .search { + flex: 1; + display: flex; + align-items: center; + gap: var(--sp-2); + max-width: 560px; + margin-left: var(--sp-4); + border: none; + border-radius: var(--r-full); + background: var(--surface); + padding: 8px 14px; + color: var(--ink-muted); + } + .search:focus-within { + outline: 2px solid var(--signal-strong); + outline-offset: 2px; + } + .search input { + flex: 1; + border: none; + background: none; + font: inherit; + color: var(--ink); + min-width: 0; + } + .search input:focus { + outline: none; + } + .search input::placeholder { + color: var(--ink-muted); + opacity: 1; + } + .search kbd { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--ink-muted); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 1px 6px; + background: var(--surface); + } + @media (pointer: coarse), (max-width: 839px) { + .search kbd { + display: none; + } + } + .open-btn { + margin-left: auto; + font: inherit; + font-size: var(--text-sm); + font-weight: var(--w-medium); + color: var(--on-ink); + background: none; + border: 1px solid oklch(0.45 0.015 352); + border-radius: var(--r-full); + padding: 5px 14px; + cursor: pointer; + white-space: nowrap; + transition: border-color var(--t-fast) var(--ease-out); + } + .open-btn:hover { + border-color: var(--on-ink-muted); + } + .open-btn:focus-visible { + outline: 2px solid var(--signal-strong); + outline-offset: 2px; + } + .regions { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + gap: var(--sp-5); + padding: var(--sp-4) var(--sp-5); + min-height: 0; + } + h2 { + font-size: var(--text-sm); + font-weight: var(--w-semibold); + color: var(--ink-muted); + margin: 0 0 var(--sp-2); + padding-left: var(--sp-2); + } + .region { + display: flex; + flex-direction: column; + min-height: 0; + } + .scroll { + overflow-y: auto; + min-height: 0; + scrollbar-width: thin; + } + .tile-ico { + width: 20px; + height: 20px; + border-radius: 5px; + display: block; + flex: none; + } + .tile-ico.fallback { + display: inline-grid; + place-items: center; + color: var(--ink-muted); + border: 1px solid var(--line-soft); + } + /* history rows */ + .row { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + border: none; + background: none; + font: inherit; + text-align: left; + padding: 8px var(--sp-2); + border-radius: var(--r-sm); + cursor: pointer; + color: var(--ink); + transition: background var(--t-fast) var(--ease-out); + } + .row:hover { + background: var(--panel); + } + .row:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + .row .title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: var(--w-medium); + } + .row .title .flag { + font-weight: var(--w-regular); + color: var(--ink-muted); + } + .row .open-dot { + width: 6px; + height: 6px; + border-radius: var(--r-full); + background: var(--signal-strong); + flex: none; + } + .row .row-menu { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--ink-muted); + cursor: pointer; + opacity: 0; + flex: none; + transition: opacity var(--t-fast) var(--ease-out); + } + .row:hover .row-menu, + .row .row-menu:focus-visible { + opacity: 1; + } + .row .row-menu:hover { + background: var(--panel-deep); + color: var(--ink); + } + .row .row-menu:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .menu { + position: fixed; + margin: 0; + padding: var(--sp-1); + min-width: 200px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-pop); + } + .menu button { + display: block; + width: 100%; + text-align: left; + font: inherit; + font-size: var(--text-sm); + color: var(--ink); + background: none; + border: none; + border-radius: var(--r-sm); + padding: 7px 10px; + cursor: pointer; + } + .menu button:hover { + background: var(--panel); + } + .menu button:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + .menu hr { + border: none; + border-top: 1px solid var(--line-soft); + margin: var(--sp-1) 0; + } + .row time { + color: var(--ink-muted); + font-size: var(--text-sm); + flex: none; + font-variant-numeric: tabular-nums; + } + /* model rows */ + .model { + display: grid; + grid-template-columns: auto 1fr; + column-gap: var(--sp-3); + align-items: start; + width: 100%; + border: 1px solid var(--line-soft); + background: var(--surface); + font: inherit; + text-align: left; + padding: var(--sp-3); + border-radius: var(--r-sm); + cursor: pointer; + color: var(--ink); + transition: border-color var(--t-fast) var(--ease-out), background var(--t-fast) var(--ease-out); + } + .model + .model { + margin-top: var(--sp-2); + } + .model:hover { + border-color: var(--ink-muted); + } + .model:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .model .tile-ico { + width: 24px; + height: 24px; + margin-top: 1px; + } + .model .name { + font-weight: var(--w-semibold); + } + .model .blurb { + color: var(--ink-muted); + font-size: var(--text-sm); + margin-top: 1px; + text-wrap: pretty; + } + .empty { + padding: var(--sp-4) var(--sp-2); + } + .empty h3 { + font-size: var(--text-xl); + font-weight: var(--w-semibold); + margin: 0 0 var(--sp-2); + letter-spacing: -0.01em; + text-wrap: balance; + } + .empty p { + margin: 0; + color: var(--ink-muted); + max-width: 40ch; + line-height: 1.55; + text-wrap: pretty; + } + .region-hint { + color: var(--ink-muted); + font-size: var(--text-sm); + margin: -2px 0 var(--sp-2); + padding-left: var(--sp-2); + text-wrap: pretty; + } + .enter-kbd { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--ink-muted); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 0 5px; + background: var(--surface); + flex: none; + } + .row.enter-target, + .model.enter-target { + background: var(--panel); + } + .model.enter-target { + border-color: var(--ink-muted); + } + .none { + color: var(--ink-muted); + padding: var(--sp-3) var(--sp-2); + } + footer { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-5); + border-top: 1px solid var(--line-soft); + background: var(--panel); + color: var(--ink-muted); + font-size: var(--text-sm); + min-height: 36px; + } + footer .glyph { + display: inline-grid; + transform: scale(0.9); + } + footer .sep { + flex: 1; + } + @media (max-width: 839px) { + .regions { + grid-template-columns: 1fr; + } + } + `, + ]; + + override render() { + const q = this.query.trim().toLowerCase(); + const recents = desk.recents.filter((d) => !q || d.name.toLowerCase().includes(q)); + const models = desk.models.filter( + (m) => !q || m.name.toLowerCase().includes(q) || (m.description ?? '').toLowerCase().includes(q), + ); + const open = desk.openDocIds; + const enterDoc = q ? recents[0] : undefined; + const enterModel = q && !enterDoc ? models[0] : undefined; + const damagedCount = desk.recents.filter((r) => r.damaged).length; + return html` +
(this.dropping = false)} + @drop=${this.onDrop} + > +
+ ${zmark} + ziran + + +
+
+
+

Recent

+
+ ${!desk.recents.length && !q + ? html` +
+

Your documents live here.

+

+ Ziran documents are tiles: they carry their own editor, live on this + machine, and never need a cloud. Start from a model to make your first + one — it will appear here. You can also drop a .tile file anywhere on + this window. +

+
+ ` + : recents.length + ? recents.map((d) => this.renderRow(d, open.has(d.id), d === enterDoc)) + : html`

Nothing called “${this.query}” — try a model instead?

`} +
+
+
+

Models

+

Editors without their data — any document can become one.

+
+ ${models.length + ? models.map((m) => this.renderModel(m, m === enterModel)) + : html`

No model matches “${this.query}”.

`} +
+
+
+ +
+ ${damagedCount ? icons.warning : icons.check} + + ${desk.recents.length + ? damagedCount + ? `${damagedCount} of ${desk.recents.length} tiles failed verification` + : 'All documents verified and saved on this machine' + : 'Everything you make here stays on this machine'} + + + ${desk.recents.length} ${desk.recents.length === 1 ? 'tile' : 'tiles'} +
+
+ `; + } + + private tileIcon(url: string | undefined) { + return url + ? html`` + : html`${icons.tile}`; + } + + private renderRow(d: RecentEntry, isOpen: boolean, enterTarget = false) { + return html` + + `; + } + + private renderRowMenu() { + const d = this.menuRecent; + if (!d) return nothing; + return html` + + +
+ + `; + } + + private openRowMenu(e: MouseEvent, d: RecentEntry) { + e.preventDefault(); + this.menuRecent = d; + const at = e.clientX + ? { x: e.clientX, y: e.clientY } + : (() => { + const r = (e.target as HTMLElement).getBoundingClientRect(); + return { x: r.left, y: r.bottom + 4 }; + })(); + this.updateComplete.then(() => { + const menu = this.renderRoot.querySelector('.menu')!; + // Overlap the cursor slightly so a right-click's own pointerup lands + // inside the popover instead of light-dismissing it. + menu.style.left = `${Math.max(8, Math.min(at.x - 6, innerWidth - 208))}px`; + menu.style.top = `${Math.max(8, Math.min(at.y - 6, innerHeight - 160))}px`; + menu.showPopover(); + menu.querySelector('button')?.focus(); + }); + } + + private rowMenuAction(fn: () => void) { + this.renderRoot.querySelector('.menu')?.hidePopover(); + this.menuRecent = undefined; + fn(); + } + + private renderModel(m: ModelEntry, enterTarget = false) { + return html` + + `; + } + + focusCommand() { + const input = this.renderRoot.querySelector('input.command') as HTMLInputElement; + input.focus(); + input.select(); + } + + private commandKey(e: KeyboardEvent) { + const input = e.target as HTMLInputElement; + if (e.key === 'Escape') { + this.query = ''; + input.blur(); + return; + } + if (e.key !== 'Enter') return; + const q = this.query.trim().toLowerCase(); + if (!q) return; + const doc = desk.recents.find((d) => d.name.toLowerCase().includes(q)); + if (doc) { + desk.openRecent(doc); + this.query = ''; + return; + } + const model = desk.models.find( + (m) => m.name.toLowerCase().includes(q) || (m.description ?? '').toLowerCase().includes(q), + ); + if (model) { + desk.newFromModel(model.id); + this.query = ''; + } + } + + private onDragOver(e: DragEvent) { + if (e.dataTransfer?.types.includes('Files')) { + e.preventDefault(); + this.dropping = true; + } + } + + private onDrop(e: DragEvent) { + this.dropping = false; + const files = [...(e.dataTransfer?.files ?? [])].filter((f) => f.name.endsWith('.tile')); + if (!files.length) return; + e.preventDefault(); + for (const file of files) desk.importFile(file); + } + +} + +customElements.define('zn-base-window', ZnBaseWindow); + +declare global { + interface HTMLElementTagNameMap { + 'zn-base-window': ZnBaseWindow; + } +} diff --git a/src/components/zn-connections.ts b/src/components/zn-connections.ts new file mode 100644 index 0000000..979a62a --- /dev/null +++ b/src/components/zn-connections.ts @@ -0,0 +1,287 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { baseStyles } from '../styles/shared.ts'; +import type { DocInfo } from '../types.ts'; +import { desk } from '../state/desk.ts'; +import { docIconUrl, formatSize } 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. */ +export class ZnConnections extends LitElement { + static override properties = { + doc: { attribute: false }, + open: { state: true }, + }; + + declare doc: DocInfo; + declare private open: boolean; + + constructor() { + super(); + this.open = false; + } + + private onStore = () => this.requestUpdate(); + + override connectedCallback() { + super.connectedCallback(); + desk.addEventListener('change', this.onStore); + } + + override disconnectedCallback() { + desk.removeEventListener('change', this.onStore); + super.disconnectedCallback(); + } + + static override styles = [ + baseStyles, + css` + .chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px 3px 5px; + border: 1px solid var(--line); + border-radius: var(--r-full); + background: var(--surface); + cursor: pointer; + font: inherit; + color: var(--ink-muted); + transition: border-color var(--t-fast) var(--ease-out); + } + .chip:hover { + border-color: var(--ink-muted); + } + .chip:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; + } + .chip[aria-expanded='true'] { + border-color: var(--signal); + } + .chip img { + width: 18px; + height: 18px; + border-radius: 4px; + display: block; + } + .chip .fallback { + display: inline-grid; + color: var(--ink-muted); + transform: scale(0.9); + } + .mark { + display: inline-grid; + transform: scale(0.8); + } + .mark.ok { + color: var(--ink-muted); + } + .mark.bad { + color: var(--danger); + } + .panel { + position: fixed; + width: 324px; + margin: 0; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-pop); + color: var(--ink); + font-size: var(--text-sm); + } + .panel::backdrop { + background: transparent; + } + section { + padding: var(--sp-3) var(--sp-4); + display: grid; + gap: var(--sp-2); + min-width: 0; + } + /* Grid items default to min-width auto, which lets a long path force + the popover wider than itself and scroll horizontally. */ + section > * { + min-width: 0; + } + 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); + } + .row { + display: flex; + align-items: center; + gap: var(--sp-2); + min-height: 24px; + } + .row .grow { + flex: 1; + min-width: 0; + } + .mono { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--ink-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .ok { + color: var(--ink-muted); + display: inline-flex; + align-items: center; + gap: 4px; + } + .muted { + color: var(--ink-muted); + } + .fine { + color: var(--ink-muted); + line-height: 1.45; + text-wrap: pretty; + margin: 0; + } + button.action { + font: inherit; + font-weight: var(--w-medium); + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-full); + padding: 3px 12px; + cursor: pointer; + transition: border-color var(--t-fast) var(--ease-out); + white-space: nowrap; + } + button.action:hover { + border-color: var(--ink-muted); + } + button.action:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; + } + `, + ]; + + override render() { + const d = this.doc; + const iconUrl = docIconUrl(d); + const label = `Tile details for ${d.name}: ${d.damaged ? 'failed verification' : 'verified'}` + + (d.modelName ? `, made from the ${d.modelName} model` : ''); + return html` + + + `; + } + + private renderModelSection() { + const d = this.doc; + const saved = d.modelId ? desk.models.find((m) => m.id === d.modelId) : undefined; + if (saved) { + return html`

Model saved in your library as “${saved.name}”.

`; + } + if (d.modelId) { + return html` +
+ Made from the ${d.modelName ?? 'a'} model — not in your library + +
+

Saving keeps the editor and drops the content — a fresh starting point.

+ `; + } + return html`

No model provenance — this tile did not come from a model.

`; + } + + private get panel(): HTMLElement { + return this.renderRoot.querySelector('.panel') as HTMLElement; + } + + private toggle() { + if (this.open) this.panel.hidePopover(); + else { + this.panel.showPopover(); + this.position(); + this.panel.querySelector('button')?.focus(); + } + } + + private position() { + const chip = this.renderRoot.querySelector('.chip') as HTMLElement; + const r = chip.getBoundingClientRect(); + const { width, height } = this.panel.getBoundingClientRect(); + const left = Math.max(8, Math.min(r.right - width, innerWidth - width - 8)); + const below = r.bottom + 8; + const top = below + height > innerHeight - 8 ? Math.max(8, r.top - height - 8) : below; + this.panel.style.left = `${left}px`; + this.panel.style.top = `${top}px`; + } + + protected override updated() { + if (this.open) this.position(); + } + + private onToggle(e: Event) { + const opened = (e as ToggleEvent).newState === 'open'; + this.open = opened; + if (!opened) (this.renderRoot.querySelector('.chip') as HTMLElement)?.focus(); + } +} + +customElements.define('zn-connections', ZnConnections); + +declare global { + interface HTMLElementTagNameMap { + 'zn-connections': ZnConnections; + } +} diff --git a/src/components/zn-doc-canvas.ts b/src/components/zn-doc-canvas.ts new file mode 100644 index 0000000..6c315b4 --- /dev/null +++ b/src/components/zn-doc-canvas.ts @@ -0,0 +1,181 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { baseStyles } from '../styles/shared.ts'; +import type { DocInfo } from '../types.ts'; +import { desk } from '../state/desk.ts'; +import { mothership } from '../ui/mothership.ts'; +import { dataHub } from '../ui/data-protocol.ts'; +import { icons } from './icons.ts'; + +/** Hosts one document's tile: loads the CAR through the mothership and + mounts the isolated shuttle iframe. Owns the opening skeleton and the + integrity-failure state (decided server-side at open time). The canvas + stays unbranded — the tile inside is entirely the document's own. */ +export class ZnDocCanvas extends LitElement { + static override properties = { + doc: { attribute: false }, + phase: { state: true }, + error: { state: true }, + }; + + // `declare` keeps these as Lit's reactive accessors; real class fields + // would shadow them under define semantics and silently break updates. + declare doc: DocInfo; + declare private phase: 'loading' | 'error' | 'ready'; + declare private error: string; + + #token = 0; + #loadedFor = ''; + + constructor() { + super(); + this.phase = 'loading'; + this.error = ''; + } + + static override styles = [ + baseStyles, + css` + :host { + display: block; + height: 100%; + overflow: hidden; + background: var(--surface); + position: relative; + } + .mount, + .mount iframe { + height: 100%; + width: 100%; + border: none; + display: block; + } + .skeleton { + position: absolute; + inset: 0; + background: var(--surface); + padding: var(--sp-7) var(--sp-7) var(--sp-5); + } + .bone { + border-radius: var(--r-sm); + background: var(--panel-deep); + animation: breathe 1.2s var(--ease-out) infinite alternate; + } + @keyframes breathe { + from { opacity: 0.55; } + to { opacity: 1; } + } + .broken { + height: 100%; + display: grid; + place-content: center; + justify-items: center; + gap: var(--sp-3); + padding: var(--sp-6); + text-align: center; + } + .broken .glyph { + color: var(--danger); + transform: scale(1.6); + margin-bottom: var(--sp-2); + } + .broken h2 { + font-size: var(--text-lg); + font-weight: var(--w-semibold); + margin: 0; + } + .broken p { + margin: 0; + max-width: 44ch; + color: var(--ink-muted); + text-wrap: pretty; + } + .broken .detail { + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--danger); + background: var(--danger-tint); + padding: 2px 10px; + border-radius: var(--r-full); + } + `, + ]; + + override render() { + if (this.doc?.damaged) { + return html``; + } + return html` + ${this.phase === 'loading' + ? html`
+
+ ${[92, 100, 96, 74].map( + (w, i) => html`
`, + )} +
` + : nothing} + ${this.phase === 'error' + ? html`` + : nothing} +
+ `; + } + + protected override updated() { + if (this.doc && !this.doc.damaged && this.#loadedFor !== this.doc.id) this.#load(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.doc) dataHub.unregister(this.doc.id); + } + + async #load() { + const mount = this.renderRoot.querySelector('.mount'); + if (!mount || !desk.tileServerPort) return; + const token = ++this.#token; + this.#loadedFor = this.doc.id; + this.phase = 'loading'; + mothership.tileServerPort = desk.tileServerPort; + try { + // Keyed by doc id only: self-saves bump mtime but must never reload + // the live editor out from under the user. + const tile = await mothership.loadTile(`${location.origin}/api/docs/${this.doc.id}/bundle.tile`); + if (token !== this.#token) return; + if (!tile) throw new Error('no loader could handle this tile'); + const ifr = tile.renderContent(Math.max(this.getBoundingClientRect().height, 240)); + ifr.style.height = '100%'; + ifr.style.width = '100%'; + mount.replaceChildren(ifr); + dataHub.register(this.doc.id, ifr as HTMLIFrameElement); + this.phase = 'ready'; + } catch (err) { + if (token !== this.#token) return; + this.#loadedFor = ''; + this.phase = 'error'; + this.error = (err as Error).message; + mount.replaceChildren(); + } + } +} + +customElements.define('zn-doc-canvas', ZnDocCanvas); + +declare global { + interface HTMLElementTagNameMap { + 'zn-doc-canvas': ZnDocCanvas; + } +} diff --git a/src/components/zn-doc-window.ts b/src/components/zn-doc-window.ts new file mode 100644 index 0000000..72ff9ed --- /dev/null +++ b/src/components/zn-doc-window.ts @@ -0,0 +1,672 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { baseStyles } from '../styles/shared.ts'; +import type { DocWindowRec, Pane } from '../types.ts'; +import { desk } from '../state/desk.ts'; +import { docIconUrl } from '../state/backend.ts'; +import { icons } from './icons.ts'; +import './zn-connections.ts'; +import './zn-doc-canvas.ts'; + +/** The content of one real OS document window. The top banner always shows + the active document (icon, editable title, facts, tile chip, actions); + when a pane holds several documents they stack as a vertical tab rail on + the left. Geometry belongs to the OS; layout state to the server. */ +export class ZnDocWindow extends LitElement { + static override properties = { + winId: { type: String }, + menuTab: { state: true }, + focusPaneId: { state: true }, + renaming: { state: true }, + }; + + declare winId: string; + declare private menuTab: { paneId: string; docId: string } | undefined; + declare private focusPaneId: string | undefined; + declare private renaming: boolean; + + constructor() { + super(); + this.winId = ''; + this.menuTab = undefined; + this.focusPaneId = undefined; + this.renaming = false; + } + + private onStore = () => this.requestUpdate(); + + override connectedCallback() { + super.connectedCallback(); + desk.addEventListener('change', this.onStore); + } + + override disconnectedCallback() { + desk.removeEventListener('change', this.onStore); + super.disconnectedCallback(); + } + + static override styles = [ + baseStyles, + css` + :host { + display: flex; + flex-direction: column; + height: 100dvh; + background: var(--surface); + } + .bar { + display: flex; + align-items: center; + gap: var(--sp-2); + height: 42px; + padding: 0 var(--sp-2) 0 var(--sp-3); + background: var(--panel); + border-bottom: 1px solid var(--line-soft); + user-select: none; + flex: none; + min-width: 0; + } + .tile-ico { + width: 18px; + height: 18px; + border-radius: 4px; + display: block; + flex: none; + } + .tile-ico.fallback { + display: inline-grid; + place-items: center; + color: var(--ink-muted); + } + .bar .title { + font-weight: var(--w-semibold); + font-size: var(--text-ui); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + padding: 3px 8px; + margin: -3px -8px; + border-radius: var(--r-sm); + cursor: text; + } + .bar .title:hover { + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--line); + } + .bar .title-input { + font: inherit; + font-weight: var(--w-semibold); + font-size: var(--text-ui); + color: var(--ink); + border: none; + background: var(--surface); + border-radius: var(--r-sm); + padding: 3px 8px; + width: min(320px, 40vw); + } + .bar .title-input:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .bar .spacer { + flex: 1; + } + .icon-btn { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: none; + background: none; + border-radius: var(--r-sm); + color: var(--ink-muted); + cursor: pointer; + position: relative; + transition: background var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out); + } + .icon-btn::before { + content: ''; + position: absolute; + inset: -8px; + } + .icon-btn:hover { + background: var(--panel-deep); + color: var(--ink); + } + .icon-btn:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .body { + flex: 1; + display: flex; + min-height: 0; + } + .pane { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + } + /* vertical tab rail */ + .rail { + width: 184px; + flex: none; + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--sp-2); + background: var(--panel); + border-right: 1px solid var(--line-soft); + overflow-y: auto; + scrollbar-width: thin; + } + .tab { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + min-width: 0; + padding: 7px 8px; + border: none; + border-radius: var(--r-sm); + background: none; + font: inherit; + font-size: var(--text-sm); + color: var(--ink-muted); + cursor: pointer; + text-align: left; + transition: background var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out); + } + .tab .tile-ico { + width: 16px; + height: 16px; + border-radius: 4px; + } + .tab .label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tab:hover { + background: var(--panel-deep); + color: var(--ink); + } + .tab:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + .tab[aria-selected='true'] { + background: var(--surface); + color: var(--ink); + font-weight: var(--w-medium); + box-shadow: var(--shadow-window); + } + .tab .close { + display: grid; + place-items: center; + width: 16px; + height: 16px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--ink-muted); + cursor: pointer; + opacity: 0; + transform: scale(0.8); + transition: opacity var(--t-fast) var(--ease-out); + flex: none; + } + .tab:hover .close, + .tab .close:focus-visible, + .tab[aria-selected='true'] .close { + opacity: 1; + } + .tab .close:hover { + color: var(--ink); + background: var(--line-soft); + } + .pane-title { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 0 var(--sp-2); + background: var(--panel); + border-bottom: 1px solid var(--line-soft); + height: 30px; + flex: none; + font-size: var(--text-sm); + font-weight: var(--w-medium); + color: var(--ink-muted); + min-width: 0; + } + .pane-title span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .pane-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + } + .canvas-host { + flex: 1; + min-height: 0; + position: relative; + } + .divider { + flex: none; + width: 5px; + cursor: col-resize; + background: var(--line-soft); + border-inline: 1px solid var(--line-soft); + touch-action: none; + transition: background var(--t-fast) var(--ease-out); + } + .divider:hover, + .divider.active { + background: var(--signal); + } + .divider:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -1px; + background: var(--signal); + } + .gone { + height: 100%; + display: grid; + place-content: center; + color: var(--ink-muted); + } + .menu { + position: fixed; + margin: 0; + padding: var(--sp-1); + min-width: 240px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-pop); + } + .menu button { + display: block; + width: 100%; + text-align: left; + font: inherit; + font-size: var(--text-sm); + color: var(--ink); + background: none; + border: none; + border-radius: var(--r-sm); + padding: 7px 10px; + cursor: pointer; + } + .menu button:hover { + background: var(--panel); + } + .menu button:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + .menu hr { + border: none; + border-top: 1px solid var(--line-soft); + margin: var(--sp-1) 0; + } + `, + ]; + + private rec(): DocWindowRec | undefined { + return desk.windows.find((w) => w.id === this.winId); + } + + /** The pane whose active doc owns the banner. */ + private focusPane(rec: DocWindowRec): Pane { + return rec.panes.find((p) => p.id === this.focusPaneId) ?? rec.panes[0]!; + } + + private tileIcon(docId: string, size = 18) { + const doc = desk.getDoc(docId); + const url = doc ? docIconUrl(doc) : undefined; + return url + ? html`` + : html`${icons.tile}`; + } + + override render() { + const rec = this.rec(); + if (!rec) { + return html`
${desk.connected ? 'This window has closed.' : 'Connecting…'}
`; + } + const activeDoc = desk.getDoc(this.focusPane(rec).active); + return html` +
+ ${activeDoc + ? html` + ${this.tileIcon(activeDoc.id)} + ${this.renaming + ? html` this.commitRename((e.target as HTMLInputElement).value)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + if (e.key === 'Escape') this.renaming = false; + }} + />` + : html` this.beginRename()} + >${activeDoc.name}`} + ` + : nothing} + + ${activeDoc ? html`` : nothing} + +
+
+ ${rec.panes.map((pane, i) => { + const parts = [this.renderPane(rec, pane)]; + if (i === 0 && rec.panes.length === 2) parts.push(this.renderDivider(rec)); + return parts; + })} +
+ + + `; + } + + protected override updated() { + const rec = this.rec(); + const doc = rec ? desk.getDoc(this.focusPane(rec).active) : undefined; + document.title = doc ? `${doc.name} — ziran` : 'ziran'; + if (this.renaming) { + const input = this.renderRoot.querySelector('.title-input'); + if (input && document.activeElement !== input && this.shadowRoot?.activeElement !== input) { + input.select(); + } + } + } + + private renderPane(rec: DocWindowRec, pane: Pane) { + const doc = desk.getDoc(pane.active); + const split = rec.panes.length === 2; + const flexBasis = split + ? `flex:0 0 calc(${(rec.panes[0] === pane ? rec.ratio : 1 - rec.ratio) * 100}% - 3px)` + : 'flex:1'; + const showPaneTitle = split && pane.tabs.length === 1 && pane !== this.focusPane(rec); + return html` +
this.focusOn(pane)}> + ${pane.tabs.length > 1 + ? html` +
+ ${pane.tabs.map((t) => this.renderTab(pane, t))} +
+ ` + : nothing} +
+ ${showPaneTitle + ? html`
+ ${this.tileIcon(pane.active, 14)} + ${doc?.name ?? ''} +
` + : nothing} +
1 ? 'tabpanel' : nothing} + aria-labelledby=${pane.tabs.length > 1 ? `tab-${pane.id}-${pane.active}` : nothing} + > + ${doc ? html`` : nothing} +
+
+
+ `; + } + + private renderTab(pane: Pane, docId: string) { + const doc = desk.getDoc(docId); + if (!doc) return nothing; + const selected = pane.active === docId; + return html` + + `; + } + + private renderDivider(rec: DocWindowRec) { + const pct = Math.round(rec.ratio * 100); + return html``; + } + + private renderMenu(rec: DocWindowRec) { + const activeDoc = desk.getDoc(this.focusPane(rec).active); + const canSplitWithin = rec.panes.length === 1 && (rec.panes[0]?.tabs.length ?? 0) >= 2; + const others = desk.windows.filter((x) => x.id !== rec.id); + const titleOf = (x: DocWindowRec) => desk.getDoc(x.panes[0]?.active ?? '')?.name ?? 'window'; + return html` + ${activeDoc + ? html` + + ` + : nothing} + ${canSplitWithin + ? html`` + : nothing} + ${others.length || canSplitWithin || activeDoc ? html`
` : nothing} + ${others.map( + (o) => html` + + ${o.panes.length === 1 + ? html`` + : nothing} + `, + )} + ${others.length ? html`
` : nothing} + + `; + } + + private renderTabMenu(rec: DocWindowRec) { + const t = this.menuTab; + if (!t) return nothing; + const doc = desk.getDoc(t.docId); + if (!doc) return nothing; + const others = desk.windows.filter((x) => x.id !== rec.id); + const titleOf = (x: DocWindowRec) => desk.getDoc(x.panes[0]?.active ?? '')?.name ?? 'window'; + return html` + + ${others.map( + (o) => html` + + `, + )} +
+ + `; + } + + private focusOn(pane: Pane) { + if (this.focusPaneId !== pane.id) { + this.renaming = false; + this.focusPaneId = pane.id; + } + } + + private tabKey(e: KeyboardEvent, pane: Pane, docId: string) { + const idx = pane.tabs.indexOf(docId); + const delta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0; + if (delta) { + e.preventDefault(); + const next = pane.tabs[(idx + delta + pane.tabs.length) % pane.tabs.length]!; + desk.activateTab(pane.id, next); + this.updateComplete.then(() => { + this.renderRoot.querySelector('.tab[aria-selected="true"]')?.focus(); + }); + } else if (e.key === 'w' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + desk.closeTab(pane.id, docId); + } + } + + private startDividerDrag(e: PointerEvent) { + e.preventDefault(); + const divider = e.currentTarget as HTMLElement; + divider.classList.add('active'); + const body = this.renderRoot.querySelector('.body') as HTMLElement; + const move = (ev: PointerEvent) => { + const rect = body.getBoundingClientRect(); + desk.setRatio((ev.clientX - rect.left) / rect.width); + }; + const up = () => { + divider.classList.remove('active'); + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + } + + private openMenu(e: Event) { + const btn = e.currentTarget as HTMLElement; + const menu = this.renderRoot.querySelector('.menu:not(.tabmenu)')!; + const r = btn.getBoundingClientRect(); + menu.style.left = `${Math.max(8, Math.min(r.right - 240, innerWidth - 248))}px`; + menu.style.top = `${r.bottom + 6}px`; + menu.showPopover(); + menu.querySelector('button')?.focus(); + } + + private menuAction(fn: () => void) { + this.renderRoot.querySelector('.menu:not(.tabmenu)')?.hidePopover(); + fn(); + } + + private openTabMenu(e: MouseEvent, pane: Pane, docId: string) { + e.preventDefault(); + this.menuTab = { paneId: pane.id, docId }; + this.updateComplete.then(() => { + const menu = this.renderRoot.querySelector('.tabmenu')!; + // Overlap the cursor slightly so the right-click's own pointerup lands + // inside the popover instead of light-dismissing it. + menu.style.left = `${Math.max(8, Math.min(e.clientX - 6, innerWidth - 248))}px`; + menu.style.top = `${Math.max(8, Math.min(e.clientY - 6, innerHeight - 200))}px`; + menu.showPopover(); + menu.querySelector('button')?.focus(); + }); + } + + private tabMenuAction(fn: () => void) { + this.renderRoot.querySelector('.tabmenu')?.hidePopover(); + this.menuTab = undefined; + fn(); + } + + private beginRename() { + const rec = this.rec(); + if (!rec) return; + if (!desk.getDoc(this.focusPane(rec).active)) return; + this.renaming = true; + } + + private commitRename(value: string) { + const rec = this.rec(); + this.renaming = false; + if (!rec) return; + const doc = desk.getDoc(this.focusPane(rec).active); + if (doc) desk.rename(doc.id, value); + } +} + +customElements.define('zn-doc-window', ZnDocWindow); + +declare global { + interface HTMLElementTagNameMap { + 'zn-doc-window': ZnDocWindow; + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1efcafb --- /dev/null +++ b/src/main.ts @@ -0,0 +1 @@ +import './components/zn-app.ts'; diff --git a/src/server/api.ts b/src/server/api.ts new file mode 100644 index 0000000..4086eae --- /dev/null +++ b/src/server/api.ts @@ -0,0 +1,249 @@ +// HTTP + WebSocket API. All UI↔backend IPC goes through here; no Deno Desktop +// bindings exist anywhere (they leak into cross-origin tile frames). + +import { fromFileUrl, join } from '@std/path'; +import mime from 'mime'; +import { parseTile, readResource, readTileData, renameTile, writeTileData } from './tilefile.ts'; +import { pickOpen, pickSave, revealInFolder } from './dialogs.ts'; +import type { ZiranStore } from './store.ts'; + +const staticRoots = [ + join(Deno.cwd(), 'static'), + fromFileUrl(new URL('../../static/', import.meta.url)), +]; + +async function serveStatic(pathname: string): Promise { + const rel = pathname === '/' ? '/index.html' : pathname; + if (rel.includes('..')) return undefined; + for (const root of staticRoots) { + try { + const bytes = await Deno.readFile(join(root, rel)); + return new Response(bytes as BodyInit, { + headers: { 'content-type': mime.getType(rel) ?? 'application/octet-stream' }, + }); + } catch { + // next root + } + } + return undefined; +} + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); +const err = (message: string, status = 400) => json({ error: message }, status); + +function iconResponse(tileLike: Parameters[0]): Response { + const icon = (tileLike.manifest.model?.icons ?? tileLike.manifest.icons ?? [])[0]; + if (!icon) return err('no icon', 404); + const bytes = readResource(tileLike, icon.src); + if (!bytes) return err('icon resource missing', 404); + const entry = tileLike.manifest.resources[icon.src] as Record | undefined; + const type = entry?.['content-type'] ?? mime.getType(icon.src) ?? 'application/octet-stream'; + return new Response(bytes as BodyInit, { + headers: { 'content-type': type, 'cache-control': 'no-cache' }, + }); +} + +export function createHandler(store: ZiranStore): (req: Request) => Promise { + return async (req: Request): Promise => { + const url = new URL(req.url); + const { pathname } = url; + + // The API is for the shell UI only. Tile frames run on *.localhost + // origins; their fetches are intercepted by the tile service worker, but + // WebSocket (and any SW-registration failure) would reach us directly — + // so every /api request with a cross-origin Origin header is refused. + if (pathname.startsWith('/api/')) { + const origin = req.headers.get('origin'); + if (origin && origin !== url.origin) return err('forbidden', 403); + } + + if (pathname === '/api/events' && req.headers.get('upgrade') === 'websocket') { + const { socket, response } = Deno.upgradeWebSocket(req); + store.addSocket(socket, url.searchParams.get('win') ?? undefined); + return response; + } + + try { + if (pathname === '/api/state') return json(store.snapshot()); + + if (pathname === '/api/pick') return json(await pickOpen()); + if (pathname === '/api/pick-save') { + return json(await pickSave(url.searchParams.get('name') ?? 'document.tile')); + } + + // Opening a document also gives it a window (unless it already has one, + // in which case that window is the answer). A record whose page has no + // live socket and which is past its boot window is a zombie — its OS + // window was closed and the disconnect grace hasn't fired yet — so it + // is replaced rather than pointed at. + const windowed = (info: Awaited>) => { + const existing = store.windows.find((w) => w.panes.some((p) => p.tabs.includes(info.id))); + if (existing) { + const booting = Date.now() - existing.createdAt < 5000; + if (store.hasLiveSockets(existing.id) || booting) { + for (const p of existing.panes) if (p.tabs.includes(info.id)) p.active = info.id; + store.broadcast(); + return json({ doc: info, win: existing, url: store.windowUrl(existing.id), existing: true }); + } + // Replace the zombie: create the new window first so the document + // stays referenced while the stale record is dropped. + const win = store.createWindow([info.id]); + store.closeWindow(existing.id); + return json({ doc: info, win, url: store.windowUrl(win.id), existing: false }); + } + const win = store.createWindow([info.id]); + return json({ doc: info, win, url: store.windowUrl(win.id), existing: false }); + }; + + if (pathname === '/api/docs/open' && req.method === 'POST') { + const { path } = await req.json(); + if (typeof path !== 'string' || !path) return err('path required'); + return windowed(await store.openPath(path)); + } + + if (pathname === '/api/docs/from-model' && req.method === 'POST') { + const { modelId } = await req.json(); + return windowed(await store.createFromModel(modelId)); + } + + if (pathname === '/api/import' && req.method === 'POST') { + const name = url.searchParams.get('name') ?? 'imported.tile'; + const bytes = new Uint8Array(await req.arrayBuffer()); + if (!bytes.length) return err('empty import'); + return windowed(await store.importBytes(name, bytes)); + } + + const winBounds = pathname.match(/^\/api\/windows\/([\w-]+)\/bounds$/); + if (winBounds && req.method === 'POST') { + const b = await req.json(); + if ([b.x, b.y, b.w, b.h].every((n: unknown) => Number.isFinite(n))) { + store.updateBounds(winBounds[1]!, { x: b.x, y: b.y, w: b.w, h: b.h }); + } + return json({ ok: true }); + } + + const winOp = pathname.match(/^\/api\/windows\/([\w-]+)\/op$/); + if (winOp && req.method === 'POST') { + const winId = winOp[1]!; + const body = await req.json(); + switch (body.op) { + case 'activate': + store.activateTab(winId, body.paneId, body.docId); + break; + case 'close-tab': + store.closeTab(winId, body.paneId, body.docId); + break; + case 'close': + store.closeWindow(winId); + break; + case 'move-tab': + store.moveTab(winId, body.paneId, body.docId, body.dstWinId, body.dstPaneId); + break; + case 'merge': + store.mergeWindows(winId, body.dstWinId); + break; + case 'split-merge': + store.splitMerge(winId, body.dstWinId, body.side === 'left' ? 'left' : 'right'); + break; + case 'split-within': + store.splitWithin(winId); + break; + case 'tear-off': { + const win = store.tearOff(winId, body.paneId, body.docId); + return win ? json({ win, url: store.windowUrl(win.id) }) : err('cannot tear off'); + } + case 'ratio': + store.setRatio(winId, Number(body.value)); + break; + default: + return err(`unknown op ${body.op}`); + } + return json({ ok: true }); + } + + const recentIcon = pathname.match(/^\/api\/recents\/([0-9a-f]+)\/icon$/); + if (recentIcon) { + const entry = store.recents.find((r) => r.id === recentIcon[1]); + if (!entry) return err('unknown recent', 404); + return iconResponse(await parseTile(entry.path, { verify: false })); + } + + const recentDelete = pathname.match(/^\/api\/recents\/([0-9a-f]+)$/); + if (recentDelete && req.method === 'DELETE') { + await store.removeRecent(recentDelete[1]!); + return json({ ok: true }); + } + + if (pathname === '/api/reveal' && req.method === 'POST') { + const { path } = await req.json(); + if (typeof path !== 'string' || !path) return err('path required'); + const known = store.recents.some((r) => r.path === path) || + [...store.open.values()].some((d) => d.info.path === path); + if (!known) return err('unknown path', 404); + return json({ ok: await revealInFolder(path) }); + } + + const docMatch = pathname.match(/^\/api\/docs\/([0-9a-f]+)\/(.+)$/); + if (docMatch) { + const [, id, rest] = docMatch; + const doc = store.doc(id!); + if (!doc) return err('unknown document', 404); + + if (rest === 'bundle.tile') { + return new Response(doc.tile.bytes as BodyInit, { + headers: { 'content-type': 'application/vnd.ipld.car', 'cache-control': 'no-cache' }, + }); + } + if (rest === 'icon') return iconResponse(doc.tile); + if (rest === 'rename' && req.method === 'POST') { + const { name } = await req.json(); + if (typeof name !== 'string' || !name.trim()) return err('name required'); + await renameTile(doc.info.path, name); + return json(await store.refresh(id!)); + } + if (rest === 'move' && req.method === 'POST') { + const { dest } = await req.json(); + if (typeof dest !== 'string' || !dest.trim()) return err('dest required'); + return json(await store.moveDoc(id!, dest)); + } + const dataMatch = rest!.match(/^data\/([\w.-]+)$/); + if (dataMatch) { + const name = dataMatch[1]!; + if (req.method === 'PUT') { + await writeTileData(doc.info.path, name, new Uint8Array(await req.arrayBuffer())); + await store.refresh(id!); + return json({ ok: true }); + } + const bytes = await readTileData(doc.info.path, name); + if (!bytes) return err('no data', 404); + return new Response(bytes as BodyInit, { + headers: { 'content-type': 'application/octet-stream' }, + }); + } + } + + const modelFrom = pathname.match(/^\/api\/models\/from\/([0-9a-f]+)$/); + if (modelFrom && req.method === 'POST') { + return json(await store.saveAsModel(modelFrom[1]!)); + } + const modelMatch = pathname.match(/^\/api\/models\/([^/]+)\/(icon)$/); + if (modelMatch) { + const model = store.modelById(decodeURIComponent(modelMatch[1]!)); + if (!model) return err('unknown model', 404); + return iconResponse(await parseTile(model.path, { verify: false })); + } + const modelDelete = pathname.match(/^\/api\/models\/([^/]+)$/); + if (modelDelete && req.method === 'DELETE') { + await store.removeModel(decodeURIComponent(modelDelete[1]!)); + return json({ ok: true }); + } + + if (pathname.startsWith('/api/')) return err('not found', 404); + } catch (e) { + return err((e as Error).message, 500); + } + + return (await serveStatic(pathname)) ?? new Response('not found', { status: 404 }); + }; +} diff --git a/src/server/dialogs.ts b/src/server/dialogs.ts new file mode 100644 index 0000000..e9253f2 --- /dev/null +++ b/src/server/dialogs.ts @@ -0,0 +1,119 @@ +// Native file dialogs without a windowing toolkit: shell out to what each +// platform ships. macOS: osascript. Windows: PowerShell + WinForms. Linux: +// zenity, then kdialog. Anything else reports unsupported and the UI falls +// back to asking for a path. + +export type DialogResult = { path: string } | { cancelled: true } | { unsupported: true }; + +async function run(cmd: string, args: string[]): Promise { + try { + const out = await new Deno.Command(cmd, { args }).output(); + if (!out.success) return undefined; + const text = new TextDecoder().decode(out.stdout).trim(); + return text || undefined; + } catch { + return undefined; + } +} + +const psQuote = (s: string) => `'${s.replace(/'/g, "''")}'`; + +export async function pickOpen(): Promise { + switch (Deno.build.os) { + case 'darwin': { + // No `of type` filter: it expects UTIs, not extensions, and greys out + // every .tile on systems where the UTI isn't registered (dev mode). + const path = await run('osascript', [ + '-e', + 'POSIX path of (choose file with prompt "Open a tile")', + ]); + return path ? { path } : { cancelled: true }; + } + case 'windows': { + const script = + `Add-Type -AssemblyName System.Windows.Forms; ` + + `$f = New-Object System.Windows.Forms.OpenFileDialog; ` + + `$f.Filter = 'Tiles (*.tile)|*.tile|All files (*.*)|*.*'; ` + + `if ($f.ShowDialog() -eq 'OK') { Write-Output $f.FileName }`; + const path = await run('powershell', ['-NoProfile', '-Command', script]); + return path ? { path } : { cancelled: true }; + } + case 'linux': { + const zenity = await run('zenity', ['--file-selection', '--title=Open a tile', '--file-filter=*.tile']); + if (zenity) return { path: zenity }; + const kdialog = await run('kdialog', ['--getopenfilename', '.', '*.tile']); + if (kdialog) return { path: kdialog }; + return { unsupported: true }; + } + default: + return { unsupported: true }; + } +} + +/** Show a file in the platform's file manager, selected when possible. */ +export async function revealInFolder(path: string): Promise { + switch (Deno.build.os) { + case 'darwin': + return (await run('open', ['-R', path])) !== undefined || + (await new Deno.Command('open', { args: ['-R', path] }).output()).success; + case 'windows': + // explorer exits non-zero even on success; fire and report true. + await new Deno.Command('explorer', { args: [`/select,${path}`] }).output().catch(() => {}); + return true; + case 'linux': { + const dbus = await new Deno.Command('dbus-send', { + args: [ + '--session', + '--dest=org.freedesktop.FileManager1', + '--type=method_call', + '/org/freedesktop/FileManager1', + 'org.freedesktop.FileManager1.ShowItems', + `array:string:file://${path}`, + 'string:', + ], + }).output().catch(() => ({ success: false })); + if (dbus.success) return true; + const dir = path.replace(/\/[^/]+$/, ''); + return (await new Deno.Command('xdg-open', { args: [dir] }).output().catch(() => ({ success: false }))).success; + } + default: + return false; + } +} + +export async function pickSave(defaultName: string): Promise { + const name = defaultName.replace(/[\\/:"<>|?*\n]/g, '-') || 'document.tile'; + switch (Deno.build.os) { + case 'darwin': { + const path = await run('osascript', [ + '-e', + `POSIX path of (choose file name with prompt "Move tile to" default name "${name.replace(/"/g, '\\"')}")`, + ]); + return path ? { path } : { cancelled: true }; + } + case 'windows': { + const script = + `Add-Type -AssemblyName System.Windows.Forms; ` + + `$f = New-Object System.Windows.Forms.SaveFileDialog; ` + + `$f.FileName = ${psQuote(name)}; ` + + `$f.Filter = 'Tiles (*.tile)|*.tile|All files (*.*)|*.*'; ` + + `if ($f.ShowDialog() -eq 'OK') { Write-Output $f.FileName }`; + const path = await run('powershell', ['-NoProfile', '-Command', script]); + return path ? { path } : { cancelled: true }; + } + case 'linux': { + const zenity = await run('zenity', [ + '--file-selection', + '--save', + '--title=Move tile to', + `--filename=${name}`, + ]); + if (zenity) return { path: zenity }; + const kdialog = await run('kdialog', ['--getsavefilename', name]); + if (kdialog) return { path: kdialog }; + return { unsupported: true }; + } + default: + return { unsupported: true }; + } +} diff --git a/src/server/paths.ts b/src/server/paths.ts new file mode 100644 index 0000000..90e659c --- /dev/null +++ b/src/server/paths.ts @@ -0,0 +1,33 @@ +// Per-user application directories, Tenet-style: everything lives under one +// app-data root keyed by the bundle identifier. ZIRAN_DATA_DIR overrides the +// root for tests and throwaway profiles. + +import { join } from '@std/path'; + +const IDENTIFIER = 'com.berjon.ziran'; + +export function appDataDir(): string { + const override = Deno.env.get('ZIRAN_DATA_DIR'); + if (override) return override; + const home = Deno.env.get('HOME') ?? '.'; + switch (Deno.build.os) { + case 'darwin': + return join(home, 'Library', 'Application Support', IDENTIFIER); + case 'windows': + return join(Deno.env.get('APPDATA') ?? join(home, 'AppData', 'Roaming'), IDENTIFIER); + default: + return join(Deno.env.get('XDG_DATA_HOME') ?? join(home, '.local', 'share'), IDENTIFIER); + } +} + +export const documentsDir = () => join(appDataDir(), 'documents'); +export const modelsDir = () => join(appDataDir(), 'models'); +export const importsDir = () => join(appDataDir(), 'imports'); +export const recentsFile = () => join(appDataDir(), 'recents.json'); +export const sessionFile = () => join(appDataDir(), 'session.json'); + +export async function ensureDirs(): Promise { + for (const dir of [documentsDir(), modelsDir(), importsDir()]) { + await Deno.mkdir(dir, { recursive: true }); + } +} diff --git a/src/server/register.ts b/src/server/register.ts new file mode 100644 index 0000000..4aee20f --- /dev/null +++ b/src/server/register.ts @@ -0,0 +1,96 @@ +// First-run file-type registration for platforms where it can't be baked +// into the build: Windows (HKCU registry) and Linux (XDG mime + desktop +// entry). macOS associations are declared in the app bundle's Info.plist at +// build time (tools/mac-bundle.ts). Everything is best-effort and marked +// done in the app-data dir so it runs once. + +import { join } from '@std/path'; +import { appDataDir } from './paths.ts'; + +const MARKER = () => join(appDataDir(), '.filetypes-registered'); +const PROG_ID = 'Ziran.Tile'; +const MIME = 'application/web-tile'; + +async function run(cmd: string, args: string[]): Promise { + try { + return (await new Deno.Command(cmd, { args }).output()).success; + } catch { + return false; + } +} + +async function registerWindows(exe: string): Promise { + const reg = (args: string[]) => run('reg', ['add', ...args, '/f']); + await reg([`HKCU\\Software\\Classes\\.tile`, '/ve', '/d', PROG_ID]); + await reg([`HKCU\\Software\\Classes\\.tile`, '/v', 'Content Type', '/d', MIME]); + await reg([`HKCU\\Software\\Classes\\MIME\\Database\\Content Type\\${MIME}`, '/v', 'Extension', '/d', '.tile']); + await reg([`HKCU\\Software\\Classes\\${PROG_ID}`, '/ve', '/d', 'DASL Web Tile']); + await reg([`HKCU\\Software\\Classes\\${PROG_ID}\\DefaultIcon`, '/ve', '/d', `"${exe}",0`]); + await reg([`HKCU\\Software\\Classes\\${PROG_ID}\\shell\\open\\command`, '/ve', '/d', `"${exe}" "%1"`]); +} + +async function registerLinux(exe: string): Promise { + const home = Deno.env.get('HOME') ?? '.'; + const dataHome = Deno.env.get('XDG_DATA_HOME') ?? join(home, '.local', 'share'); + const apps = join(dataHome, 'applications'); + const mimeDir = join(dataHome, 'mime', 'packages'); + await Deno.mkdir(apps, { recursive: true }); + await Deno.mkdir(mimeDir, { recursive: true }); + + await Deno.writeTextFile( + join(mimeDir, 'com.berjon.ziran.xml'), + ` + + + DASL Web Tile + + + + +`, + ); + await Deno.writeTextFile( + join(apps, 'com.berjon.ziran.desktop'), + `[Desktop Entry] +Type=Application +Name=Ziran +Comment=A local-first document environment built on self-editing tiles +Exec="${exe}" %f +Icon=com.berjon.ziran +Terminal=false +Categories=Office; +MimeType=${MIME}; +`, + ); + // Icon (shipped in the compiled app's VFS under build/). + try { + const icon = new URL('../../build/icon-256.png', import.meta.url).pathname; + await Deno.stat(icon); + await run('xdg-icon-resource', ['install', '--novendor', '--size', '256', icon, 'com.berjon.ziran']); + } catch { + // no icon available; the desktop entry still works + } + await run('update-mime-database', [join(dataHome, 'mime')]); + await run('update-desktop-database', [apps]); + await run('xdg-mime', ['default', 'com.berjon.ziran.desktop', MIME]); +} + +/** Run once per profile; never throws. */ +export async function registerFileTypes(): Promise { + if (Deno.build.os === 'darwin') return; // baked into the bundle at build time + try { + await Deno.stat(MARKER()); + return; // already registered + } catch { + // first run + } + try { + const exe = Deno.execPath(); + if (Deno.build.os === 'windows') await registerWindows(exe); + else if (Deno.build.os === 'linux') await registerLinux(exe); + await Deno.writeTextFile(MARKER(), new Date().toISOString()); + console.log('registered .tile file type for this user'); + } catch (err) { + console.warn('file-type registration failed:', (err as Error).message); + } +} diff --git a/src/server/store.ts b/src/server/store.ts new file mode 100644 index 0000000..d6d937c --- /dev/null +++ b/src/server/store.ts @@ -0,0 +1,583 @@ +// Server-side state: recents, models, open documents, session, and the +// WebSocket broadcast. Models are a directory scan (no index) per Tenet; +// recents are ziran's own (Tenet has none, but ziran's home base is built +// around resumption). + +import { basename, dirname, join } from '@std/path'; +import type { Bounds, DeskSession, DocWindowRec, Pane } from '../types.ts'; +import { documentsDir, ensureDirs, importsDir, modelsDir, recentsFile, sessionFile } from './paths.ts'; +import { + docIdForPath, + type MaslIcon, + type ParsedTile, + parseTile, + readResource, + safeModelStem, + writeModelTile, +} from './tilefile.ts'; + +export interface DocInfo { + id: string; + path: string; + name: string; + damaged: boolean; + damage?: string; + hasIcon: boolean; + modelName?: string; + modelId?: string; + size: number; + mtime: number; + fingerprint: string; +} + +export interface RecentEntry extends DocInfo { + lastOpened: number; + /** Last known geometry of this document's window; reused on reopen. */ + bounds?: Bounds; +} + +export interface ModelEntry { + id: string; + stem: string; + path: string; + name: string; + description?: string; + hasIcon: boolean; +} + +interface OpenDoc { + info: DocInfo; + tile: ParsedTile; +} + +function firstIcon(tile: ParsedTile): MaslIcon | undefined { + return (tile.manifest.model?.icons ?? tile.manifest.icons ?? [])[0]; +} + +async function docInfo(path: string, tile: ParsedTile): Promise { + return { + id: await docIdForPath(path), + path, + name: tile.manifest.name ?? basename(path, '.tile'), + damaged: tile.damaged, + damage: tile.damage, + hasIcon: Boolean(firstIcon(tile) && readResource(tile, firstIcon(tile)!.src)), + modelName: tile.manifest.model?.name, + modelId: tile.manifest.model?.id, + size: tile.size, + mtime: tile.mtime, + fingerprint: tile.fingerprint, + }; +} + +export class ZiranStore { + recents: RecentEntry[] = []; + models: ModelEntry[] = []; + open = new Map(); + /** Real OS document windows' content layout. */ + windows: DocWindowRec[] = []; + tileServerPort = 0; + appPort = 0; + desktop = false; + /** Opens a real OS window for a document-window URL (set by main.ts). */ + openOsWindow: ((winId: string, url: string, title: string, bounds?: Bounds) => boolean) | undefined; + /** Last reported geometry of the base window. */ + mainBounds: Bounds | undefined; + + #sockets = new Set(); + #winSockets = new Map>(); + #winSeq = 0; + #paneSeq = 0; + #session: DeskSession | null = null; + #sessionTimer: number | undefined; + + async load(): Promise { + await ensureDirs(); + try { + this.recents = JSON.parse(await Deno.readTextFile(recentsFile())); + } catch { + this.recents = []; + } + try { + this.#session = JSON.parse(await Deno.readTextFile(sessionFile())); + } catch { + this.#session = null; + } + await this.seedStarterModels(); + await this.scanModels(); + } + + /* ——— windows (real OS windows; the server owns their content layout) ——— */ + + windowUrl(winId: string): string { + return `http://127.0.0.1:${this.appPort}/?win=${winId}`; + } + + findWindow(winId: string): DocWindowRec | undefined { + return this.windows.find((w) => w.id === winId); + } + + private newPane(tabs: string[], active?: string): Pane { + return { id: `pane-${++this.#paneSeq}`, tabs, active: active ?? tabs[tabs.length - 1] ?? '' }; + } + + /** True when at least one page is currently rendering this window. */ + hasLiveSockets(winId: string): boolean { + return (this.#winSockets.get(winId)?.size ?? 0) > 0; + } + + /** Create a document-window record and (in desktop mode) its OS window. + With no explicit bounds, a single-doc window reuses the geometry its + document last had (remembered on the recents entry). */ + createWindow(docIds: string[], { spawn = true, bounds }: { spawn?: boolean; bounds?: Bounds } = {}): DocWindowRec { + if (!bounds && docIds.length === 1) { + bounds = this.recents.find((r) => r.id === docIds[0])?.bounds; + } + const rec: DocWindowRec = { + id: `win-${++this.#winSeq}`, + panes: [this.newPane([...docIds])], + ratio: 0.5, + bounds, + createdAt: Date.now(), + }; + this.windows.push(rec); + if (spawn && this.openOsWindow) { + const title = this.open.get(docIds[0] ?? '')?.info.name ?? 'ziran'; + this.openOsWindow(rec.id, this.windowUrl(rec.id), title, bounds); + } + this.afterLayoutChange(); + return rec; + } + + /** Geometry reported by a window page ('base' or a win id). Persisted via + the session debounce but not broadcast — geometry is not shared state. + Doc-window bounds are also stamped onto each shown document's recents + entry, so a document reopened later gets its window back where it was. */ + updateBounds(winId: string, bounds: Bounds): void { + if (winId === 'base') this.mainBounds = bounds; + else { + const rec = this.findWindow(winId); + if (!rec) return; + rec.bounds = bounds; + const shown = new Set(rec.panes.flatMap((p) => p.tabs)); + for (const entry of this.recents) { + if (shown.has(entry.id)) entry.bounds = bounds; + } + } + this.persistLayoutSoon(); + } + + activateTab(winId: string, paneId: string, docId: string): void { + const pane = this.findWindow(winId)?.panes.find((p) => p.id === paneId); + if (pane && pane.tabs.includes(docId)) { + pane.active = docId; + this.afterLayoutChange(); + } + } + + closeTab(winId: string, paneId: string, docId: string): void { + const win = this.findWindow(winId); + const pane = win?.panes.find((p) => p.id === paneId); + if (!win || !pane) return; + pane.tabs = pane.tabs.filter((t) => t !== docId); + if (pane.active === docId) pane.active = pane.tabs[pane.tabs.length - 1] ?? ''; + if (pane.tabs.length === 0) { + win.panes = win.panes.filter((p) => p.id !== paneId); + win.ratio = 0.5; + } + if (win.panes.length === 0) this.windows = this.windows.filter((w) => w.id !== winId); + this.releaseUnreferenced(); + this.afterLayoutChange(); + } + + closeWindow(winId: string): void { + if (!this.findWindow(winId)) return; + this.windows = this.windows.filter((w) => w.id !== winId); + this.releaseUnreferenced(); + this.afterLayoutChange(); + } + + moveTab(srcWinId: string, srcPaneId: string, docId: string, dstWinId: string, dstPaneId: string): void { + const srcWin = this.findWindow(srcWinId); + const srcPane = srcWin?.panes.find((p) => p.id === srcPaneId); + const dstPane = this.findWindow(dstWinId)?.panes.find((p) => p.id === dstPaneId); + if (!srcWin || !srcPane || !dstPane || srcPane === dstPane) return; + if (!srcPane.tabs.includes(docId) || dstPane.tabs.includes(docId)) return; + srcPane.tabs = srcPane.tabs.filter((t) => t !== docId); + if (srcPane.active === docId) srcPane.active = srcPane.tabs[srcPane.tabs.length - 1] ?? ''; + if (srcPane.tabs.length === 0) { + srcWin.panes = srcWin.panes.filter((p) => p.id !== srcPaneId); + srcWin.ratio = 0.5; + } + if (srcWin.panes.length === 0) this.windows = this.windows.filter((w) => w.id !== srcWinId); + dstPane.tabs.push(docId); + dstPane.active = docId; + this.afterLayoutChange(); + } + + /** All of src's docs join dst's last pane as tabs; src window goes away. */ + mergeWindows(srcWinId: string, dstWinId: string): void { + const src = this.findWindow(srcWinId); + const dst = this.findWindow(dstWinId); + if (!src || !dst || src === dst) return; + const pane = dst.panes[dst.panes.length - 1]!; + const incoming = src.panes.flatMap((p) => p.tabs).filter((t) => !pane.tabs.includes(t)); + pane.tabs.push(...incoming); + if (incoming.length) pane.active = incoming[incoming.length - 1]!; + this.windows = this.windows.filter((w) => w.id !== srcWinId); + this.afterLayoutChange(); + } + + /** Tile src's docs as a new pane inside dst; src window goes away. */ + splitMerge(srcWinId: string, dstWinId: string, side: 'left' | 'right'): void { + const src = this.findWindow(srcWinId); + const dst = this.findWindow(dstWinId); + if (!src || !dst || src === dst || dst.panes.length > 1) return; + const tabs = src.panes.flatMap((p) => p.tabs); + if (!tabs.length) return; + const pane = this.newPane(tabs); + if (side === 'left') dst.panes.unshift(pane); + else dst.panes.push(pane); + dst.ratio = 0.5; + this.windows = this.windows.filter((w) => w.id !== srcWinId); + this.afterLayoutChange(); + } + + /** Split within one window: the pane's active doc moves to a new right pane. */ + splitWithin(winId: string): void { + const win = this.findWindow(winId); + if (!win || win.panes.length !== 1) return; + const pane = win.panes[0]!; + if (pane.tabs.length < 2) return; + const doc = pane.active; + pane.tabs = pane.tabs.filter((t) => t !== doc); + pane.active = pane.tabs[pane.tabs.length - 1]!; + win.panes.push(this.newPane([doc])); + win.ratio = 0.5; + this.afterLayoutChange(); + } + + /** Pull one tab out into its own new OS window. */ + tearOff(winId: string, paneId: string, docId: string): DocWindowRec | undefined { + const win = this.findWindow(winId); + const pane = win?.panes.find((p) => p.id === paneId); + if (!win || !pane || !pane.tabs.includes(docId)) return undefined; + if (win.panes.length === 1 && pane.tabs.length === 1) return undefined; + pane.tabs = pane.tabs.filter((t) => t !== docId); + if (pane.active === docId) pane.active = pane.tabs[pane.tabs.length - 1] ?? ''; + if (pane.tabs.length === 0) { + win.panes = win.panes.filter((p) => p.id !== paneId); + win.ratio = 0.5; + } + return this.createWindow([docId]); + } + + setRatio(winId: string, ratio: number): void { + const win = this.findWindow(winId); + if (!win) return; + win.ratio = Math.min(0.8, Math.max(0.2, ratio)); + this.afterLayoutChange(); + } + + /** Close server-side docs that no window shows anymore. */ + private releaseUnreferenced(): void { + const referenced = new Set(this.windows.flatMap((w) => w.panes.flatMap((p) => p.tabs))); + for (const id of [...this.open.keys()]) { + if (!referenced.has(id)) this.open.delete(id); + } + } + + /* ——— session (server-owned; derived from the window registry) ——— */ + + private persistLayoutSoon(): void { + clearTimeout(this.#sessionTimer); + this.#sessionTimer = setTimeout(() => { + const pathOf = (id: string) => this.open.get(id)?.info.path ?? ''; + const session: DeskSession = { + main: this.mainBounds, + windows: this.windows.map((w) => ({ + ratio: w.ratio, + bounds: w.bounds, + panes: w.panes.map((p) => ({ + tabs: p.tabs.map(pathOf).filter(Boolean), + active: pathOf(p.active), + })), + })), + }; + this.#session = session; + Deno.writeTextFile(sessionFile(), JSON.stringify(session, null, 2)).catch(() => {}); + this.persistRecents().catch(() => {}); // bounds stamps ride along + }, 500) as unknown as number; + } + + private afterLayoutChange(): void { + this.persistLayoutSoon(); + this.broadcast(); + } + + /** Desktop-mode boot: reopen last session's windows as real OS windows. */ + async restoreSession(): Promise { + const session = this.#session; + this.mainBounds = session?.main; + if (!session?.windows?.length || !this.openOsWindow) return; + for (const win of session.windows) { + const panes: Pane[] = []; + for (const paneSpec of win.panes) { + const tabs: string[] = []; + let active = ''; + for (const path of paneSpec.tabs) { + try { + const info = await this.openPath(path); + tabs.push(info.id); + if (path === paneSpec.active) active = info.id; + } catch { + // moved or deleted since last session + } + } + if (tabs.length) panes.push(this.newPane(tabs, active || undefined)); + } + if (!panes.length) continue; + const rec: DocWindowRec = { + id: `win-${++this.#winSeq}`, + panes: panes.slice(0, 2), + ratio: win.ratio ?? 0.5, + bounds: win.bounds, + createdAt: Date.now(), + }; + this.windows.push(rec); + const title = this.open.get(rec.panes[0]!.active)?.info.name ?? 'ziran'; + this.openOsWindow(rec.id, this.windowUrl(rec.id), title, win.bounds); + } + this.broadcast(); + } + + /** First run: copy bundled starter tiles into the models dir. */ + async seedStarterModels(): Promise { + for await (const _ of Deno.readDir(modelsDir())) return; // not empty: done + const roots = [join(Deno.cwd(), 'tiles', 'built'), new URL('../../tiles/built/', import.meta.url).pathname]; + for (const root of roots) { + try { + for await (const entry of Deno.readDir(root)) { + if (!entry.isFile || !entry.name.endsWith('.tile')) continue; + const src = join(root, entry.name); + try { + const model = await writeModelTile(src, join(modelsDir(), entry.name)); + console.log(`seeded starter model ${model.name ?? entry.name}`); + } catch (err) { + console.warn(`could not seed ${entry.name}:`, (err as Error).message); + } + } + return; + } catch { + // try next root + } + } + } + + async scanModels(): Promise { + const found: ModelEntry[] = []; + for await (const entry of Deno.readDir(modelsDir())) { + if (!entry.isFile || !entry.name.endsWith('.tile')) continue; + const path = join(modelsDir(), entry.name); + try { + const tile = await parseTile(path, { verify: false }); + const model = tile.manifest.model; + if (!model?.id) continue; + found.push({ + id: model.id, + stem: entry.name.replace(/\.tile$/, ''), + path, + name: tile.manifest.name ?? model.name ?? entry.name, + description: tile.manifest.description ?? model.description, + hasIcon: Boolean(firstIcon(tile) && readResource(tile, firstIcon(tile)!.src)), + }); + } catch (err) { + console.warn(`skipping unreadable model ${entry.name}:`, (err as Error).message); + } + } + found.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + this.models = found; + } + + modelById(id: string): ModelEntry | undefined { + return this.models.find((m) => m.id === id); + } + + doc(id: string): OpenDoc | undefined { + return this.open.get(id); + } + + /** Open (or re-open) a tile at a path; registers it in recents. */ + async openPath(path: string): Promise { + const tile = await parseTile(path); + const info = await docInfo(path, tile); + this.open.set(info.id, { info, tile }); + this.touchRecent(info); + await this.persistRecents(); + this.broadcast(); + return info; + } + + /** Re-parse an open doc from disk (after a data write). */ + async refresh(id: string): Promise { + const doc = this.open.get(id); + if (!doc) return undefined; + const tile = await parseTile(doc.info.path, { verify: false }); + const info = await docInfo(doc.info.path, tile); + info.damaged = doc.info.damaged; // verified state is decided at open time + this.open.set(id, { info, tile }); + this.touchRecent(info); + await this.persistRecents(); + this.broadcast(); + return info; + } + + /** Instantiate a document from a model: copy the stripped model tile. */ + async createFromModel(modelId: string): Promise { + const model = this.modelById(modelId); + if (!model) throw new Error(`unknown model ${modelId}`); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const dest = join(documentsDir(), `${model.stem}-${stamp}.tile`); + await Deno.copyFile(model.path, dest); + return await this.openPath(dest); + } + + /** Save an open document as a model (upsert by model.id). */ + async saveAsModel(docId: string): Promise { + const doc = this.open.get(docId); + if (!doc) throw new Error('document is not open'); + const model = doc.tile.manifest.model; + if (!model?.id?.trim()) throw new Error('This document was not made from a model, so it cannot become one yet'); + const stem = await safeModelStem(model.id); + await writeModelTile(doc.info.path, join(modelsDir(), `${stem}.tile`)); + await this.scanModels(); + this.broadcast(); + const entry = this.modelById(model.id); + if (!entry) throw new Error('model vanished after write'); + return entry; + } + + async removeModel(modelId: string): Promise { + const model = this.modelById(modelId); + if (!model) return; + await Deno.remove(model.path); + await this.scanModels(); + this.broadcast(); + } + + /** Move a document's file; the doc id changes because ids are path-derived. + Returns the old id plus the fresh info so clients can remap in place. */ + async moveDoc(id: string, dest: string): Promise<{ oldId: string; info: DocInfo }> { + const doc = this.open.get(id); + if (!doc) throw new Error('document is not open'); + const destPath = dest.endsWith('.tile') ? dest : `${dest}.tile`; + if (destPath === doc.info.path) return { oldId: id, info: doc.info }; + await Deno.mkdir(dirname(destPath), { recursive: true }); + try { + await Deno.rename(doc.info.path, destPath); + } catch { + // cross-device: copy then remove + await Deno.copyFile(doc.info.path, destPath); + await Deno.remove(doc.info.path); + } + this.open.delete(id); + this.recents = this.recents.filter((r) => r.path !== doc.info.path && r.path !== destPath); + const info = await this.openPath(destPath); + // Ids are path-derived: remap the moved doc inside every window record so + // open windows carry on undisturbed. + for (const win of this.windows) { + for (const pane of win.panes) { + pane.tabs = pane.tabs.map((t) => (t === id ? info.id : t)); + if (pane.active === id) pane.active = info.id; + } + } + this.afterLayoutChange(); + return { oldId: id, info }; + } + + /** Import dropped .tile bytes, then open the imported copy. */ + async importBytes(name: string, bytes: Uint8Array): Promise { + const clean = basename(name).replace(/[^\w.-]+/g, '-') || 'imported.tile'; + let dest = join(importsDir(), clean); + try { + await Deno.stat(dest); + dest = join(importsDir(), `${crypto.randomUUID().slice(0, 8)}-${clean}`); + } catch { + // free name + } + await Deno.writeFile(dest, bytes); + return await this.openPath(dest); + } + + /** Drop an entry from Recent (the file itself is untouched). */ + async removeRecent(id: string): Promise { + this.recents = this.recents.filter((r) => r.id !== id); + await this.persistRecents(); + this.broadcast(); + } + + touchRecent(info: DocInfo): void { + const prior = this.recents.find((r) => r.path === info.path); + this.recents = [ + { ...info, lastOpened: Date.now(), bounds: prior?.bounds }, + ...this.recents.filter((r) => r.path !== info.path), + ].slice(0, 100); + } + + async persistRecents(): Promise { + await Deno.writeTextFile(recentsFile(), JSON.stringify(this.recents, null, 2)); + } + + snapshot() { + return { + recents: this.recents, + models: this.models.map(({ path: _path, ...m }) => m), + open: [...this.open.values()].map((d) => d.info), + windows: this.windows, + mainBounds: this.mainBounds, + tileServerPort: this.tileServerPort, + desktop: this.desktop, + }; + } + + /** Sockets identify which window they render ('base' or a win id). When a + window's last socket closes and no reload reclaims it briefly, the OS + window is gone: drop its record and release its docs. */ + addSocket(ws: WebSocket, winId?: string): void { + this.#sockets.add(ws); + if (winId && winId !== 'base') { + const set = this.#winSockets.get(winId) ?? new Set(); + set.add(ws); + this.#winSockets.set(winId, set); + } + ws.addEventListener('close', () => { + this.#sockets.delete(ws); + if (winId && winId !== 'base') { + const set = this.#winSockets.get(winId); + set?.delete(ws); + if (set && set.size === 0) { + setTimeout(() => { + const current = this.#winSockets.get(winId); + if ((!current || current.size === 0) && this.findWindow(winId)) { + this.closeWindow(winId); + } + }, 2000); + } + } + }); + if (ws.readyState === WebSocket.OPEN) this.#send(ws); + else ws.addEventListener('open', () => this.#send(ws)); + } + + #send(ws: WebSocket): void { + try { + ws.send(JSON.stringify({ type: 'state', state: this.snapshot() })); + } catch { + this.#sockets.delete(ws); + } + } + + broadcast(): void { + for (const ws of this.#sockets) this.#send(ws); + } +} diff --git a/src/server/tilefile.ts b/src/server/tilefile.ts new file mode 100644 index 0000000..8184e28 --- /dev/null +++ b/src/server/tilefile.ts @@ -0,0 +1,305 @@ +// The tile engine: read, verify, and rewrite .tile files. +// +// A .tile is a CARv1: a varint-length-prefixed dCBOR MASL manifest (with +// version: 1 and roots: []), followed by blocks of +// `varint(36 + len) + CID bytes (36) + resource bytes`. Resource CIDs are +// CIDv1 / raw 0x55 / sha2-256 — byte-compatible with @dasl/tile-writer and +// the atile CLI. Reading goes through @atcute/car exactly like +// @dasl/tile-loader's CARTileLoader; writing is our own (tile-writer only +// takes file paths, we rebuild from memory). +// +// Ziran deliberately adds what both Blastile and Tenet skip: every resource's +// bytes are re-hashed against its CID at parse time, and a mismatch marks the +// tile damaged (the UI's integrity-failure state). + +import { basename, dirname, join } from '@std/path'; +import { fromUint8Array } from '@atcute/car'; +import { encode as encodeCbor } from '@atcute/cbor'; +import { CODEC_RAW, create, toCidLink, toString as stringifyCID } from '@atcute/cid'; + +export const STORAGE_PREFIX = '/.well-known/web-tiles-storage/'; + +export interface MaslIcon { + src: string; + sizes?: string; + purpose?: string; +} + +export interface ModelManifest { + id?: string; + name?: string; + description?: string; + short_name?: string; + theme_color?: string; + background_color?: string; + icons?: MaslIcon[]; +} + +// Resource entries are flat: `src` plus HTTP headers as sibling keys. +export type ResourceEntry = Record & { src?: { $link: string } }; + +export interface Masl { + name?: string; + description?: string; + short_name?: string; + theme_color?: string; + background_color?: string; + icons?: MaslIcon[]; + model?: ModelManifest; + sizing?: { width?: number; height?: number }; + resources: Record; + version?: number; + roots?: unknown[]; + [key: string]: unknown; +} + +export interface ParsedTile { + path: string; + bytes: Uint8Array; + manifest: Masl; + /** CID string → [bytesStart, bytesEnd] into `bytes`. */ + offsets: Record; + mtime: number; + size: number; + /** Short hex content fingerprint of the whole file (sha-256). */ + fingerprint: string; + damaged: boolean; + damage?: string; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +function encodeUvarint(n: number): Uint8Array { + const out: number[] = []; + while (n >= 0x80) { + out.push((n & 0x7f) | 0x80); + n = Math.floor(n / 128); + } + out.push(n); + return new Uint8Array(out); +} + +function cidOf(entry: ResourceEntry): string | undefined { + const src = entry?.src as { $link?: string; ref?: { $link?: string } } | undefined; + if (!src) return undefined; + if (typeof src.$link === 'string') return src.$link; + return src.ref?.$link; +} + +/** Parse a .tile file; verifies each resource's bytes against its CID. */ +export async function parseTile(path: string, { verify = true } = {}): Promise { + const bytes = await Deno.readFile(path); + const stat = await Deno.stat(path); + const base: Omit = { + path, + bytes, + mtime: stat.mtime?.getTime() ?? 0, + size: bytes.length, + fingerprint: (await sha256Hex(bytes)).slice(0, 16), + damaged: false, + }; + let car; + try { + car = fromUint8Array(bytes); + } catch (err) { + return { + ...base, + manifest: { name: basename(path), resources: {} }, + offsets: {}, + damaged: true, + damage: `not a readable tile: ${(err as Error).message}`, + }; + } + // Decoded CID links are class instances; normalize each src to its plain + // {$link} JSON form (exactly like CARTileLoader) BEFORE cloning — the + // instances' accessors do not survive structuredClone. + const raw = (car.header.data ?? {}) as Masl; + raw.resources ??= {}; + for (const entry of Object.values(raw.resources)) { + if (entry?.src) { + entry.src = (toCidLink(entry.src as never) as unknown as { toJSON(): unknown }) + .toJSON() as { $link: string }; + } + } + const manifest = structuredClone(raw) as Masl; + delete manifest.version; + delete manifest.roots; + manifest.resources ??= {}; + const offsets: Record = {}; + for await (const entry of car) { + const { cid, bytesStart, bytesEnd } = entry as unknown as { + cid: unknown; + bytesStart: number; + bytesEnd: number; + }; + offsets[stringifyCID(cid as never)] = [bytesStart, bytesEnd]; + } + const parsed: ParsedTile = { ...base, manifest, offsets }; + if (!manifest.resources['/']) { + parsed.damaged = true; + parsed.damage = 'tile has no "/" resource'; + return parsed; + } + if (verify) { + for (const [resPath, entry] of Object.entries(manifest.resources)) { + const cidStr = cidOf(entry); + if (!cidStr || !offsets[cidStr]) { + parsed.damaged = true; + parsed.damage = `resource ${resPath} is missing from the archive`; + break; + } + const [start, end] = offsets[cidStr]; + const computed = stringifyCID(await create(CODEC_RAW, bytes.subarray(start, end))); + if (computed !== cidStr) { + parsed.damaged = true; + parsed.damage = `resource ${resPath} does not match its address`; + break; + } + } + } + return parsed; +} + +/** Raw bytes of a resource by its path (e.g. "/", "/icon.svg"). */ +export function readResource(tile: ParsedTile, resPath: string): Uint8Array | undefined { + const entry = tile.manifest.resources[resPath]; + const cidStr = entry ? cidOf(entry) : undefined; + const span = cidStr ? tile.offsets[cidStr] : undefined; + if (!span) return undefined; + return tile.bytes.subarray(span[0], span[1]); +} + +/** All resource bytes, keyed by resource path. */ +function allResources(tile: ParsedTile): Map { + const out = new Map(); + for (const resPath of Object.keys(tile.manifest.resources)) { + const bytes = readResource(tile, resPath); + if (bytes) out.set(resPath, bytes); + } + return out; +} + +/** + * Write a tile from a manifest plus in-memory resource bytes, byte-compatible + * with @dasl/tile-writer: "/" first, deduped blocks, version 1, empty roots. + * Atomic: temp file in the destination directory, then rename. + */ +export async function writeTile( + manifest: Masl, + resources: Map, + outPath: string, +): Promise { + const masl = structuredClone(manifest) as Masl; + masl.resources = {}; + const order = [...resources.keys()].sort((a, b) => (a === '/' ? -1 : b === '/' ? 1 : 0)); + const blocks: Uint8Array[] = []; + const seen = new Set(); + for (const resPath of order) { + const buf = resources.get(resPath)!; + const cid = await create(CODEC_RAW, buf); + const headers = { ...(manifest.resources[resPath] ?? {}) }; + delete headers.src; + // The encoder gets the link INSTANCE (as @dasl/tile-writer does); plain + // {$link} objects are not guaranteed to serialize as Tag 42. + masl.resources[resPath] = { + ...headers, + src: toCidLink(cid) as unknown as { $link: string }, + }; + const cidStr = stringifyCID(cid); + if (seen.has(cidStr)) continue; + seen.add(cidStr); + blocks.push(encodeUvarint(36 + buf.length), cid.bytes, buf); + } + masl.version = 1; + masl.roots = []; + const meta = encodeCbor(masl); + const parts = [encodeUvarint(meta.length), meta, ...blocks]; + const total = parts.reduce((s, p) => s + p.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + const tmp = join(dirname(outPath), `.${basename(outPath)}.tmp`); + await Deno.writeFile(tmp, out); + await Deno.rename(tmp, outPath); +} + +/** Self-storage write: upsert /.well-known/web-tiles-storage/. */ +export async function writeTileData(path: string, name: string, data: Uint8Array): Promise { + const tile = await parseTile(path, { verify: false }); + if (!tile.manifest.resources['/']) throw new Error('not a tile'); + const resPath = STORAGE_PREFIX + name; + const resources = allResources(tile); + resources.set(resPath, data); + tile.manifest.resources[resPath] = { + ...(tile.manifest.resources[resPath] ?? {}), + 'content-type': 'application/octet-stream', + }; + await writeTile(tile.manifest, resources, path); +} + +/** Self-storage read; undefined when the tile has no such data yet. */ +export async function readTileData(path: string, name: string): Promise { + const tile = await parseTile(path, { verify: false }); + return readResource(tile, STORAGE_PREFIX + name); +} + +/** Rename: set masl.name and rewrite in place. */ +export async function renameTile(path: string, name: string): Promise { + const tile = await parseTile(path, { verify: false }); + const clean = name.trim(); + if (!clean) return; + tile.manifest.name = clean.length > 300 ? clean.slice(0, 300) : clean; + await writeTile(tile.manifest, allResources(tile), path); +} + +/** + * Save a tile as a model, Tenet semantics: requires model.id; hoists model + * metadata over the top level; strips all self-storage resources (block GC + * falls out of rebuilding from surviving resources); keeps the model field so + * instances stay model-backed. + */ +export async function writeModelTile(srcPath: string, destPath: string): Promise { + const tile = await parseTile(srcPath, { verify: false }); + const model = tile.manifest.model; + if (!model?.id?.trim()) throw new Error('tile has no model.id; only model-backed tiles can become models'); + const masl = structuredClone(tile.manifest) as Masl; + for (const key of ['name', 'description', 'short_name', 'theme_color', 'background_color'] as const) { + if (model[key] !== undefined) masl[key] = model[key]; + } + const icons = model.icons?.length ? model.icons : tile.manifest.icons; + if (icons?.length) { + masl.icons = icons; + masl.model = { ...model, icons }; + } + const resources = new Map(); + for (const [resPath, bytes] of allResources(tile)) { + if (resPath.startsWith(STORAGE_PREFIX)) { + delete masl.resources[resPath]; + continue; + } + resources.set(resPath, bytes); + } + await writeTile(masl, resources, destPath); + return masl.model ?? model; +} + +/** Tenet's model filename: slug of the id + first 8 hex of sha-256(id). */ +export async function safeModelStem(id: string): Promise { + const slug = id + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + const hash = (await sha256Hex(new TextEncoder().encode(id))).slice(0, 8); + return `${slug || 'model'}-${hash}`; +} + +/** Path-stable doc id (Tenet's authority pattern: derived from path, not content). */ +export async function docIdForPath(path: string): Promise { + return (await sha256Hex(new TextEncoder().encode(path))).slice(0, 16); +} diff --git a/src/server/tileserver.ts b/src/server/tileserver.ts new file mode 100644 index 0000000..e6f8b23 --- /dev/null +++ b/src/server/tileserver.ts @@ -0,0 +1,91 @@ +// The tile-isolation server, ported from Blastile: @dasl/tile-server's router +// serving the shuttle/worker runtime on *.localhost origins. The stock +// load. redirect drops the port (only valid on :80), so we mint the +// random subdomain ourselves in front of it. We also serve +// vendor/web-tiles/{shuttle.js,data.js} (dasl-tiles HEAD, ahead of the npm +// release) plus ziran's own store.js (the Tenet loadData/putData contract +// implemented over tp-data). + +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'; + +const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; +export function randomSubdomain(length = 20): string { + const bytes = crypto.getRandomValues(new Uint8Array(length)); + 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', 'store.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'); + let port = 0; + app.use((req, res, next) => { + if (req.hostname === 'load.localhost') { + res.redirect(303, `http://${randomSubdomain()}.localhost:${port}${req.originalUrl || '/'}`); + return; + } + 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. + const server = app.listen(0, '127.0.0.1', (e?: Error) => { + if (e) return reject(e); + const addr = server.address(); + if (!addr || typeof addr === 'string') return reject(new Error('tile server: no port')); + port = addr.port; + resolve(port); + }); + server.on('error', reject); + }); +} diff --git a/src/server/windows.ts b/src/server/windows.ts new file mode 100644 index 0000000..4b9948e --- /dev/null +++ b/src/server/windows.ts @@ -0,0 +1,198 @@ +// Deno Desktop windows (Deno ≥ 2.9). Window instances are retained so native +// events drive the app: `close` cleans up window records instantly, `move` / +// `resize` persist geometry, `focus` tracks the ⌘W target, and `menuclick` +// carries the application menu's custom items. No bindings are ever +// registered — the bindings proxy is reachable from cross-origin tile frames. + +import type { Bounds } from '../types.ts'; + +type MenuItem = + | { item: { label: string; id?: string; accelerator?: string; enabled: boolean } } + | { submenu: { label: string; items: MenuItem[] } } + | 'separator' + | { role: { role: string } }; + +interface BrowserWindowLike { + navigate?: (url: string) => void | Promise; + close?: () => void; + getBounds?: () => { x: number; y: number; width: number; height: number }; + setApplicationMenu?: (menu: MenuItem[]) => void; + onclose?: ((ev: Event) => void) | null; + onfocus?: ((ev: Event) => void) | null; + onmove?: ((ev: { detail: { x: number; y: number } }) => void) | null; + onresize?: ((ev: { detail: { width: number; height: number } }) => void) | null; + onmenuclick?: ((ev: { detail: { id: string } }) => void) | null; +} + +interface BrowserWindowCtor { + new (options?: Record): BrowserWindowLike; +} + +export interface DesktopHandlers { + /** A real OS window was closed by the user ('main' or a win id). */ + onClosed: (winId: string) => void; + onBounds: (winId: string, bounds: Bounds) => void; + /** File > Open Tile. */ + onOpenTile: () => void; +} + +const APP_MENU: MenuItem[] = [ + { + submenu: { + label: 'Ziran', + items: [ + { role: { role: 'hide' } }, + 'separator', + { item: { label: 'Quit Ziran', id: 'quit', accelerator: 'CmdOrCtrl+Q', enabled: true } }, + ], + }, + }, + { + submenu: { + label: 'File', + items: [ + { item: { label: 'Open Tile…', id: 'open-tile', accelerator: 'CmdOrCtrl+O', enabled: true } }, + 'separator', + { item: { label: 'Close Window', id: 'close-window', accelerator: 'CmdOrCtrl+W', enabled: true } }, + ], + }, + }, + { + submenu: { + label: 'Edit', + items: [ + { role: { role: 'undo' } }, + { role: { role: 'redo' } }, + 'separator', + { role: { role: 'cut' } }, + { role: { role: 'copy' } }, + { role: { role: 'paste' } }, + { role: { role: 'selectAll' } }, + ], + }, + }, + { + submenu: { + label: 'Window', + items: [{ role: { role: 'minimize' } }], + }, + }, +]; + +const wins = new Map(); +let lastFocused = 'main'; +let handlers: DesktopHandlers | undefined; + +function ctor(): BrowserWindowCtor | undefined { + return (Deno as unknown as { BrowserWindow?: BrowserWindowCtor }).BrowserWindow; +} + +export function hasDesktopWindows(): boolean { + return typeof ctor() === 'function'; +} + +function handleMenu(id: string): void { + switch (id) { + case 'quit': + Deno.exit(0); + break; + case 'close-window': + wins.get(lastFocused)?.close?.(); + break; + case 'open-tile': + handlers?.onOpenTile(); + break; + } +} + +function wire(win: BrowserWindowLike, winId: string): void { + wins.set(winId, win); + const bounds: Partial = {}; + try { + const b = win.getBounds?.(); + if (b) { + bounds.x = b.x; + bounds.y = b.y; + bounds.w = b.width; + bounds.h = b.height; + } + } catch { + // move/resize events will fill it in + } + const report = () => { + const { x, y, w, h } = bounds; + if ([x, y, w, h].every((n) => Number.isFinite(n))) { + handlers?.onBounds(winId, { x: x!, y: y!, w: w!, h: h! }); + } + }; + try { + win.onfocus = () => { + lastFocused = winId; + }; + win.onclose = () => { + wins.delete(winId); + if (lastFocused === winId) lastFocused = 'main'; + handlers?.onClosed(winId); + }; + win.onmove = (ev) => { + bounds.x = ev.detail.x; + bounds.y = ev.detail.y; + report(); + }; + win.onresize = (ev) => { + bounds.w = ev.detail.width; + bounds.h = ev.detail.height; + report(); + }; + win.onmenuclick = (ev) => handleMenu(ev.detail.id); + } catch (err) { + console.warn('window event wiring failed:', err); + } +} + +/** Adopt/setup the main startup window and the application menu. */ +export function setupMainWindow(h: DesktopHandlers): void { + handlers = h; + const BW = ctor(); + if (!BW) return; + try { + const win = new BW({ title: 'Ziran', width: 1100, height: 780 }); + wire(win, 'main'); + try { + win.setApplicationMenu?.(APP_MENU); + } catch (err) { + console.warn('could not set the application menu:', err); + } + } catch (err) { + console.warn('could not set up main window:', err); + } +} + +/** Open a document window (its own real OS window). */ +export function openDocWindow( + winId: string, + url: string, + title: string, + bounds?: Bounds, +): boolean { + const BW = ctor(); + if (!BW) return false; + try { + const opts: Record = { + title: title || 'ziran', + width: bounds?.w ?? 820, + height: bounds?.h ?? 680, + }; + if (bounds) { + opts.x = bounds.x; + opts.y = bounds.y; + } + const win = new BW(opts); + wire(win, winId); + win.navigate?.(url); + return true; + } catch (err) { + console.warn('could not open document window:', err); + return false; + } +} diff --git a/src/state/backend.ts b/src/state/backend.ts new file mode 100644 index 0000000..5614ebc --- /dev/null +++ b/src/state/backend.ts @@ -0,0 +1,104 @@ +// Client side of the backend API: fetch wrappers plus the /api/events +// WebSocket with auto-reconnect. All server truth arrives as full state +// snapshots; the desk store consumes them. + +import type { DocInfo, DocWindowRec, ModelEntry, ServerState } from '../types.ts'; + +export interface OpenResult { + doc: DocInfo; + win: DocWindowRec; + url: string; + existing: boolean; +} + +async function post(url: string, body?: unknown): Promise { + const res = await fetch(url, { + method: 'POST', + headers: body === undefined ? {} : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `${url} failed (${res.status})`); + return data as T; +} + +export const backend = { + onState: undefined as ((state: ServerState) => void) | undefined, + + connect(winId: string): void { + const ws = new WebSocket(`ws://${location.host}/api/events?win=${encodeURIComponent(winId)}`); + ws.addEventListener('message', (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type === 'state') this.onState?.(msg.state); + } catch { + // ignore malformed frames + } + }); + ws.addEventListener('close', () => setTimeout(() => this.connect(winId), 1500)); + }, + + state: (): Promise => fetch('/api/state').then((r) => r.json()), + openPath: (path: string): Promise => post('/api/docs/open', { path }), + fromModel: (modelId: string): Promise => post('/api/docs/from-model', { modelId }), + winOp: (winId: string, body: Record): Promise => + post(`/api/windows/${winId}/op`, body), + rename: (id: string, name: string): Promise => post(`/api/docs/${id}/rename`, { name }), + saveAsModel: (docId: string): Promise => post(`/api/models/from/${docId}`), + pick: (): Promise<{ path?: string; cancelled?: boolean; unsupported?: boolean }> => + fetch('/api/pick').then((r) => r.json()), + pickSave: (name: string): Promise<{ path?: string; cancelled?: boolean; unsupported?: boolean }> => + fetch(`/api/pick-save?name=${encodeURIComponent(name)}`).then((r) => r.json()), + moveDoc: (id: string, dest: string): Promise<{ oldId: string; info: DocInfo }> => + post(`/api/docs/${id}/move`, { dest }), + removeRecent: (id: string): Promise => + fetch(`/api/recents/${id}`, { method: 'DELETE' }).then((r) => r.json()), + reveal: (path: string): Promise<{ ok: boolean }> => post('/api/reveal', { path }), + importTile: async (name: string, bytes: ArrayBuffer): Promise => { + const res = await fetch(`/api/import?name=${encodeURIComponent(name)}`, { + method: 'POST', + body: bytes, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? 'import failed'); + return data as OpenResult; + }, + + loadData: async (docId: string, name: string): Promise => { + const res = await fetch(`/api/docs/${docId}/data/${name}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`loadData failed (${res.status})`); + return new Uint8Array(await res.arrayBuffer()); + }, + putData: async (docId: string, name: string, data: Uint8Array): Promise => { + const res = await fetch(`/api/docs/${docId}/data/${name}`, { + method: 'PUT', + body: data as BodyInit, + }); + if (!res.ok) throw new Error(`putData failed (${res.status})`); + }, +}; + +export const docIconUrl = (d: DocInfo): string | undefined => + d.hasIcon ? `/api/docs/${d.id}/icon` : undefined; +export const recentIconUrl = (d: DocInfo): string | undefined => + d.hasIcon ? `/api/recents/${d.id}/icon` : undefined; +export const modelIconUrl = (m: ModelEntry): string | undefined => + m.hasIcon ? `/api/models/${encodeURIComponent(m.id)}/icon` : undefined; + +const MIN = 60_000, HOUR = 3_600_000, DAY = 86_400_000; +export function relativeTime(ts: number): string { + const delta = Date.now() - ts; + if (delta < MIN) return 'just now'; + if (delta < HOUR) return `${Math.round(delta / MIN)}m ago`; + if (delta < DAY) return `${Math.round(delta / HOUR)}h ago`; + if (delta < 2 * DAY) return 'yesterday'; + if (delta < 14 * DAY) return `${Math.round(delta / DAY)}d ago`; + return `${Math.round(delta / (7 * DAY))}w ago`; +} + +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/state/desk.ts b/src/state/desk.ts new file mode 100644 index 0000000..103515d --- /dev/null +++ b/src/state/desk.ts @@ -0,0 +1,272 @@ +// Client state: a thin mirror of the server. Every page is one real OS +// window — the base window (?win absent or 'base') or a document window +// (?win=). The server owns documents, models, recents, AND window +// layout; this store mirrors snapshots and forwards actions. + +import type { DocInfo, DocWindowRec, ModelEntry, RecentEntry, ServerState } from '../types.ts'; +import { backend, type OpenResult } from './backend.ts'; + +class DeskStore extends EventTarget { + recents: RecentEntry[] = []; + models: ModelEntry[] = []; + windows: DocWindowRec[] = []; + docs = new Map(); + tileServerPort = 0; + desktop = false; + connected = false; + + /** Which window this page renders. */ + winId: string = 'base'; + + private sawMyWindow = false; + + init(): void { + this.winId = new URLSearchParams(location.search).get('win') ?? 'base'; + backend.onState = (state) => this.applyServerState(state); + backend.connect(this.winId); + backend.state().then((s) => this.applyServerState(s)).catch(() => {}); + } + + applyServerState(state: ServerState): void { + this.recents = state.recents; + this.models = state.models; + this.windows = state.windows; + this.tileServerPort = state.tileServerPort; + this.desktop = state.desktop; + this.connected = true; + this.docs = new Map(state.open.map((d) => [d.id, d])); + if (this.winId !== 'base') { + if (this.myWindow()) this.sawMyWindow = true; + else if (this.sawMyWindow) { + // Our record is gone (last tab closed or merged away): the OS window + // should go with it. + window.close(); + } + } + this.startGeometry(state); + this.emit(); + } + + /* ——— window geometry: applied once, then reported to the server ——— */ + + private geometryStarted = false; + + private startGeometry(state: ServerState): void { + if (this.geometryStarted) return; + this.geometryStarted = true; + const stored = this.winId === 'base' ? state.mainBounds : this.myWindow()?.bounds; + try { + if (stored) { + moveTo(stored.x, stored.y); + resizeTo(stored.w, stored.h); + } else if (this.winId === 'base') { + // Default: centred on the current screen. + moveTo( + Math.max(0, Math.round((screen.availWidth - outerWidth) / 2)), + Math.max(0, Math.round((screen.availHeight - outerHeight) / 2)), + ); + } + } catch { + // The environment may refuse script moves; constructor options and + // window.open features already did their best. + } + let last = ''; + const bounds = () => ({ x: screenX, y: screenY, w: outerWidth, h: outerHeight }); + const report = () => { + const b = bounds(); + const key = `${b.x},${b.y},${b.w},${b.h}`; + if (!b.w || key === last) return; + last = key; + fetch(`/api/windows/${this.winId}/bounds`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(b), + }).catch(() => {}); + }; + setInterval(report, 2000); + addEventListener('resize', report); + addEventListener('pagehide', () => { + navigator.sendBeacon(`/api/windows/${this.winId}/bounds`, JSON.stringify(bounds())); + }); + setTimeout(report, 1200); + } + + myWindow(): DocWindowRec | undefined { + return this.windows.find((w) => w.id === this.winId); + } + + getDoc(id: string): DocInfo | undefined { + return this.docs.get(id); + } + + get openDocIds(): Set { + return new Set(this.windows.flatMap((w) => w.panes.flatMap((p) => p.tabs))); + } + + /* ——— opening things (from the base window) ——— */ + + private handleOpened(res: OpenResult): void { + // Desktop mode: the server spawned the OS window. Browser mode: we open + // it ourselves (we are inside the user's click, so popups are allowed). + if (!this.desktop && !res.existing) { + const b = res.win.bounds; + const features = `popup,width=${b?.w ?? 820},height=${b?.h ?? 680}` + + (b ? `,left=${b.x},top=${b.y}` : ''); + globalThis.open(res.url, res.win.id, features); + } else if (res.existing) { + this.toast(`“${res.doc.name}” is already open — look for its window`); + } + } + + async openRecent(entry: RecentEntry): Promise { + try { + this.handleOpened(await backend.openPath(entry.path)); + } catch (err) { + this.toast(`Could not open “${entry.name}”: ${(err as Error).message}`); + } + } + + async newFromModel(modelId: string): Promise { + try { + this.handleOpened(await backend.fromModel(modelId)); + } catch (err) { + this.toast(`Could not create document: ${(err as Error).message}`); + } + } + + async openExternal(): Promise { + const picked = await backend.pick(); + if (picked.cancelled) return; + let path = picked.path; + if (picked.unsupported || !path) { + path = prompt('Absolute path to a .tile file:') ?? undefined; + if (!path) return; + } + try { + this.handleOpened(await backend.openPath(path)); + } catch (err) { + this.toast(`Could not open tile: ${(err as Error).message}`); + } + } + + async importFile(file: File): Promise { + try { + const res = await backend.importTile(file.name, await file.arrayBuffer()); + this.handleOpened(res); + this.toast(`Imported “${res.doc.name}” — it lives on this machine now`); + } catch (err) { + this.toast(`Could not import: ${(err as Error).message}`); + } + } + + /* ——— window ops (from a document window) ——— */ + + activateTab(paneId: string, docId: string): void { + backend.winOp(this.winId, { op: 'activate', paneId, docId }).catch(() => {}); + } + + closeTab(paneId: string, docId: string): void { + backend.winOp(this.winId, { op: 'close-tab', paneId, docId }).catch(() => {}); + } + + closeThisWindow(): void { + backend.winOp(this.winId, { op: 'close' }).catch(() => {}); + } + + merge(dstWinId: string): void { + backend.winOp(this.winId, { op: 'merge', dstWinId }).catch(() => {}); + } + + splitMerge(dstWinId: string, side: 'left' | 'right'): void { + backend.winOp(this.winId, { op: 'split-merge', dstWinId, side }).catch(() => {}); + } + + splitWithin(): void { + backend.winOp(this.winId, { op: 'split-within' }).catch(() => {}); + } + + moveTab(paneId: string, docId: string, dstWinId: string, dstPaneId: string): void { + backend.winOp(this.winId, { op: 'move-tab', paneId, docId, dstWinId, dstPaneId }).catch(() => {}); + } + + async tearOff(paneId: string, docId: string): Promise { + try { + const res = await backend.winOp(this.winId, { op: 'tear-off', paneId, docId }) as { url?: string }; + if (!this.desktop && res.url) globalThis.open(res.url, '_blank', 'popup,width=820,height=680'); + } catch (err) { + this.toast((err as Error).message); + } + } + + setRatio(ratio: number): void { + backend.winOp(this.winId, { op: 'ratio', value: ratio }).catch(() => {}); + } + + /* ——— document actions ——— */ + + async rename(docId: string, title: string): Promise { + const clean = title.trim(); + const doc = this.docs.get(docId); + if (!doc || !clean || doc.name === clean) return; + try { + await backend.rename(docId, clean); + } catch (err) { + this.toast(`Could not rename: ${(err as Error).message}`); + } + } + + async saveAsModel(docId: string): Promise { + try { + const model = await backend.saveAsModel(docId); + this.toast(`“${model.name}” is now a model — find it under Models`); + } catch (err) { + this.toast((err as Error).message); + } + } + + /** Move the file; the server remaps the path-derived id in every window. */ + async moveDoc(docId: string): Promise { + const doc = this.docs.get(docId); + if (!doc) return; + const picked = await backend.pickSave(doc.path.split('/').pop() ?? `${doc.name}.tile`); + if (picked.cancelled) return; + let dest = picked.path; + if (picked.unsupported || !dest) { + dest = prompt('Move tile to (absolute path):', doc.path) ?? undefined; + if (!dest || dest === doc.path) return; + } + try { + const { info } = await backend.moveDoc(docId, dest); + this.toast(`Moved to ${info.path}`); + } catch (err) { + this.toast(`Could not move: ${(err as Error).message}`); + } + } + + async removeRecent(entry: RecentEntry): Promise { + try { + await backend.removeRecent(entry.id); + } catch (err) { + this.toast(`Could not remove: ${(err as Error).message}`); + } + } + + async reveal(path: string): Promise { + try { + const res = await backend.reveal(path); + if (!res.ok) this.toast('No file manager responded'); + } catch (err) { + this.toast(`Could not reveal: ${(err as Error).message}`); + } + } + + toast(message: string) { + this.dispatchEvent(new CustomEvent('toast', { detail: message })); + } + + private emit() { + this.dispatchEvent(new Event('change')); + } +} + +export const desk = new DeskStore(); diff --git a/src/styles/shared.ts b/src/styles/shared.ts new file mode 100644 index 0000000..3a40e5d --- /dev/null +++ b/src/styles/shared.ts @@ -0,0 +1,15 @@ +import { css } from 'lit'; + +/** Shadow roots don't inherit document-level resets; every component + includes this first in its static styles. The pink selection is UI + chrome only — zn-doc-canvas overrides it to stay unbranded. */ +export const baseStyles = css` + *, + *::before, + *::after { + box-sizing: border-box; + } + ::selection { + background: var(--signal-tint-strong); + } +`; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c5c666c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,79 @@ +// Shared shapes. Doc/model/recent types mirror the server's (src/server/store.ts). + +export interface DocInfo { + id: string; + path: string; + name: string; + damaged: boolean; + damage?: string; + hasIcon: boolean; + modelName?: string; + modelId?: string; + size: number; + mtime: number; + fingerprint: string; +} + +export interface RecentEntry extends DocInfo { + lastOpened: number; + /** Last known geometry of this document's window; reused on reopen. */ + bounds?: Bounds; +} + +export interface ModelEntry { + id: string; + stem: string; + name: string; + description?: string; + hasIcon: boolean; +} + +export interface ServerState { + recents: RecentEntry[]; + models: ModelEntry[]; + open: DocInfo[]; + windows: DocWindowRec[]; + mainBounds?: Bounds; + tileServerPort: number; + desktop: boolean; +} + +export interface Bounds { + x: number; + y: number; + w: number; + h: number; +} + +/** Layout persisted to session.json; tabs are stored as tile paths so a + restore can re-open them (doc ids are derived from paths server-side). + Geometry is reported by each window page (screenX/outerWidth) since the + desktop API exposes no bounds events. */ +export interface DeskSession { + main?: Bounds; + windows: Array<{ + panes: Array<{ tabs: string[]; active: string }>; + ratio: number; + bounds?: Bounds; + }>; +} + +export interface Pane { + id: string; + /** Doc ids. */ + tabs: string[]; + active: string; +} + +/** A real OS document window's content layout, owned by the server. */ +export interface DocWindowRec { + id: string; + /** One pane = single view; two panes = vertical split. */ + panes: Pane[]; + /** Width fraction of the first pane when split. */ + ratio: number; + bounds?: Bounds; + /** Spawn time; a freshly created window has no sockets yet and must not + be mistaken for a zombie. */ + createdAt: number; +} diff --git a/src/ui/data-protocol.ts b/src/ui/data-protocol.ts new file mode 100644 index 0000000..0597f91 --- /dev/null +++ b/src/ui/data-protocol.ts @@ -0,0 +1,77 @@ +// 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` back down. +// Ziran routes `ziranStore` payloads (the store.js loadData/putData contract) +// to the backend, which rewrites the tile file's self-storage. + +import { backend } from '../state/backend.ts'; + +const UP_PREFIX = 'tiles-protocol-up-data-'; +const UP_PAYLOAD = `${UP_PREFIX}payload`; +const DOWN_PAYLOAD = 'tiles-protocol-down-data-payload'; + +interface StoreRequest { + reqId: string; + op: 'load' | 'put'; + name: string; + data?: Uint8Array; +} + +class DataProtocolHub { + #frames = new Map(); + + init(): void { + globalThis.addEventListener('message', (ev: MessageEvent) => { + const action = ev.data?.action; + if (typeof action !== 'string' || !action.startsWith(UP_PREFIX)) return; + const docId = this.#docIdFor(ev.source); + if (!docId) return; + if (action === UP_PAYLOAD && ev.data.payload?.ziranStore) { + this.#handleStore(docId, ev.data.payload.ziranStore as StoreRequest); + } + }); + } + + async #handleStore(docId: string, req: StoreRequest): Promise { + const reply = (msg: Record) => + this.send(docId, { ziranStore: { reqId: req.reqId, ...msg } }); + try { + if (req.op === 'load') { + const data = await backend.loadData(docId, req.name); + reply({ data }); + } else if (req.op === 'put') { + await backend.putData(docId, req.name, new Uint8Array(req.data ?? [])); + reply({ ok: true }); + } else { + reply({ error: `unknown op` }); + } + } catch (err) { + reply({ error: (err as Error).message }); + } + } + + #docIdFor(source: MessageEventSource | null): string | undefined { + for (const [id, ifr] of this.#frames) { + if (ifr.contentWindow && ifr.contentWindow === source) return id; + } + return undefined; + } + + /** Called by the tile frame whenever it mounts a fresh shuttle iframe. */ + register(docId: string, iframe: HTMLIFrameElement): void { + this.#frames.set(docId, iframe); + } + + unregister(docId: string, iframe?: HTMLIFrameElement): void { + if (!iframe || this.#frames.get(docId) === iframe) this.#frames.delete(docId); + } + + send(docId: string, payload: unknown): boolean { + const win = this.#frames.get(docId)?.contentWindow; + if (!win) return false; + win.postMessage({ action: DOWN_PAYLOAD, payload }, '*'); + return true; + } +} + +export const dataHub = new DataProtocolHub(); diff --git a/src/ui/mothership.ts b/src/ui/mothership.ts new file mode 100644 index 0000000..a130815 --- /dev/null +++ b/src/ui/mothership.ts @@ -0,0 +1,28 @@ +// One mothership for the whole UI (Blastile's pattern): each rendered tile +// gets a freshly minted random *.localhost origin served by our embedded tile +// server — that random origin is what isolates tiles from the app and from +// each other. The stock TileMothership hardcodes https:// and portless hosts +// in getLoadSource(), hence the subclass. + +import { TileMothership } from '@dasl/tile-loader'; +import { CARTileLoader } from '@dasl/tile-loader/car'; + +const LETTERS = 'abcdefghijklmnopqrstuvwxyz'; +function randomSubdomain(length = 20): string { + const bytes = crypto.getRandomValues(new Uint8Array(length)); + return Array.from(bytes, (b) => LETTERS[b % LETTERS.length]).join(''); +} + +export class LocalTileMothership extends TileMothership { + /** Port of the embedded @dasl/tile-server; set from app state. */ + tileServerPort = 0; + + override getLoadSource(): string { + if (!this.tileServerPort) throw new Error('tile server port not known yet'); + return `http://${randomSubdomain()}.localhost:${this.tileServerPort}/.well-known/web-tiles/`; + } +} + +export const mothership = new LocalTileMothership(); +mothership.init(); +mothership.addLoader(new CARTileLoader()); diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..2e2b4a5 --- /dev/null +++ b/static/app.js @@ -0,0 +1,4106 @@ +// node_modules/.deno/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js +var t = globalThis; +var e = t.ShadowRoot && (void 0 === t.ShadyCSS || t.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype; +var s = Symbol(); +var o = /* @__PURE__ */ new WeakMap(); +var n = class { + constructor(t3, e4, o5) { + if (this._$cssResult$ = true, o5 !== s) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + this.cssText = t3, this.t = e4; + } + get styleSheet() { + let t3 = this.o; + const s4 = this.t; + if (e && void 0 === t3) { + const e4 = void 0 !== s4 && 1 === s4.length; + e4 && (t3 = o.get(s4)), void 0 === t3 && ((this.o = t3 = new CSSStyleSheet()).replaceSync(this.cssText), e4 && o.set(s4, t3)); + } + return t3; + } + toString() { + return this.cssText; + } +}; +var r = (t3) => new n("string" == typeof t3 ? t3 : t3 + "", void 0, s); +var i = (t3, ...e4) => { + const o5 = 1 === t3.length ? t3[0] : e4.reduce((e5, s4, o6) => e5 + ((t4) => { + if (true === t4._$cssResult$) return t4.cssText; + if ("number" == typeof t4) return t4; + throw Error("Value passed to 'css' function must be a 'css' function result: " + t4 + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); + })(s4) + t3[o6 + 1], t3[0]); + return new n(o5, t3, s); +}; +var S = (s4, o5) => { + if (e) s4.adoptedStyleSheets = o5.map((t3) => t3 instanceof CSSStyleSheet ? t3 : t3.styleSheet); + else for (const e4 of o5) { + const o6 = document.createElement("style"), n4 = t.litNonce; + void 0 !== n4 && o6.setAttribute("nonce", n4), o6.textContent = e4.cssText, s4.appendChild(o6); + } +}; +var c = e ? (t3) => t3 : (t3) => t3 instanceof CSSStyleSheet ? ((t4) => { + let e4 = ""; + for (const s4 of t4.cssRules) e4 += s4.cssText; + return r(e4); +})(t3) : t3; + +// node_modules/.deno/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js +var { is: i2, defineProperty: e2, getOwnPropertyDescriptor: h, getOwnPropertyNames: r2, getOwnPropertySymbols: o2, getPrototypeOf: n2 } = Object; +var a = globalThis; +var c2 = a.trustedTypes; +var l = c2 ? c2.emptyScript : ""; +var p = a.reactiveElementPolyfillSupport; +var d = (t3, s4) => t3; +var u = { + toAttribute(t3, s4) { + switch (s4) { + case Boolean: + t3 = t3 ? l : null; + break; + case Object: + case Array: + t3 = null == t3 ? t3 : JSON.stringify(t3); + } + return t3; + }, + fromAttribute(t3, s4) { + let i5 = t3; + switch (s4) { + case Boolean: + i5 = null !== t3; + break; + case Number: + i5 = null === t3 ? null : Number(t3); + break; + case Object: + case Array: + try { + i5 = JSON.parse(t3); + } catch (t4) { + i5 = null; + } + } + return i5; + } +}; +var f = (t3, s4) => !i2(t3, s4); +var b = { + attribute: true, + type: String, + converter: u, + reflect: false, + useDefault: false, + hasChanged: f +}; +Symbol.metadata ??= Symbol("metadata"), a.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +var y = class extends HTMLElement { + static addInitializer(t3) { + this._$Ei(), (this.l ??= []).push(t3); + } + static get observedAttributes() { + return this.finalize(), this._$Eh && [ + ...this._$Eh.keys() + ]; + } + static createProperty(t3, s4 = b) { + if (s4.state && (s4.attribute = false), this._$Ei(), this.prototype.hasOwnProperty(t3) && ((s4 = Object.create(s4)).wrapped = true), this.elementProperties.set(t3, s4), !s4.noAccessor) { + const i5 = Symbol(), h3 = this.getPropertyDescriptor(t3, i5, s4); + void 0 !== h3 && e2(this.prototype, t3, h3); + } + } + static getPropertyDescriptor(t3, s4, i5) { + const { get: e4, set: r4 } = h(this.prototype, t3) ?? { + get() { + return this[s4]; + }, + set(t4) { + this[s4] = t4; + } + }; + return { + get: e4, + set(s5) { + const h3 = e4?.call(this); + r4?.call(this, s5), this.requestUpdate(t3, h3, i5); + }, + configurable: true, + enumerable: true + }; + } + static getPropertyOptions(t3) { + return this.elementProperties.get(t3) ?? b; + } + static _$Ei() { + if (this.hasOwnProperty(d("elementProperties"))) return; + const t3 = n2(this); + t3.finalize(), void 0 !== t3.l && (this.l = [ + ...t3.l + ]), this.elementProperties = new Map(t3.elementProperties); + } + static finalize() { + if (this.hasOwnProperty(d("finalized"))) return; + if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d("properties"))) { + const t4 = this.properties, s4 = [ + ...r2(t4), + ...o2(t4) + ]; + for (const i5 of s4) this.createProperty(i5, t4[i5]); + } + const t3 = this[Symbol.metadata]; + if (null !== t3) { + const s4 = litPropertyMetadata.get(t3); + if (void 0 !== s4) for (const [t4, i5] of s4) this.elementProperties.set(t4, i5); + } + this._$Eh = /* @__PURE__ */ new Map(); + for (const [t4, s4] of this.elementProperties) { + const i5 = this._$Eu(t4, s4); + void 0 !== i5 && this._$Eh.set(i5, t4); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(s4) { + const i5 = []; + if (Array.isArray(s4)) { + const e4 = new Set(s4.flat(1 / 0).reverse()); + for (const s5 of e4) i5.unshift(c(s5)); + } else void 0 !== s4 && i5.push(c(s4)); + return i5; + } + static _$Eu(t3, s4) { + const i5 = s4.attribute; + return false === i5 ? void 0 : "string" == typeof i5 ? i5 : "string" == typeof t3 ? t3.toLowerCase() : void 0; + } + constructor() { + super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev(); + } + _$Ev() { + this._$ES = new Promise((t3) => this.enableUpdating = t3), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), this.constructor.l?.forEach((t3) => t3(this)); + } + addController(t3) { + (this._$EO ??= /* @__PURE__ */ new Set()).add(t3), void 0 !== this.renderRoot && this.isConnected && t3.hostConnected?.(); + } + removeController(t3) { + this._$EO?.delete(t3); + } + _$E_() { + const t3 = /* @__PURE__ */ new Map(), s4 = this.constructor.elementProperties; + for (const i5 of s4.keys()) this.hasOwnProperty(i5) && (t3.set(i5, this[i5]), delete this[i5]); + t3.size > 0 && (this._$Ep = t3); + } + createRenderRoot() { + const t3 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return S(t3, this.constructor.elementStyles), t3; + } + connectedCallback() { + this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(true), this._$EO?.forEach((t3) => t3.hostConnected?.()); + } + enableUpdating(t3) { + } + disconnectedCallback() { + this._$EO?.forEach((t3) => t3.hostDisconnected?.()); + } + attributeChangedCallback(t3, s4, i5) { + this._$AK(t3, i5); + } + _$ET(t3, s4) { + const i5 = this.constructor.elementProperties.get(t3), e4 = this.constructor._$Eu(t3, i5); + if (void 0 !== e4 && true === i5.reflect) { + const h3 = (void 0 !== i5.converter?.toAttribute ? i5.converter : u).toAttribute(s4, i5.type); + this._$Em = t3, null == h3 ? this.removeAttribute(e4) : this.setAttribute(e4, h3), this._$Em = null; + } + } + _$AK(t3, s4) { + const i5 = this.constructor, e4 = i5._$Eh.get(t3); + if (void 0 !== e4 && this._$Em !== e4) { + const t4 = i5.getPropertyOptions(e4), h3 = "function" == typeof t4.converter ? { + fromAttribute: t4.converter + } : void 0 !== t4.converter?.fromAttribute ? t4.converter : u; + this._$Em = e4; + const r4 = h3.fromAttribute(s4, t4.type); + this[e4] = r4 ?? this._$Ej?.get(e4) ?? r4, this._$Em = null; + } + } + requestUpdate(t3, s4, i5, e4 = false, h3) { + if (void 0 !== t3) { + const r4 = this.constructor; + if (false === e4 && (h3 = this[t3]), i5 ??= r4.getPropertyOptions(t3), !((i5.hasChanged ?? f)(h3, s4) || i5.useDefault && i5.reflect && h3 === this._$Ej?.get(t3) && !this.hasAttribute(r4._$Eu(t3, i5)))) return; + this.C(t3, s4, i5); + } + false === this.isUpdatePending && (this._$ES = this._$EP()); + } + C(t3, s4, { useDefault: i5, reflect: e4, wrapped: h3 }, r4) { + i5 && !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t3) && (this._$Ej.set(t3, r4 ?? s4 ?? this[t3]), true !== h3 || void 0 !== r4) || (this._$AL.has(t3) || (this.hasUpdated || i5 || (s4 = void 0), this._$AL.set(t3, s4)), true === e4 && this._$Em !== t3 && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t3)); + } + async _$EP() { + this.isUpdatePending = true; + try { + await this._$ES; + } catch (t4) { + Promise.reject(t4); + } + const t3 = this.scheduleUpdate(); + return null != t3 && await t3, !this.isUpdatePending; + } + scheduleUpdate() { + return this.performUpdate(); + } + performUpdate() { + if (!this.isUpdatePending) return; + if (!this.hasUpdated) { + if (this.renderRoot ??= this.createRenderRoot(), this._$Ep) { + for (const [t5, s5] of this._$Ep) this[t5] = s5; + this._$Ep = void 0; + } + const t4 = this.constructor.elementProperties; + if (t4.size > 0) for (const [s5, i5] of t4) { + const { wrapped: t5 } = i5, e4 = this[s5]; + true !== t5 || this._$AL.has(s5) || void 0 === e4 || this.C(s5, void 0, i5, e4); + } + } + let t3 = false; + const s4 = this._$AL; + try { + t3 = this.shouldUpdate(s4), t3 ? (this.willUpdate(s4), this._$EO?.forEach((t4) => t4.hostUpdate?.()), this.update(s4)) : this._$EM(); + } catch (s5) { + throw t3 = false, this._$EM(), s5; + } + t3 && this._$AE(s4); + } + willUpdate(t3) { + } + _$AE(t3) { + this._$EO?.forEach((t4) => t4.hostUpdated?.()), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t3)), this.updated(t3); + } + _$EM() { + this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false; + } + get updateComplete() { + return this.getUpdateComplete(); + } + getUpdateComplete() { + return this._$ES; + } + shouldUpdate(t3) { + return true; + } + update(t3) { + this._$Eq &&= this._$Eq.forEach((t4) => this._$ET(t4, this[t4])), this._$EM(); + } + updated(t3) { + } + firstUpdated(t3) { + } +}; +y.elementStyles = [], y.shadowRootOptions = { + mode: "open" +}, y[d("elementProperties")] = /* @__PURE__ */ new Map(), y[d("finalized")] = /* @__PURE__ */ new Map(), p?.({ + ReactiveElement: y +}), (a.reactiveElementVersions ??= []).push("2.1.2"); + +// node_modules/.deno/lit-html@3.3.3/node_modules/lit-html/lit-html.js +var t2 = globalThis; +var i3 = (t3) => t3; +var s2 = t2.trustedTypes; +var e3 = s2 ? s2.createPolicy("lit-html", { + createHTML: (t3) => t3 +}) : void 0; +var h2 = "$lit$"; +var o3 = `lit$${Math.random().toFixed(9).slice(2)}$`; +var n3 = "?" + o3; +var r3 = `<${n3}>`; +var l2 = document; +var c3 = () => l2.createComment(""); +var a2 = (t3) => null === t3 || "object" != typeof t3 && "function" != typeof t3; +var u2 = Array.isArray; +var d2 = (t3) => u2(t3) || "function" == typeof t3?.[Symbol.iterator]; +var f2 = "[ \n\f\r]"; +var v = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g; +var _ = /-->/g; +var m = />/g; +var p2 = RegExp(`>|${f2}(?:([^\\s"'>=/]+)(${f2}*=${f2}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"); +var g = /'/g; +var $ = /"/g; +var y2 = /^(?:script|style|textarea|title)$/i; +var x = (t3) => (i5, ...s4) => ({ + _$litType$: t3, + strings: i5, + values: s4 +}); +var b2 = x(1); +var w = x(2); +var T = x(3); +var E = Symbol.for("lit-noChange"); +var A = Symbol.for("lit-nothing"); +var C = /* @__PURE__ */ new WeakMap(); +var P = l2.createTreeWalker(l2, 129); +function V(t3, i5) { + if (!u2(t3) || !t3.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return void 0 !== e3 ? e3.createHTML(i5) : i5; +} +var N = (t3, i5) => { + const s4 = t3.length - 1, e4 = []; + let n4, l3 = 2 === i5 ? "" : 3 === i5 ? "" : "", c4 = v; + for (let i6 = 0; i6 < s4; i6++) { + const s5 = t3[i6]; + let a3, u3, d3 = -1, f3 = 0; + for (; f3 < s5.length && (c4.lastIndex = f3, u3 = c4.exec(s5), null !== u3); ) f3 = c4.lastIndex, c4 === v ? "!--" === u3[1] ? c4 = _ : void 0 !== u3[1] ? c4 = m : void 0 !== u3[2] ? (y2.test(u3[2]) && (n4 = RegExp("" === u3[0] ? (c4 = n4 ?? v, d3 = -1) : void 0 === u3[1] ? d3 = -2 : (d3 = c4.lastIndex - u3[2].length, a3 = u3[1], c4 = void 0 === u3[3] ? p2 : '"' === u3[3] ? $ : g) : c4 === $ || c4 === g ? c4 = p2 : c4 === _ || c4 === m ? c4 = v : (c4 = p2, n4 = void 0); + const x2 = c4 === p2 && t3[i6 + 1].startsWith("/>") ? " " : ""; + l3 += c4 === v ? s5 + r3 : d3 >= 0 ? (e4.push(a3), s5.slice(0, d3) + h2 + s5.slice(d3) + o3 + x2) : s5 + o3 + (-2 === d3 ? i6 : x2); + } + return [ + V(t3, l3 + (t3[s4] || "") + (2 === i5 ? "" : 3 === i5 ? "" : "")), + e4 + ]; +}; +var S2 = class _S { + constructor({ strings: t3, _$litType$: i5 }, e4) { + let r4; + this.parts = []; + let l3 = 0, a3 = 0; + const u3 = t3.length - 1, d3 = this.parts, [f3, v2] = N(t3, i5); + if (this.el = _S.createElement(f3, e4), P.currentNode = this.el.content, 2 === i5 || 3 === i5) { + const t4 = this.el.content.firstChild; + t4.replaceWith(...t4.childNodes); + } + for (; null !== (r4 = P.nextNode()) && d3.length < u3; ) { + if (1 === r4.nodeType) { + if (r4.hasAttributes()) for (const t4 of r4.getAttributeNames()) if (t4.endsWith(h2)) { + const i6 = v2[a3++], s4 = r4.getAttribute(t4).split(o3), e5 = /([.?@])?(.*)/.exec(i6); + d3.push({ + type: 1, + index: l3, + name: e5[2], + strings: s4, + ctor: "." === e5[1] ? I : "?" === e5[1] ? L : "@" === e5[1] ? z : H + }), r4.removeAttribute(t4); + } else t4.startsWith(o3) && (d3.push({ + type: 6, + index: l3 + }), r4.removeAttribute(t4)); + if (y2.test(r4.tagName)) { + const t4 = r4.textContent.split(o3), i6 = t4.length - 1; + if (i6 > 0) { + r4.textContent = s2 ? s2.emptyScript : ""; + for (let s4 = 0; s4 < i6; s4++) r4.append(t4[s4], c3()), P.nextNode(), d3.push({ + type: 2, + index: ++l3 + }); + r4.append(t4[i6], c3()); + } + } + } else if (8 === r4.nodeType) if (r4.data === n3) d3.push({ + type: 2, + index: l3 + }); + else { + let t4 = -1; + for (; -1 !== (t4 = r4.data.indexOf(o3, t4 + 1)); ) d3.push({ + type: 7, + index: l3 + }), t4 += o3.length - 1; + } + l3++; + } + } + static createElement(t3, i5) { + const s4 = l2.createElement("template"); + return s4.innerHTML = t3, s4; + } +}; +function M(t3, i5, s4 = t3, e4) { + if (i5 === E) return i5; + let h3 = void 0 !== e4 ? s4._$Co?.[e4] : s4._$Cl; + const o5 = a2(i5) ? void 0 : i5._$litDirective$; + return h3?.constructor !== o5 && (h3?._$AO?.(false), void 0 === o5 ? h3 = void 0 : (h3 = new o5(t3), h3._$AT(t3, s4, e4)), void 0 !== e4 ? (s4._$Co ??= [])[e4] = h3 : s4._$Cl = h3), void 0 !== h3 && (i5 = M(t3, h3._$AS(t3, i5.values), h3, e4)), i5; +} +var R = class { + constructor(t3, i5) { + this._$AV = [], this._$AN = void 0, this._$AD = t3, this._$AM = i5; + } + get parentNode() { + return this._$AM.parentNode; + } + get _$AU() { + return this._$AM._$AU; + } + u(t3) { + const { el: { content: i5 }, parts: s4 } = this._$AD, e4 = (t3?.creationScope ?? l2).importNode(i5, true); + P.currentNode = e4; + let h3 = P.nextNode(), o5 = 0, n4 = 0, r4 = s4[0]; + for (; void 0 !== r4; ) { + if (o5 === r4.index) { + let i6; + 2 === r4.type ? i6 = new k(h3, h3.nextSibling, this, t3) : 1 === r4.type ? i6 = new r4.ctor(h3, r4.name, r4.strings, this, t3) : 6 === r4.type && (i6 = new Z(h3, this, t3)), this._$AV.push(i6), r4 = s4[++n4]; + } + o5 !== r4?.index && (h3 = P.nextNode(), o5++); + } + return P.currentNode = l2, e4; + } + p(t3) { + let i5 = 0; + for (const s4 of this._$AV) void 0 !== s4 && (void 0 !== s4.strings ? (s4._$AI(t3, s4, i5), i5 += s4.strings.length - 2) : s4._$AI(t3[i5])), i5++; + } +}; +var k = class _k { + get _$AU() { + return this._$AM?._$AU ?? this._$Cv; + } + constructor(t3, i5, s4, e4) { + this.type = 2, this._$AH = A, this._$AN = void 0, this._$AA = t3, this._$AB = i5, this._$AM = s4, this.options = e4, this._$Cv = e4?.isConnected ?? true; + } + get parentNode() { + let t3 = this._$AA.parentNode; + const i5 = this._$AM; + return void 0 !== i5 && 11 === t3?.nodeType && (t3 = i5.parentNode), t3; + } + get startNode() { + return this._$AA; + } + get endNode() { + return this._$AB; + } + _$AI(t3, i5 = this) { + t3 = M(this, t3, i5), a2(t3) ? t3 === A || null == t3 || "" === t3 ? (this._$AH !== A && this._$AR(), this._$AH = A) : t3 !== this._$AH && t3 !== E && this._(t3) : void 0 !== t3._$litType$ ? this.$(t3) : void 0 !== t3.nodeType ? this.T(t3) : d2(t3) ? this.k(t3) : this._(t3); + } + O(t3) { + return this._$AA.parentNode.insertBefore(t3, this._$AB); + } + T(t3) { + this._$AH !== t3 && (this._$AR(), this._$AH = this.O(t3)); + } + _(t3) { + this._$AH !== A && a2(this._$AH) ? this._$AA.nextSibling.data = t3 : this.T(l2.createTextNode(t3)), this._$AH = t3; + } + $(t3) { + const { values: i5, _$litType$: s4 } = t3, e4 = "number" == typeof s4 ? this._$AC(t3) : (void 0 === s4.el && (s4.el = S2.createElement(V(s4.h, s4.h[0]), this.options)), s4); + if (this._$AH?._$AD === e4) this._$AH.p(i5); + else { + const t4 = new R(e4, this), s5 = t4.u(this.options); + t4.p(i5), this.T(s5), this._$AH = t4; + } + } + _$AC(t3) { + let i5 = C.get(t3.strings); + return void 0 === i5 && C.set(t3.strings, i5 = new S2(t3)), i5; + } + k(t3) { + u2(this._$AH) || (this._$AH = [], this._$AR()); + const i5 = this._$AH; + let s4, e4 = 0; + for (const h3 of t3) e4 === i5.length ? i5.push(s4 = new _k(this.O(c3()), this.O(c3()), this, this.options)) : s4 = i5[e4], s4._$AI(h3), e4++; + e4 < i5.length && (this._$AR(s4 && s4._$AB.nextSibling, e4), i5.length = e4); + } + _$AR(t3 = this._$AA.nextSibling, s4) { + for (this._$AP?.(false, true, s4); t3 !== this._$AB; ) { + const s5 = i3(t3).nextSibling; + i3(t3).remove(), t3 = s5; + } + } + setConnected(t3) { + void 0 === this._$AM && (this._$Cv = t3, this._$AP?.(t3)); + } +}; +var H = class { + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t3, i5, s4, e4, h3) { + this.type = 1, this._$AH = A, this._$AN = void 0, this.element = t3, this.name = i5, this._$AM = e4, this.options = h3, s4.length > 2 || "" !== s4[0] || "" !== s4[1] ? (this._$AH = Array(s4.length - 1).fill(new String()), this.strings = s4) : this._$AH = A; + } + _$AI(t3, i5 = this, s4, e4) { + const h3 = this.strings; + let o5 = false; + if (void 0 === h3) t3 = M(this, t3, i5, 0), o5 = !a2(t3) || t3 !== this._$AH && t3 !== E, o5 && (this._$AH = t3); + else { + const e5 = t3; + let n4, r4; + for (t3 = h3[0], n4 = 0; n4 < h3.length - 1; n4++) r4 = M(this, e5[s4 + n4], i5, n4), r4 === E && (r4 = this._$AH[n4]), o5 ||= !a2(r4) || r4 !== this._$AH[n4], r4 === A ? t3 = A : t3 !== A && (t3 += (r4 ?? "") + h3[n4 + 1]), this._$AH[n4] = r4; + } + o5 && !e4 && this.j(t3); + } + j(t3) { + t3 === A ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t3 ?? ""); + } +}; +var I = class extends H { + constructor() { + super(...arguments), this.type = 3; + } + j(t3) { + this.element[this.name] = t3 === A ? void 0 : t3; + } +}; +var L = class extends H { + constructor() { + super(...arguments), this.type = 4; + } + j(t3) { + this.element.toggleAttribute(this.name, !!t3 && t3 !== A); + } +}; +var z = class extends H { + constructor(t3, i5, s4, e4, h3) { + super(t3, i5, s4, e4, h3), this.type = 5; + } + _$AI(t3, i5 = this) { + if ((t3 = M(this, t3, i5, 0) ?? A) === E) return; + const s4 = this._$AH, e4 = t3 === A && s4 !== A || t3.capture !== s4.capture || t3.once !== s4.once || t3.passive !== s4.passive, h3 = t3 !== A && (s4 === A || e4); + e4 && this.element.removeEventListener(this.name, this, s4), h3 && this.element.addEventListener(this.name, this, t3), this._$AH = t3; + } + handleEvent(t3) { + "function" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t3) : this._$AH.handleEvent(t3); + } +}; +var Z = class { + constructor(t3, i5, s4) { + this.element = t3, this.type = 6, this._$AN = void 0, this._$AM = i5, this.options = s4; + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t3) { + M(this, t3); + } +}; +var B = t2.litHtmlPolyfillSupport; +B?.(S2, k), (t2.litHtmlVersions ??= []).push("3.3.3"); +var D = (t3, i5, s4) => { + const e4 = s4?.renderBefore ?? i5; + let h3 = e4._$litPart$; + if (void 0 === h3) { + const t4 = s4?.renderBefore ?? null; + e4._$litPart$ = h3 = new k(i5.insertBefore(c3(), t4), t4, void 0, s4 ?? {}); + } + return h3._$AI(t3), h3; +}; + +// node_modules/.deno/lit-element@4.2.2/node_modules/lit-element/lit-element.js +var s3 = globalThis; +var i4 = class extends y { + constructor() { + super(...arguments), this.renderOptions = { + host: this + }, this._$Do = void 0; + } + createRenderRoot() { + const t3 = super.createRenderRoot(); + return this.renderOptions.renderBefore ??= t3.firstChild, t3; + } + update(t3) { + const r4 = this.render(); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t3), this._$Do = D(r4, this.renderRoot, this.renderOptions); + } + connectedCallback() { + super.connectedCallback(), this._$Do?.setConnected(true); + } + disconnectedCallback() { + super.disconnectedCallback(), this._$Do?.setConnected(false); + } + render() { + return E; + } +}; +i4._$litElement$ = true, i4["finalized"] = true, s3.litElementHydrateSupport?.({ + LitElement: i4 +}); +var o4 = s3.litElementPolyfillSupport; +o4?.({ + LitElement: i4 +}); +(s3.litElementVersions ??= []).push("4.2.2"); + +// src/styles/shared.ts +var baseStyles = i` + *, + *::before, + *::after { + box-sizing: border-box; + } + ::selection { + background: var(--signal-tint-strong); + } +`; + +// src/state/backend.ts +async function post(url, body) { + const res = await fetch(url, { + method: "POST", + headers: body === void 0 ? {} : { + "content-type": "application/json" + }, + body: body === void 0 ? void 0 : JSON.stringify(body) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `${url} failed (${res.status})`); + return data; +} +var backend = { + onState: void 0, + connect(winId) { + const ws = new WebSocket(`ws://${location.host}/api/events?win=${encodeURIComponent(winId)}`); + ws.addEventListener("message", (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type === "state") this.onState?.(msg.state); + } catch { + } + }); + ws.addEventListener("close", () => setTimeout(() => this.connect(winId), 1500)); + }, + state: () => fetch("/api/state").then((r4) => r4.json()), + openPath: (path) => post("/api/docs/open", { + path + }), + fromModel: (modelId) => post("/api/docs/from-model", { + modelId + }), + winOp: (winId, body) => post(`/api/windows/${winId}/op`, body), + rename: (id, name) => post(`/api/docs/${id}/rename`, { + name + }), + saveAsModel: (docId) => post(`/api/models/from/${docId}`), + pick: () => fetch("/api/pick").then((r4) => r4.json()), + pickSave: (name) => fetch(`/api/pick-save?name=${encodeURIComponent(name)}`).then((r4) => r4.json()), + moveDoc: (id, dest) => post(`/api/docs/${id}/move`, { + dest + }), + removeRecent: (id) => fetch(`/api/recents/${id}`, { + method: "DELETE" + }).then((r4) => r4.json()), + reveal: (path) => post("/api/reveal", { + path + }), + importTile: async (name, bytes) => { + const res = await fetch(`/api/import?name=${encodeURIComponent(name)}`, { + method: "POST", + body: bytes + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "import failed"); + return data; + }, + loadData: async (docId, name) => { + const res = await fetch(`/api/docs/${docId}/data/${name}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`loadData failed (${res.status})`); + return new Uint8Array(await res.arrayBuffer()); + }, + putData: async (docId, name, data) => { + const res = await fetch(`/api/docs/${docId}/data/${name}`, { + method: "PUT", + body: data + }); + if (!res.ok) throw new Error(`putData failed (${res.status})`); + } +}; +var docIconUrl = (d3) => d3.hasIcon ? `/api/docs/${d3.id}/icon` : void 0; +var recentIconUrl = (d3) => d3.hasIcon ? `/api/recents/${d3.id}/icon` : void 0; +var modelIconUrl = (m2) => m2.hasIcon ? `/api/models/${encodeURIComponent(m2.id)}/icon` : void 0; +var MIN = 6e4; +var HOUR = 36e5; +var DAY = 864e5; +function relativeTime(ts) { + const delta = Date.now() - ts; + if (delta < MIN) return "just now"; + if (delta < HOUR) return `${Math.round(delta / MIN)}m ago`; + if (delta < DAY) return `${Math.round(delta / HOUR)}h ago`; + if (delta < 2 * DAY) return "yesterday"; + if (delta < 14 * DAY) return `${Math.round(delta / DAY)}d ago`; + return `${Math.round(delta / (7 * DAY))}w ago`; +} +function formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// src/state/desk.ts +var DeskStore = class extends EventTarget { + recents = []; + models = []; + windows = []; + docs = /* @__PURE__ */ new Map(); + tileServerPort = 0; + desktop = false; + connected = false; + /** Which window this page renders. */ + winId = "base"; + sawMyWindow = false; + init() { + this.winId = new URLSearchParams(location.search).get("win") ?? "base"; + backend.onState = (state) => this.applyServerState(state); + backend.connect(this.winId); + backend.state().then((s4) => this.applyServerState(s4)).catch(() => { + }); + } + applyServerState(state) { + this.recents = state.recents; + this.models = state.models; + this.windows = state.windows; + this.tileServerPort = state.tileServerPort; + this.desktop = state.desktop; + this.connected = true; + this.docs = new Map(state.open.map((d3) => [ + d3.id, + d3 + ])); + if (this.winId !== "base") { + if (this.myWindow()) this.sawMyWindow = true; + else if (this.sawMyWindow) { + window.close(); + } + } + this.startGeometry(state); + this.emit(); + } + /* ——— window geometry: applied once, then reported to the server ——— */ + geometryStarted = false; + startGeometry(state) { + if (this.geometryStarted) return; + this.geometryStarted = true; + const stored = this.winId === "base" ? state.mainBounds : this.myWindow()?.bounds; + try { + if (stored) { + moveTo(stored.x, stored.y); + resizeTo(stored.w, stored.h); + } else if (this.winId === "base") { + moveTo(Math.max(0, Math.round((screen.availWidth - outerWidth) / 2)), Math.max(0, Math.round((screen.availHeight - outerHeight) / 2))); + } + } catch { + } + let last = ""; + const bounds = () => ({ + x: screenX, + y: screenY, + w: outerWidth, + h: outerHeight + }); + const report = () => { + const b3 = bounds(); + const key = `${b3.x},${b3.y},${b3.w},${b3.h}`; + if (!b3.w || key === last) return; + last = key; + fetch(`/api/windows/${this.winId}/bounds`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify(b3) + }).catch(() => { + }); + }; + setInterval(report, 2e3); + addEventListener("resize", report); + addEventListener("pagehide", () => { + navigator.sendBeacon(`/api/windows/${this.winId}/bounds`, JSON.stringify(bounds())); + }); + setTimeout(report, 1200); + } + myWindow() { + return this.windows.find((w2) => w2.id === this.winId); + } + getDoc(id) { + return this.docs.get(id); + } + get openDocIds() { + return new Set(this.windows.flatMap((w2) => w2.panes.flatMap((p3) => p3.tabs))); + } + /* ——— opening things (from the base window) ——— */ + handleOpened(res) { + if (!this.desktop && !res.existing) { + const b3 = res.win.bounds; + const features = `popup,width=${b3?.w ?? 820},height=${b3?.h ?? 680}` + (b3 ? `,left=${b3.x},top=${b3.y}` : ""); + globalThis.open(res.url, res.win.id, features); + } else if (res.existing) { + this.toast(`\u201C${res.doc.name}\u201D is already open \u2014 look for its window`); + } + } + async openRecent(entry) { + try { + this.handleOpened(await backend.openPath(entry.path)); + } catch (err) { + this.toast(`Could not open \u201C${entry.name}\u201D: ${err.message}`); + } + } + async newFromModel(modelId) { + try { + this.handleOpened(await backend.fromModel(modelId)); + } catch (err) { + this.toast(`Could not create document: ${err.message}`); + } + } + async openExternal() { + const picked = await backend.pick(); + if (picked.cancelled) return; + let path = picked.path; + if (picked.unsupported || !path) { + path = prompt("Absolute path to a .tile file:") ?? void 0; + if (!path) return; + } + try { + this.handleOpened(await backend.openPath(path)); + } catch (err) { + this.toast(`Could not open tile: ${err.message}`); + } + } + async importFile(file) { + try { + const res = await backend.importTile(file.name, await file.arrayBuffer()); + this.handleOpened(res); + this.toast(`Imported \u201C${res.doc.name}\u201D \u2014 it lives on this machine now`); + } catch (err) { + this.toast(`Could not import: ${err.message}`); + } + } + /* ——— window ops (from a document window) ——— */ + activateTab(paneId, docId) { + backend.winOp(this.winId, { + op: "activate", + paneId, + docId + }).catch(() => { + }); + } + closeTab(paneId, docId) { + backend.winOp(this.winId, { + op: "close-tab", + paneId, + docId + }).catch(() => { + }); + } + closeThisWindow() { + backend.winOp(this.winId, { + op: "close" + }).catch(() => { + }); + } + merge(dstWinId) { + backend.winOp(this.winId, { + op: "merge", + dstWinId + }).catch(() => { + }); + } + splitMerge(dstWinId, side) { + backend.winOp(this.winId, { + op: "split-merge", + dstWinId, + side + }).catch(() => { + }); + } + splitWithin() { + backend.winOp(this.winId, { + op: "split-within" + }).catch(() => { + }); + } + moveTab(paneId, docId, dstWinId, dstPaneId) { + backend.winOp(this.winId, { + op: "move-tab", + paneId, + docId, + dstWinId, + dstPaneId + }).catch(() => { + }); + } + async tearOff(paneId, docId) { + try { + const res = await backend.winOp(this.winId, { + op: "tear-off", + paneId, + docId + }); + if (!this.desktop && res.url) globalThis.open(res.url, "_blank", "popup,width=820,height=680"); + } catch (err) { + this.toast(err.message); + } + } + setRatio(ratio) { + backend.winOp(this.winId, { + op: "ratio", + value: ratio + }).catch(() => { + }); + } + /* ——— document actions ——— */ + async rename(docId, title) { + const clean = title.trim(); + const doc = this.docs.get(docId); + if (!doc || !clean || doc.name === clean) return; + try { + await backend.rename(docId, clean); + } catch (err) { + this.toast(`Could not rename: ${err.message}`); + } + } + async saveAsModel(docId) { + try { + const model = await backend.saveAsModel(docId); + this.toast(`\u201C${model.name}\u201D is now a model \u2014 find it under Models`); + } catch (err) { + this.toast(err.message); + } + } + /** Move the file; the server remaps the path-derived id in every window. */ + async moveDoc(docId) { + const doc = this.docs.get(docId); + if (!doc) return; + const picked = await backend.pickSave(doc.path.split("/").pop() ?? `${doc.name}.tile`); + if (picked.cancelled) return; + let dest = picked.path; + if (picked.unsupported || !dest) { + dest = prompt("Move tile to (absolute path):", doc.path) ?? void 0; + if (!dest || dest === doc.path) return; + } + try { + const { info } = await backend.moveDoc(docId, dest); + this.toast(`Moved to ${info.path}`); + } catch (err) { + this.toast(`Could not move: ${err.message}`); + } + } + async removeRecent(entry) { + try { + await backend.removeRecent(entry.id); + } catch (err) { + this.toast(`Could not remove: ${err.message}`); + } + } + async reveal(path) { + try { + const res = await backend.reveal(path); + if (!res.ok) this.toast("No file manager responded"); + } catch (err) { + this.toast(`Could not reveal: ${err.message}`); + } + } + toast(message) { + this.dispatchEvent(new CustomEvent("toast", { + detail: message + })); + } + emit() { + this.dispatchEvent(new Event("change")); + } +}; +var desk = new DeskStore(); + +// src/ui/data-protocol.ts +var UP_PREFIX = "tiles-protocol-up-data-"; +var UP_PAYLOAD = `${UP_PREFIX}payload`; +var DOWN_PAYLOAD = "tiles-protocol-down-data-payload"; +var DataProtocolHub = class { + #frames = /* @__PURE__ */ new Map(); + init() { + globalThis.addEventListener("message", (ev) => { + const action = ev.data?.action; + if (typeof action !== "string" || !action.startsWith(UP_PREFIX)) return; + const docId = this.#docIdFor(ev.source); + if (!docId) return; + if (action === UP_PAYLOAD && ev.data.payload?.ziranStore) { + this.#handleStore(docId, ev.data.payload.ziranStore); + } + }); + } + async #handleStore(docId, req) { + const reply = (msg) => this.send(docId, { + ziranStore: { + reqId: req.reqId, + ...msg + } + }); + try { + if (req.op === "load") { + const data = await backend.loadData(docId, req.name); + reply({ + data + }); + } else if (req.op === "put") { + await backend.putData(docId, req.name, new Uint8Array(req.data ?? [])); + reply({ + ok: true + }); + } else { + reply({ + error: `unknown op` + }); + } + } catch (err) { + reply({ + error: err.message + }); + } + } + #docIdFor(source) { + for (const [id, ifr] of this.#frames) { + if (ifr.contentWindow && ifr.contentWindow === source) return id; + } + return void 0; + } + /** Called by the tile frame whenever it mounts a fresh shuttle iframe. */ + register(docId, iframe) { + this.#frames.set(docId, iframe); + } + unregister(docId, iframe) { + if (!iframe || this.#frames.get(docId) === iframe) this.#frames.delete(docId); + } + send(docId, payload) { + const win = this.#frames.get(docId)?.contentWindow; + if (!win) return false; + win.postMessage({ + action: DOWN_PAYLOAD, + payload + }, "*"); + return true; + } +}; +var dataHub = new DataProtocolHub(); + +// src/components/icons.ts +var wrap = (body) => w` + `; +var icons = { + text: wrap(w``), + sheet: wrap(w``), + deck: wrap(w``), + model: wrap(w``), + search: wrap(w``), + close: wrap(w``), + plus: wrap(w``), + dots: wrap(w``), + publish: wrap(w``), + lock: wrap(w``), + warning: wrap(w``), + check: wrap(w``), + drive: wrap(w``), + laptop: wrap(w``), + people: wrap(w``), + link: wrap(w``), + tile: wrap(w``) +}; +var zmark = w` + `; + +// src/components/zn-base-window.ts +var ZnBaseWindow = class extends i4 { + static properties = { + query: { + state: true + }, + dropping: { + state: true + }, + menuRecent: { + state: true + } + }; + constructor() { + super(); + this.query = ""; + this.dropping = false; + this.menuRecent = void 0; + } + onStore = () => this.requestUpdate(); + connectedCallback() { + super.connectedCallback(); + desk.addEventListener("change", this.onStore); + } + disconnectedCallback() { + desk.removeEventListener("change", this.onStore); + super.disconnectedCallback(); + } + static styles = [ + baseStyles, + i` + :host { + display: block; + height: 100dvh; + } + .base { + height: 100%; + display: flex; + flex-direction: column; + background: var(--surface); + overflow: hidden; + } + .base.dropping { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + /* The ink banner: only the home base wears it. */ + header { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-4) var(--sp-5); + background: var(--ink); + color: var(--on-ink); + user-select: none; + } + .mark { + display: inline-grid; + place-items: center; + color: var(--signal-strong); + flex: none; + } + .wordmark { + font-size: var(--text-md); + font-weight: var(--w-semibold); + letter-spacing: 0.01em; + color: var(--on-ink); + } + .search { + flex: 1; + display: flex; + align-items: center; + gap: var(--sp-2); + max-width: 560px; + margin-left: var(--sp-4); + border: none; + border-radius: var(--r-full); + background: var(--surface); + padding: 8px 14px; + color: var(--ink-muted); + } + .search:focus-within { + outline: 2px solid var(--signal-strong); + outline-offset: 2px; + } + .search input { + flex: 1; + border: none; + background: none; + font: inherit; + color: var(--ink); + min-width: 0; + } + .search input:focus { + outline: none; + } + .search input::placeholder { + color: var(--ink-muted); + opacity: 1; + } + .search kbd { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--ink-muted); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 1px 6px; + background: var(--surface); + } + @media (pointer: coarse), (max-width: 839px) { + .search kbd { + display: none; + } + } + .open-btn { + margin-left: auto; + font: inherit; + font-size: var(--text-sm); + font-weight: var(--w-medium); + color: var(--on-ink); + background: none; + border: 1px solid oklch(0.45 0.015 352); + border-radius: var(--r-full); + padding: 5px 14px; + cursor: pointer; + white-space: nowrap; + transition: border-color var(--t-fast) var(--ease-out); + } + .open-btn:hover { + border-color: var(--on-ink-muted); + } + .open-btn:focus-visible { + outline: 2px solid var(--signal-strong); + outline-offset: 2px; + } + .regions { + flex: 1; + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + gap: var(--sp-5); + padding: var(--sp-4) var(--sp-5); + min-height: 0; + } + h2 { + font-size: var(--text-sm); + font-weight: var(--w-semibold); + color: var(--ink-muted); + margin: 0 0 var(--sp-2); + padding-left: var(--sp-2); + } + .region { + display: flex; + flex-direction: column; + min-height: 0; + } + .scroll { + overflow-y: auto; + min-height: 0; + scrollbar-width: thin; + } + .tile-ico { + width: 20px; + height: 20px; + border-radius: 5px; + display: block; + flex: none; + } + .tile-ico.fallback { + display: inline-grid; + place-items: center; + color: var(--ink-muted); + border: 1px solid var(--line-soft); + } + /* history rows */ + .row { + display: flex; + align-items: center; + gap: var(--sp-3); + width: 100%; + border: none; + background: none; + font: inherit; + text-align: left; + padding: 8px var(--sp-2); + border-radius: var(--r-sm); + cursor: pointer; + color: var(--ink); + transition: background var(--t-fast) var(--ease-out); + } + .row:hover { + background: var(--panel); + } + .row:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + .row .title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: var(--w-medium); + } + .row .title .flag { + font-weight: var(--w-regular); + color: var(--ink-muted); + } + .row .open-dot { + width: 6px; + height: 6px; + border-radius: var(--r-full); + background: var(--signal-strong); + flex: none; + } + .row .row-menu { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--ink-muted); + cursor: pointer; + opacity: 0; + flex: none; + transition: opacity var(--t-fast) var(--ease-out); + } + .row:hover .row-menu, + .row .row-menu:focus-visible { + opacity: 1; + } + .row .row-menu:hover { + background: var(--panel-deep); + color: var(--ink); + } + .row .row-menu:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .menu { + position: fixed; + margin: 0; + padding: var(--sp-1); + min-width: 200px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-pop); + } + .menu button { + display: block; + width: 100%; + text-align: left; + font: inherit; + font-size: var(--text-sm); + color: var(--ink); + background: none; + border: none; + border-radius: var(--r-sm); + padding: 7px 10px; + cursor: pointer; + } + .menu button:hover { + background: var(--panel); + } + .menu button:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + .menu hr { + border: none; + border-top: 1px solid var(--line-soft); + margin: var(--sp-1) 0; + } + .row time { + color: var(--ink-muted); + font-size: var(--text-sm); + flex: none; + font-variant-numeric: tabular-nums; + } + /* model rows */ + .model { + display: grid; + grid-template-columns: auto 1fr; + column-gap: var(--sp-3); + align-items: start; + width: 100%; + border: 1px solid var(--line-soft); + background: var(--surface); + font: inherit; + text-align: left; + padding: var(--sp-3); + border-radius: var(--r-sm); + cursor: pointer; + color: var(--ink); + transition: border-color var(--t-fast) var(--ease-out), background var(--t-fast) var(--ease-out); + } + .model + .model { + margin-top: var(--sp-2); + } + .model:hover { + border-color: var(--ink-muted); + } + .model:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .model .tile-ico { + width: 24px; + height: 24px; + margin-top: 1px; + } + .model .name { + font-weight: var(--w-semibold); + } + .model .blurb { + color: var(--ink-muted); + font-size: var(--text-sm); + margin-top: 1px; + text-wrap: pretty; + } + .empty { + padding: var(--sp-4) var(--sp-2); + } + .empty h3 { + font-size: var(--text-xl); + font-weight: var(--w-semibold); + margin: 0 0 var(--sp-2); + letter-spacing: -0.01em; + text-wrap: balance; + } + .empty p { + margin: 0; + color: var(--ink-muted); + max-width: 40ch; + line-height: 1.55; + text-wrap: pretty; + } + .region-hint { + color: var(--ink-muted); + font-size: var(--text-sm); + margin: -2px 0 var(--sp-2); + padding-left: var(--sp-2); + text-wrap: pretty; + } + .enter-kbd { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--ink-muted); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 0 5px; + background: var(--surface); + flex: none; + } + .row.enter-target, + .model.enter-target { + background: var(--panel); + } + .model.enter-target { + border-color: var(--ink-muted); + } + .none { + color: var(--ink-muted); + padding: var(--sp-3) var(--sp-2); + } + footer { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-5); + border-top: 1px solid var(--line-soft); + background: var(--panel); + color: var(--ink-muted); + font-size: var(--text-sm); + min-height: 36px; + } + footer .glyph { + display: inline-grid; + transform: scale(0.9); + } + footer .sep { + flex: 1; + } + @media (max-width: 839px) { + .regions { + grid-template-columns: 1fr; + } + } + ` + ]; + render() { + const q = this.query.trim().toLowerCase(); + const recents = desk.recents.filter((d3) => !q || d3.name.toLowerCase().includes(q)); + const models = desk.models.filter((m2) => !q || m2.name.toLowerCase().includes(q) || (m2.description ?? "").toLowerCase().includes(q)); + const open = desk.openDocIds; + const enterDoc = q ? recents[0] : void 0; + const enterModel = q && !enterDoc ? models[0] : void 0; + const damagedCount = desk.recents.filter((r4) => r4.damaged).length; + return b2` +
this.dropping = false} + @drop=${this.onDrop} + > +
+ ${zmark} + ziran + + +
+
+
+

Recent

+
+ ${!desk.recents.length && !q ? b2` +
+

Your documents live here.

+

+ Ziran documents are tiles: they carry their own editor, live on this + machine, and never need a cloud. Start from a model to make your first + one — it will appear here. You can also drop a .tile file anywhere on + this window. +

+
+ ` : recents.length ? recents.map((d3) => this.renderRow(d3, open.has(d3.id), d3 === enterDoc)) : b2`

Nothing called “${this.query}” — try a model instead?

`} +
+
+
+

Models

+

Editors without their data — any document can become one.

+
+ ${models.length ? models.map((m2) => this.renderModel(m2, m2 === enterModel)) : b2`

No model matches “${this.query}”.

`} +
+
+
+ +
+ ${damagedCount ? icons.warning : icons.check} + + ${desk.recents.length ? damagedCount ? `${damagedCount} of ${desk.recents.length} tiles failed verification` : "All documents verified and saved on this machine" : "Everything you make here stays on this machine"} + + + ${desk.recents.length} ${desk.recents.length === 1 ? "tile" : "tiles"} +
+
+ `; + } + tileIcon(url) { + return url ? b2`` : b2`${icons.tile}`; + } + renderRow(d3, isOpen, enterTarget = false) { + return b2` + + `; + } + renderRowMenu() { + const d3 = this.menuRecent; + if (!d3) return A; + return b2` + + +
+ + `; + } + openRowMenu(e4, d3) { + e4.preventDefault(); + this.menuRecent = d3; + const at = e4.clientX ? { + x: e4.clientX, + y: e4.clientY + } : (() => { + const r4 = e4.target.getBoundingClientRect(); + return { + x: r4.left, + y: r4.bottom + 4 + }; + })(); + this.updateComplete.then(() => { + const menu = this.renderRoot.querySelector(".menu"); + menu.style.left = `${Math.max(8, Math.min(at.x - 6, innerWidth - 208))}px`; + menu.style.top = `${Math.max(8, Math.min(at.y - 6, innerHeight - 160))}px`; + menu.showPopover(); + menu.querySelector("button")?.focus(); + }); + } + rowMenuAction(fn) { + this.renderRoot.querySelector(".menu")?.hidePopover(); + this.menuRecent = void 0; + fn(); + } + renderModel(m2, enterTarget = false) { + return b2` + + `; + } + focusCommand() { + const input = this.renderRoot.querySelector("input.command"); + input.focus(); + input.select(); + } + commandKey(e4) { + const input = e4.target; + if (e4.key === "Escape") { + this.query = ""; + input.blur(); + return; + } + if (e4.key !== "Enter") return; + const q = this.query.trim().toLowerCase(); + if (!q) return; + const doc = desk.recents.find((d3) => d3.name.toLowerCase().includes(q)); + if (doc) { + desk.openRecent(doc); + this.query = ""; + return; + } + const model = desk.models.find((m2) => m2.name.toLowerCase().includes(q) || (m2.description ?? "").toLowerCase().includes(q)); + if (model) { + desk.newFromModel(model.id); + this.query = ""; + } + } + onDragOver(e4) { + if (e4.dataTransfer?.types.includes("Files")) { + e4.preventDefault(); + this.dropping = true; + } + } + onDrop(e4) { + this.dropping = false; + const files = [ + ...e4.dataTransfer?.files ?? [] + ].filter((f3) => f3.name.endsWith(".tile")); + if (!files.length) return; + e4.preventDefault(); + for (const file of files) desk.importFile(file); + } +}; +customElements.define("zn-base-window", ZnBaseWindow); + +// src/components/zn-connections.ts +var ZnConnections = class extends i4 { + static properties = { + doc: { + attribute: false + }, + open: { + state: true + } + }; + constructor() { + super(); + this.open = false; + } + onStore = () => this.requestUpdate(); + connectedCallback() { + super.connectedCallback(); + desk.addEventListener("change", this.onStore); + } + disconnectedCallback() { + desk.removeEventListener("change", this.onStore); + super.disconnectedCallback(); + } + static styles = [ + baseStyles, + i` + .chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px 3px 5px; + border: 1px solid var(--line); + border-radius: var(--r-full); + background: var(--surface); + cursor: pointer; + font: inherit; + color: var(--ink-muted); + transition: border-color var(--t-fast) var(--ease-out); + } + .chip:hover { + border-color: var(--ink-muted); + } + .chip:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; + } + .chip[aria-expanded='true'] { + border-color: var(--signal); + } + .chip img { + width: 18px; + height: 18px; + border-radius: 4px; + display: block; + } + .chip .fallback { + display: inline-grid; + color: var(--ink-muted); + transform: scale(0.9); + } + .mark { + display: inline-grid; + transform: scale(0.8); + } + .mark.ok { + color: var(--ink-muted); + } + .mark.bad { + color: var(--danger); + } + .panel { + position: fixed; + width: 324px; + margin: 0; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-pop); + color: var(--ink); + font-size: var(--text-sm); + } + .panel::backdrop { + background: transparent; + } + section { + padding: var(--sp-3) var(--sp-4); + display: grid; + gap: var(--sp-2); + min-width: 0; + } + /* Grid items default to min-width auto, which lets a long path force + the popover wider than itself and scroll horizontally. */ + section > * { + min-width: 0; + } + 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); + } + .row { + display: flex; + align-items: center; + gap: var(--sp-2); + min-height: 24px; + } + .row .grow { + flex: 1; + min-width: 0; + } + .mono { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--ink-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .ok { + color: var(--ink-muted); + display: inline-flex; + align-items: center; + gap: 4px; + } + .muted { + color: var(--ink-muted); + } + .fine { + color: var(--ink-muted); + line-height: 1.45; + text-wrap: pretty; + margin: 0; + } + button.action { + font: inherit; + font-weight: var(--w-medium); + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--r-full); + padding: 3px 12px; + cursor: pointer; + transition: border-color var(--t-fast) var(--ease-out); + white-space: nowrap; + } + button.action:hover { + border-color: var(--ink-muted); + } + button.action:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; + } + ` + ]; + render() { + const d3 = this.doc; + const iconUrl = docIconUrl(d3); + const label = `Tile details for ${d3.name}: ${d3.damaged ? "failed verification" : "verified"}` + (d3.modelName ? `, made from the ${d3.modelName} model` : ""); + return b2` + + + `; + } + renderModelSection() { + const d3 = this.doc; + const saved = d3.modelId ? desk.models.find((m2) => m2.id === d3.modelId) : void 0; + if (saved) { + return b2`

Model saved in your library as “${saved.name}”.

`; + } + if (d3.modelId) { + return b2` +
+ Made from the ${d3.modelName ?? "a"} model — not in your library + +
+

Saving keeps the editor and drops the content — a fresh starting point.

+ `; + } + return b2`

No model provenance — this tile did not come from a model.

`; + } + get panel() { + return this.renderRoot.querySelector(".panel"); + } + toggle() { + if (this.open) this.panel.hidePopover(); + else { + this.panel.showPopover(); + this.position(); + this.panel.querySelector("button")?.focus(); + } + } + position() { + const chip = this.renderRoot.querySelector(".chip"); + const r4 = chip.getBoundingClientRect(); + const { width, height } = this.panel.getBoundingClientRect(); + const left = Math.max(8, Math.min(r4.right - width, innerWidth - width - 8)); + const below = r4.bottom + 8; + const top = below + height > innerHeight - 8 ? Math.max(8, r4.top - height - 8) : below; + this.panel.style.left = `${left}px`; + this.panel.style.top = `${top}px`; + } + updated() { + if (this.open) this.position(); + } + onToggle(e4) { + const opened = e4.newState === "open"; + this.open = opened; + if (!opened) this.renderRoot.querySelector(".chip")?.focus(); + } +}; +customElements.define("zn-connections", ZnConnections); + +// node_modules/.deno/@dasl+tile-loader@2.0.0/node_modules/@dasl/tile-loader/dist/lib/el.js +function el(n4, attrs, kids, p3) { + const e4 = document.createElement(n4); + Object.entries(attrs || {}).forEach(([k2, v2]) => { + if (v2 == null) return; + if (k2 === "style") { + Object.entries(v2).forEach(([prop, value]) => { + const snake = prop.split("-").map((part, idx) => idx ? part.charAt(0).toUpperCase() + part.slice(1) : part).join(""); + e4.style[snake] = value; + }); + return; + } + e4.setAttribute(k2, String(v2)); + }); + (kids || []).forEach((n5) => { + if (typeof n5 === "string") e4.append(txt(n5)); + else e4.append(n5); + }); + if (p3) p3.append(e4); + return e4; +} +function txt(str) { + return document.createTextNode(str); +} + +// node_modules/.deno/@dasl+tile-loader@2.0.0/node_modules/@dasl/tile-loader/dist/index.js +var TILES_PFX = "tiles-"; +var SHUTTLE_PFX = "tiles-shuttle-"; +var SND_SHUTTLE_LOAD = `${SHUTTLE_PFX}load`; +var RCV_SHUTTLE_READY = `${SHUTTLE_PFX}ready`; +var SND_SET_TITLE = `${SHUTTLE_PFX}set-title`; +var SND_SET_ICON = `${SHUTTLE_PFX}set-icon`; +var WORKER_PFX = "tiles-worker-"; +var SND_WORKER_LOAD = `${WORKER_PFX}load`; +var RCV_WORKER_READY = `${WORKER_PFX}ready`; +var RCV_WORKER_REQUEST = `${WORKER_PFX}request`; +var SND_WORKER_RESPONSE = `${WORKER_PFX}response`; +var TILES_WARNING = `${TILES_PFX}warn`; +var TILES_ERROR = `${TILES_PFX}error`; +var TileMothership = class { + #loaders = []; + #conf; + #id2shuttle = /* @__PURE__ */ new Map(); + #id2tile = /* @__PURE__ */ new Map(); + constructor(conf = {}) { + this.#conf = conf; + } + init() { + window.addEventListener("message", async (ev) => { + const { action } = ev.data || {}; + if (action === TILES_WARNING) { + const { msg, id } = ev.data; + console.warn(`[W:${id}]`, ...msg); + } + if (action === TILES_ERROR) { + const { msg, id } = ev.data; + console.error(`[S:${id}]`, ...msg); + } else if (action === RCV_SHUTTLE_READY) { + const { id } = ev.data; + this.sendToShuttle(id, SND_WORKER_LOAD, { + id + }); + } else if (action === RCV_WORKER_READY) { + const { id } = ev.data; + const tile = this.#id2tile.get(id); + if (!tile) throw new Error(`No tile shuttle with ID ${id}`); + tile.dispatchEvent(new Event("load")); + } else if (action === RCV_WORKER_REQUEST) { + const { type, id, payload } = ev.data; + if (type === "resolve-path") { + const { path, requestId } = payload; + const tile = this.#id2tile.get(id); + if (!tile) throw new Error(`No tile shuttle with ID ${id}`); + const res = await tile.resolvePath(path); + const response = res.ok ? { + status: res.status, + headers: res.headers, + body: res.body + } : { + status: res.status, + headers: void 0, + body: void 0 + }; + this.sendToShuttle(id, SND_WORKER_RESPONSE, { + requestId, + response + }); + } + } + }); + } + sendToShuttle(id, action, payload) { + const ifr = this.#id2shuttle.get(id); + if (!ifr) return console.error(`No shuttle for ID ${id}`); + ifr.contentWindow?.postMessage({ + id, + action, + payload + }, "*"); + } + registerShuttleFrame(ifr, tile) { + const id = crypto.randomUUID(); + this.#id2shuttle.set(id, ifr); + this.#id2tile.set(id, tile); + return id; + } + startShuttle(id) { + this.sendToShuttle(id, SND_SHUTTLE_LOAD, { + id + }); + } + // Adds a loader that will handle matching requests to load a tile. + // - `loader` is an object that knows how to load a tile for a specific scheme + // (and types) + addLoader(loader) { + this.#loaders.push(loader); + } + // Remove using same reference. + removeLoader(loader) { + this.#loaders = this.#loaders.filter((ldr) => ldr !== loader); + } + getLoadSource() { + return `https://${this.#conf?.loadDomain || "load.webtil.es"}/.well-known/web-tiles/`; + } + // Load a tile. + async loadTile(url) { + for (const ldr of this.#loaders) { + const tile = await ldr.load(url, this); + if (tile) return tile; + } + return false; + } +}; +var Tile = class extends EventTarget { + #mothership; + #url; + #manifest; + #pathLoader; + #shuttleId; + constructor(mothership2, url, manifest, pathLoader) { + super(); + this.#mothership = mothership2; + this.#url = url; + this.#manifest = manifest; + this.#pathLoader = pathLoader; + this.addEventListener("load", () => { + if (this.#manifest?.name) { + const title = this.#manifest?.name; + this.#mothership.sendToShuttle(this.#shuttleId, SND_SET_TITLE, { + title + }); + const ev = new Event("title-change"); + ev.title = title; + this.dispatchEvent(ev); + } + const icon = this.#manifest?.icons?.[0]?.src; + if (icon) { + this.#mothership.sendToShuttle(this.#shuttleId, SND_SET_ICON, { + path: icon + }); + } + }); + } + get url() { + return this.#url; + } + get manifest() { + return this.#manifest; + } + async resolvePath(path) { + const u3 = new URL(`fake:${path}`); + return this.#pathLoader.resolvePath(u3.pathname); + } + async renderCard(options) { + const card = el("div", { + style: { + border: "1px solid lightgrey", + "border-radius": "3px", + cursor: "pointer" + } + }); + card.addEventListener("click", async () => { + const tileRenderer = await this.renderContent(options?.contentHeight || this.#manifest?.sizing?.height || Math.max(card.offsetHeight, 300)); + card.replaceWith(tileRenderer); + }); + if (this.#manifest?.screenshots?.[0]?.src) { + const res = await this.resolvePath(this.#manifest.screenshots[0].src); + if (res.ok) { + const blob = new Blob([ + res.body + ], { + type: res.headers?.["content-type"] + }); + const url = URL.createObjectURL(blob); + el("div", { + style: { + "background-image": `url(${url})`, + "background-size": "cover", + "background-position": "50%", + "aspect-ratio": "16/9" + } + }, [], card); + } + } + const title = el("div", { + style: { + padding: "0.5rem 1rem", + display: "flex", + "align-items": "center" + } + }, [], card); + if (this.#manifest?.icons?.[0]?.src) { + const res = await this.resolvePath(this.#manifest.icons[0].src); + if (res.ok) { + const blob = new Blob([ + res.body + ], { + type: res.headers?.["content-type"] + }); + const url = URL.createObjectURL(blob); + el("img", { + src: url, + width: "48", + height: "48", + alt: "icon", + style: { + "padding-right": "0.5rem" + } + }, [], title); + } + } + el("span", { + style: { + "font-weight": "bold" + } + }, [ + this.#manifest.name || "Untitled Tile" + ], title); + if (this.#manifest.description) { + el("p", { + style: { + "margin": "0.5rem 1rem 1rem 1rem" + } + }, [ + this.#manifest.description + ], card); + } + return card; + } + getLoadSource() { + return this.#mothership.getLoadSource(); + } + attachIframe(ifr) { + this.#shuttleId = this.#mothership.registerShuttleFrame(ifr, this); + ifr.addEventListener("load", () => this.#mothership.startShuttle(this.#shuttleId)); + } + renderContent(height = 300) { + const ifr = el("iframe", { + src: this.#mothership.getLoadSource(), + style: { + display: "block", + width: "100%", + height: `${height}px`, + border: "none" + } + }); + this.attachIframe(ifr); + return ifr; + } +}; + +// node_modules/.deno/@atcute+uint8array@1.1.4/node_modules/@atcute/uint8array/dist/index.js +var textEncoder = new TextEncoder(); +var textDecoder = new TextDecoder("utf-8", { + ignoreBOM: true +}); +var subtle = crypto.subtle; +var _fromCharCode = String.fromCharCode; +var _shortString = (from, p3, length) => { + if (length < 4) { + if (length < 2) { + if (length === 0) { + return ""; + } + const a5 = from[p3]; + if (a5 & 128) { + return null; + } + return _fromCharCode(a5); + } + const a4 = from[p3]; + const b4 = from[p3 + 1]; + if ((a4 | b4) & 128) { + return null; + } + if (length === 2) { + return _fromCharCode(a4, b4); + } + const c5 = from[p3 + 2]; + if (c5 & 128) { + return null; + } + return _fromCharCode(a4, b4, c5); + } + const a3 = from[p3]; + const b3 = from[p3 + 1]; + const c4 = from[p3 + 2]; + const d3 = from[p3 + 3]; + if ((a3 | b3 | c4 | d3) & 128) { + return null; + } + if (length < 8) { + if (length === 4) { + return _fromCharCode(a3, b3, c4, d3); + } + const e5 = from[p3 + 4]; + if (e5 & 128) { + return null; + } + if (length === 5) { + return _fromCharCode(a3, b3, c4, d3, e5); + } + const f4 = from[p3 + 5]; + if (f4 & 128) { + return null; + } + if (length === 6) { + return _fromCharCode(a3, b3, c4, d3, e5, f4); + } + const g3 = from[p3 + 6]; + if (g3 & 128) { + return null; + } + return _fromCharCode(a3, b3, c4, d3, e5, f4, g3); + } + const e4 = from[p3 + 4]; + const f3 = from[p3 + 5]; + const g2 = from[p3 + 6]; + const h3 = from[p3 + 7]; + if ((e4 | f3 | g2 | h3) & 128) { + return null; + } + if (length < 12) { + if (length === 8) { + return _fromCharCode(a3, b3, c4, d3, e4, f3, g2, h3); + } + const i6 = from[p3 + 8]; + if (i6 & 128) { + return null; + } + if (length === 9) { + return _fromCharCode(a3, b3, c4, d3, e4, f3, g2, h3, i6); + } + const j2 = from[p3 + 9]; + if (j2 & 128) { + return null; + } + if (length === 10) { + return _fromCharCode(a3, b3, c4, d3, e4, f3, g2, h3, i6, j2); + } + const k3 = from[p3 + 10]; + if (k3 & 128) { + return null; + } + return _fromCharCode(a3, b3, c4, d3, e4, f3, g2, h3, i6, j2, k3); + } + const i5 = from[p3 + 8]; + const j = from[p3 + 9]; + const k2 = from[p3 + 10]; + const l3 = from[p3 + 11]; + if ((i5 | j | k2 | l3) & 128) { + return null; + } + if (length === 12) { + return _fromCharCode(a3, b3, c4, d3, e4, f3, g2, h3, i5, j, k2, l3); + } + const m2 = from[p3 + 12]; + if (m2 & 128) { + return null; + } + if (length === 13) { + return _fromCharCode(a3, b3, c4, d3, e4, f3, g2, h3, i5, j, k2, l3, m2); + } + const n4 = from[p3 + 13]; + if (n4 & 128) { + return null; + } + if (length === 14) { + return _fromCharCode(a3, b3, c4, d3, e4, f3, g2, h3, i5, j, k2, l3, m2, n4); + } + const o5 = from[p3 + 14]; + if (o5 & 128) { + return null; + } + return _fromCharCode(a3, b3, c4, d3, e4, f3, g2, h3, i5, j, k2, l3, m2, n4, o5); +}; +var decodeUtf8From = (from, offset = 0, length = from.length) => { + if (length <= 15) { + const result = _shortString(from, offset, length); + if (result !== null) { + return result; + } + } + if (offset === 0 && length === from.length) { + return textDecoder.decode(from); + } + return textDecoder.decode(from.subarray(offset, offset + length)); +}; + +// node_modules/.deno/@atcute+multibase@1.2.4/node_modules/@atcute/multibase/dist/utils.js +var createRfc4648Encode = (alphabet, bitsPerChar, pad) => { + return (bytes) => { + const mask = (1 << bitsPerChar) - 1; + let str = ""; + let bits = 0; + let buffer = 0; + for (let i5 = 0; i5 < bytes.length; ++i5) { + buffer = buffer << 8 | bytes[i5]; + bits += 8; + while (bits > bitsPerChar) { + bits -= bitsPerChar; + str += alphabet[mask & buffer >> bits]; + } + } + if (bits !== 0) { + str += alphabet[mask & buffer << bitsPerChar - bits]; + } + if (pad) { + while ((str.length * bitsPerChar & 7) !== 0) { + str += "="; + } + } + return str; + }; +}; + +// node_modules/.deno/@atcute+multibase@1.2.4/node_modules/@atcute/multibase/dist/bases/base64-web-native.js +var toBase64 = (bytes) => { + return bytes.toBase64({ + alphabet: "base64", + omitPadding: true + }); +}; + +// node_modules/.deno/@atcute+multibase@1.2.4/node_modules/@atcute/multibase/dist/bases/base64-web-polyfill.js +var BASE64_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +var toBase642 = /* @__PURE__ */ createRfc4648Encode(BASE64_CHARSET, 6, false); + +// node_modules/.deno/@atcute+multibase@1.2.4/node_modules/@atcute/multibase/dist/bases/base64-web.js +var HAS_NATIVE_SUPPORT = "fromBase64" in Uint8Array; +var toBase643 = !HAS_NATIVE_SUPPORT ? toBase642 : toBase64; + +// node_modules/.deno/@atcute+multibase@1.2.4/node_modules/@atcute/multibase/dist/bases/base32-encode.js +var ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; +var _cc = /* @__PURE__ */ (() => { + const t3 = new Uint8Array(32); + for (let i5 = 0; i5 < 32; i5++) { + t3[i5] = ALPHABET.charCodeAt(i5); + } + return t3; +})(); +var _fromCharCode2 = String.fromCharCode; +var toBase32 = (bytes) => { + const len = bytes.length; + const full = len / 5 | 0; + const rem = len - full * 5; + const cc = _cc; + let str = ""; + let ip = 0; + const pairs = full / 2 | 0; + for (let g2 = 0; g2 < pairs; g2++) { + const a0 = bytes[ip], a1 = bytes[ip + 1], a22 = bytes[ip + 2], a3 = bytes[ip + 3], a4 = bytes[ip + 4]; + const b0 = bytes[ip + 5], b1 = bytes[ip + 6], b22 = bytes[ip + 7], b3 = bytes[ip + 8], b4 = bytes[ip + 9]; + str += _fromCharCode2(cc[a0 >>> 3], cc[(a0 << 2 | a1 >>> 6) & 31], cc[a1 >>> 1 & 31], cc[(a1 << 4 | a22 >>> 4) & 31], cc[(a22 << 1 | a3 >>> 7) & 31], cc[a3 >>> 2 & 31], cc[(a3 << 3 | a4 >>> 5) & 31], cc[a4 & 31], cc[b0 >>> 3], cc[(b0 << 2 | b1 >>> 6) & 31], cc[b1 >>> 1 & 31], cc[(b1 << 4 | b22 >>> 4) & 31], cc[(b22 << 1 | b3 >>> 7) & 31], cc[b3 >>> 2 & 31], cc[(b3 << 3 | b4 >>> 5) & 31], cc[b4 & 31]); + ip += 10; + } + if (full & 1) { + const b0 = bytes[ip], b1 = bytes[ip + 1], b22 = bytes[ip + 2], b3 = bytes[ip + 3], b4 = bytes[ip + 4]; + str += _fromCharCode2(cc[b0 >>> 3], cc[(b0 << 2 | b1 >>> 6) & 31], cc[b1 >>> 1 & 31], cc[(b1 << 4 | b22 >>> 4) & 31], cc[(b22 << 1 | b3 >>> 7) & 31], cc[b3 >>> 2 & 31], cc[(b3 << 3 | b4 >>> 5) & 31], cc[b4 & 31]); + ip += 5; + } + if (rem > 0) { + let buffer = 0; + let bits = 0; + for (let i5 = ip; i5 < len; i5++) { + buffer = buffer << 8 | bytes[i5]; + bits += 8; + } + while (bits >= 5) { + bits -= 5; + str += _fromCharCode2(cc[buffer >>> bits & 31]); + } + if (bits > 0) { + str += _fromCharCode2(cc[buffer << 5 - bits & 31]); + } + } + return str; +}; + +// node_modules/.deno/@atcute+cid@2.4.2/node_modules/@atcute/cid/dist/codec.js +var CID_VERSION = 1; +var HASH_SHA256 = 18; +var CODEC_RAW = 85; +var CODEC_DCBOR = 113; +var decodeFirst = (bytes) => { + if (bytes.length < 36) { + throw new RangeError(`cid too short`); + } + const version = bytes[0]; + const codec = bytes[1]; + const digestType = bytes[2]; + const digestSize = bytes[3]; + if (version !== CID_VERSION) { + throw new RangeError(`incorrect cid version (got v${version})`); + } + if (codec !== CODEC_DCBOR && codec !== CODEC_RAW) { + throw new RangeError(`incorrect cid codec (got 0x${codec.toString(16)})`); + } + if (digestType !== HASH_SHA256) { + throw new RangeError(`incorrect cid digest codec (got 0x${digestType.toString(16)})`); + } + if (digestSize !== 32) { + throw new RangeError(`incorrect cid digest size (got ${digestSize})`); + } + const cid = { + version: CID_VERSION, + codec, + digest: { + codec: digestType, + contents: bytes.subarray(4, 36) + }, + bytes: bytes.subarray(0, 36) + }; + return [ + cid, + bytes.subarray(36) + ]; +}; +var decode = (bytes) => { + const [cid, remainder] = decodeFirst(bytes); + if (remainder.length !== 0) { + throw new RangeError(`cid bytes includes remainder`); + } + return cid; +}; +var toString = (cid) => { + return `b${toBase32(cid.bytes)}`; +}; +var fromBinary = (input) => { + if (input.length !== 37 || input[0] !== 0) { + throw new SyntaxError(`invalid binary cid`); + } + return decode(input.subarray(1)); +}; + +// node_modules/.deno/@atcute+cid@2.4.2/node_modules/@atcute/cid/dist/cid-link.js +var CID_LINK_SYMBOL = Symbol.for("@atcute/cid-link-wrapper"); +var CidLinkWrapper = class { + /** @internal */ + [CID_LINK_SYMBOL] = true; + bytes; + constructor(bytes) { + this.bytes = bytes; + } + get $link() { + const link = `b${toBase32(this.bytes)}`; + Object.defineProperty(this, "$link", { + value: link, + enumerable: true + }); + return link; + } + toJSON() { + return { + $link: this.$link + }; + } +}; +var toCidLink = (cid) => { + return new CidLinkWrapper(cid.bytes); +}; + +// node_modules/.deno/@atcute+cbor@2.3.5/node_modules/@atcute/cbor/dist/bytes.js +var BYTES_SYMBOL = Symbol.for("@atcute/bytes-wrapper"); +var BytesWrapper = class { + /** @internal */ + [BYTES_SYMBOL] = true; + buf; + constructor(buf) { + this.buf = buf; + } + get $bytes() { + return toBase643(this.buf); + } + toJSON() { + return { + $bytes: this.$bytes + }; + } +}; +var toBytes = (buf) => { + return new BytesWrapper(buf); +}; + +// node_modules/.deno/@atcute+cbor@2.3.5/node_modules/@atcute/cbor/dist/decode.js +var requireBytes = (state, needed) => { + if (needed > state.b.length - state.p) { + throw new RangeError(`unexpected end of input`); + } +}; +var readArgument = (state, info) => { + if (info < 24) { + return info; + } + let arg; + switch (info) { + case 24: { + requireBytes(state, 1); + arg = readUint8(state); + if (arg < 24) { + throw new TypeError(`non-canonical argument encoding`); + } + break; + } + case 25: { + requireBytes(state, 2); + arg = readUint16(state); + if (arg < 256) { + throw new TypeError(`non-canonical argument encoding`); + } + break; + } + case 26: { + requireBytes(state, 4); + arg = readUint32(state); + if (arg < 65536) { + throw new TypeError(`non-canonical argument encoding`); + } + break; + } + case 27: { + requireBytes(state, 8); + arg = readUint53(state); + if (arg < 4294967296) { + throw new TypeError(`non-canonical argument encoding`); + } + break; + } + default: { + throw new Error(`invalid argument encoding; got ${info}`); + } + } + return arg; +}; +var readFloat64 = (state) => { + requireBytes(state, 8); + const view = state.v ??= new DataView(state.b.buffer, state.b.byteOffset, state.b.byteLength); + const value = view.getFloat64(state.p); + if (!Number.isFinite(value)) { + throw new RangeError(`NaN and Infinity values not supported`); + } + state.p += 8; + return value; +}; +var readUint8 = (state) => { + return state.b[state.p++]; +}; +var readUint16 = (state) => { + let pos = state.p; + const buf = state.b; + const value = buf[pos++] << 8 | buf[pos++]; + state.p = pos; + return value; +}; +var readUint32 = (state) => { + let pos = state.p; + const buf = state.b; + const value = (buf[pos++] << 24 | buf[pos++] << 16 | buf[pos++] << 8 | buf[pos++]) >>> 0; + state.p = pos; + return value; +}; +var readUint53 = (state) => { + const hi = readUint32(state); + const lo = readUint32(state); + if (hi > 2097151) { + throw new RangeError(`can't decode integers beyond safe integer range`); + } + return hi * 2 ** 32 + lo; +}; +var readString = (state, length) => { + requireBytes(state, length); + const string = decodeUtf8From(state.b, state.p, length); + state.p += length; + return string; +}; +var readBytes = (state, length) => { + requireBytes(state, length); + const slice = state.b.subarray(state.p, state.p += length); + return toBytes(slice); +}; +var readCid = (state, length) => { + requireBytes(state, length); + const cid = fromBinary(state.b.subarray(state.p, state.p += length)); + return new CidLinkWrapper(cid.bytes); +}; +var decodeStringKey = (state) => { + requireBytes(state, 1); + const prelude = readUint8(state); + const type = prelude >> 5; + if (type !== 3) { + throw new TypeError(`expected map to only have string keys; got type ${type}`); + } + const info = prelude & 31; + const length = info < 24 ? info : readArgument(state, info); + state.ks = state.p; + state.kl = length; + return readString(state, length); +}; +var decodeFirst2 = (buf) => { + const len = buf.length; + const state = { + b: buf, + v: null, + p: 0, + ks: 0, + kl: 0 + }; + let stack = null; + let value; + jump: while (state.p < len) { + const prelude = readUint8(state); + const type = prelude >> 5; + const info = prelude & 31; + const arg = type === 7 ? 0 : info < 24 ? info : readArgument(state, info); + switch (type) { + case 0: { + value = arg; + break; + } + case 1: { + value = -1 - arg; + if (value < Number.MIN_SAFE_INTEGER) { + throw new RangeError(`can't decode integers beyond safe integer range`); + } + break; + } + case 2: { + value = readBytes(state, arg); + break; + } + case 3: { + value = readString(state, arg); + break; + } + case 4: { + if (arg > 0) { + requireBytes(state, arg); + stack = { + t: 1, + c: value = new Array(arg), + k: null, + r: arg, + n: stack + }; + continue jump; + } + value = []; + break; + } + case 5: { + value = {}; + if (arg > 0) { + const first = decodeStringKey(state); + stack = { + t: 0, + c: value, + k: first, + ks: state.ks, + kl: state.kl, + r: arg, + n: stack + }; + continue jump; + } + break; + } + case 6: { + switch (arg) { + case 42: { + requireBytes(state, 1); + const prelude2 = readUint8(state); + const type2 = prelude2 >> 5; + const info2 = prelude2 & 31; + if (type2 !== 2) { + throw new TypeError(`expected cid-link to be type 2 (bytes); got type ${type2}`); + } + const len2 = readArgument(state, info2); + value = readCid(state, len2); + break; + } + default: { + throw new TypeError(`unsupported tag; got ${arg}`); + } + } + break; + } + case 7: { + switch (info) { + case 20: + case 21: { + value = info === 21; + break; + } + case 22: { + value = null; + break; + } + case 27: { + value = readFloat64(state); + break; + } + default: { + throw new Error(`invalid simple value; got ${info}`); + } + } + break; + } + default: { + throw new TypeError(`invalid type; got ${type}`); + } + } + while (stack !== null) { + switch (stack.t) { + case 0: { + const obj = stack.c; + const key = stack.k; + if (key === "__proto__") { + Object.defineProperty(obj, key, { + enumerable: true, + configurable: true, + writable: true + }); + } + obj[key] = value; + break; + } + case 1: { + const arr = stack.c; + const index = arr.length - stack.r; + arr[index] = value; + break; + } + } + if (--stack.r) { + if (!stack.t) { + const prevStart = stack.ks; + const prevLength = stack.kl; + stack.k = decodeStringKey(state); + const curStart = state.ks; + const curLength = state.kl; + let cmp = curLength - prevLength; + if (cmp === 0) { + const buf2 = state.b; + for (let i5 = 0; i5 < curLength; i5++) { + cmp = buf2[curStart + i5] - buf2[prevStart + i5]; + if (cmp !== 0) { + break; + } + } + } + if (cmp <= 0) { + throw new TypeError(`map keys are not in canonical order or contain duplicates`); + } + stack.ks = curStart; + stack.kl = curLength; + } + continue jump; + } + value = stack.c; + stack = stack.n; + } + return [ + value, + buf.subarray(state.p) + ]; + } + throw new RangeError(`unexpected end of input`); +}; +var decode2 = (buf) => { + const [value, remainder] = decodeFirst2(buf); + if (remainder.length !== 0) { + throw new Error(`decoded value contains remainder`); + } + return value; +}; + +// node_modules/.deno/@atcute+varint@2.0.1/node_modules/@atcute/varint/dist/index.js +var MSB = 128; +var REST = 127; +var MSBALL = ~REST; +var INT = 2 ** 31; +var N1 = 2 ** 7; +var N2 = 2 ** 14; +var N3 = 2 ** 21; +var N4 = 2 ** 28; +var N5 = 2 ** 35; +var N6 = 2 ** 42; +var N7 = 2 ** 49; +var N8 = 2 ** 56; +var N9 = 2 ** 63; +var MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; +var _min = Math.min; +var decode3 = (buf, offset = 0, length = buf.length) => { + const end = _min(offset + length, buf.length); + let counter = offset; + if (counter >= end) { + throw new RangeError("could not decode varint"); + } + let b3 = buf[counter++]; + let res = b3 & REST; + if (b3 < MSB) { + return { + value: res, + nextOffset: counter + }; + } + if (counter >= end) { + throw new RangeError("could not decode varint"); + } + b3 = buf[counter++]; + res |= (b3 & REST) << 7; + if (b3 < MSB) { + return { + value: res, + nextOffset: counter + }; + } + if (counter >= end) { + throw new RangeError("could not decode varint"); + } + b3 = buf[counter++]; + res |= (b3 & REST) << 14; + if (b3 < MSB) { + return { + value: res, + nextOffset: counter + }; + } + if (counter >= end) { + throw new RangeError("could not decode varint"); + } + b3 = buf[counter++]; + res |= (b3 & REST) << 21; + if (b3 < MSB) { + return { + value: res, + nextOffset: counter + }; + } + if (counter >= end) { + throw new RangeError("could not decode varint"); + } + b3 = buf[counter++]; + res += (b3 & REST) * N4; + if (b3 < MSB) { + return { + value: res, + nextOffset: counter + }; + } + let shift = 35; + do { + if (counter >= end) { + throw new RangeError("could not decode varint"); + } + b3 = buf[counter++]; + res += (b3 & REST) * 2 ** shift; + shift += 7; + } while (b3 >= MSB); + return { + value: res, + nextOffset: counter + }; +}; + +// node_modules/.deno/@atcute+car@6.0.2/node_modules/@atcute/car/dist/types.js +var isCarV1Header = (value) => { + if (value === null || typeof value !== "object") { + return false; + } + const { version, roots } = value; + return version === 1 && Array.isArray(roots) && roots.every((root) => root instanceof CidLinkWrapper); +}; + +// node_modules/.deno/@atcute+car@6.0.2/node_modules/@atcute/car/dist/reader.js +var fromUint8Array = (buffer) => { + const { header, nextOffset: headerOffset } = readHeader(buffer, 0); + return { + header, + roots: header.data.roots, + [Symbol.iterator]() { + let pos = headerOffset; + return { + next() { + if (pos >= buffer.length) { + return { + done: true, + value: void 0 + }; + } + const entryStart = pos; + const { value: entryLength, nextOffset: lengthOffset } = decode3(buffer, pos, 8); + pos = lengthOffset; + if (entryLength < 36) { + throw new RangeError(`invalid car block; length=${entryLength}`); + } + const cidStart = pos; + const { cid, nextOffset: cidOffset } = readCid2(buffer, pos); + pos = cidOffset; + const bytesStart = pos; + const bytesSize = entryLength - 36; + if (bytesStart + bytesSize > buffer.length) { + throw new RangeError("unexpected end of data"); + } + const bytesEnd = bytesStart + bytesSize; + const bytes = buffer.subarray(bytesStart, bytesEnd); + pos = bytesEnd; + const cidEnd = bytesStart; + const entryEnd = bytesEnd; + return { + done: false, + value: { + cid, + bytes, + entryStart, + entryEnd, + cidStart, + cidEnd, + bytesStart, + bytesEnd + } + }; + }, + [Symbol.iterator]() { + return this; + } + }; + } + }; +}; +var readHeader = (source, offset) => { + const headerStart = offset; + const { value: length, nextOffset: lengthOffset } = decode3(source, offset, 8); + if (length === 0) { + throw new RangeError(`invalid car header; length=0`); + } + const dataStart = lengthOffset; + const dataEnd = dataStart + length; + if (dataEnd > source.length) { + throw new RangeError("unexpected end of data"); + } + const data = decode2(source.subarray(dataStart, dataEnd)); + if (!isCarV1Header(data)) { + throw new TypeError(`expected a car v1 archive`); + } + const headerEnd = dataEnd; + return { + header: { + data, + headerStart, + headerEnd, + dataStart, + dataEnd + }, + nextOffset: dataEnd + }; +}; +var readCid2 = (source, offset) => { + const cidEnd = offset + 36; + if (cidEnd > source.length) { + throw new RangeError("unexpected end of data"); + } + const bytes = source.subarray(offset, cidEnd); + const version = bytes[0]; + const codec = bytes[1]; + const digestType = bytes[2]; + const digestSize = bytes[3]; + if (version !== CID_VERSION) { + throw new RangeError(`incorrect cid version (got v${version})`); + } + if (codec !== CODEC_DCBOR && codec !== CODEC_RAW) { + throw new RangeError(`incorrect cid codec (got 0x${codec.toString(16)})`); + } + if (digestType !== HASH_SHA256) { + throw new RangeError(`incorrect cid digest type (got 0x${digestType.toString(16)})`); + } + if (digestSize !== 32) { + throw new RangeError(`incorrect cid digest size (got ${digestSize})`); + } + return { + cid: { + version, + codec, + digest: { + codec: digestType, + contents: bytes.subarray(4, 36) + }, + bytes + }, + nextOffset: cidEnd + }; +}; + +// node_modules/.deno/@dasl+tile-loader@2.0.0/node_modules/@dasl/tile-loader/dist/content-scheme.js +var ContentSchemeTileLoader = class { + #schemes; + constructor(schemes = [ + "http", + "file" + ]) { + this.#schemes = new Set(schemes); + } + async load(url, mothership2) { + let u3; + try { + u3 = new URL(url); + } catch { + return false; + } + if (u3.protocol === "https:" || u3.protocol === "http:") { + if (!this.#schemes.has("http")) return false; + const res = await fetch(url); + if (!res.ok) return false; + return await this.processContent(await res.arrayBuffer(), u3.protocol, url, mothership2); + } + if (u3.protocol === "file:") { + if (!this.#schemes.has("file")) return false; + } + return void 0; + } +}; + +// node_modules/.deno/@dasl+tile-loader@2.0.0/node_modules/@dasl/tile-loader/dist/lib/masl.js +var NOT_FOUND = { + ok: false, + status: 404, + statusText: "Not found" +}; +var maslHeaders = [ + "content-disposition", + "content-encoding", + "content-language", + "content-security-policy", + "content-type", + "link", + "permissions-policy", + "referrer-policy", + "service-worker-allowed", + "sourcemap", + "speculation-rules", + "supports-loading-mode", + "x-content-type-options" +]; +function maslResponse(masl, body) { + if (!body) return NOT_FOUND; + const headers = {}; + maslHeaders.forEach((k2) => { + if (typeof masl[k2] !== "undefined") headers[k2] = masl[k2]; + }); + if (typeof body === "string") body = new TextEncoder().encode(body); + return { + ok: true, + status: 200, + statusText: "Ok", + headers, + body + }; +} + +// node_modules/.deno/@dasl+tile-loader@2.0.0/node_modules/@dasl/tile-loader/dist/car.js +var CARTileLoader = class extends ContentSchemeTileLoader { + constructor(schemes) { + super(schemes); + } + // NOTE: ignoring scheme for now, this is pure HTTP + async processContent(carData, scheme, url, mothership2) { + let car; + try { + car = fromUint8Array(new Uint8Array(carData)); + } catch (e4) { + return false; + } + console.warn(`car`, car); + const { data } = car.header; + const manifest = data; + delete manifest.version; + delete manifest.roots; + Object.keys(manifest.resources).forEach((k2) => { + manifest.resources[k2].src = toCidLink(manifest.resources[k2].src).toJSON(); + }); + const offsets = {}; + for await (const entry of car) { + const { cid, bytesStart, bytesEnd } = entry; + offsets[toString(cid)] = [ + bytesStart, + bytesEnd + ]; + } + const loader = new CARPathLoader(manifest, carData, offsets); + return new Tile(mothership2, url, manifest, loader); + } +}; +var CARPathLoader = class { + #manifest; + #carData; + #offsets; + constructor(manifest, carData, offsets) { + this.#manifest = manifest; + this.#carData = carData; + this.#offsets = offsets; + } + async resolvePath(path) { + const entry = this.#manifest?.resources?.[path]; + if (!entry?.src) return NOT_FOUND; + const src = entry.src; + const cid = "$link" in src ? src.$link : src.ref.$link; + const [start, end] = this.#offsets[cid]; + return maslResponse(entry, this.#carData.slice(start, end)); + } +}; + +// src/ui/mothership.ts +var LETTERS = "abcdefghijklmnopqrstuvwxyz"; +function randomSubdomain(length = 20) { + const bytes = crypto.getRandomValues(new Uint8Array(length)); + return Array.from(bytes, (b3) => LETTERS[b3 % LETTERS.length]).join(""); +} +var LocalTileMothership = class extends TileMothership { + /** Port of the embedded @dasl/tile-server; set from app state. */ + tileServerPort = 0; + getLoadSource() { + if (!this.tileServerPort) throw new Error("tile server port not known yet"); + return `http://${randomSubdomain()}.localhost:${this.tileServerPort}/.well-known/web-tiles/`; + } +}; +var mothership = new LocalTileMothership(); +mothership.init(); +mothership.addLoader(new CARTileLoader()); + +// src/components/zn-doc-canvas.ts +var ZnDocCanvas = class extends i4 { + static properties = { + doc: { + attribute: false + }, + phase: { + state: true + }, + error: { + state: true + } + }; + #token = 0; + #loadedFor = ""; + constructor() { + super(); + this.phase = "loading"; + this.error = ""; + } + static styles = [ + baseStyles, + i` + :host { + display: block; + height: 100%; + overflow: hidden; + background: var(--surface); + position: relative; + } + .mount, + .mount iframe { + height: 100%; + width: 100%; + border: none; + display: block; + } + .skeleton { + position: absolute; + inset: 0; + background: var(--surface); + padding: var(--sp-7) var(--sp-7) var(--sp-5); + } + .bone { + border-radius: var(--r-sm); + background: var(--panel-deep); + animation: breathe 1.2s var(--ease-out) infinite alternate; + } + @keyframes breathe { + from { opacity: 0.55; } + to { opacity: 1; } + } + .broken { + height: 100%; + display: grid; + place-content: center; + justify-items: center; + gap: var(--sp-3); + padding: var(--sp-6); + text-align: center; + } + .broken .glyph { + color: var(--danger); + transform: scale(1.6); + margin-bottom: var(--sp-2); + } + .broken h2 { + font-size: var(--text-lg); + font-weight: var(--w-semibold); + margin: 0; + } + .broken p { + margin: 0; + max-width: 44ch; + color: var(--ink-muted); + text-wrap: pretty; + } + .broken .detail { + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--danger); + background: var(--danger-tint); + padding: 2px 10px; + border-radius: var(--r-full); + } + ` + ]; + render() { + if (this.doc?.damaged) { + return b2``; + } + return b2` + ${this.phase === "loading" ? b2`
+
+ ${[ + 92, + 100, + 96, + 74 + ].map((w2, i5) => b2`
`)} +
` : A} + ${this.phase === "error" ? b2`` : A} +
+ `; + } + updated() { + if (this.doc && !this.doc.damaged && this.#loadedFor !== this.doc.id) this.#load(); + } + disconnectedCallback() { + super.disconnectedCallback(); + if (this.doc) dataHub.unregister(this.doc.id); + } + async #load() { + const mount = this.renderRoot.querySelector(".mount"); + if (!mount || !desk.tileServerPort) return; + const token = ++this.#token; + this.#loadedFor = this.doc.id; + this.phase = "loading"; + mothership.tileServerPort = desk.tileServerPort; + try { + const tile = await mothership.loadTile(`${location.origin}/api/docs/${this.doc.id}/bundle.tile`); + if (token !== this.#token) return; + if (!tile) throw new Error("no loader could handle this tile"); + const ifr = tile.renderContent(Math.max(this.getBoundingClientRect().height, 240)); + ifr.style.height = "100%"; + ifr.style.width = "100%"; + mount.replaceChildren(ifr); + dataHub.register(this.doc.id, ifr); + this.phase = "ready"; + } catch (err) { + if (token !== this.#token) return; + this.#loadedFor = ""; + this.phase = "error"; + this.error = err.message; + mount.replaceChildren(); + } + } +}; +customElements.define("zn-doc-canvas", ZnDocCanvas); + +// src/components/zn-doc-window.ts +var ZnDocWindow = class extends i4 { + static properties = { + winId: { + type: String + }, + menuTab: { + state: true + }, + focusPaneId: { + state: true + }, + renaming: { + state: true + } + }; + constructor() { + super(); + this.winId = ""; + this.menuTab = void 0; + this.focusPaneId = void 0; + this.renaming = false; + } + onStore = () => this.requestUpdate(); + connectedCallback() { + super.connectedCallback(); + desk.addEventListener("change", this.onStore); + } + disconnectedCallback() { + desk.removeEventListener("change", this.onStore); + super.disconnectedCallback(); + } + static styles = [ + baseStyles, + i` + :host { + display: flex; + flex-direction: column; + height: 100dvh; + background: var(--surface); + } + .bar { + display: flex; + align-items: center; + gap: var(--sp-2); + height: 42px; + padding: 0 var(--sp-2) 0 var(--sp-3); + background: var(--panel); + border-bottom: 1px solid var(--line-soft); + user-select: none; + flex: none; + min-width: 0; + } + .tile-ico { + width: 18px; + height: 18px; + border-radius: 4px; + display: block; + flex: none; + } + .tile-ico.fallback { + display: inline-grid; + place-items: center; + color: var(--ink-muted); + } + .bar .title { + font-weight: var(--w-semibold); + font-size: var(--text-ui); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + padding: 3px 8px; + margin: -3px -8px; + border-radius: var(--r-sm); + cursor: text; + } + .bar .title:hover { + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--line); + } + .bar .title-input { + font: inherit; + font-weight: var(--w-semibold); + font-size: var(--text-ui); + color: var(--ink); + border: none; + background: var(--surface); + border-radius: var(--r-sm); + padding: 3px 8px; + width: min(320px, 40vw); + } + .bar .title-input:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .bar .spacer { + flex: 1; + } + .icon-btn { + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: none; + background: none; + border-radius: var(--r-sm); + color: var(--ink-muted); + cursor: pointer; + position: relative; + transition: background var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out); + } + .icon-btn::before { + content: ''; + position: absolute; + inset: -8px; + } + .icon-btn:hover { + background: var(--panel-deep); + color: var(--ink); + } + .icon-btn:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 1px; + } + .body { + flex: 1; + display: flex; + min-height: 0; + } + .pane { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + } + /* vertical tab rail */ + .rail { + width: 184px; + flex: none; + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--sp-2); + background: var(--panel); + border-right: 1px solid var(--line-soft); + overflow-y: auto; + scrollbar-width: thin; + } + .tab { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + min-width: 0; + padding: 7px 8px; + border: none; + border-radius: var(--r-sm); + background: none; + font: inherit; + font-size: var(--text-sm); + color: var(--ink-muted); + cursor: pointer; + text-align: left; + transition: background var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out); + } + .tab .tile-ico { + width: 16px; + height: 16px; + border-radius: 4px; + } + .tab .label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tab:hover { + background: var(--panel-deep); + color: var(--ink); + } + .tab:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + .tab[aria-selected='true'] { + background: var(--surface); + color: var(--ink); + font-weight: var(--w-medium); + box-shadow: var(--shadow-window); + } + .tab .close { + display: grid; + place-items: center; + width: 16px; + height: 16px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--ink-muted); + cursor: pointer; + opacity: 0; + transform: scale(0.8); + transition: opacity var(--t-fast) var(--ease-out); + flex: none; + } + .tab:hover .close, + .tab .close:focus-visible, + .tab[aria-selected='true'] .close { + opacity: 1; + } + .tab .close:hover { + color: var(--ink); + background: var(--line-soft); + } + .pane-title { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 0 var(--sp-2); + background: var(--panel); + border-bottom: 1px solid var(--line-soft); + height: 30px; + flex: none; + font-size: var(--text-sm); + font-weight: var(--w-medium); + color: var(--ink-muted); + min-width: 0; + } + .pane-title span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .pane-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + } + .canvas-host { + flex: 1; + min-height: 0; + position: relative; + } + .divider { + flex: none; + width: 5px; + cursor: col-resize; + background: var(--line-soft); + border-inline: 1px solid var(--line-soft); + touch-action: none; + transition: background var(--t-fast) var(--ease-out); + } + .divider:hover, + .divider.active { + background: var(--signal); + } + .divider:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -1px; + background: var(--signal); + } + .gone { + height: 100%; + display: grid; + place-content: center; + color: var(--ink-muted); + } + .menu { + position: fixed; + margin: 0; + padding: var(--sp-1); + min-width: 240px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); + box-shadow: var(--shadow-pop); + } + .menu button { + display: block; + width: 100%; + text-align: left; + font: inherit; + font-size: var(--text-sm); + color: var(--ink); + background: none; + border: none; + border-radius: var(--r-sm); + padding: 7px 10px; + cursor: pointer; + } + .menu button:hover { + background: var(--panel); + } + .menu button:focus-visible { + outline: 2px solid var(--signal); + outline-offset: -2px; + } + .menu hr { + border: none; + border-top: 1px solid var(--line-soft); + margin: var(--sp-1) 0; + } + ` + ]; + rec() { + return desk.windows.find((w2) => w2.id === this.winId); + } + /** The pane whose active doc owns the banner. */ + focusPane(rec) { + return rec.panes.find((p3) => p3.id === this.focusPaneId) ?? rec.panes[0]; + } + tileIcon(docId, size = 18) { + const doc = desk.getDoc(docId); + const url = doc ? docIconUrl(doc) : void 0; + return url ? b2`` : b2`${icons.tile}`; + } + render() { + const rec = this.rec(); + if (!rec) { + return b2`
${desk.connected ? "This window has closed." : "Connecting\u2026"}
`; + } + const activeDoc = desk.getDoc(this.focusPane(rec).active); + return b2` +
+ ${activeDoc ? b2` + ${this.tileIcon(activeDoc.id)} + ${this.renaming ? b2` this.commitRename(e4.target.value)} + @keydown=${(e4) => { + if (e4.key === "Enter") e4.target.blur(); + if (e4.key === "Escape") this.renaming = false; + }} + />` : b2` this.beginRename()} + >${activeDoc.name}`} + ` : A} + + ${activeDoc ? b2`` : A} + +
+
+ ${rec.panes.map((pane, i5) => { + const parts = [ + this.renderPane(rec, pane) + ]; + if (i5 === 0 && rec.panes.length === 2) parts.push(this.renderDivider(rec)); + return parts; + })} +
+ + + `; + } + updated() { + const rec = this.rec(); + const doc = rec ? desk.getDoc(this.focusPane(rec).active) : void 0; + document.title = doc ? `${doc.name} \u2014 ziran` : "ziran"; + if (this.renaming) { + const input = this.renderRoot.querySelector(".title-input"); + if (input && document.activeElement !== input && this.shadowRoot?.activeElement !== input) { + input.select(); + } + } + } + renderPane(rec, pane) { + const doc = desk.getDoc(pane.active); + const split = rec.panes.length === 2; + const flexBasis = split ? `flex:0 0 calc(${(rec.panes[0] === pane ? rec.ratio : 1 - rec.ratio) * 100}% - 3px)` : "flex:1"; + const showPaneTitle = split && pane.tabs.length === 1 && pane !== this.focusPane(rec); + return b2` +
this.focusOn(pane)}> + ${pane.tabs.length > 1 ? b2` +
+ ${pane.tabs.map((t3) => this.renderTab(pane, t3))} +
+ ` : A} +
+ ${showPaneTitle ? b2`
+ ${this.tileIcon(pane.active, 14)} + ${doc?.name ?? ""} +
` : A} +
1 ? "tabpanel" : A} + aria-labelledby=${pane.tabs.length > 1 ? `tab-${pane.id}-${pane.active}` : A} + > + ${doc ? b2`` : A} +
+
+
+ `; + } + renderTab(pane, docId) { + const doc = desk.getDoc(docId); + if (!doc) return A; + const selected = pane.active === docId; + return b2` + + `; + } + renderDivider(rec) { + const pct = Math.round(rec.ratio * 100); + return b2``; + } + renderMenu(rec) { + const activeDoc = desk.getDoc(this.focusPane(rec).active); + const canSplitWithin = rec.panes.length === 1 && (rec.panes[0]?.tabs.length ?? 0) >= 2; + const others = desk.windows.filter((x2) => x2.id !== rec.id); + const titleOf = (x2) => desk.getDoc(x2.panes[0]?.active ?? "")?.name ?? "window"; + return b2` + ${activeDoc ? b2` + + ` : A} + ${canSplitWithin ? b2`` : A} + ${others.length || canSplitWithin || activeDoc ? b2`
` : A} + ${others.map((o5) => b2` + + ${o5.panes.length === 1 ? b2`` : A} + `)} + ${others.length ? b2`
` : A} + + `; + } + renderTabMenu(rec) { + const t3 = this.menuTab; + if (!t3) return A; + const doc = desk.getDoc(t3.docId); + if (!doc) return A; + const others = desk.windows.filter((x2) => x2.id !== rec.id); + const titleOf = (x2) => desk.getDoc(x2.panes[0]?.active ?? "")?.name ?? "window"; + return b2` + + ${others.map((o5) => b2` + + `)} +
+ + `; + } + focusOn(pane) { + if (this.focusPaneId !== pane.id) { + this.renaming = false; + this.focusPaneId = pane.id; + } + } + tabKey(e4, pane, docId) { + const idx = pane.tabs.indexOf(docId); + const delta = e4.key === "ArrowDown" ? 1 : e4.key === "ArrowUp" ? -1 : 0; + if (delta) { + e4.preventDefault(); + const next = pane.tabs[(idx + delta + pane.tabs.length) % pane.tabs.length]; + desk.activateTab(pane.id, next); + this.updateComplete.then(() => { + this.renderRoot.querySelector('.tab[aria-selected="true"]')?.focus(); + }); + } else if (e4.key === "w" && (e4.metaKey || e4.ctrlKey)) { + e4.preventDefault(); + desk.closeTab(pane.id, docId); + } + } + startDividerDrag(e4) { + e4.preventDefault(); + const divider = e4.currentTarget; + divider.classList.add("active"); + const body = this.renderRoot.querySelector(".body"); + const move = (ev) => { + const rect = body.getBoundingClientRect(); + desk.setRatio((ev.clientX - rect.left) / rect.width); + }; + const up = () => { + divider.classList.remove("active"); + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + } + openMenu(e4) { + const btn = e4.currentTarget; + const menu = this.renderRoot.querySelector(".menu:not(.tabmenu)"); + const r4 = btn.getBoundingClientRect(); + menu.style.left = `${Math.max(8, Math.min(r4.right - 240, innerWidth - 248))}px`; + menu.style.top = `${r4.bottom + 6}px`; + menu.showPopover(); + menu.querySelector("button")?.focus(); + } + menuAction(fn) { + this.renderRoot.querySelector(".menu:not(.tabmenu)")?.hidePopover(); + fn(); + } + openTabMenu(e4, pane, docId) { + e4.preventDefault(); + this.menuTab = { + paneId: pane.id, + docId + }; + this.updateComplete.then(() => { + const menu = this.renderRoot.querySelector(".tabmenu"); + menu.style.left = `${Math.max(8, Math.min(e4.clientX - 6, innerWidth - 248))}px`; + menu.style.top = `${Math.max(8, Math.min(e4.clientY - 6, innerHeight - 200))}px`; + menu.showPopover(); + menu.querySelector("button")?.focus(); + }); + } + tabMenuAction(fn) { + this.renderRoot.querySelector(".tabmenu")?.hidePopover(); + this.menuTab = void 0; + fn(); + } + beginRename() { + const rec = this.rec(); + if (!rec) return; + if (!desk.getDoc(this.focusPane(rec).active)) return; + this.renaming = true; + } + commitRename(value) { + const rec = this.rec(); + this.renaming = false; + if (!rec) return; + const doc = desk.getDoc(this.focusPane(rec).active); + if (doc) desk.rename(doc.id, value); + } +}; +customElements.define("zn-doc-window", ZnDocWindow); + +// src/components/zn-app.ts +var ZnApp = class extends i4 { + static properties = { + toasts: { + state: true + } + }; + constructor() { + super(); + this.toasts = []; + } + toastSeq = 0; + toastTimers = /* @__PURE__ */ new Map(); + onChange = () => this.requestUpdate(); + onToast = (e4) => this.pushToast(e4.detail); + onKey = (e4) => { + if (desk.winId === "base" && (e4.metaKey || e4.ctrlKey) && e4.key.toLowerCase() === "k") { + e4.preventDefault(); + this.renderRoot.querySelector("zn-base-window")?.focusCommand(); + } + }; + static styles = [ + baseStyles, + i` + :host { + display: block; + height: 100dvh; + background: var(--surface); + } + .toasts { + position: fixed; + bottom: var(--sp-5); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-2); + z-index: var(--z-toast); + pointer-events: none; + } + .toast { + background: var(--ink); + color: var(--surface); + font-size: var(--text-sm); + font-weight: var(--w-medium); + padding: 9px 18px; + border-radius: var(--r-full); + box-shadow: var(--shadow-pop); + animation: toast-in var(--t-slow) var(--ease-out); + pointer-events: auto; + } + @keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + } + ` + ]; + connectedCallback() { + super.connectedCallback(); + desk.addEventListener("change", this.onChange); + desk.addEventListener("toast", this.onToast); + window.addEventListener("keydown", this.onKey); + dataHub.init(); + desk.init(); + } + disconnectedCallback() { + super.disconnectedCallback(); + desk.removeEventListener("change", this.onChange); + desk.removeEventListener("toast", this.onToast); + window.removeEventListener("keydown", this.onKey); + } + render() { + return b2` + ${desk.winId === "base" ? b2`` : b2``} +
+ ${this.toasts.map((t3) => b2`
this.holdToast(t3.id)} + @mouseleave=${() => this.armToast(t3.id, 1600)} + > + ${t3.text} +
`)} +
+ `; + } + pushToast(text) { + const id = ++this.toastSeq; + this.toasts = [ + ...this.toasts.slice(-2), + { + id, + text + } + ]; + this.armToast(id, 3400 + Math.max(0, text.length - 40) * 25); + } + armToast(id, ms) { + this.holdToast(id); + this.toastTimers.set(id, setTimeout(() => { + this.toastTimers.delete(id); + this.toasts = this.toasts.filter((t3) => t3.id !== id); + }, ms)); + } + holdToast(id) { + const timer = this.toastTimers.get(id); + if (timer !== void 0) clearTimeout(timer); + this.toastTimers.delete(id); + } +}; +customElements.define("zn-app", ZnApp); +/*! Bundled license information: + +@lit/reactive-element/css-tag.js: + (** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + *) + +@lit/reactive-element/reactive-element.js: +lit-html/lit-html.js: +lit-element/lit-element.js: + (** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + *) + +lit-html/is-server.js: + (** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + *) +*/ diff --git a/static/base.css b/static/base.css new file mode 100644 index 0000000..f66cdad --- /dev/null +++ b/static/base.css @@ -0,0 +1,42 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + font-family: var(--font-ui); + font-size: var(--text-ui); + font-kerning: normal; + line-height: 1.45; + color: var(--ink); + background: var(--desk); + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +::selection { + background: var(--signal-tint-strong); +} + +:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..1d4fb48 --- /dev/null +++ b/static/index.html @@ -0,0 +1,19 @@ + + + + + + + ziran + + + + + + + + + diff --git a/static/tokens.css b/static/tokens.css new file mode 100644 index 0000000..861e423 --- /dev/null +++ b/static/tokens.css @@ -0,0 +1,88 @@ +/* Ziran design tokens — "The Sunlit Workbench". + Contrast pairs are verified by tools/contrast.mjs; keep both in sync. */ + +:root { + color-scheme: light; + + /* Color — daylight neutrals, one hot signal. */ + --desk: oklch(0.955 0.004 352); + --surface: oklch(1 0 0); + --panel: oklch(0.972 0.0035 352); + --panel-deep: oklch(0.945 0.005 352); + --ink: oklch(0.205 0.012 352); + --ink-muted: oklch(0.46 0.015 352); + --ink-faint: oklch(0.62 0.012 352); /* disabled only, never information */ + --line: oklch(0.885 0.006 352); + --line-soft: oklch(0.925 0.005 352); + + /* The Hot Signal. Fills carry white text (4.84:1); the full-chroma anchor + is reserved for strokes, dots, and small non-text marks. */ + --signal: oklch(0.58 0.22 352); + --signal-hover: oklch(0.54 0.21 352); + --signal-strong: oklch(0.647 0.263 352); + --signal-ink: oklch(0.5 0.21 352); + --signal-tint: oklch(0.965 0.02 352); + --signal-tint-strong: oklch(0.93 0.045 352); + + --danger: oklch(0.5 0.19 27); + --danger-tint: oklch(0.965 0.018 27); + --on-signal: oklch(1 0 0); + + /* The home base wears the ink banner; these carry text on it. */ + --on-ink: oklch(1 0 0); + --on-ink-muted: oklch(0.75 0.012 352); + + /* Type — one tuned system sans; documents carry their own voice. */ + --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --font-doc-serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif; + --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + + --text-xs: 0.6875rem; /* 11 — tracked caps micro-labels only */ + --text-sm: 0.8125rem; /* 13 — secondary UI, metadata */ + --text-ui: 0.875rem; /* 14 — default UI */ + --text-md: 1rem; /* 16 — emphasized UI, doc body floor */ + --text-lg: 1.1875rem; /* 19 — window/section titles */ + --text-xl: 1.4375rem; /* 23 — base-window greeting */ + + --w-regular: 400; + --w-medium: 500; + --w-semibold: 600; + + /* Space — 4pt scale. */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-7: 48px; + --sp-8: 64px; + + /* Shape */ + --r-sm: 6px; + --r-md: 10px; + --r-full: 999px; + + /* Depth — flat at rest; shadow answers state (focus, drag, transient). */ + --shadow-window: 0 1px 2px oklch(0.2 0.02 352 / 0.05); + --shadow-window-focus: + 0 2px 6px oklch(0.2 0.02 352 / 0.07), + 0 14px 36px oklch(0.2 0.02 352 / 0.13); + --shadow-drag: + 0 4px 10px oklch(0.2 0.02 352 / 0.1), + 0 24px 56px oklch(0.2 0.02 352 / 0.2); + --shadow-pop: + 0 2px 6px oklch(0.2 0.02 352 / 0.08), + 0 12px 32px oklch(0.2 0.02 352 / 0.16); + + /* Motion — responsive, never choreographed. */ + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --t-fast: 120ms; + --t-med: 200ms; + --t-slow: 300ms; + + /* Z — semantic scale. Desk windows live in 10–499, managed by the store. */ + --z-drag: 500; + --z-popover: 600; + --z-toast: 700; +} diff --git a/tiles/built/checklist.tile b/tiles/built/checklist.tile new file mode 100644 index 0000000000000000000000000000000000000000..4943262b3fc66f9d89da1a7556f174f9547d9ebf GIT binary patch literal 4852 zcmaFGydotpF*mg+G_NGHBqudRAvq&8IXfq_xFj_*IX|zsaZz${QF5j}h@)3rmY!Oi zS(RFxoS0Xcnwy`JnzJM+Go?Z*IX_n~DYYmoKToeJvnVl74{i$B^h{@B4b4tTElw`V zEGWs$&#SNq$;ix0FIFhYS4hd%Q7F$W$xtZCNL46KEXgcROH2kSQpitJD9KFD&df^( z$>l2Kr={to7Uk!c6gQ+5XI5qAr7ub?&rB)FkYqAQ%Sg>k&nS^(YDh0jEh^5;&tuFi zN-fSWElN%;UX-X0_UjF;2vr8gP$7j!3(lF({8XCsV}H=@a+Rqz;cv3mJl!Cju2JW+ z)#AsEtvSj0c_pcNCAuY*1*w@OsTC#q86~+nh_FI6ZcE>;@6+a2?EkfR{SwRRbuw;~ zjH4^>W^dZPjJaopM;W?Wtpkv z1^GoKN(#`>vQ+}dhHXk}S!QyoE|>$+1x|o=@T6j+4-o*_R9sS-lL|6g0qiOrg{1tH zN`-0#NPJo;7#bL;S}Wux7NuwASt%G;E7Wo+C@4Txftd;lDVfCuIf<243TZj171mHe z5L-7TvnUmun5-0%^K(jb^I)<`iOJdNMfs(9DOL)~X=!ON@#OrR{30s_Wy55{B*PS# zXj*<=iEdhAZe~uUl|pf4aY<^fZfT~DLUCeVv2Jl{Q6^kJSS2VUSt%Hr7C;>Xak6q| zNosDfLN(ZxRtkpJ3i)NJMQJ(t<+_zt3W=p9`PK>ri76?WdFfUPMivDX3dTkS6$%yw z6);chflN@ShI$aIuM=}J)AMve>WZxtl2h|aQj1`oNKY&P1t-i=3JA;03n~;$5aLPs zMJcI8x63T7}Fh&RBFu*v|rU7=b5H5|>-Ow-I!U7ML#P+AfVN_DpC;G!cb zzd}7$p;`f)JggK9LB=Cu8_Y{g2Dw}pEiRxj54K+~B|k4!Atx~@HAkUZ0hB>?Q&N-j zixNQvhm}H3W?rgpNk&nAX?lh=DCWU&YLRGRW|4~OJeU!n)B=tZBu^?Nm6nv`=fMLU z5-wH>dHH#%)+j!Qh``(nvnRQ0S}7EP>MK~@ga?;_f}sg4)0Y;f7U>qJ=AAv48R30wv!DHP@Bq}nQhYJDX;8~v2bGO!{@1fOdY zsER2rQK(kP$xlpiNi0d!Q79-a0WlP66>Jp}%M&w86f$!`)scp}zFv81PL6JNUVeF= zetBw=Zb@cNYO#KCNq$kPURJTXCM48BZJ-hba4@;%DA+2b1)NB?VOjC`j>X0Li23fCLymJrETzT{)>x&nVa`M8{flfzt}a zk4cp!sm1VMf(0R($1>9tG{6cq;d!#8s8XRC5<&{L3SPnfetHFSuGZ$9-rIqG^n*j>NiDjt@8VE09H#j)4 zEHf|tQNuE*p$ZDgIjM<7A(^?U`K2WqV1uElNI?P2Q?OMiPA!2cNGz_*O9okEYX|p& zf&w@=!O;S0!6-meEi@g&Vnas(7Pzi?u)s|PvoydFP+U@!nU|iKR;d9_mYSMycYqAV z=^c2$f>gmGM zc2Lm-rgapI4Gf?ztA)e}JPL|Z^HNfaK%oO`$$)+0nxj{g3NCY;Gct2hic<45^z^`$ zeX(9{Vu1$Ozd8z;nhGd^3Q6Hb`Q^wtCAlayu_P6mi8a(yGRt7O1mwD+{BphIoW$Z{ zzr@^BP{IS*udbl20JZ|sw6IrDR{*s!)D^50)X_B;rc@wjwFNLJw2~f}93#G9dyPBn=i$O;NA~=T5MbQ59r@ zw!6XB}JLJ2xXv}MiDHbsQ_^w%x$0mEY>S1EzZz@ zwCb%C!0L3sdC^KCEfG{HAkrJc3Q%5wM;SbcLJM0^p%1ejq!}!Xst9ZnxH$+>Xrm8l zi`a45=tG)THsB!uJFZ#GpuRStF27B2S-L_+ZcbjYtx`rwNr9EVetCJhUb(Seeo?x< zk%55$Xaqn>0n~|h%CE3hGEgv3FfvgvG66SuY(U*s1#lP1R>{aj3EDw|u!|~el}wcs z(lT>$Y?YJ^(oB+#6P5JAt*wH@k_?3uTP0spJyQj9J<}XRJ#z&^J#!yJ13eQ3GqA9s z2|`@KSkFk;SkFjFp}3?dKReY{NjcRb%``O?CIIQh+bS9AnZt!ZP37do0$U|;{~1L% uD?c+2E)H=ZsBeVsIzwXxiwsjeQ(r@41w&I13uX#A4$uchQgK59C=@4_r4}n>mK3Mvq$wm8D^%u}DwI?fr0S&><>!|aH>4G3 zR%Pa;FG?-XOex8bWV(=+k(!yFQ6kCIkY1KrRGgWg$Cz1^TAW{6l$=_;C{Z8mqZ?Wg zstk;wLJHB!1q-ep)Xi_xYd`#j;rS&7mXjRnXY#9kyF}05HH+@bNzTtJNzE(K1=*Pi za+ZEZNp21zL{NpwLyL?Va7G*-Mf_MXz zj1r4d6BVkFTy3Rb2(vgTzbGZO$VwqEKQ9#~Rs_y^sA2_)DJhwG=~fCR1_c!gCKd&V zV93uau~Nv)%SbKCEP)vT_75lnTPYZtBh=<(=B4Vw176R}6s8^;Drlx4hha%kVqS4U zVo_=y)Dl$xS*7JCmli8jE994!fNX)f9vYd-#U+U)AZb`2AP0AGWpPPru5M|jjzV!_ zUa>Ax7%C_rd&Cgo5vY5W%~H$~%?)6_00p*gVoqjyo|QrosAPaiAVRO8Lc!3aph5vD z085Kgi*$=qb5fH_psohH668M{eQ4^n(a!*98XHjJ1tn%1kX_)2%}lXX0*NXq6yzi( zr)K2mq@)(vDg~D$7L_QJ7iE@Y=B4W?gk)qEg9}@fQch1vp|~J5Cnq^0H96Z>sideh zRmsjqA7(b#qLj=s1&|$(AXHLFEXqvO&B-iFwN)y}&&ezS>qyBg11pA zr8FlMoHrFfxwp7Pp;{p)KQYB6u_RGPp`f$`#89YJuvJJbPs}V)$jk-hbPaWVz4FwY z9Np}^{PH~g^3){VlFXdcV*TQh{GwF7tYURdNVtM(jS@&kwN*&TPcF?(%`4GMPc3oH zNd+;TDm_y))Ini`ssrK^1w48nD$sPLmIOmmk*$ITD1Z0!`P6(DN$KqAhN0$jls zT&Y+q)LL_aqY~=S%97M#xL;x6prH=Z39~9QO+h0Gq)ro_VL|TKD@)8NO;xZ}$V)9( z2uZCdaY;?iPf0D((9}x-v!I$F;i8~W40Ef7dT?S{Y6{HwT7}e{;#9cNNXq+lQWJ|pGILY&OG`AsRzTxf0b-$oEy!Om1&PI#dC6$z zD=3r{Rl;IfIEVFZKDs#UUpnI`XHBrXi(*9$F+?KR5KH55!e)$r7KkA=HwOIDrJ*U@so8Cn4D~GFLZFg8IkCW230$#4%}>rOO3q1DNUpF|GBnpSRZ>W< gv{f=R1F?#1m5lVv;Lb`lH8wF%18K7X`P7aJ0LeXQX#fBK literal 0 HcmV?d00001 diff --git a/tiles/checklist/icon.svg b/tiles/checklist/icon.svg new file mode 100644 index 0000000..ab35a29 --- /dev/null +++ b/tiles/checklist/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tiles/checklist/index.html b/tiles/checklist/index.html new file mode 100644 index 0000000..f01e381 --- /dev/null +++ b/tiles/checklist/index.html @@ -0,0 +1,119 @@ + + + + + + Checklist + + + +
+
+ +
+
+ + + diff --git a/tiles/checklist/manifest.json b/tiles/checklist/manifest.json new file mode 100644 index 0000000..f3b3f21 --- /dev/null +++ b/tiles/checklist/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "Untitled checklist", + "description": "Things to do, with the satisfaction of ticking them off.", + "icons": [{ "src": "/icon.svg", "sizes": "any" }], + "sizing": { "width": 560, "height": 640 }, + "model": { + "id": "com.berjon.ziran.checklist", + "name": "Checklist", + "description": "Things to do, with the satisfaction of ticking them off.", + "icons": [{ "src": "/icon.svg", "sizes": "any" }] + } +} diff --git a/tiles/note/icon.svg b/tiles/note/icon.svg new file mode 100644 index 0000000..88892ea --- /dev/null +++ b/tiles/note/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tiles/note/index.html b/tiles/note/index.html new file mode 100644 index 0000000..49ca1af --- /dev/null +++ b/tiles/note/index.html @@ -0,0 +1,71 @@ + + + + + + Note + + + + +
+ + + diff --git a/tiles/note/manifest.json b/tiles/note/manifest.json new file mode 100644 index 0000000..f7c5c3d --- /dev/null +++ b/tiles/note/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "Untitled note", + "description": "Plain text that saves itself as you type.", + "icons": [{ "src": "/icon.svg", "sizes": "any" }], + "sizing": { "width": 720, "height": 640 }, + "model": { + "id": "com.berjon.ziran.note", + "name": "Note", + "description": "Plain text that saves itself as you type.", + "icons": [{ "src": "/icon.svg", "sizes": "any" }] + } +} diff --git a/tools/build-tiles.ts b/tools/build-tiles.ts new file mode 100644 index 0000000..0500188 --- /dev/null +++ b/tools/build-tiles.ts @@ -0,0 +1,41 @@ +// Build the starter tile source dirs under tiles/ into .tile files in +// tiles/built/, using ziran's own writer (byte-compatible with +// @dasl/tile-writer / atile). Usage: deno task build:tiles + +import { basename, join } from '@std/path'; +import mime from 'mime'; +import { type Masl, writeTile } from '../src/server/tilefile.ts'; + +async function* walkFiles(root: string, prefix = ''): AsyncGenerator { + for await (const entry of Deno.readDir(join(root, prefix))) { + const rel = `${prefix}/${entry.name}`; + if (entry.isDirectory) yield* walkFiles(root, rel); + else if (entry.isFile) yield rel; + } +} + +async function buildTileDir(dir: string, out: string): Promise { + const manifest = JSON.parse(await Deno.readTextFile(join(dir, 'manifest.json'))) as Masl; + manifest.resources ??= {}; + const resources = new Map(); + for await (const file of walkFiles(dir)) { + if (file === '/manifest.json' || basename(file) === '.DS_Store') continue; + const res = file === '/index.html' ? '/' : file; + resources.set(res, await Deno.readFile(join(dir, file))); + manifest.resources[res] = { + ...(manifest.resources[res] ?? {}), + 'content-type': mime.getType(file) ?? 'application/octet-stream', + }; + } + if (!resources.has('/')) throw new Error(`${dir} has no index.html`); + await writeTile(manifest, resources, out); + console.log(`built ${out} (${(await Deno.stat(out)).size} bytes)`); +} + +const tilesRoot = new URL('../tiles/', import.meta.url).pathname; +const outDir = join(tilesRoot, 'built'); +await Deno.mkdir(outDir, { recursive: true }); +for await (const entry of Deno.readDir(tilesRoot)) { + if (!entry.isDirectory || entry.name === 'built') continue; + await buildTileDir(join(tilesRoot, entry.name), join(outDir, `${entry.name}.tile`)); +} diff --git a/tools/contrast.mjs b/tools/contrast.mjs new file mode 100644 index 0000000..0b424ad --- /dev/null +++ b/tools/contrast.mjs @@ -0,0 +1,77 @@ +// WCAG contrast checker for the OKLCH token palette. +// Usage: node tools/contrast.mjs — exits non-zero if any pair fails its threshold. + +function oklchToSrgb(L, C, hDeg) { + const h = (hDeg * Math.PI) / 180; + const a = C * Math.cos(h); + const b = C * Math.sin(h); + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.291485548 * b; + const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3; + let r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + let bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + const enc = (v) => { + v = Math.min(1, Math.max(0, v)); + return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055; + }; + return [enc(r), enc(g), enc(bl)]; +} + +function relLuminance([r, g, b]) { + const lin = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +function contrast(c1, c2) { + const y1 = relLuminance(oklchToSrgb(...c1)); + const y2 = relLuminance(oklchToSrgb(...c2)); + const [hi, lo] = y1 > y2 ? [y1, y2] : [y2, y1]; + return (hi + 0.05) / (lo + 0.05); +} + +// Keep in sync with src/styles/tokens.css +const T = { + deskBg: [0.955, 0.004, 352], + surface: [1, 0, 0], + panel: [0.972, 0.0035, 352], + ink: [0.205, 0.012, 352], + inkMuted: [0.46, 0.015, 352], + line: [0.885, 0.006, 352], + signal: [0.58, 0.22, 352], + signalStrong: [0.647, 0.263, 352], + signalInk: [0.5, 0.21, 352], + signalTint: [0.965, 0.02, 352], + danger: [0.5, 0.19, 27], + white: [1, 0, 0], + onInkMuted: [0.75, 0.012, 352], +}; + +const checks = [ + ['ink on surface (body text)', T.ink, T.surface, 7], + ['ink on panel', T.ink, T.panel, 7], + ['ink on desk', T.ink, T.deskBg, 7], + ['ink on signal-tint (selection wash)', T.ink, T.signalTint, 7], + ['muted on surface (secondary text)', T.inkMuted, T.surface, 4.5], + ['muted on panel', T.inkMuted, T.panel, 4.5], + ['white on signal (button text)', T.white, T.signal, 4.5], + ['signal-ink on surface (pink text)', T.signalInk, T.surface, 4.5], + ['signal-ink on signal-tint', T.signalInk, T.signalTint, 4.5], + ['danger on surface (error text)', T.danger, T.surface, 4.5], + ['signal vs surface (focus ring, non-text ≥3)', T.signal, T.surface, 3], + ['signal-strong vs surface (live dot, non-text ≥3)', T.signalStrong, T.surface, 3], + ['signal vs panel (focus ring on panel)', T.signal, T.panel, 3], + ['white on ink (banner text)', T.white, T.ink, 4.5], + ['on-ink-muted on ink (banner secondary)', T.onInkMuted, T.ink, 4.5], + ['signal-strong vs ink (Z mark on banner, large graphic ≥3)', T.signalStrong, T.ink, 3], +]; + +let fail = 0; +for (const [name, a, b, min] of checks) { + const r = contrast(a, b); + const ok = r >= min; + if (!ok) fail++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${r.toFixed(2)}:1 (min ${min}) ${name}`); +} +process.exit(fail ? 1 : 0); diff --git a/tools/mac-bundle.ts b/tools/mac-bundle.ts new file mode 100644 index 0000000..b5be181 --- /dev/null +++ b/tools/mac-bundle.ts @@ -0,0 +1,75 @@ +// Post-process a built Ziran.app: declare the .tile document type (UTI +// ing.dasl.tile, MIME application/web-tile), attach the document icon, and +// refresh LaunchServices. deno desktop's plist template has no document-type +// support, so this runs after `deno desktop --output *.app`. +// Usage: deno run -A tools/mac-bundle.ts dist/Ziran.app + +import { join } from '@std/path'; + +const app = Deno.args[0]; +if (!app || !app.endsWith('.app')) { + console.error('usage: deno run -A tools/mac-bundle.ts '); + Deno.exit(1); +} +const plist = join(app, 'Contents', 'Info.plist'); +const resources = join(app, 'Contents', 'Resources'); +await Deno.stat(plist); + +async function run(cmd: string, args: string[], ignoreFailure = false): Promise { + const res = await new Deno.Command(cmd, { args }).output(); + if (!res.success && !ignoreFailure) { + throw new Error(`${cmd} ${args[0]}: ${new TextDecoder().decode(res.stderr).slice(0, 300)}`); + } +} + +// 1. Document icon: build/doc-1024.png → tile-doc.icns via sips + iconutil. +const docPng = new URL('../build/doc-1024.png', import.meta.url).pathname; +try { + await Deno.stat(docPng); + const iconset = await Deno.makeTempDir({ suffix: '.iconset' }); + for (const size of [16, 32, 64, 128, 256, 512]) { + await run('sips', ['-z', String(size), String(size), docPng, '--out', join(iconset, `icon_${size}x${size}.png`)]); + await run('sips', ['-z', String(size * 2), String(size * 2), docPng, '--out', join(iconset, `icon_${size}x${size}@2x.png`)]); + } + await run('iconutil', ['-c', 'icns', iconset, '-o', join(resources, 'tile-doc.icns')]); + await Deno.remove(iconset, { recursive: true }).catch(() => {}); + console.log('document icon installed'); +} catch (err) { + console.warn('skipping document icon:', (err as Error).message); +} + +// 2. Declare the document type + exported UTI. +const documentTypes = [ + { + CFBundleTypeName: 'DASL Web Tile', + CFBundleTypeRole: 'Editor', + LSHandlerRank: 'Owner', + LSItemContentTypes: ['ing.dasl.tile'], + CFBundleTypeIconFile: 'tile-doc', + }, +]; +const exportedTypes = [ + { + UTTypeIdentifier: 'ing.dasl.tile', + UTTypeDescription: 'DASL Web Tile', + UTTypeConformsTo: ['public.data'], + UTTypeIconFile: 'tile-doc', + UTTypeTagSpecification: { + 'public.filename-extension': ['tile'], + 'public.mime-type': ['application/web-tile'], + }, + }, +]; +await run('plutil', ['-remove', 'CFBundleDocumentTypes', plist], true); +await run('plutil', ['-remove', 'UTExportedTypeDeclarations', plist], true); +await run('plutil', ['-insert', 'CFBundleDocumentTypes', '-json', JSON.stringify(documentTypes), plist]); +await run('plutil', ['-insert', 'UTExportedTypeDeclarations', '-json', JSON.stringify(exportedTypes), plist]); +console.log('document types declared'); + +// 3. Tell LaunchServices. +await run( + '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister', + ['-f', app], + true, +); +console.log(`registered ${app} for .tile / application/web-tile`); diff --git a/tools/make-icons.ts b/tools/make-icons.ts new file mode 100644 index 0000000..c97a80c --- /dev/null +++ b/tools/make-icons.ts @@ -0,0 +1,101 @@ +// Generate app + document icons into build/ by rasterizing the brand SVGs +// with headless Chrome (no dependencies). Outputs: +// build/icon-1024.png app icon (macOS/Linux, passed to deno desktop --icon) +// build/icon-256.png Linux desktop-database icon +// build/icon.ico Windows app icon (PNG-embedded single-image ICO) +// build/doc-1024.png macOS document icon source (tools/mac-bundle.ts) +// Usage: deno run -A tools/make-icons.ts + +import { join } from '@std/path'; + +const CHROME_PATHS = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/google-chrome', + '/usr/bin/chromium', +]; + +const Z_PATH = 'M3 2.5h18v5L12.2 16.5H21v5H3v-5L11.8 7.5H3z'; + +// App icon: ink rounded square, hot pink Z. +const appSvg = ` + + + + +`; + +// Document icon: white page with folded corner, small ink Z badge. +const docSvg = ` + + + + + + +`; + +function chrome(): string { + for (const p of CHROME_PATHS) { + try { + Deno.statSync(p); + return p; + } catch { /* next */ } + } + throw new Error('No Chrome/Chromium found to rasterize icons'); +} + +async function rasterize(svg: string, size: number, out: string): Promise { + const html = `${svg}`; + const tmp = await Deno.makeTempFile({ suffix: '.html' }); + await Deno.writeTextFile(tmp, html); + try { + const res = await new Deno.Command(chrome(), { + args: [ + '--headless', + '--disable-gpu', + `--screenshot=${out}`, + `--window-size=${size},${size}`, + '--default-background-color=00000000', + '--hide-scrollbars', + `file://${tmp}`, + ], + }).output(); + if (!res.success) throw new Error(new TextDecoder().decode(res.stderr).slice(0, 300)); + } finally { + await Deno.remove(tmp).catch(() => {}); + } + console.log(`wrote ${out}`); +} + +/** Single-image ICO wrapping a PNG (valid since Vista). */ +async function pngToIco(pngPath: string, out: string): Promise { + const png = await Deno.readFile(pngPath); + const header = new Uint8Array(6 + 16); + const view = new DataView(header.buffer); + view.setUint16(0, 0, true); // reserved + view.setUint16(2, 1, true); // type: icon + view.setUint16(4, 1, true); // count + header[6] = 0; // width 256 → 0 + header[7] = 0; // height 256 → 0 + header[8] = 0; // palette + header[9] = 0; // reserved + view.setUint16(10, 1, true); // planes + view.setUint16(12, 32, true); // bpp + view.setUint32(14, png.length, true); + view.setUint32(18, 22, true); // offset + const ico = new Uint8Array(header.length + png.length); + ico.set(header); + ico.set(png, header.length); + await Deno.writeFile(out, ico); + console.log(`wrote ${out}`); +} + +const buildDir = new URL('../build/', import.meta.url).pathname; +await Deno.mkdir(buildDir, { recursive: true }); +await rasterize(appSvg, 1024, join(buildDir, 'icon-1024.png')); +await rasterize(appSvg, 256, join(buildDir, 'icon-256.png')); +await rasterize(docSvg, 1024, join(buildDir, 'doc-1024.png')); +await pngToIco(join(buildDir, 'icon-256.png'), join(buildDir, 'icon.ico')); diff --git a/tools/tile-selftest.ts b/tools/tile-selftest.ts new file mode 100644 index 0000000..23d7d73 --- /dev/null +++ b/tools/tile-selftest.ts @@ -0,0 +1,68 @@ +// Engine self-test: parse the built starter tiles with verification on, then +// round-trip self-storage writes and model stripping through temp copies. +// Usage: deno run -A tools/tile-selftest.ts + +import { + parseTile, + readResource, + readTileData, + STORAGE_PREFIX, + writeModelTile, + writeTileData, +} from '../src/server/tilefile.ts'; + +let failures = 0; +function check(label: string, ok: boolean, detail = '') { + console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); + if (!ok) failures++; +} + +for (const name of ['note', 'checklist']) { + const t = await parseTile(new URL(`../tiles/built/${name}.tile`, import.meta.url).pathname); + check(`${name}: parses undamaged`, !t.damaged, t.damage); + check(`${name}: has / resource`, Boolean(t.manifest.resources['/'])); + check(`${name}: model id present`, Boolean(t.manifest.model?.id)); + check(`${name}: icon resolves`, Boolean(readResource(t, '/icon.svg')?.length)); +} + +const tmp = await Deno.makeTempFile({ suffix: '.tile' }); +const tmpModel = await Deno.makeTempFile({ suffix: '.tile' }); +try { + await Deno.copyFile(new URL('../tiles/built/note.tile', import.meta.url).pathname, tmp); + await writeTileData(tmp, 'text', new TextEncoder().encode('hello tiles')); + const back = await readTileData(tmp, 'text'); + check('storage round-trip', new TextDecoder().decode(back!) === 'hello tiles'); + const t2 = await parseTile(tmp); + check('still verifies after data write', !t2.damaged, t2.damage); + check('storage resource recorded', Boolean(t2.manifest.resources[`${STORAGE_PREFIX}text`])); + + // second write to the same name must replace, not duplicate + await writeTileData(tmp, 'text', new TextEncoder().encode('replaced')); + check('storage upsert', new TextDecoder().decode((await readTileData(tmp, 'text'))!) === 'replaced'); + + await writeModelTile(tmp, tmpModel); + const model = await parseTile(tmpModel); + check('model verifies', !model.damaged, model.damage); + check( + 'model stripped of storage', + !Object.keys(model.manifest.resources).some((r) => r.startsWith(STORAGE_PREFIX)), + ); + check('model hoisted name', model.manifest.name === 'Note'); + check('model keeps model field', model.manifest.model?.id === 'com.berjon.ziran.note'); + + // damage detection: flip one content byte and expect a verify failure + const bytes = await Deno.readFile(tmp); + bytes[bytes.length - 4] ^= 0xff; + await Deno.writeFile(tmp, bytes); + const damaged = await parseTile(tmp); + check('corruption detected', damaged.damaged, damaged.damage); +} finally { + await Deno.remove(tmp).catch(() => {}); + await Deno.remove(tmpModel).catch(() => {}); +} + +if (failures) { + console.error(`\n${failures} failure(s)`); + Deno.exit(1); +} +console.log('\nall good'); diff --git a/vendor/web-tiles/README.md b/vendor/web-tiles/README.md new file mode 100644 index 0000000..72d0fc3 --- /dev/null +++ b/vendor/web-tiles/README.md @@ -0,0 +1,13 @@ +# Vendored web-tiles runtime + +`shuttle.js` and `data.js` are copied verbatim from the dasl-tiles monorepo at +HEAD (commit `068e139`, "support protocol signalling and add data protocol"). +They add tiles-protocols support — the shuttle forwards +`tiles-protocol-up-*`/`tiles-protocol-down-*` messages between the tile and +the host, and `data.js` is the tp-data protocol module tiles import from +`/.well-known/web-tiles/data.js`. + +npm's `@dasl/tile-server@2.0.0` predates that commit, so Blastile serves these +two files in front of the stock router (`src/server/tileserver.ts`). Once a +tile-server release ships them, delete this directory and the vendored-static +mount and bump the dependency. diff --git a/vendor/web-tiles/data.js b/vendor/web-tiles/data.js new file mode 100644 index 0000000..5ee83ec --- /dev/null +++ b/vendor/web-tiles/data.js @@ -0,0 +1,31 @@ + + +const PFX_PROTOCOL_TO_HOST = 'tiles-protocol-up-data-'; +const PFX_PROTOCOL_TO_TILE = 'tiles-protocol-down-data-'; +const handlers = []; + +export function addDataHandler (hand) { + if (handlers.find(h => h === hand)) return; + handlers.push(hand); +} + +export function removeDataHandler (hand) { + const idx = handlers.findIndex(h => h === hand); + if (idx < 0) return; + handlers.splice(idx, 1); +} + +window.addEventListener('message', async (ev) => { + const { action, payload } = ev.data; + if (action === `${PFX_PROTOCOL_TO_TILE}payload`) { + handlers.forEach(h => h(payload)); + } +}); + +export function listen () { + window.parent.postMessage({ action: `${PFX_PROTOCOL_TO_HOST}ready` }, '*'); +} + +export function sendData (payload) { + window.parent.postMessage({ action: `${PFX_PROTOCOL_TO_HOST}payload`, payload }, '*'); +} diff --git a/vendor/web-tiles/shuttle.js b/vendor/web-tiles/shuttle.js new file mode 100644 index 0000000..c0f9441 --- /dev/null +++ b/vendor/web-tiles/shuttle.js @@ -0,0 +1,133 @@ + +/* +████████╗██╗██╗ ███████╗███████╗ +╚══██╔══╝██║██║ ██╔════╝██╔════╝ + ██║ ██║██║ █████╗ ███████╗ + ██║ ██║██║ ██╔══╝ ╚════██║ + ██║ ██║███████╗███████╗███████║ + ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ + •--~~~## SHUTTLE ##~~~--• + +This script loads in the context that the mothership creates, sets up the +worker, and triggers the iframe that the worker operates on. + +Other than that, it passes messages between the worker and the mothership. +*/ + +let workerId; // keep track of the worker id so the mothership knows who we are when we talk +let mothership; // hold on to the source so we can initiate sending up +let worker; // instance of the worker +let iframe; // instance of the iframe +const { promise: readyToLoad, resolve: resolveReadyToLoad } = Promise.withResolvers(); +const { promise: iframeLoaded, resolve: resolveIframeLoaded } = Promise.withResolvers(); + +const PFX = 'tiles-shuttle-'; +const PFX_PROTOCOL_TO_HOST = 'tiles-protocol-up-'; +const PFX_PROTOCOL_TO_TILE = 'tiles-protocol-down-'; +const RCV_LOAD = `${PFX}load`; // mothership tells us to start loading +const SND_READY = `${PFX}ready`; // tell mothership we're loaded and ready +const SND_ERROR = `${PFX}error`; // error to mothership +const RCV_SET_TITLE = `${PFX}set-title`; // set the title +const RCV_SET_ICON = `${PFX}set-icon`; // set the icon +const TILES_PFX = 'tiles-'; +const SND_WARNING = `${TILES_PFX}warn`; // warn mothership + +// XXX TODO +// - If there's a way to pass references around, get the worker and mothership +// to talk directly instead of through us. But this is an optimisation, only +// do it once this fully works. +// - One thing that is worth implementing here (unless the mothership has +// direct script access to our window — this depends on which arrangement of +// sandboxing we use) is setting the title and icon (for the windowed case). +// - We should receive instructions from the mothership to distinguish +// between loading the active tile and rendering its card. This would means +// that we need to communicate readiness to the mothership and receive +// local instructions, it also probably means that we need to be able to +// convey size instructions of our own based on card rendering. + +// --- Communicating with worker and mothership +// - mothership -> worker +window.addEventListener('message', async (ev) => { + const { action, id } = ev.data; + if (action?.startsWith(PFX)) { + if (action === RCV_LOAD) { + workerId = id; + mothership = ev.source; + await loadWorker(); + window.parent.postMessage({ id, action: SND_READY }, '*'); + } + else if (action === RCV_SET_TITLE) { + document.title = ev.data.payload?.title; + } + else if (action === RCV_SET_ICON) { + const path = ev.data.payload?.path; + // If the worker is loaded, we just load the icon straight up. + // Otherwise we need to request resolving the path from the mothership, + // the way that workers do. + if (worker) { + document.querySelector('link[rel="icon"]')?.setAttribute('href', path); + } + else { + // XXX make request system reusable + } + } + } + else if (action?.startsWith(PFX_PROTOCOL_TO_HOST)) { + window.parent.postMessage(ev.data, '*'); + } + else if (action?.startsWith(PFX_PROTOCOL_TO_TILE)) { + await iframeLoaded; + iframe.contentWindow.postMessage(ev.data, '*'); + } + else { + await readyToLoad; + worker.postMessage(ev.data); + } +}); +// - worker -> mothership +navigator.serviceWorker.onmessage = (ev) => { + window.parent.postMessage(ev.data, '*'); +} + +// Let's goooo +async function loadWorker () { + // social justice worker + let curSWReg; + try { + curSWReg = await navigator.serviceWorker.getRegistration(); + } + catch (err) { + console.warn(`Failed to get worker registration`, err); + } + if (!curSWReg) { + curSWReg = await navigator.serviceWorker.register('worker.js', { scope: '/' }); + } + await navigator.serviceWorker.ready; + // navigator.serviceWorker.onmessage + worker = curSWReg.active; + worker.onerror = (ev) => error(`Worker error`, ev); + resolveReadyToLoad(); + renderWorkerFrame(); +} + +function renderWorkerFrame () { + const ifr = document.createElement('iframe'); + ifr.setAttribute('src', '/'); + ifr.setAttribute('frameborder', '0'); // oh hell yeah + iframe = ifr; + ifr.addEventListener('load', resolveIframeLoaded); + document.body.appendChild(ifr); +} + +async function error (...msg) { + console.warn(...msg); + if (!mothership) return; + mothership.postMessage({ action: SND_ERROR, msg, id: workerId }); +} + +// eslint-disable-next-line +async function warn (...msg) { + console.warn(...msg); + if (!mothership) return; + mothership.postMessage({ action: SND_WARNING, msg, id: workerId }); +} diff --git a/vendor/web-tiles/store.js b/vendor/web-tiles/store.js new file mode 100644 index 0000000..c6f31b8 --- /dev/null +++ b/vendor/web-tiles/store.js @@ -0,0 +1,39 @@ +// Ziran self-storage for tiles: the Tenet store.js contract (loadData / +// putData) carried over the tp-data tiles protocol instead of a custom URL +// scheme. The host relays each request to the backend, which rewrites the +// tile file's /.well-known/web-tiles-storage/ resource. + +import { addDataHandler, listen, sendData } from '/.well-known/web-tiles/data.js'; + +const pending = new Map(); +let seq = 0; + +addDataHandler((payload) => { + const msg = payload && payload.ziranStore; + if (!msg || !pending.has(msg.reqId)) return; + const { resolve, reject } = pending.get(msg.reqId); + pending.delete(msg.reqId); + if (msg.error) reject(new Error(msg.error)); + else resolve(msg.data ?? null); +}); +listen(); + +function request(op, name, data) { + const reqId = `${op}-${++seq}`; + return new Promise((resolve, reject) => { + pending.set(reqId, { resolve, reject }); + sendData({ ziranStore: { reqId, op, name, data } }); + }); +} + +/** Load named data; resolves to a Uint8Array, or null if never saved. */ +export async function loadData(name) { + const data = await request('load', name); + return data == null ? null : new Uint8Array(data); +} + +/** Save named data (Uint8Array / ArrayBuffer). */ +export async function putData(name, data) { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + await request('put', name, bytes); +} -- 2.51.2