diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..987f1c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CI: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + + package-tests: + name: Package tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Test workspace packages + env: + DECKD_URL: http://127.0.0.1:1 + run: pnpm run --filter './packages/**' --if-present -r test + + deckd-build: + name: Rust deckd build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Show Rust toolchain + run: rustc --version && cargo --version + - name: Build deckd + run: cargo build --locked --manifest-path packages/deckd/Cargo.toml + - name: Preserve deckd for collaboration replay + uses: actions/upload-artifact@v4 + with: + name: deckd-linux + path: packages/deckd/target/debug/deckd + if-no-files-found: error + retention-days: 1 + + collaboration: + name: Collaboration replay + needs: deckd-build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: deckd-linux + path: .ci/deckd + - name: Replay collaboration through the built deckd + env: + DECKD_BIN: .ci/deckd/deckd + run: | + chmod +x "$DECKD_BIN" + pnpm test:collaboration diff --git a/.gitignore b/.gitignore index 68bbe91..adb1308 100644 --- a/.gitignore +++ b/.gitignore @@ -7,15 +7,16 @@ .pnpm-store/ .deck/ # A depot carries its own machinery inside itself, and none of it is source: -# `.deck/` is the store, `.jj/` is the seal log, and jj colocates a backing -# `.git` beside it — which git otherwise mistakes for an embedded repository -# and refuses to descend into. +# `.deck/` contains the store, Repository refs, indexes, and History. deckd's +# jj backend also colocates machine-generated `.jj/` and `.git/` directories, +# which the outer git repository must ignore. .jj/ examples/*/depot/.git/ examples/*/depot/app/viewer/build.json examples/*/depot/app/viewer/states.json examples/*/depot/app/library/shelf.json # `deck fork` WRITES these into the depot: they are demo output, not source. +examples/collaboration/depot/ examples/contribute-to-codelist/depot/ana/ examples/contribute-to-codelist/depot/kim/ examples/two-agents/depot/agent-a/ diff --git a/CLI.md b/CLI.md index fc50392..582fe83 100644 --- a/CLI.md +++ b/CLI.md @@ -54,7 +54,7 @@ That scaffolds a depot, serves it, and prints a URL. Open it, edit `app.js`, reload. Then the loop that runs for the rest of the day: ```bash -deck serve --root ./work --history auto +deck serve --root ./work --history deckd ``` `--root` is a directory *of* depots: every child directory becomes one, @@ -66,7 +66,7 @@ every save: seal, no noise. Touching a file is not a change. 3. **Auto-publish** `-dev.N`, tagged `dev`. Immutable from that instant. -4. **Seal it as a Step** (when `jj`, historyd, or **deckd** is attached) — +4. **Seal it as a Step** (when **deckd** is attached) — including the saves that did *not* publish, so the states between versions exist too. With `DECKD_URL`, deckd’s FS watch owns seals; Hub still publishes + HMR. diff --git a/INSTALL.md b/INSTALL.md index 8c992f2..7d631f4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -16,8 +16,8 @@ from this repository instead. | --- | --- | --- | | **Node** | ≥ 22 | required — the CLI is TypeScript with no build step, and relies on Node's native type stripping | | **pnpm** | 11 | required to install the workspace's dev dependencies (`packageManager` pins the exact version) | -| **jj** | any recent | optional — Timeline via the jj CLI when no daemon is running | -| **Rust / cargo** | stable | optional — only to build `packages/deckd`, the in-tree Timeline daemon | +| **jj** | any recent | optional — History via the jj CLI when no daemon is running | +| **Rust / cargo** | stable | optional — only to build `packages/deckd`, the in-tree History daemon | Verified on Node v24.17.0, pnpm 11.0.9, jj 0.43.0. @@ -98,16 +98,16 @@ pnpm test && pnpm lint Expect 189 core, 22 history, 79 server, 4 conformance, 13 CLI — all passing — and no lint or type errors. -## Check whether you got a Timeline +## Check whether every-save History is active ```bash deck hello ./playground ``` The banner it prints includes a line naming the history backend it attached -to, or the reason it could not. `jj` present means a Timeline and a Step per -save; anything else means Versions only, which is still every settled save as an -immutable version. +to, or the reason it could not. An attached deckd means every-save History and +a Step per save; anything else means Versions only, which still keeps every +settled save as an immutable version. ## Environment @@ -115,7 +115,7 @@ immutable version. | --- | --- | --- | | `DECK_KEYRING` | `~/.deck/keys` | where `deck keygen` writes private keys. They never leave the machine | | `JJ_BIN` | `jj` | which `jj` binary to seal with when no daemon URL is set | -| `DECKD_URL` | — | point the Timeline at in-tree **deckd** (`packages/deckd`, default listen `http://127.0.0.1:8787`). Preferred over `DECK_HISTORYD_URL` when both are set | +| `DECKD_URL` | — | point the History at in-tree **deckd** (`packages/deckd`, default listen `http://127.0.0.1:8787`). Preferred over `DECK_HISTORYD_URL` when both are set | | `DECK_HISTORYD_URL` | — | legacy alias for an external historyd; same HTTP contract as deckd | To run Hub with deckd from this clone: @@ -136,7 +136,7 @@ and [`docs/deck-daemon.md`](docs/deck-daemon.md). | `Cannot find package '@cardstack/deck'` | `pnpm install` has not run, or you are running a copy of `packages/cli` from outside the workspace | | `EADDRINUSE` from `deck serve` | the port is taken. `deck hello` walks forward up to ten ports on its own; `serve` does not, because a server you asked to be on 8788 should not quietly be somewhere else | | `deck: --realm is now --depot` | an old flag. It still works and will keep working; the message is a note, not a failure | -| history reports Versions only | `jj` is not on PATH, and `DECKD_URL` / `DECK_HISTORYD_URL` point at nothing. The live loop is unaffected | +| history reports Versions only | deckd is not running (`DECKD_URL` / `DECK_HISTORYD_URL` point at nothing). The live loop is unaffected | ## Uninstall diff --git a/README.md b/README.md index 27af6dc..9651766 100644 --- a/README.md +++ b/README.md @@ -1,423 +1,292 @@ # Deck -**Deck is version management without the commit, package management -without the install, and cloud delivery without the deploy.** - -One thing makes all three true: there is only one kind of object here — a -file, named by the hash of its own bytes, reachable at a URL. A tree is a -list of those names. A Version, a Step, a dependency, a fork and a release -are all pointers to trees. Commit, install and deploy all do the same job: -they move a copy to a second place that names things differently. Here -there is no second place. - -| | | -| --- | --- | -| A commit, a save, a snapshot are just | the hash of the tree, taken when you stopped typing. | -| A version, a tag, a release, a staging environment are just | pointers to one of those hashes. Moving a pointer copies nothing. | -| An import, an install, a lockfile, `node_modules` are just | an address in an import map, written into a file you can read. | -| A branch, a fork, a pull request are just | the same package under your own publisher, with the tree you started from recorded. That record is the whole difference between a copy and a proposal. | -| A code review, a preview environment are just | the page loaded with `?with=` pointing somewhere else, for one request. | -| A merge is just | three trees: yours, theirs, and the one you both started from. | -| A revert, a rollback, a checkout are just | reading a different address. Nothing on disk changes. | -| A build, a bundle, a deploy, a CDN are just | absent — except the bundle, which is another version derived from the first, at its own address. | - -None of those is really a mechanism. They exist because three systems each -keep their own address space, and something has to translate between them. -With one address space, committing, installing and deploying are not three -things you do to code — they are three names for pointing at it. - -```bash -node packages/cli/bin/deck.js hello +**Version management without the commit.
+Package management without the install.
+Code collaboration without the clone.
+Cloud delivery without the deploy.** + +Deck gives files, packages, dependencies, runnable applications, and collaborative +work one content-addressed system. The browser can run the same immutable state +that a reviewer inspected, a release named, and another package imported. + +Every settled save is safe without asking the author to make a commit. Intentional +collaboration still has the familiar nouns—repository, branch, fork, Review, and +merge—but those nouns coordinate immutable package state instead of copying a +monorepo between machines. + +## The model + +```text +bytes ──hash──▶ package tree ──name──▶ Version ──URL──▶ browser + │ + └──────────────▶ History Step (every settled save) + +exact package Versions + exact RRI lock ──▶ Repository + │ + authored as + ▼ + Checkpoint + ▲ + branch ──┘ mutable ref + +source branch ──exact Checkpoint──┐ + ├──▶ Review ──preview──▶ Browse + Run +target branch ──exact Checkpoint──┘ │ + └──merge──▶ two-parent Checkpoint + │ + └──CAS──▶ target branch ``` -That scaffolds a working app, serves it live, and prints the URL. Edit -a file, reload, see it. That is the whole loop. +The layers have deliberately different jobs: -Nothing here is published to npm yet, and **`npx deck` is somebody else's -package** — install from this repo. [INSTALL.md](INSTALL.md) covers the -three ways; [CLI.md](CLI.md) is the CLI's own readme. +| Layer | Meaning | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Version** | An immutable name for one package tree. Settled saves can create Versions automatically; releases add durable semver names without copying bytes. | +| **History Step** | One ordered every-save state from `deckd`. History is daily safety and does not require commit ceremony. | +| **Repository** | An immutable set of exact package Versions plus one exact Realm Resource Identifier (RRI) lock. A mutable package is already an implicit one-package Repository; `repo-init` names a multi-package Repository. | +| **Checkpoint** | An authored collaboration point that binds a Repository, History head, completed index generation, message, and ordered parent Checkpoints. | +| **Branch** | A mutable compare-and-swap ref inside one owner/Repository. Creating one clones the selected Repository state; unchanged content remains shared in CAS. | +| **Fork** | The same storage operation across an ownership boundary. It creates a mutable package in another publisher scope and records ancestry. | +| **Review** | Durable source-to-target metadata bound to exact Checkpoints, with an append-only event history and an exact runnable preview. | +| **Merge** | A three-way reconciliation of base, target, and source Repositories, followed by one target-branch compare-and-swap and a new two-parent Checkpoint. | + +Imported packages are read-only. To change one, create a branch when you control +its Repository, or fork it into your own publisher scope. A fork of Three.js can +therefore become a Repository too; package and Repository are not competing +containers in Deck. ## The layout is the address -A depot is a directory. What `ls` shows and what the URL says are the -same thing: +A depot is a directory. Live source has the same shape on disk and over HTTP: +```text +/site/acme/palette/index.js on disk + /site/acme/palette/index.js live — every save, no-store + /site/acme/palette@1.1.0/index.js + exact Version, immutable forever + /site/_history//acme/palette/index.js + an earlier every-save state ``` -/site/acme/palette/palette.js on disk - /site/acme/palette/palette.js live — every save, no-store - /site/acme/palette@1.1.0/palette.js - the sealed copy, immutable forever - /site/_history//acme/palette/palette.js - any state it was ever in, published or not + +Canonical identities are RRIs such as `@acme/palette/` and +`@acme/palette@1.1.0/`. Realm URLs are transport projections. Authored locks, +Repositories, Checkpoints, and indexes use RRI identity so moving a realm does +not change what its content means. + +Each depot carries its own Deck resources: + +```text +site/ +├── acme/… live authored packages +└── .deck/ + ├── store/ immutable blobs, trees, Versions, locks, + │ Repositories, and Checkpoints + ├── refs/heads/ mutable branch refs (conditional writes) + ├── reviews/ Review openings, heads, and event chains + ├── history/ deckd/jj every-save History + └── indexes/ immutable completed index generations ``` -`ls` and `cat` give you the LATEST of everything, and an agent can -change any of it. Adding `@` to the package segment reads the -same path out of a seal instead. A depot carries its own machinery -inside itself — `.deck/store/` for sealed versions, `.jj/` for seals — -both invisible to the tree hash, so copying the directory takes the -live files, every published version, and the whole history with it. +Agents can inspect this state as ordinary files. Mutations go through Deck APIs +and conditional writes so two writers cannot silently replace one another. A +desktop backend uses the same contracts on a regular filesystem; a hosted realm +can place the realm-contained tree on S3 Files and use S3 conditional writes for +durable refs. -## Three levels of volatility, all just URLs +## Run it locally -| You want | You write | You get | -| --- | --- | --- | -| every save, instantly — 18 agents on one tree | `"acme/palette": "live"` | `/site/acme/palette/…` | -| the freshest seal, for a shape still moving | `"acme/palette": "dev"` | `/site/acme/palette@1.2.0-dev.7/…` | -| something dependable underneath you | `"acme/palette": "^1.0.0"` | `/site/acme/palette@1.1.0/…` | +Prerequisites and install options are in [INSTALL.md](./INSTALL.md). -`latest` is a dist-tag on a stable release. **`latest` is not live** — -live is the bytes on disk, with no version segment at all. +```bash +pnpm install +./scripts/run-hub-with-deckd.sh +``` -Ranges never match prereleases, so a `^1.0.0` dependency cannot pick up -somebody's auto-published dev line by accident. Opting into churn is -always explicit. +Open . One port serves the Hub chrome, Browse, Run, +Track, Review, and every demo through `*.localhost:8788` virtual hosts. -## The workflow +The Hub has four working views over the same state: + +- **Browse** — files, packages, Versions, branches, exact diffs, and the + Repository underneath. +- **Run** — a page or a generated harness using an exact Repository projection. +- **Track** — automatic Versions and every-save History Steps. +- **Review** — a queue of durable Reviews, exact package diffs, runnable + previews, activity, Checkpoints, and merge state. + +## The daily loop ```bash -deck serve --root ./work # every depot under ./work, live + auto-publishing -# …edit anything. Every settled save auto-publishes -dev.N (tag: dev) -# and is sealed, whether or not it is published. +deck serve --root ./work --history deckd +# edit normally; every settled save is addressable and kept in History deck release --depot ./work/site --publisher acme --name palette \ - --version 1.1.0 --from dev --tag latest # instantly a real semver, zero bytes copied + --version 1.1.0 --from dev --tag latest +# the Version is a pointer to existing sealed state: zero bytes copied -deck lock --depot ./work/site --publisher you --name app -# resolves this deck's declared RANGES to exact versions and pins them in -# its import map — what your users get, so they never inherit your -# dependencies' working trees. Move them forward when you're ready. +deck history ./work/site +deck restore ./work/site +# restore replays forward, so the state you leave remains recoverable +``` -deck fork --depot ./work/site --from acme/palette --as you/palette --use-in you/app -# copy someone's deck into YOUR publisher scope and change it at byte speed. -# Separate version line, separate dist-tags. Their versions never move. +Volatility is explicit: -deck offers --depot ./work/site # every open proposal, and whether it still merges -deck rebase --depot ./work/site # re-merge them all; conflicts are reported, not written +| Dependency choice | Result | +| ---------------------- | ----------------------------------------- | +| `workspace:*` / `live` | Current bytes on disk, `no-store` | +| `dev` | Latest automatically published prerelease | +| `^1.0.0` | A stable Version satisfying the range | +| `1.1.0` | One exact immutable Version | -deck history ./work/site # every state the depot has been in -deck restore ./work/site # go back to any of them; the state you - # leave is sealed first, so this is undoable -``` +`latest` is a dist-tag, not the working tree. Ranges never select prereleases. +The conventional `package.json` suggests ranges; the exact import-map lock +decides what executes. -The publisher segment IS the fork boundary: `acme/palette` and -`you/palette` are different names with different version lines in the -same depot. An undiverged fork costs no bytes — the store is content- -addressed, so identical content is stored once. - -How each promise is kept: - -| Promise | Mechanism | -| --- | --- | -| instant feedback | the version-less URL serves your working directory, `no-store` | -| never lose work | every settled save auto-publishes an immutable version, and every save is sealed | -| revert anytime | `deck restore ` — replayed forward, never rewinding | -| ship any version | `deck release … --from dev` — names a sealed state, copies nothing | -| share any milestone | `deck pack ` → one Deck Pack (`.pack.zip`) any zip tool opens | -| use any library | `deck vendor https://esm.sh/three@0.160.0 …` — pulled in for good, hermetic | -| fork freely | `deck fork` — your own publisher scope; theirs is untouched | -| stability where you need it | `deck lock` — ranges in the tree, exact pins in the map | -| whole web platform | plain ES modules and import maps; no bundler, no transpile | - -Deck is deliberately independent: - -- **No framework required.** The protocol serves ANY vanilla JS/HTML - app. Nothing here depends on `.gts`, card-api, or Boxel — those - are one consumer. -- **Reference implementation here**: a CLI and a minimal Node - webserver, developed and tested standalone. -- **The Boxel realm server will IMPLEMENT Deck later**, when this is - mature. A working at-rest implementation already exists inside the - realm server (branches `deck/01`–`deck/07` on the - `jj-history-sidecar` worktree of the boxel monorepo, L1–L7, fully - tested) — it is prior art and a re-import target, not the home. - -## Contribution: the proposal is a version, not a diff - -A fork records its base — package, version, and treeHash — in its own import -map, so a fork IS a proposal. Nothing registers it; there is no branch and no -side table. That makes two things possible that a diff-based flow cannot do. - -**Review by running, not by reading.** Every proposal is already published at -a URL, so trying one is a query parameter: +## Branch, Review, and merge -``` -/site/you/app/index.html?with=acme/palette=you/palette -/site/you/app/index.html?with=acme/palette@1.1.0 -``` +The CLI exposes the same protocol that the Hub and agents use: -The server resolves the app's import map differently for that one request — -same rules `deck lock` uses, applied at serve time. No checkout, no build, no -branch, and the app's own tree is untouched. Repeat `with` to try several at -once. A trial is never cached as if it were the sealed thing. +```bash +deck repo-init --depot ./work/site --owner acme --name dashboard \ + --root app/dashboard --member app/dashboard --member acme/editor \ + --member acme/theme --history "$HISTORY_HEAD" --index "$INDEX_HASH" + +deck branch --depot ./work/site --repo acme/dashboard \ + --name kim/carrier-routing --from main + +# edit the branch's packages, then create an intentional collaboration point +deck checkpoint --depot ./work/site --repo acme/dashboard \ + --branch kim/carrier-routing --message "Route recovery work" \ + --history "$HISTORY_HEAD" --index "$INDEX_HASH" -**Nobody rebases.** `deck serve --rebase` keeps every open proposal merged -against upstream continuously: the base is exact and both sides are -content-addressed, so re-merging is a pure function a machine can run. Clean -merges are written into the proposal's own tree — which auto-publishes and -seals like any other save — and the base pointer moves to what it absorbed. -Conflicted ones are reported and **left strictly alone**: a machine never -damages a contributor's work. +deck review --depot ./work/site --repo acme/dashboard \ + --source kim/carrier-routing --target main \ + --title "Route recovery work to carrier desks" -So a maintainer with 200 open proposals sees the three that stopped merging, -not 197 that need a rebase. +deck merge --depot ./work/site --repo acme/dashboard --number 2 \ + --history "$HISTORY_HEAD" --index "$INDEX_HASH" +``` + +`History` and index values come from the realm services in an integrated host; +the explicit CLI flags keep the protocol boundary visible. The executable +[collaboration replay](./scripts/replay-collaboration.mjs) builds a realistic +multi-party Repository from statements, including vendoring, automatic +Versions, branches, Checkpoints, a realistic Review queue, an approved merge, +and a rejected conflict. Run its assertions with: -JSON is merged as data rather than as lines. In a pretty-printed object, -adding a key makes the line above grow a comma, so a line merge would call -that a conflict — and manifests and card instances are JSON. Merging by key -means only the same key, changed differently on both sides, is a real -disagreement. +```bash +pnpm test:collaboration +``` -Accepting a proposal is a pointer move: upstream publishes the treeHash the -proposal already holds. **Zero bytes are copied.** +Temporary `?with=` substitutions remain useful for trying alternate package +Versions. They are preview transport, not Review identity. A Review records the +exact Repository and Checkpoints that were examined. -## Bring a CDN library in-house +## Bring a library in-house ```bash deck vendor https://esm.sh/three@0.160.0 --name three --version 0.160.0 \ --depot ./work/site --publisher acme --tag latest ``` -That pulls three.js's whole module graph from esm.sh ONCE, rewrites -every cross-module reference to stay inside the pack, and seals it. -Your app's import map then points at your own server: - -```html - - +Deck walks the ESM graph once, rewrites internal references to remain inside the +vendored tree, verifies it, and seals it. Applications then import the reviewed +Version from their own Deck server. Vendoring is hermetic by default: an +unresolved external edge fails instead of hiding a runtime dependency on the +public web. + +For npm packages with dependencies of their own, `vendor-tree` brings in the +graph as independently addressable packages. Shared content is stored once. + +## Packs and delivery + +`deck pack` is transport, backup, cache fill, and interop—not a second package +identity. A canonical `.pack.zip` round-trips to the same tree hash and can be +opened by ordinary ZIP tooling. + +Exact content URLs are immutable and safe behind a CDN. Public catalog entries +can use ordinary public caching. Private realms can use an authenticating edge +or signed cookies/URLs while retaining immutable cache keys. The origin remains +authoritative for authorization and mutable refs; the CDN delivers content. + +## CLI + +[CLI.md](./CLI.md) is the command reference. `deck help` prints the live command +map, and `deck help ` explains one command from the same records used by +the dispatcher. + +The groups are: + +```text +start hello serve +seal hash pack unpack list verify +publish publish tag release prune store derive verify-derivation +depend vendor vendor-tree lock verify-links +repository repo-init branch checkpoint review merge +propose fork offers rebase +endorse keygen sign verify-sig +recall history restore +prove conform +say claim note +``` + +The older package-level `fork` / `offers` / `rebase` flow remains a useful +single-package proposal surface. Repository Reviews are the durable multi-package +collaboration surface and are the basis of the Boxel backport. + +## Protocol documentation + +- **[Deck laws](./docs/deck-laws.md)** — the executable protocol contract. +- **[At rest](./docs/deck-at-rest.md)** — depot layout, storage, addresses, and + History. +- **[Collaboration protocol](./docs/deck-collaboration-protocol.md)** — RRI-native + Repository, Checkpoint, branch, Review, and merge objects. +- **[Step, Version, and History](./docs/deck-step-version-vocabulary.md)** — the + every-save and authored-state vocabulary. +- **[Pack equivalence](./docs/deck-pack-equivalence.md)** — canonical pack laws and + carriage modes. +- **[Storage backends](./docs/deck-storage-backends.md)** — local filesystem and + realm-contained S3 Files architecture. +- **[Efficient delivery](./docs/deck-efficient-delivery-spec.md)** — derived trees, + compression, preload closure, and CDN responsibilities. +- **[Hub tour](./docs/deck-hub-tour.md)** and **[UI requirements](./docs/deck-ui-spec.md)** + — how to inspect the implementation and judge the product surface. + +## Implementation + +The packages layer so Deck Core can move into Boxel without importing the +standalone server or tools: + +```text +core ← server ← tools ← cli + └── history contract ← deckd (Rust + jj-lib) + └── conformance laws ``` -Verified end to end against the real esm.sh: three.js r160 loads and -executes in a browser with every request going to the Deck server and -none to any CDN. Vendoring is **hermetic by default** — if the graph -reaches a host the walker will not follow, the command fails rather -than sealing a pack with a live external reference inside it. -`--allow-external` (keep it live) and `--allow-host` (follow it too) -are the explicit opt-outs. The canonical source travels with the -bytes as `vendoredFrom`, so a consumer can always tell what a -vendored module IS — dedupe it, upgrade it, or check it upstream. - -Because a browser only honours an INLINE import map, the server -injects each document's map when it serves HTML — including replacing -an inert ` + + -
loading…
+ +
+ - diff --git a/apps/hub/hub/app/browse/package.json b/apps/hub/hub/app/browse/package.json new file mode 100644 index 0000000..8d6c027 --- /dev/null +++ b/apps/hub/hub/app/browse/package.json @@ -0,0 +1,15 @@ +{ + "name": "browse", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./browse.js" }, + "dependencies": { + "ui/nav": "workspace:*", + "diff": "^9.0.0", + "highlight-js": "esm", + "@tanstack/table-core": "browser", + "ui/activity": "workspace:*", + "ui/history": "workspace:*", + "ui/tour": "workspace:*" + } +} diff --git a/apps/hub/hub/app/review/index.html b/apps/hub/hub/app/review/index.html new file mode 100644 index 0000000..a0cf678 --- /dev/null +++ b/apps/hub/hub/app/review/index.html @@ -0,0 +1,26 @@ + + + + + + review — Deck Hub + + + + + + + + +
+ + + + + diff --git a/apps/hub/hub/app/review/package.json b/apps/hub/hub/app/review/package.json new file mode 100644 index 0000000..a82bcce --- /dev/null +++ b/apps/hub/hub/app/review/package.json @@ -0,0 +1,11 @@ +{ + "name": "review", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./review.js" + }, + "dependencies": { + "ui/nav": "workspace:*" + } +} diff --git a/apps/hub/hub/app/review/review.js b/apps/hub/hub/app/review/review.js new file mode 100644 index 0000000..5fe3f0f --- /dev/null +++ b/apps/hub/hub/app/review/review.js @@ -0,0 +1,414 @@ +// /review — a working queue over durable branch Reviews. +import 'ui/nav'; +import { installNavStyles, onLiveChange, wrapTables } from 'ui/nav'; + +installNavStyles(); +const app = document.getElementById('app'); +const keys = document.querySelector('deck-keys'); +let current; +let endpoint; +let reviewSource; +let queueMode = 'open'; +let busy = false; + +const esc = (value) => + String(value ?? '').replace( + /[&<>"]/g, + (char) => + ({ '&': '&', '<': '<', '>': '>', '"': '"' })[char], + ); + +async function json(url, options) { + let response = await fetch(url, { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + ...options, + }); + let body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? response.statusText); + return body; +} + +async function selectRepository() { + let catalog = await json('/_hub/repositories'); + let params = new URLSearchParams(location.search); + let wanted = params.get('repository'); + let depot = params.get('depot'); + let selected = + catalog.repositories.find( + (item) => + item.repository === wanted && (!depot || item.depot === depot), + ) ?? + catalog.repositories.find((item) => item.initialized) ?? + catalog.repositories[0]; + let sourceName = params.get('source'); + let source = + sourceName && + catalog.repositories.find( + (item) => + item.repository === sourceName && item.depot === selected?.depot, + ); + let review = Number(params.get('review')); + queueMode = params.get('view') === 'all' ? 'all' : 'open'; + return { + selected, + source, + review: Number.isSafeInteger(review) && review > 0 ? review : undefined, + }; +} + +function updateQuery(values, replace = false) { + let params = new URLSearchParams(location.search); + for (let [name, value] of Object.entries(values)) { + if (value === undefined || value === null || value === '') { + params.delete(name); + } else { + params.set(name, String(value)); + } + } + history[replace ? 'replaceState' : 'pushState']( + {}, + '', + `?${decodeURIComponent(params.toString())}`, + ); +} + +function disposition(review) { + if (review.state === 'merged') return { key: 'merged', label: 'merged' }; + if (review.state === 'closed') return { key: 'closed', label: 'closed' }; + let verdict = [...review.events] + .reverse() + .find((event) => event.type === 'reviewed')?.verdict; + if (verdict === 'approved') return { key: 'approved', label: 'approved' }; + if (verdict === 'changes-requested') { + return { key: 'changes', label: 'changes requested' }; + } + return { key: 'waiting', label: 'waiting' }; +} + +function updatedAt(review) { + return review.events.at(-1)?.createdAt ?? review.createdAt; +} + +function when(value) { + if (!value) return ''; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); +} + +function visibleReviews(state) { + return queueMode === 'all' + ? state.reviews + : state.reviews.filter((review) => review.state === 'open'); +} + +function queue(state, canOpen) { + let visible = visibleReviews(state); + let open = state.reviews.filter((review) => review.state === 'open'); + let approved = open.filter( + (review) => disposition(review).key === 'approved', + ).length; + let changes = open.filter( + (review) => disposition(review).key === 'changes', + ).length; + let filters = + `` + + ``; + let rows = visible + .map((review) => { + let status = disposition(review); + let selected = review.number === state.review?.number; + return ( + `` + ); + }) + .join(''); + return ( + `
` + + `01

Reviews

` + + `${approved} approved · ${changes} need changes` + + `
${filters}
` + + (canOpen + ? `` + : '') + + `
` + + (rows || `

No ${queueMode} Reviews.

`) + + `
` + ); +} + +function changes(state) { + if (!state.changes.length) { + return `

No package changes in this Review.

`; + } + return ( + `
${state.changes + .map( + (change) => + `${esc(change.name)}` + + `Version diff →`, + ) + .join('')}
` + ); +} + +function reviewEvents(review) { + let opened = + `
  • opened${esc(review.author.name ?? review.author.id)}` + + `
  • `; + let events = review.events + .map((event) => { + let label = + event.type === 'reviewed' && event.verdict + ? event.verdict.replace('-', ' ') + : event.type.replace('-', ' '); + return ( + `
  • ${esc(label)}` + + `${esc(event.actor.name ?? event.actor.id)}` + + (event.checkpointHash + ? ` · ${esc(event.checkpointHash.slice(0, 8))}` + : '') + + `
  • ` + ); + }) + .join(''); + return `
      ${opened}${events}
    `; +} + +function historyView(state) { + return ( + `
      ${state.history + .map( + (item) => + `
    1. ${esc(item.checkpointHash.slice(0, 12))}
      ` + + `${esc(item.message)}
      ` + + `${esc(item.author.name)} · ${esc(when(item.createdAt))}` + + `${item.parents.length === 2 ? ' · merge' : ''}
    2. `, + ) + .join('')}
    ` + ); +} + +function protocol(state) { + return state.protocol + .map( + (object) => + `
    ${esc(object.label)}` + + `${esc(object.path)}` + + `
    ${esc(JSON.stringify(object.value, null, 2))}
    `, + ) + .join(''); +} + +function detail(state, runnable, rootAddress) { + let review = state.review; + if (!review) { + return ( + `
    ` + + `02

    Selected

    ` + + `

    Select a Review from the queue.

    ` + ); + } + let status = disposition(review); + let merge = ''; + if (review.state === 'open' && status.key === 'approved') { + merge = ``; + } else if (review.state === 'open' && status.key === 'changes') { + merge = ``; + } else if (review.state === 'open') { + merge = ``; + } + return ( + `
    ` + + `02

    Selected

    ` + + `#${review.number}
    ${merge}
    ` + + `
    ` + + `${esc(status.label)}` + + `
    ${esc(review.title)}` + + `

    ${esc(review.body)}

    ` + + `
    ${esc(review.author.name ?? review.author.id)}` + + `${esc(review.source.repository)}:${esc(review.source.branch)}` + + `→${esc(review.target.repository)}:${esc(review.target.branch)}
    ` + + `

    Packages

    ${changes(state)}` + + `
    ` + + `
    ` + ); +} + +function render(state) { + current = state; + if (!state.initialized) { + app.innerHTML = + `
    ` + + `
    01

    Reviews

    ` + + `

    ${esc(state.repository)} is mutable, but its main branch has not been initialized yet. Create a branch from Browse to begin.

    ` + + `
    `; + app.setAttribute('aria-busy', 'false'); + return; + } + let runnable = state.preview?.projection ?? state.main.projection; + let rootAddress = runnable.members[state.roots[0]]; + let featureBranches = state.branches.filter( + (branch) => branch.name !== 'main', + ); + let forkReady = + reviewSource?.initialized && reviewSource.forkedFrom?.checkpointHash; + let canOpen = forkReady || featureBranches.length; + app.innerHTML = + `
    ` + + `` + + queue(state, canOpen) + + detail(state, runnable, rootAddress) + + `
    03` + + `

    Work

    ${historyView(state)}
    ` + + `
    ` + + `04

    Underneath

    ` + + `

    Canonical refs and immutable objects. This is the state the CLI, agents, and Hub share.

    ` + + `${protocol(state)}
    `; + app.setAttribute('aria-busy', 'false'); + wrapTables(app); + keys.keys = [ + ['j', 'next review'], + ['k', 'previous review'], + ['r', 'run selected'], + ['m', 'merge selected'], + ['u', 'underneath'], + ]; +} + +async function chooseReview(number, replace = false) { + app.setAttribute('aria-busy', 'true'); + let state = await json(`${endpoint}/reviews/${number}`); + updateQuery({ review: number }, replace); + render(state); +} + +async function load() { + try { + let { selected, source, review } = await selectRepository(); + if (!selected) { + app.innerHTML = + '

    No depot declares a repository.json yet.

    '; + return; + } + endpoint = selected.url; + reviewSource = source ? await json(source.url) : undefined; + let state = await json(review ? `${endpoint}/reviews/${review}` : endpoint); + if (!review && state.review) { + updateQuery({ review: state.review.number }, true); + } + render(state); + } catch (error) { + app.innerHTML = + `
    review — ${esc(error.message)}
    `; + } +} + +app.addEventListener('click', async (event) => { + let filter = event.target.closest('[data-queue-mode]'); + if (filter) { + queueMode = filter.dataset.queueMode; + updateQuery({ view: queueMode === 'all' ? 'all' : undefined }); + render(current); + return; + } + let row = event.target.closest('[data-review-number]'); + if (row && !busy) { + await chooseReview(Number(row.dataset.reviewNumber)); + return; + } + let button = event.target.closest('[data-action]'); + if (!button || busy) return; + busy = true; + button.disabled = true; + try { + if (button.dataset.action === 'review') { + let branches = current.branches.filter( + (branch) => branch.name !== 'main', + ); + let source = reviewSource + ? 'main' + : prompt('Source branch', branches.at(-1)?.name ?? ''); + if (!source) return; + let title = prompt('Review title'); + if (!title) return; + let state = await json(`${endpoint}/reviews`, { + method: 'POST', + body: JSON.stringify({ + source, + target: 'main', + title, + ...(reviewSource + ? { sourceRepository: reviewSource.repository } + : {}), + }), + }); + updateQuery({ review: state.review?.number }, true); + render(state); + } else { + let state = await json( + `${endpoint}/reviews/${button.dataset.number}/merge`, + { method: 'POST', body: '{}' }, + ); + updateQuery({ review: state.review?.number }, true); + render(state); + } + } catch (error) { + button.insertAdjacentHTML( + 'afterend', + ` ${esc(error.message)}`, + ); + } finally { + busy = false; + } +}); + +addEventListener('keydown', async (event) => { + if (event.target.matches('input, textarea, select')) return; + if ((event.key === 'j' || event.key === 'k') && current) { + let rows = visibleReviews(current); + let index = rows.findIndex( + (review) => review.number === current.review?.number, + ); + let delta = event.key === 'j' ? 1 : -1; + let next = rows[Math.max(0, Math.min(rows.length - 1, index + delta))]; + if (next && next.number !== current.review?.number) { + await chooseReview(next.number); + } + } + if (event.key === 'r' && current) { + location.href = (current.preview?.projection ?? current.main.projection) + .runUrl; + } + if (event.key === 'm') { + document.querySelector('[data-action="merge"]')?.click(); + } + if (event.key === 'u') { + document + .querySelector('.underneath') + ?.scrollIntoView({ behavior: 'smooth' }); + } +}); + +addEventListener('popstate', () => load()); +onLiveChange(() => load()); +load(); diff --git a/apps/hub/hub/app/run/importmap.json b/apps/hub/hub/app/run/importmap.json deleted file mode 100644 index 5ebb849..0000000 --- a/apps/hub/hub/app/run/importmap.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "deck": { - "packages": { - "run": { - "version": "0.1.0", - "entry": "$DECK/run.js" - } - }, - "dependencies": { - "ui/nav": "live", - "ui/timeline": "live", - "ui/activity": "live" - } - }, - "imports": { - "ui/nav/": "/hub/ui/nav/", - "ui/nav": "/hub/ui/nav/nav.js", - "ui/timeline/": "/hub/ui/timeline/", - "ui/timeline": "/hub/ui/timeline/timeline.js", - "ui/activity/": "/hub/ui/activity/", - "ui/activity": "/hub/ui/activity/activity.js" - } -} diff --git a/apps/hub/hub/app/run/index.html b/apps/hub/hub/app/run/index.html index 1e1b393..6c77bf6 100644 --- a/apps/hub/hub/app/run/index.html +++ b/apps/hub/hub/app/run/index.html @@ -16,6 +16,9 @@ addressable and deliberately not importable — the same shape the ProseMirror `src/` decks have, for the same reason. --> + + + + + -
    loading…
    + +
    + - diff --git a/apps/hub/hub/app/run/package.json b/apps/hub/hub/app/run/package.json new file mode 100644 index 0000000..2a80d57 --- /dev/null +++ b/apps/hub/hub/app/run/package.json @@ -0,0 +1,12 @@ +{ + "name": "run", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./run.js" }, + "dependencies": { + "ui/nav": "workspace:*", + "ui/history": "workspace:*", + "ui/activity": "workspace:*", + "ui/tour": "workspace:*" + } +} diff --git a/apps/hub/hub/app/run/run.js b/apps/hub/hub/app/run/run.js index 0c9cdd5..001979d 100644 --- a/apps/hub/hub/app/run/run.js +++ b/apps/hub/hub/app/run/run.js @@ -15,15 +15,16 @@ // a readout, sitting directly below the thing doing the running. import 'ui/nav'; import { installNavStyles, onLiveChange, wrapTables } from 'ui/nav'; +import { resumeHubTour } from 'ui/tour'; import { attachScrub, foldBursts, - installTimelineStyles, + installHistoryStyles, measureFrame, stepBy, stepsFor, - timelineHtml, -} from 'ui/timeline'; + historyHtml, +} from 'ui/history'; import { ago, activityCell, @@ -36,7 +37,7 @@ import { installNavStyles(); installActivityStyles(); -installTimelineStyles(); +installHistoryStyles(); const app = document.getElementById('app'); const keys = document.querySelector('deck-keys'); @@ -50,7 +51,10 @@ const esc = (value) => const params = () => new URLSearchParams(location.search); const currentAt = () => params().get('at') ?? ''; -const currentWith = () => params().getAll('with'); +// Run keeps substitutions as UI-only `sub` params. The framed document gets +// Deck's protocol-level `with` query in `runTarget`; putting `with` on this +// app document would ask the server to substitute app/run itself on reload. +const currentWith = () => [...params().getAll('sub'), ...params().getAll('with')]; /** Which Step the stage is showing. '' is live — a position, not a mode. */ const currentStep = () => params().get('step') ?? ''; @@ -67,7 +71,7 @@ function isHubApp(at) { } /** - * The Timeline for a running page: every Step that touched ANYTHING it loads. + * The History for a running page: every Step that touched ANYTHING it loads. * * Not just the page's own file. You are watching the assembled thing, and a * palette three decks away changes what you see — so "undo" has to mean "the @@ -102,7 +106,7 @@ function stepsOf(depot) { depot, getJson(`/${depot}/${HISTORY}`) .then((body) => body.data.map((step) => step.attributes)) - // Versions only, no Timeline. Not an error — fewer addresses. + // Version history only. Not an error — fewer addresses. .catch(() => []), ); } @@ -139,7 +143,7 @@ function go(next = {}) { let step = 'step' in next ? next.step : currentStep(); if (at) query.set('at', at); if (step) query.set('step', step); - for (let value of withs) query.append('with', value); + for (let value of withs) query.append('sub', value); let text = query.toString(); history.pushState({}, '', text ? `?${decodeURIComponent(text)}` : location.pathname); render(); @@ -160,43 +164,44 @@ async function getJson(url) { /** * Where a deck actually RUNS. * - * The Hub mounts every demo depot under its example name so nine of them can - * coexist, but each was sealed under the name `site`, and a pin names a - * depot. Read from `/codelist/...` the bytes are right and the pins point at - * an address this origin does not answer. So the Hub serves each demo depot a - * second time, as `site`, on its own port — and this is where /run points the - * frame. Same directory, same store, correct pins. + * The Hub mounts every demo depot under its example name so they can coexist, + * but each was sealed under the name `site`, and a pin names a depot. A + * virtual host gives each demo that truthful `/site` root while every request + * still enters through the Hub's one public port. */ -let ORIGINS = {}; -let ORIGIN_ERRORS = {}; -let ORIGINS_REACHABLE = false; - -async function loadOrigins() { - try { - let response = await fetch('/_hub/origins'); - if (!response.ok) throw new Error(`/_hub/origins answered ${response.status}`); - let body = await response.json(); - ORIGINS = body.origins ?? {}; - ORIGIN_ERRORS = body.errors ?? {}; - ORIGINS_REACHABLE = true; - } catch { - ORIGINS_REACHABLE = false; - } +function demoOrigin(depot) { + let origin = new URL(location.origin); + origin.hostname = `${depot}.localhost`; + return origin.origin; } /** - * Where a deck runs, or WHY IT CANNOT — never a quiet substitute. + * Where a deck runs. * - * The Hub's own decks run at the Hub's origin, because that is the depot they - * were sealed for. A demo runs at its second mount and nowhere else. When the - * origin for a demo is unknown there is no fallback worth having: framing it - * here would load an HTML page whose every import 404s, which renders as an - * empty white box with nothing in the console the reader can see. Silence is - * the one answer this must never give, so a missing origin returns a reason. + * The Hub's own decks run at the Hub host. A demo runs at + * `.localhost` on the same port, where its sealed `/site` pins resolve. */ /** Concrete page last framed — scrubbing must not use a depot-only `?at=`. */ let stageAt = ''; +/** + * A seal is a working-tree snapshot. `@1.2.3` is a store Version address — + * not a directory under `/_history//`. Browse already rebuilds Step + * addresses without the version segment; /run has to do the same or the + * iframe loads `…/app/form@0.1.0-dev.2/index.html` and the history backend + * correctly answers file-not-found. + */ +function sealPath(inner) { + return inner + .split('/') + .map((segment) => { + let at = segment.indexOf('@'); + return at === -1 ? segment : segment.slice(0, at); + }) + .filter(Boolean) + .join('/'); +} + function runTarget(at, withs = currentWith(), step = currentStep()) { let [, depot, ...rest] = at.split('/'); let query = withs.map((value) => `with=${value}`).join('&'); @@ -216,33 +221,16 @@ function runTarget(at, withs = currentWith(), step = currentStep()) { // import map with its live pins rewritten to resolve inside the step, so // what loads is the page AND its dependencies as they actually were. if (step) { + let path = sealPath(inner); return { url: depot === 'hub' - ? `/hub/${HISTORY}/${step}/${inner}${suffix}` - : ORIGINS[depot] - ? `${ORIGINS[depot]}/site/${HISTORY}/${step}/${inner}${suffix}` - : undefined, - why: ORIGINS[depot] || depot === 'hub' ? undefined : - `No depot named ${depot} is mounted, so there is no origin to run it at.`, + ? `/hub/${HISTORY}/${step}/${path}${suffix}` + : `${demoOrigin(depot)}/site/${HISTORY}/${step}/${path}${suffix}`, }; } if (depot === 'hub') return { url: `/${depot}/${inner}${suffix}` }; - let origin = ORIGINS[depot]; - if (origin) return { url: `${origin}/site/${inner}${suffix}` }; - if (!ORIGINS_REACHABLE) { - return { - why: - 'This Hub is not reporting where its demos run. That is what a stale ' + - 'server looks like: an older process still holding this port. Stop it ' + - 'and start again — `lsof -ti:8788 | xargs kill`.', - }; - } - return { - why: - ORIGIN_ERRORS[depot] ?? - `No depot named ${depot} is mounted, so there is no origin to run it at.`, - }; + return { url: `${demoOrigin(depot)}/site/${inner}${suffix}` }; } // --------------------------------------------------------------------------- @@ -256,11 +244,12 @@ function runTarget(at, withs = currentWith(), step = currentStep()) { // a case it hides. // --------------------------------------------------------------------------- -async function inventory() { +async function inventory(depotName) { let { data } = await getJson('/_packages'); + let depots = data.filter((depot) => !depotName || depot.name === depotName); let found = []; await Promise.all( - data.flatMap((depot) => + depots.flatMap((depot) => depot.packages.map(async (deck) => { let name = `${deck.publisher}/${deck.name}`; let version = deck.distTags?.latest ?? Object.keys(deck.versions ?? {}).at(-1); @@ -361,17 +350,33 @@ async function resolvedMap(url) { } } +/** + * Turn a runtime pin back into the address the Hub reads. + * + * Demo pages execute from a private origin where their depot is named + * `site`; the Hub mounts that same depot under its real name. Exposing the + * runtime path in Browse produced links to a depot that does not exist here. + */ +function hubAddress(url) { + let target = String(url).replace(/^\//, '').split('/'); + if (target[0] === 'site') { + target[0] = currentAt().replace(/^\//, '').split('/')[0]; + } + return `/${target.join('/')}`; +} + /** Every mapped URL becomes a link into /browse at that exact address. */ function mapRows(table, indent = false) { return Object.entries(table) .filter(([specifier]) => !specifier.endsWith('/')) .map(([specifier, url]) => { - let target = url.replace(/^\//, '').split('/'); + let address = hubAddress(url); + let target = address.replace(/^\//, '').split('/'); let [depot, publisher, pkg] = target; return ( `` + `${esc(specifier)}` + - `` + + `` + `${esc(depot ?? '')}/` + `${esc(publisher ?? '')}/` + `${esc(pkg ?? '')}` + @@ -394,9 +399,11 @@ function mapRows(table, indent = false) { * deck's page. The subject does not change, the substitution is stated, and * the reader gets the thing they meant: the file they were reading, running. * - * A deck with no page at all is the one honest refusal, and it is a fact - * about that deck — the eleven ProseMirror `src/` decks publish no entry — - * rather than a failure of the strip. + * A deck with no page continues across the graph instead: resolved import + * maps say which pages consume it, and `?with=` can place the selected + * Version into one of those pages without a checkout. Only a library with no + * reverse edge stops, and even then Run explains the edge it needs rather + * than presenting an error. */ /** * Every page in one deck, by full path. @@ -420,6 +427,213 @@ function pagesOf(deck) { return pagesCache.get(deck); } +/** + * The package coordinate inside any Deck address. + * + * Paths and files are deliberately ignored: Run needs the graph node, not + * the particular source file through which somebody arrived at it. + */ +function packageCoordinate(at) { + let [depot, publisher, pkgAndSpec] = at.replace(/^\//, '').split('/'); + if (!depot || !publisher || !pkgAndSpec) return; + let marker = pkgAndSpec.indexOf('@'); + let pkg = marker === -1 ? pkgAndSpec : pkgAndSpec.slice(0, marker); + let version = marker === -1 ? '' : pkgAndSpec.slice(marker + 1); + return { + depot, + publisher, + pkg, + version, + identity: `${publisher}/${pkg}`, + }; +} + +/** The package coordinate named by one resolved import-map target. */ +function mappedCoordinate(target) { + let pathname; + try { + pathname = new URL(String(target), location.origin).pathname; + } catch { + return; + } + // At runtime the first segment is normally `site`, not the Hub's mount + // name. Publisher and package remain the protocol identity in both forms. + let [, , publisher, pkgAndSpec] = pathname.split('/'); + if (!publisher || !pkgAndSpec) return; + let marker = pkgAndSpec.indexOf('@'); + let pkg = marker === -1 ? pkgAndSpec : pkgAndSpec.slice(0, marker); + return { + publisher, + pkg, + version: marker === -1 ? '' : pkgAndSpec.slice(marker + 1), + identity: `${publisher}/${pkg}`, + }; +} + +async function forkedFromOf(at) { + let focus = packageCoordinate(at); + if (!focus) return; + let address = + `/${focus.depot}/${focus.identity}` + + (focus.version ? `@${focus.version}` : ''); + let map = await fetch(`${address}/importmap.json`) + .then((response) => (response.ok ? response.json() : undefined)) + .catch(() => undefined); + let entry = map?.deck?.packages?.[focus.pkg]?.forkedFrom; + let identity = entry?.package ?? entry?.deck; + return identity ? { identity, version: entry.version ?? '' } : undefined; +} + +function consumerHref(page, focus, alreadyUsing, sourceIdentity) { + let query = new URLSearchParams({ at: page }); + if (!alreadyUsing) { + let target = focus.version + ? `${focus.identity}@${focus.version}` + : focus.identity; + query.set( + 'sub', + sourceIdentity === focus.identity + ? target + : `${sourceIdentity}=${target}`, + ); + } + return `?${query.toString()}`; +} + +/** + * Runnable pages whose resolved import map reaches this package. + * + * This is the missing half of Run. Most packages are libraries by design; + * asking only whether the package itself contains HTML turns most of the + * graph into dead ends. The import map already says where a library becomes + * visible, and `?with=` can put the selected Version into that page for one + * no-store trial without changing its lock. + */ +async function consumersOf(at) { + let focus = packageCoordinate(at); + if (!focus) return []; + let forkedFrom = await forkedFromOf(at); + let accepted = new Set([ + focus.identity, + ...(forkedFrom ? [forkedFrom.identity] : []), + ]); + let pages = await inventory(focus.depot); + let found = await Promise.all( + pages.map(async (item) => { + let target = runTarget(item.at, [], ''); + if (!target.url) return; + let map = await resolvedMap(target.url).catch(() => undefined); + if (!map) return; + let bindings = [ + ...Object.entries(map.imports ?? {}), + ...Object.values(map.scopes ?? {}).flatMap((scope) => + Object.entries(scope), + ), + ]; + let matches = bindings + .map(([specifier, value]) => ({ + specifier, + target: mappedCoordinate(value), + })) + .filter( + (binding) => + binding.target && accepted.has(binding.target.identity), + ); + if (!matches.length) return; + let alreadyUsing = matches.some(({ target }) => { + if (target.identity !== focus.identity) return false; + return focus.version + ? target.version === focus.version + : target.version === ''; + }); + let samePackage = matches.find( + ({ target }) => target.identity === focus.identity, + ); + let sourceIdentity = samePackage?.target.identity ?? matches[0].target.identity; + return { + ...item, + specifiers: [...new Set(matches.map(({ specifier }) => specifier))], + current: [ + ...new Set( + matches.map(({ target }) => + `${target.identity}${target.version ? `@${target.version}` : ' live'}`, + ), + ), + ], + sourceIdentity, + alreadyUsing, + href: consumerHref(item.at, focus, alreadyUsing, sourceIdentity), + }; + }), + ); + return found.filter(Boolean); +} + +function consumerView(input, consumers) { + let focus = packageCoordinate(input); + let selected = focus.version || 'live'; + let rows = consumers + .map((consumer) => { + let relation = consumer.alreadyUsing + ? `already uses ${selected}` + : `currently uses ${consumer.current.join(', ')}`; + return ( + `` + + `` + + `${esc(consumer.publisher)}/` + + `${esc(consumer.deck.split('/')[1])}` + + `${esc(consumer.specifiers.join(', '))}` + + `${esc(relation)}` + + `` + + `${consumer.alreadyUsing ? 'Run where used' : `Run with ${esc(selected)}`} →` + ); + }) + .join(''); + return ( + `
    ` + + `
    library no page of its own
    ` + + `
    used by ${consumers.length} runnable ${consumers.length === 1 ? 'page' : 'pages'}
    ` + + `

    This package runs inside other pages.

    ` + + `

    ${esc(focus.identity)}@${esc(selected)} exports code rather than HTML. ` + + `Choose a consumer below and Deck will assemble that page with this ` + + `${focus.version ? 'exact Version' : 'live package'} in its import map.

    ` + + `
    pageimports` + + `${esc(focus.identity)}@${esc(selected)}fromCAS
    ` + + `

    The trial is no-store and changes no files or locks. ` + + `It is a runnable question, not a checkout.

    ` + + `` + + `${rows}
    Run inImported asCurrent pin
    ` + + `

    Browse this package · ` + + `See its History

    ` + ); +} + +function unusedLibraryView(input) { + let focus = packageCoordinate(input); + let selected = focus.version || 'live'; + return ( + `
    ` + + `
    library no page of its own
    ` + + `
    graph consumers 0
    ` + + `

    No consumer declared in the graph.

    ` + + `

    ${esc(focus.identity)}@${esc(selected)} is valid code, but no ` + + `page in ${esc(focus.depot)} currently names it ` + + `in a resolved import map. Run does not guess from source text or computed ` + + `URLs, so it cannot prove a page in which to show this package.

    ` + + `
    ${esc(focus.identity)}add to` + + `import mapthen load fromHTML page
    ` + + `

    If a page loads this through a computed URL, that edge is ` + + `invisible to Deck. Add an ESM import and map its specifier to this package. ` + + `Run will discover the reverse edge automatically—no Run configuration ` + + `or special entry file is required.

    ` + + `
    import value from '${esc(focus.identity)}';\n\n` +
    +    `{"imports":{"${esc(focus.identity)}":"/${esc(focus.depot)}/${esc(focus.identity)}${focus.version ? `@${esc(focus.version)}` : ''}/index.js"}}
    ` + + `

    Browse this package · ` + + `Find a runnable page · ` + + `See its History

    ` + ); +} + async function resolveRunnable(at) { if (/\.html?$/.test(at)) return { at }; let parts = at.replace(/^\//, '').split('/'); @@ -430,7 +644,7 @@ async function resolveRunnable(at) { // a candidate, and there is usually more than one, so this never picks. if (!publisher || !pkgAndSpec) { let prefix = `/${parts.join('/')}`; - let under = (await inventory()).filter( + let under = (await inventory(depot)).filter( (item) => item.at === prefix || item.at.startsWith(`${prefix}/`), ); if (!under.length) { @@ -451,13 +665,7 @@ async function resolveRunnable(at) { let within = rest.join('/'); let pages = await pagesOf(deck); if (!pages.length) { - return { - why: - `Nothing in ${pkgAndSpec} is a page, so there is nothing here to run. ` + - `That is a fact about the deck, not a missing feature — a deck can be ` + - `addressable, hashed and diffable and still have no entry a browser ` + - `can be pointed at.`, - }; + return { library: true }; } // DOWN BEFORE UP. The address is the most specific thing the reader has @@ -519,6 +727,12 @@ async function viewOne(input) { if (!resolved.at) { subject.trail = addressTrail(input); subject.fact = ''; + if (resolved.library) { + let consumers = await consumersOf(input); + return consumers.length + ? consumerView(input, consumers) + : unusedLibraryView(input); + } return ( `
    nothing to run — ${esc(resolved.why)}
    ` + `

    Read it in /browse, ` + @@ -600,10 +814,10 @@ async function viewOne(input) { let map = await resolvedMap(url); let depotName = at.replace(/^\//, '').split('/')[0]; - let timeline = foldBursts( + let history = foldBursts( stepsFor(await stepsOf(depotName), closureOf(at, map)), ); - lastTimeline = timeline; + lastHistory = history; let scopes = Object.entries(map.scopes ?? {}); let specifiers = Object.keys(map.imports ?? {}).filter((key) => !key.endsWith('/')); let answered = specifiers.map((key) => @@ -630,8 +844,8 @@ async function viewOne(input) { // grid rows, so a fifth child lands in an implicit row and the whole // pane grows a band of dead space. A grid with a fixed track list is a // contract about how many children it has. - timelineHtml({ - steps: timeline, + historyHtml({ + steps: history, current: currentStep(), label: 'undo', }) + @@ -650,10 +864,10 @@ async function viewOne(input) { `` + `Pop Out ↗` + `` + - `

    ` + `
    ` + - `
    ` + + `
    ` + // "imports 1 · scopes 0" is two numbers and no meaning. What a reader // wants is the sentence those numbers are evidence for. `

    ${ @@ -708,8 +922,8 @@ const stageWidth = () => localStorage.getItem(WIDTH_KEY) ?? '100%'; const isFull = () => localStorage.getItem(FULL_KEY) === '1'; const splitAt = () => Number(localStorage.getItem(SPLIT_KEY) ?? 62); -/** The timeline the last render drew, so the keys and buttons agree with it. */ -let lastTimeline = []; +/** The History the last render drew, so the keys and buttons agree with it. */ +let lastHistory = []; function applyStage() { let split = app.querySelector('[data-role="split"]'); @@ -811,6 +1025,14 @@ async function render() { cursor = -1; wrapTables(app); keys.keys = bindings(); + maybeResumeTour(); +} + +let tourResumed = false; +function maybeResumeTour() { + if (tourResumed) return; + tourResumed = true; + resumeHubTour({ waitMs: 80 }); } let cursor = -1; @@ -883,9 +1105,9 @@ addEventListener('keydown', (event) => { case 'f': return app.querySelector('[data-role="full"]')?.click(); case '[': - return go({ step: stepBy(lastTimeline, currentStep(), -1) }); + return go({ step: stepBy(lastHistory, currentStep(), -1) }); case ']': - return go({ step: stepBy(lastTimeline, currentStep(), 1) }); + return go({ step: stepBy(lastHistory, currentStep(), 1) }); case 'r': return app.querySelector('[data-role="reload"]')?.click(); default: @@ -908,7 +1130,7 @@ app.addEventListener('click', (event) => { // walks that same order — so `◀` (-1) is one step older and `▶` (+1) is // one newer, which is what the arrows point at. This negated the delta // and the buttons ran backwards: pressing "back" went forward. - return go({ step: stepBy(lastTimeline, currentStep(), Number(by.dataset.by)) }); + return go({ step: stepBy(lastHistory, currentStep(), Number(by.dataset.by)) }); } let width = event.target.closest('[data-role="width"]'); @@ -958,8 +1180,6 @@ app.addEventListener('input', (event) => { if (readout) readout.textContent = needle ? `${shown} shown` : ''; }); -await loadOrigins(); -await loadActivity(); onActivityChange(() => { // Only the index is decorated, and re-rendering while a demo is framed // would tear the iframe down and restart whatever it was doing. @@ -968,3 +1188,4 @@ onActivityChange(() => { onLiveChange(() => loadActivity()); render(); +loadActivity(); diff --git a/apps/hub/hub/app/start/importmap.json b/apps/hub/hub/app/start/importmap.json deleted file mode 100644 index 269bf09..0000000 --- a/apps/hub/hub/app/start/importmap.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "imports": { - "ui/nav/": "/hub/ui/nav/", - "ui/nav": "/hub/ui/nav/nav.js", - "ui/activity/": "/hub/ui/activity/", - "ui/activity": "/hub/ui/activity/activity.js", - "highlight-js/": "/hub/lib/highlight-js@11.11.1-esm.412fd1a8/", - "highlight-js": "/hub/lib/highlight-js@11.11.1-esm.412fd1a8/lib/core.js" - }, - "deck": { - "packages": { - "start": { - "version": "0.1.0", - "entry": "$DECK/start.js" - } - }, - "dependencies": { - "ui/nav": "live", - "ui/activity": "live", - "highlight-js": "lib/highlight-js@esm" - } - } -} diff --git a/apps/hub/hub/app/start/index.html b/apps/hub/hub/app/start/index.html index 483a788..015c037 100644 --- a/apps/hub/hub/app/start/index.html +++ b/apps/hub/hub/app/start/index.html @@ -12,14 +12,34 @@ nothing at runtime, and a webfont request is a third party learning every reader's IP and holding the power to change the bytes. --> + + + + + -

    loading…
    + +
    + + - diff --git a/apps/hub/hub/app/start/package.json b/apps/hub/hub/app/start/package.json new file mode 100644 index 0000000..6aa01a9 --- /dev/null +++ b/apps/hub/hub/app/start/package.json @@ -0,0 +1,12 @@ +{ + "name": "start", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./start.js" }, + "dependencies": { + "ui/nav": "workspace:*", + "ui/activity": "workspace:*", + "ui/tour": "workspace:*", + "highlight-js": "esm" + } +} diff --git a/apps/hub/hub/app/start/start.js b/apps/hub/hub/app/start/start.js index cffe7be..8747f53 100644 --- a/apps/hub/hub/app/start/start.js +++ b/apps/hub/hub/app/start/start.js @@ -14,15 +14,19 @@ // that could be checked and was not is a sentence nobody has to believe. // // WHAT THIS PAGE IS NOT. -// A tour. Every section here is short and ends in a link into /browse, /run -// or /track, because reading about a runnable thing when the runnable thing -// is one click away is the worst of both. +// A self-contained tutorial. Every section here is short and ends in a link +// into /browse, /run or /track, because reading about a runnable thing when +// the runnable thing is one click away is the worst of both. The interactive +// protocol stories (ui/tour) launch from the hero — live demos, not a Hub UI tutorial — +// captions live in the bottom bar; it does not replace the live numbers +// or the doctrine below. // // The long-form book still exists at /deckbook — captured transcripts, worked // use cases, the full CLI walkthrough. That page is a document. This one is // an instrument, and it is the one that has to be true right now. import 'ui/nav'; import { installNavStyles, wrapTables } from 'ui/nav'; +import { mountTourButton, resumeHubTour } from 'ui/tour'; import hljs from 'highlight-js'; import bash from 'highlight-js/es/languages/bash.js'; @@ -92,42 +96,23 @@ function ago(when) { // which is the claim, and this is the test of it // --------------------------------------------------------------------------- +/** + * First paint — `/_packages` only. + * + * Activity and per-depot `/_history` used to sit on the critical path and + * turned this panel into a multi-second wait behind seal-log walks. Claims + * arrive in `enrichNow`; step totals are no longer fetched here (the History + * link is enough — counting every seal on start was the expensive lie). + */ async function now() { - let [packages, activity] = await Promise.all([ - getJson('/_packages').catch(() => ({ data: [] })), - getJson('/_activity?top=200&grain=file').catch(() => ({ foci: [] })), - ]); + let packages = await getJson('/_packages').catch(() => ({ data: [] })); let depots = packages.data ?? []; - - // Steps come per depot and only where a history backend attached. A depot - // without one is not broken — its Versions ARE its history — so - // it reports no Timeline rather than zero Steps, which would be a lie - // about a tape that does not exist. - // - // ONE AT A TIME, not `Promise.all`. Twelve depots is twelve requests, a - // browser allows six per origin, and one of those six is already spent on - // the SSE stream that makes this page live. Firing them together does not - // make them finish sooner — they queue in the browser instead of on the - // server — and it takes every connection the page has, so anything else it - // needs is stuck behind a fan-out that exists only to fill a table. - // - // Sequential is slower in the best case and the only one that cannot starve - // the page in the worst. - let timelines = []; - for (let depot of depots) { - if (!depot.history) { - timelines.push({ depot: depot.name, steps: undefined }); - continue; - } - let body = await getJson(`/${depot.name}/_history`).catch(() => undefined); - let steps = body?.data?.map((step) => step.attributes) ?? []; - timelines.push({ - depot: depot.name, - steps: steps.length, - newest: steps[0]?.timestamp, - }); - } - let tape = new Map(timelines.map((row) => [row.depot, row])); + let tape = new Map( + depots.map((depot) => [ + depot.name, + { depot: depot.name, steps: undefined, newest: undefined }, + ]), + ); let decks = 0; let versions = 0; @@ -148,12 +133,31 @@ async function now() { } } } - let claims = (activity.foci ?? []).flatMap((focus) => + + return { + depots, + tape, + decks, + versions, + vendored, + live, + claims: [], + steps: undefined, + newestVersion, + }; +} + +/** Claims only — after the depot table is already on screen. */ +async function enrichNow(state) { + let activity = await getJson('/_activity?top=200&grain=package').catch( + () => ({ + foci: [], + }), + ); + state.claims = (activity.foci ?? []).flatMap((focus) => (focus.claims ?? []).map((claim) => ({ ...claim, at: focus.id })), ); - let steps = timelines.reduce((sum, row) => sum + (row.steps ?? 0), 0); - - return { depots, tape, decks, versions, vendored, live, claims, steps, newestVersion }; + return state; } function statLine(state) { @@ -185,11 +189,14 @@ function statLine(state) { return ( `
    ` + cells - .map( - ([value, label, href]) => + .map(([value, label, href]) => { + let shown = value === undefined || value === null ? '—' : value; + return ( `` + - `${value}${esc(label)}`, - ) + `${shown}` + + `${esc(label)}` + ); + }) .join('') + `
    ` ); @@ -208,9 +215,14 @@ function depotTable(state) { `${ depot.history ? `` + - `${tape?.steps ?? 0} Step${tape?.steps === 1 ? '' : 's'}` + - (tape?.newest ? ` · ${esc(ago(tape.newest))}` : '') - : `Versions only — no Timeline` + (tape?.steps == null + ? `History` + : `${tape.steps} Step${tape.steps === 1 ? '' : 's'}`) + + `` + + (tape?.newest + ? ` · ${esc(ago(tape.newest))}` + : '') + : `Version history only` }` + `run →` + `` @@ -220,7 +232,7 @@ function depotTable(state) { return ( `` + `` + - `` + + `` + `${rows}
    depotdecksliveTimelineHistory
    ` ); } @@ -264,7 +276,7 @@ Run Deck from this repository.

    node packages/cli/bin/deck.js hello ./playground # or serve a directory that exists -node packages/cli/bin/deck.js serve --root ./work --history auto +node packages/cli/bin/deck.js serve --root ./work --history deckd

    Open the URL that the command prints. Then change a file in a deck directory. The server does these steps for each change:

    @@ -287,6 +299,115 @@ a name to a state, use a different command:

    This command copies no bytes. The command gives a name to a state that is already in the store.

    + +

    Work together on an ordinary package

    + +

    Every mutable package is already a repository. It needs no +repository.json and no initialization ceremony. Create a branch, +edit the package, capture an intentional Checkpoint, open a Review, and merge +it:

    + +
    deck branch --depot ./work/site --repo acme/palette \\
    +  --name fix/contrast --from main
    +
    +# edit ./work/site/acme/palette, then capture the branch
    +deck checkpoint --depot ./work/site --repo acme/palette \\
    +  --branch fix/contrast --message "Improve contrast"
    +
    +deck review --depot ./work/site --repo acme/palette \\
    +  --source fix/contrast --target main --title "Improve contrast"
    +
    +deck merge --depot ./work/site --repo acme/palette --number 1
    + +

    Browse shows the package, its branches, and its Checkpoint history. Review +shows the exact source and target Checkpoints, the file differences, a runnable +preview, and the merge result. Both surfaces read the same durable objects +that the CLI writes.

    + +

    Fork an imported package before changing it

    + +

    An import is immutable input. Deck never gives an upstream package a +writable branch in place. Forking materializes the chosen Version under your +publisher, records its ancestry, and creates a mutable package repository:

    + +
    deck fork --depot ./work/site \\
    +  --from three/three@0.179.1 --as you/three --use-in you/app
    + +

    A branch and a fork use the same cheap content-addressed ancestry. The +difference is ownership: a branch stays inside one repository; a fork crosses +into another publisher's package and can be reviewed back upstream when it +has mutable upstream lineage.

    + +

    Group packages only when they must land together

    + +

    Most work stops at the package repository above. When a change to several +packages and the exact import map must be atomic, use +deck repo-init. It writes repository.json to name the +members and roots. This is Deck's monorepo: a Repository Checkpoint points to +several independently addressable package trees and one exact lock.

    +`; + +const SYSTEM = ` +

    Deck has one content-addressed foundation and two kinds of +state. Files, package trees, Versions, Repositories and Checkpoints are immutable +objects. Branches, tags and Review heads are small mutable pointers to those +objects. That division is the whole system.

    + +
    files on disk
    +     │
    +     │ every settled save — automatic; no commit
    +     ▼
    +package tree ──hash──┬──▶ Version ──URL──▶ Browse + Run
    +                     │
    +                     └──▶ History Step ──▶ inspect · compare · restore
    +                                            DAILY SAFETY
    +
    +chosen package tree(s) + exact import-map lock
    +     │
    +     │ deck checkpoint — intentional authorship
    +     ▼
    +Repository ◀── Checkpoint ◀── branch  (mutable compare-and-swap ref)
    +
    +source branch ──exact Checkpoint──┐
    +                                ├──▶ Review ──preview──▶ Browse + Run
    +target branch ──exact Checkpoint──┘       │
    +                                       └──merge──▶ new two-parent Checkpoint
    +                                                        │
    +                                                        └──CAS──▶ target branch
    + + + + + + + + + + +
    PackageThe independently addressable, +runnable and releasable unit. A mutable package is implicitly a repository.
    VersionA permanent semver name for one +package tree. Automatic -dev.N Versions preserve every settled +save.
    StepAn observed filesystem capture in a +depot History. It answers “what happened while work was in progress?”
    CheckpointAn intentional authored history +event. It points to one Repository and its parent Checkpoints.
    BranchA named compare-and-swap ref inside +one owner and repository.
    ForkThe same isolation and ancestry +mechanics across an ownership boundary. Imported packages are read-only until +forked.
    ReviewDurable metadata connecting exact +source and target Checkpoints, plus append-only review and merge events.
    RepositoryThe atomic collaboration state. +Usually it contains one package implicitly. A repository.json +exists only when several packages and their lock must advance together.
    + +

    Distribution stays read-only. HTTP serves live and immutable package +addresses. The CLI and daemon author content-addressed objects and move refs. +Local Deck coordinates ref updates with atomic files; an S3-backed Deck uses +conditional object writes. Neither needs a Git object store.

    + +

    The Hub is an inspector for this state. Browse answers what exists +and which repository governs it. Run executes pages and follows library nodes +into the pages that consume them, with an exact Version available as a +no-store trial. Track shows automatic Steps and activity. Review shows proposed +Checkpoint-to-Checkpoint changes and moves the target branch only after a clean +merge.

    `; // ───────────────────────────────────────────────────────────────────────── @@ -322,7 +443,7 @@ const ANY_SYSTEM = [ marks it immutable, and the browser does not ask about it again. A second visit to the page transfers zero bytes. This is not a setting that somebody tuned. It is a result of the address, and the - timeline in run prints the number of bytes to show it.`, + transfer readout in run prints the number of bytes to show it.`, '/run', 'see 0 KB', ], @@ -338,15 +459,16 @@ const ANY_SYSTEM = [ 'read the catalog', ], [ - 'There is one kind of checkpoint', - `Many systems have three kinds: the undo in the editor, the milestone in - the tool, and the commit in the version control. Deck has one, and it is - the Step. To combine work that two people did at the same time, Deck - merges the two trees against the base tree that both started from. Deck - merges JSON as data, key by key. Two people who add different keys - therefore do not make a conflict.`, + 'Automatic history and authored history stay connected', + `A Step is what the filesystem watcher observed on a save. A Checkpoint is + what a person or agent intentionally captured on a branch. Both point + into the same content-addressed store, so review can retain the authored + story without losing the work between Checkpoints. To combine concurrent + work, Deck merges package trees against the exact base Checkpoint. JSON is + merged as data, key by key, so two additions at different keys do not + conflict.`, '/track', - 'see a merge', + 'see the work', ], [ 'Install, upgrade, remix and use are four different operations', @@ -354,9 +476,9 @@ const ANY_SYSTEM = [ package. The differences appear later, and they are painful. In Deck the four stay separate. To use a deck, add one line to an import map. To install it, make that line an exact address. To upgrade it, change the - address. To remix it, fork the deck into your own publisher space; the - fork records the tree that it came from, so you can offer your changes - back.`, + address. To remix an imported package, fork it into your own publisher + space. The import stays read-only; the fork becomes a mutable package + repository and records the exact ancestry needed to review changes back.`, '/browse', 'look at a fork', ], @@ -371,14 +493,13 @@ const ANY_SYSTEM = [ ], [ 'You can run a contribution before you accept it', - `A proposal is a fork that records the tree that it came from. A proposal - is therefore a Version, and you can run it. You do not have to imagine - the result of a difference between two files. Deck merges the clean - proposals. If a merge has a conflict, Deck reports the conflict and - changes nothing, because a machine that damages the work of a - contributor is worse than a machine that stops.`, - '/track?kind=proposed,conflicted', - 'open proposals', + `A Review durably names exact source and target Checkpoints. Deck prepares + the same three-way Repository merge for Preview that it will use for + Merge, then hands the resulting exact package Versions to Run. You do not + have to infer the assembled result from a diff. If the merge conflicts, + Deck reports qualified package paths and advances no branch.`, + '/review', + 'open Reviews', ], [ 'A fork copies no bytes until it differs', @@ -424,22 +545,22 @@ const ANY_SYSTEM = [ `Each change becomes a Step. An agent can also declare where it intends to work. A page that shows the result and a list that shows the changes can therefore both move while a person or an agent makes changes. This - application shows a simple form of this, with a frame and a timeline. A + application shows a simple form of this, with a frame and the History + scrubber. A system with a true preview surface can do more.`, '/run', 'the running thing', ], [ 'Review tools can use more than a difference between two files', - `The protocol records who did the work, what they declared before they - started, what they wrote afterwards, and which tree each statement is - about. It identifies the tree by its content, not by the name of a - branch. A review tool that reads this has more to show than two columns - of text. This application shows the simple form: a review of several - files, marks on the words that changed, and hidden lines that you can - open.`, - '/browse', - 'a review', + `A Review retains familiar source and target branch names while binding + them to exact immutable Checkpoints. Its event chain records source + updates, verdicts, merge preparation and completion. The Hub can + therefore show files, authored history, executable preview, merge state, + and the canonical objects underneath instead of reconstructing truth + from two moving branch names.`, + '/review', + 'inspect a Review', ], ]; @@ -465,18 +586,78 @@ const ANY_SYSTEM = [ // Each law carries which half it governs. Eleven of the twelve are at rest, // which is the clearest statement of where this system is finished. const LAWS = [ - ['L1', 'rest', 'Identity', 'Identity is tree-hash-v1 over the logical file tree, and nothing else enters it. Not zip framing, not compression, not the order bytes landed on disk, not the version anybody attached. Paths are Unicode NFC. Two trees with the same files hash the same on every implementation, forever.'], - ['L2', 'rest', 'Round trip', 'Packing and unpacking is lossless and byte-exact: pack(unpack(P)) === P for a canonical pack, and the treeHash survives the trip.'], - ['L3', 'rest', 'Pointer', 'Versions and dist-tags name treeHashes. Naming a state copies no bytes.'], - ['L4', 'rest', 'Immutability', 'A published exact version never changes, and re-publishing one is refused. There is no unpublish. The only way to say a version is bad is to say so about it.'], - ['L5', 'rest', 'Volatility', 'A deck has exactly three kinds of address and they are different URLs: no version segment is the working tree (no-store), @<exact> is one sealed state (immutable), and a range or tag is a name that costs one short hop. Ranges never match prereleases.'], - ['L6', 'rest', 'Hermeticity', 'A sealed deck resolves every specifier it contains — inside itself, or to a dependency it declares. Nothing reaches the live web at runtime. A deck that would need a CDN to work is refused rather than warned about.'], - ['L7', 'rest', 'Resolution', 'Exact before range, tags before ranges, and the same answer on every implementation given the same store.'], - ['L8', 'rest', 'Provenance', 'Where bytes came from travels with them, per version, and cannot be edited away by a later publish.'], - ['L9', 'rest', 'Endorsement', 'Signatures are about a tree hash, not about a package or a version — so a fork holding the same bytes reads the same endorsements.'], - ['L10', 'rest', 'Carry-over', 'A statement is addressed to a tree hash, so it applies wherever those exact bytes appear. Nothing is derived from a parent: the statement did not move, the content turned up in a second place.'], - ['L11', 'both', 'Fail closed', 'An unknown answer is not a safe answer. Unverifiable provenance, an unparsable publish time, a specifier that cannot be resolved: refuse, do not warn and continue.'], - ['L12', 'rest', 'Serving', 'The distribution surface is read-only — GET and HEAD. Every write goes through a tool that arranges state on disk; distribution follows. This is why declared activity is read from files rather than posted to an endpoint.'], + [ + 'L1', + 'rest', + 'Identity', + 'Identity is tree-hash-v1 over the logical file tree, and nothing else enters it. Not zip framing, not compression, not the order bytes landed on disk, not the version anybody attached. Paths are Unicode NFC. Two trees with the same files hash the same on every implementation, forever.', + ], + [ + 'L2', + 'rest', + 'Round trip', + 'Packing and unpacking is lossless and byte-exact: pack(unpack(P)) === P for a canonical pack, and the treeHash survives the trip.', + ], + [ + 'L3', + 'rest', + 'Pointer', + 'Versions and dist-tags name treeHashes. Naming a state copies no bytes.', + ], + [ + 'L4', + 'rest', + 'Immutability', + 'A published exact version never changes, and re-publishing one is refused. There is no unpublish. The only way to say a version is bad is to say so about it.', + ], + [ + 'L5', + 'rest', + 'Volatility', + 'A deck has exactly three kinds of address and they are different URLs: no version segment is the working tree (no-store), @<exact> is one sealed state (immutable), and a range or tag is a name that costs one short hop. Ranges never match prereleases.', + ], + [ + 'L6', + 'rest', + 'Hermeticity', + 'A sealed deck resolves every specifier it contains — inside itself, or to a dependency it declares. Nothing reaches the live web at runtime. A deck that would need a CDN to work is refused rather than warned about.', + ], + [ + 'L7', + 'rest', + 'Resolution', + 'Exact before range, tags before ranges, and the same answer on every implementation given the same store.', + ], + [ + 'L8', + 'rest', + 'Provenance', + 'Where bytes came from travels with them, per version, and cannot be edited away by a later publish.', + ], + [ + 'L9', + 'rest', + 'Endorsement', + 'Signatures are about a tree hash, not about a package or a version — so a fork holding the same bytes reads the same endorsements.', + ], + [ + 'L10', + 'rest', + 'Carry-over', + 'A statement is addressed to a tree hash, so it applies wherever those exact bytes appear. Nothing is derived from a parent: the statement did not move, the content turned up in a second place.', + ], + [ + 'L11', + 'both', + 'Fail closed', + 'An unknown answer is not a safe answer. Unverifiable provenance, an unparsable publish time, a specifier that cannot be resolved: refuse, do not warn and continue.', + ], + [ + 'L12', + 'rest', + 'Serving', + 'The distribution surface is read-only — GET and HEAD. Every write goes through a tool that arranges state on disk; distribution follows. This is why declared activity is read from files rather than posted to an endpoint.', + ], ]; const PROTOCOL = ` @@ -497,43 +678,44 @@ the same sense that a tarball and a digest are formats. The store layout, the command-line tool and the watcher are implementation, and the laws say so: nothing requires a server to store anything the way this one does.

    -

    At rest, and in motion

    +

    Immutable content, durable collaboration

    -

    Deck has two halves, and they are not equally finished. To read them as -one thing makes the settled half look provisional and the unsettled half look -decided.

    +

    Deck has two layers with different jobs. To collapse them makes immutable +identity look mutable and collaboration state look less durable than it is.

    At rest is what a byte, a tree and a Version are. The answer does not change with time, with who asks, or with what anybody intends to do next. This half is settled: the twelve laws state it, the conformance suite examines it, and you can write a second implementation of it today.

    -

    In motion is what people and agents do to each other's work: they -propose, review, merge, declare and comment. The answers change by the -minute, and more than one party is involved. This half is not settled.

    +

    Collaboration records what people and agents do to work. Repository, +Checkpoint, branch-head, Review opening and event formats are implemented +and tested locally. The mutable parts are deliberately small: a branch ref, a +Review head, and its event pointer. Everything they name is immutable.

    -

    The question that divides them: does the answer change if nobody does -anything? A tree hash does not. A proposal does — it becomes old, it is -rebased, and somebody accepts it or abandons it.

    +

    The question that divides them is: is this an object or a pointer? A +tree, Repository, Checkpoint and Review event never change. A branch or numbered +Review ref can move, but only with compare-and-swap against the value its +writer observed.

    - + - + - + - +
    at rest — settledin motion — not settled
    immutable objectsmutable coordination
    identitytree hash, packlist, object—
    namingVersion, dist-tag, range—
    namingVersion, Repository, Checkpointdist-tag, range, branch head
    distributionthe address grammar, the cache rules—
    timeStep, Timelinethe live loop that makes them
    timeStep, Checkpoint, Review eventHistory head, branch head, Review head
    provenancewhere bytes came from, and when—
    trustan endorsement of a tree hashwhose endorsements you accept
    change—offer, rebase, conflict
    changeReview opening and eventsbranch, fork, Review, merge
    intent—claim
    talk—note, reply
    presence—activity
    testingthe run record, over a tree hashrunning the suite, and who signs the result
    -

    Two entries sit on the side that surprises people, and both split the same -way: the record is at rest, the act that produced it is in motion.

    +

    The same split applies throughout: the record is immutable; the act that +produces it and the ref that selects it are mutable.

    A Step is at rest. It is one immutable capture with an address, and it reads the same in a year. The watcher that makes Steps is in motion; the @@ -559,26 +741,24 @@ freshness rule is hard because failing closed (L11) must not turn an unreachable list into a refusal to run. Do not build anything that depends on taking a signature back. See docs/deck-at-rest.md §7.

    -

    The at-rest half is closed. Do not add to it without a strong +

    The package at-rest half is closed. Do not add to it without a strong reason: an artifact stored today must still verify under a reader written next year. If a new idea can be expressed as data at an address, it belongs in motion. That is how claims were built — files at a known path, read by an ordinary GET, and no change to identity, naming or serving.

    -

    The in-motion half is where the unfinished work is. Notes are written but -not read back. Signing can say yes and cannot yet say no. Automated testing -is designed and not built — a suite is a deck, a run is one suite Version -against one tree hash, you run it locally, and the result is a statement -signed by a person rather than a mark issued by a service. Two runs of one -pair that disagree is the useful signal, because it means the suite is not -hermetic, and a service that runs the pair once cannot notice. See -docs/deck-testing.md.

    - -

    And one gap contains the others. The in-motion half has one large gap. Nothing here defines how a depot -offers a change to a depot on another machine, or how a review travels -between them. Until that exists, Deck in motion is a set of local -conventions with a good data model, and Deck at rest is the part another -implementation can join. See docs/deck-in-motion.md.

    +

    The collaboration object model is implemented; hosting is unfinished. +Local Deck has Repository and Checkpoint objects, compare-and-swap branches, +durable Reviews, executable previews, three-way merge, and crash recovery. +Conditional object-store adapters implement the same ref contract for S3. +What remains is production AWS wiring, identity and permissions, policy for +required reviews and checks, and transport between independently administered +servers. Those are origin concerns, not missing package identity.

    + +

    Notes are written but not yet read back. Signing can say yes and cannot yet +say no. Automated tests as signed statements are designed and not built. See +docs/deck-collaboration-protocol.md, +docs/deck-in-motion.md, and docs/deck-testing.md.

    The shape of an address

    /<depot>/<publisher>/<package>[@<spec>]/<path>
    @@ -607,26 +787,49 @@ ${LAWS.map(
     GET …@<exact>/<path>one sealed state, immutable, with a content digest
     GET …@<range|tag>/<path>exactly one redirect to the exact address
     GET …@<exact>?format=packthe stored pack bytes, byte-exact (L2)
    -GET /_packagesthe catalog, and the capability truth — including whether each depot has a Timeline
    +GET /_packagesthe catalog, and the capability truth — including whether each depot has every-save History
     
     
     

    What a server MAY answer

    - + - + +
    GET /<depot>/_history[/<changeId>/…]the Timeline, when a history backend is attached. Absence means the depot has Versions only. It is not an error.
    GET /<depot>/_history[/<changeId>/…]every-save History, when a history backend is attached. Otherwise the depot still has Version history.
    GET /<depot>/_offersopen proposals with their merge status
    GET /<depot>/_trees/<treeHash>/signaturesendorsements, addressed by tree (L9)
    GET /_activitywhere work is happening, and what anyone declared
    GET /_live/eventsa change bus
    GET /_live/statea short-lived moving change snapshot for Hub chrome
    GET /_live/eventsan optional SSE change bus for streaming clients

    A client MUST treat every one of these as optional and degrade rather than fail. The capability block in /_packages is how it finds out, and a server MUST NOT report a capability it does not have (L11).

    +

    What a collaboration adapter MUST preserve

    +

    The HTTP distribution surface stays read-only. Collaboration writes go +through an authoring adapter, but local files and S3 objects obey the same +contract:

    + + + + + + +
    immutable putLocks, Repositories, +Checkpoints, Review openings and events are canonical, content addressed, +and write-once.
    conditional ref updateA branch moves only +when its current generation equals the writer's expected generation. Local Deck +uses an atomic rename; S3 uses If-Match.
    unique allocationReview numbers, openings +and new event objects use create-if-absent. Concurrent writers reread and +retry allocation; they do not overwrite.
    recoverable mergeDeck records the prepared +merge Checkpoint before advancing the target branch, then records completion. +Retry completes the same merge rather than creating another.
    preview parityPreview and Merge use the +same base, source, target and Repository merge engine, so an unchanged clean +preview names the state that will land.
    +

    What is not specified

    • how a server stores anything — the store layout here is one implementation of L1–L4, not part of the contract
    • which history backend provides Steps, or whether any does
    • -
    • how writes happen. L12 says the distribution surface is read-only; it does not say what arranges the bytes
    • +
    • which authoring transport exposes collaboration writes. The object and conditional-update semantics above are the contract; local CLI and hosted APIs are implementations
    • authentication and authorisation, which are a deployment’s business
    @@ -653,7 +856,12 @@ const BOOK = [ - + + + + + + @@ -661,48 +869,51 @@ const BOOK = [ - + - +
    VersionA published tree that does not change. A semver number addresses it.
    StepOne capture of the working tree. The server records one Step for each save.
    TimelineAll the Steps of one depot, in order.
    HistoryAll the Steps of one depot, in order.
    RepositoryThe exact package trees and lock that advance atomically. Every mutable package has an implicit one-member Repository.
    CheckpointAn immutable authored history event pointing to one Repository and its parent Checkpoints.
    branchA named mutable ref to a Checkpoint inside one owner and repository.
    forkA mutable package under a different publisher, with exact source ancestry when available.
    ReviewA durable proposal from one exact branch Checkpoint to another, with append-only events, preview and merge state.
    ImportA specifier that a deck asks for.
    PinThe exact address that answers an Import.
    EntryThe module that a deck offers as the way into itself.
    ClosureEvery module that a page reaches, directly or through another module.
    Deck PackOne tree and its packlist in one file. The file form is .pack.zip, and any zip tool opens it.
    depotA directory that is also a URL space.
    deckA package. It is <publisher>/<name>, and it has an importmap.json.
    deckA package. It is <publisher>/<name>, and it has a package.json.
    publisherThe first segment of a deck name. It is the boundary at which a fork occurs.
    offerA fork that records the tree it came from. You can run an offer.
    offerThe older package-level proposal view derived from fork lineage. New team workflows use a durable Review.
    tree hashThe identity of a whole tree, calculated from its content. The code calls it treeHash.

    Three words are no longer in use. Write Version, Step or to seal, and never seal as a noun: it meant all three at once. Write Step and never frame or tick. Write - Timeline or Versions only, and never tier.

    `, + every-save History or Version history only, and never tier.

    `, ], [ 'The layout is the address', `

    What ls shows and what the URL says are the same shape. A depot carries its machinery inside itself and the machinery is invisible to the tree hash, so copying the directory takes the live files, the - published Versions and the Timeline with it.

    + published Versions and the History with it.

    <root>/site/acme/palette/palette.js      on disk
            /site/acme/palette/palette.js     live — every save, no-store
            /site/acme/palette@1.1.0/palette.js
                                              a Version, immutable forever
            /site/_history/<changeId>/acme/palette/palette.js
                                              any Step it was ever in
    -

    A depot is close to a monorepo. Many packages in one directory, one - history, one place to clone, and a change that crosses several of them is one - change. The differences are worth knowing:

    +

    A depot is a package address space, not automatically a monorepo. + It may contain many unrelated packages, forks and repositories. Each + mutable package is its own repository unless a repository.json + explicitly groups several packages so one Checkpoint can advance them and + their lock atomically.

      -
    • Each deck in a depot has its own version line. A monorepo usually - either versions everything together or needs a tool to avoid it.
    • +
    • A package remains the independently addressable execution and release + boundary, even when it belongs to a multi-package Repository.
    • The directory layout is the URL space, so a consumer can import one module from one deck without a build step that extracts it.
    • -
    • The publisher segment means two people can hold the same package - name in one depot. That is a fork inside the repository, and it costs - nothing until the bytes differ.
    • -
    • There is no workspace file and no link step. A deck depends on another - deck by address, and deck lock turns the range into a pin.
    • +
    • A dependency edge means “needed to run.” Repository membership means + “allowed to change and land atomically.” They are deliberately different.
    • +
    • There is no workspace link step. A package depends on another by + address, and the exact import map is the executable lock.
    -

    The publisher segment is the fork boundary. +

    The publisher segment is an ownership boundary. acme/palette and you/palette are different decks - with different version lines in one depot, and an undiverged fork costs no - extra bytes.

    `, + with different version lines in one depot. That is a fork. A named ref + such as acme/palette:fix/contrast stays inside the same + repository and is shown as a branch. Both are cheap because unchanged + objects are shared by hash.

    `, ], [ 'Three levels of volatility', @@ -760,16 +971,17 @@ const BOOK = [ hit.

    The automatic publish chooses the next name for you. It reads the - version in your import map, and it adds the next number:

    -
    # importmap.json declares 1.5.0
    +     version in your package.json, and it adds the next number:

    +
    # package.json declares 1.5.0
     # the store holds 1.5.0-dev.1 and 1.5.0-dev.2
     # the next save publishes:
     1.5.0-dev.3

    To make the stable Version from that work, use - deck release. The stable Version must agree with the import - map: if the map declares 1.5.0, you can release - 1.5.0. To release 1.6.0, first change the map. - That change is one line, and it is part of the tree that you seal.

    `, + deck release. The stable Version must agree with + package.json: if it declares 1.5.0, you can + release 1.5.0. To release 1.6.0, first change + the file. That change is one line, and it is part of the tree that you + seal.

    `, ], [ 'The store', @@ -785,14 +997,14 @@ const BOOK = [ what keeps pack(unpack(P)) === P byte-exact.

    `, ], [ - 'History: Versions, or a Timeline', + 'History: every save, or Versions only', `

    A depot has one of two answers, and the server reports which one. It does not pretend.

    - - + - + +
    History: VersionsEach settled save becomes an immutable -dev.N Version. The Versions are the history. Always available.
    History: TimelineEach save also +
    History: Every saveEach save also becomes a Step, at /<depot>/_history/<changeId>/…, and deck history and deck restore work. Available @@ -814,13 +1026,16 @@ const BOOK = [ --depot ./work/site --publisher acme --tag latest`, ], [ - 'Fork, lock, offer, rebase', - `

    Contribution is a version you can run.

    + 'Fork, branch, Review, preview, merge', + `

    Contribution is exact state you can inspect and run.

    deck fork --depot ./work/site --from acme/palette --as you/palette --use-in you/app
     deck lock --depot ./work/site --publisher you --name app
    -deck offers --depot ./work/site
    -deck rebase --depot ./work/site
    -

    Or try someone else’s deck in your app without checking anything out:

    +deck branch --depot ./work/site --repo you/palette --name fix/contrast +deck checkpoint --depot ./work/site --repo you/palette --branch fix/contrast --message "Improve contrast" +deck review --depot ./work/site --repo you/palette --source fix/contrast --title "Improve contrast" +deck merge --depot ./work/site --repo you/palette --number 1 +

    To try another package in your app without creating durable review + state:

    /site/you/app/index.html?with=acme/palette=you/palette

    The server rewrites resolution for that one request. No checkout, no build, and it is never cached as sealed content.

    `, @@ -836,10 +1051,11 @@ deck rebase --depot ./work/site
    GET …@<tag|range>/…one hop to exact
    ?metamachine-readable listing
    ?format=packthe stored pack bytes
    GET /<depot>/_historythe Timeline
    GET /<depot>/_historythe History
    GET /_packagescatalog and capability truth
    GET /_activitywhere the work is, and who declared it
    GET /_live/eventsthe change bus
    GET /_live/statea pollable moving change snapshot for Hub chrome
    GET /_live/eventsan optional SSE change bus for streaming clients
    `, ], ]; @@ -860,10 +1076,12 @@ bytes: a CDN is someone else’s server that can change its mind, and vendoring makes the same graph yours, hermetic, and diffable. Deck can be your CDN; a CDN cannot be your registry. gitBoth keep every state. Git keeps -commits you author; Deck keeps saves it observed, automatically, -and gives each one a URL that serves. Nothing here competes with branching and -history rewriting — Deck’s Timeline is a tape of what actually happened, which -is a different and smaller promise. +commits you author in a repository object graph. Deck keeps automatic +Steps and authored Checkpoints over the same content-addressed package trees, +and gives every runnable state a URL. Deck now has familiar branches, forks, +Reviews and three-way merge, but does not reproduce Git's staging area, +working-copy checkout or history-rewriting model. Its branch refs select exact +Repositories whose package trees remain directly importable. a bundlerThere isn’t one, and that is not an omission. If you bundle, the bytes that run are not the bytes you wrote, and every other claim here depends on those being the same. You can @@ -877,7 +1095,7 @@ to interpret. `; const CLI = ` -

    Twenty-seven commands in eight groups, ordered the way the work happens. +

    Thirty-four commands in ten groups, ordered the way the work happens. deck help prints this map from the command records themselves, so it cannot drift from what exists.

    @@ -892,14 +1110,20 @@ it cannot drift from what exists.

    - + + +
    dependwhat a deck uses, and which bytes answer to it — vendor vendor-tree lock verify-links
    proposechange someone else’s deck without a -branch — fork offers rebase
    repositoryauthor durable collaboration +state — repo-init branch checkpoint +review merge
    proposematerialize someone else's package +under your ownership — fork; inspect older package offers with +offers and rebase
    endorsewho stands behind these bytes — keygen sign verify-sig
    recallevery state the depot has been in — history restore
    provethe executable contract — conform
    saydeclare work and leave durable context — +claim note
    `; @@ -909,18 +1133,77 @@ const STATUS = [ ['live serve and auto-publish', 'built', 'tested'], ['vendoring hermetic graphs, with a cooldown policy', 'built', 'tested'], ['lock, fork, merge, offers, rebase', 'built', 'tested'], + [ + 'Repository, Checkpoint and compare-and-swap branch heads', + 'built', + 'local and S3 adapters tested', + ], + [ + 'durable Reviews, exact preview, merge and recovery', + 'built', + 'implemented and tested locally', + ], + [ + 'single-package implicit repositories', + 'built', + 'no repository.json required', + ], + [ + 'multi-package repository.json grouping', + 'built', + 'atomic Repository Checkpoint and merge', + ], + [ + 'Hub Browse and Review projections', + 'built', + 'read the same durable objects as the CLI', + ], ['try-by-URL overrides', 'built', 'tested'], ['import-map injection and the change bus', 'built', 'tested'], ['history backends — Steps and restore', 'built', 'attaches when available'], ['publish times, local and upstream', 'built', 'tested'], ['the activity protocol — touch and declared claims', 'built', 'tested'], ['signatures on tree hashes', 'built', 'tested'], - ['minified derived Versions, and compression', 'built', 'reproducible: verify-derivation re-runs it and compares hashes'], - ['cross-depot dependency pins', 'not built', 'lock refuses them rather than guessing'], - ['notes and replies on a tree hash', 'not built', 'deck note writes them; /track does not read them'], - ['revocation, warning, incident', 'not built', 'a signature can say yes and cannot yet say no — deck-at-rest.md §7'], - ['test runs as signed statements', 'not built', 'designed in deck-testing.md'], - ['anything between two servers', 'not built', 'offers and reviews stay inside one depot'], + [ + 'minified derived Versions, and compression', + 'built', + 'reproducible: verify-derivation re-runs it and compares hashes', + ], + [ + 'cross-depot dependency pins', + 'not built', + 'lock refuses them rather than guessing', + ], + [ + 'notes and replies on a tree hash', + 'not built', + 'deck note writes them; /track does not read them', + ], + [ + 'revocation, warning, incident', + 'not built', + 'a signature can say yes and cannot yet say no — deck-at-rest.md §7', + ], + [ + 'test runs as signed statements', + 'not built', + 'designed in deck-testing.md', + ], + [ + 'production S3 credentials and Hub wiring', + 'not built', + 'conditional object-store contracts are tested', + ], + [ + 'identity, permissions, required checks and review policy', + 'not built', + 'formats exist; enforcement does not', + ], + [ + 'federation between independently administered servers', + 'not built', + 'no remote Review transport yet', + ], ]; // --------------------------------------------------------------------------- @@ -936,6 +1219,15 @@ function section(id, number, title, body) { ); } +const DECK_MARK = ` +██████╗ ███████╗ ██████╗██╗ ██╗ +██╔══██╗██╔════╝██╔════╝██║ ██╔╝ +██║ ██║█████╗ ██║ █████╔╝ +██║ ██║██╔══╝ ██║ ██╔═██╗ +██████╔╝███████╗╚██████╗██║ ██╗ +╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ +`.trim(); + /** * `state` may be undefined, and that is the normal first call. * @@ -960,7 +1252,6 @@ function view(state) { ``, ).join(''); - let book = BOOK.map( ([title, body]) => `

    ${esc(title)}

    ${body}
    `, @@ -987,13 +1278,20 @@ function view(state) { [ 'now', 'What this server is running right now', + `

    One public port. Hub chrome and every runnable + demo enter through *.localhost:${esc(location.port)}. + A demo's virtual host preserves its sealed /site/… pins; + it does not start another server.

    ` + `
    ${ - state ? depotTable(state) + activeNote(state) : `

    reading the depots…

    ` + state + ? depotTable(state) + activeNote(state) + : `

    reading the depots…

    ` }
    `, ], + ['system', 'How the whole Deck system fits together', SYSTEM], [ 'quickstart', - 'Quickstart: from nothing to a running deck', + 'Quickstart: run, branch, review and merge', QUICKSTART + // Said here, not in a closing section, because this is the paragraph // a reader has their hands on the keyboard for. @@ -1007,10 +1305,9 @@ function view(state) { 'What Deck gives any system', `

    Each item below is a mechanism in this repository. No item is a plan. Each link goes to the place where you can examine the - item.

    ` + - `
      ${benefit}
    `, + item.

    ` + `
      ${benefit}
    `, ], - ['protocol', 'The protocol: laws, addresses, endpoints', PROTOCOL], + ['protocol', 'The protocol: content, collaboration and hosting', PROTOCOL], ['compare', 'How Deck differs from npm, CDNs, git and bundlers', COMPARE], ['book', 'Reference: vocabulary, addressing, storage, history', book], ['cli', 'Command reference', CLI], @@ -1044,10 +1341,8 @@ function view(state) { // `
    ` and not a scripted menu: it opens without JavaScript, it is // focusable and announced by a screen reader for free, and the browser // already knows how to draw the state. - // `.prose` is the documentation type system, defined once in theme.css - // and specified in docs/deck-hub-typography.md. It governs this page and - // the Deckbook and touches nothing in the three lens apps. - `
    ` + + // Same lattice as browse / run / track — one face, one size, no prose scale. + `
    ` + // OPEN BY DEFAULT ON A WIDE SCREEN, AND IT HAS TO BE THE ATTRIBUTE. // // A closed `
    ` hides its children in the browser's own shadow @@ -1061,78 +1356,118 @@ function view(state) { // contents and a desktop reader can still collapse it. `
    ` + `contents` + - // The Deckbook is the long form of this page and a separate document, so - // it belongs in the same contents list — marked as a departure rather - // than dressed up as one more section of this one. `` + `
    ` + `
    ` + - // WHAT IS THIS. The hero said "Everything here is runnable", which is a - // true and clever thing to notice about the page and tells a first-time - // reader nothing about what the project is. A landing page owes the - // reader the description before the observation. `
    ` + - // The mark, then the sentence. Only the NAME is the mark — "is" belongs to - // the sentence and takes the sentence's face, or the mark grows a word. - `

    DECK is version management without the commit, package - management without the install, and cloud delivery without the deploy.

    ` + - // The unifying assumption, before any mechanism. Three claims that each + `

    hubstart

    ` + + `` + + `

    version management without the commit
    ` + + `package management without the install
    ` + + `code collaboration without the clone
    ` + + `cloud delivery without the deploy

    ` + + `
    ` + + `deckbook` + + `` + + `
    ` + + `

    Every save is kept. After a short quiet period, + Deck gives the package tree an immutable automatic Version. When an + every-save History is attached, the same save is also an ordered Step: the exact + depot state, when it happened, and which files changed. You can inspect, + run, compare and restore work that was never committed. A later + deck checkpoint does a different job: it turns chosen package + state into an authored Repository Checkpoint for branch and Review + history.

    ` + + // The unifying assumption, before any mechanism. Four claims that each // sound like a separate trick are one claim said three times, and a // reader who does not get that in the second paragraph reads the rest as // a feature list. - `

    One thing makes all three true: there is only one kind of - object here — a file, named by the hash of its own bytes, reachable at a - URL. A tree is a list of those names. A Version, a Step, a dependency, a - fork and a release are all pointers to trees. Commit, install and deploy - all do the same job: they move a copy to a second place that names things - differently. Here there is no second place.

    ` + + `

    One foundation makes all four true: files and package + trees are named by their content and reachable at URLs. Versions, + Repositories and Checkpoints name immutable state above those trees. + Branches, tags and Review heads are the small mutable pointers. Execution, + distribution and collaboration therefore share one address space instead + of copying code through a repository, package registry and deployment + system that each names it differently.

    ` + // The same claim again, in the reader's own vocabulary. The grouping is - // the argument: `fork` and `pull request` land on one line because they - // are one thing, and `build`, `deploy` and `CDN` land on one line because - // they are none. + // The argument is the separation between immutable objects and the small + // mutable refs that select them. Familiar workflow nouns stay distinct, + // even when they share content-addressed mechanics underneath. `
    ` + [ - ['A commit, a save, a snapshot are just', - 'the hash of the tree, taken when you stopped typing.'], - ['A version, a tag, a release, a staging environment are just', - 'pointers to one of those hashes. Moving a pointer copies nothing.'], - ['An import, an install, a lockfile, node_modules are just', - 'an address in an import map, written into a file you can read.'], - ['A branch, a fork, a pull request are just', - 'the same package under your own publisher, with the tree you started' + - ' from recorded. That record is the whole difference between a copy' + - ' and a proposal.'], - ['A code review, a preview environment are just', - 'the page loaded with ?with= pointing somewhere else, for' + - ' one request.'], - ['A merge is just', - 'three trees: yours, theirs, and the one you both started from.'], - ['A revert, a rollback, a checkout are just', - 'reading a different address. Nothing on disk changes.'], - ['A build, a bundle, a deploy, a CDN are just', - 'absent — except the bundle, which is another version derived from the' + - ' first, at its own address.'], + [ + 'Every settled save becomes', + 'an immutable automatic Version and—when attached—an ordered Step in History.', + ], + [ + 'A Checkpoint is', + 'an authored immutable object pointing to exact Repository state.', + ], + [ + 'A version, a tag, a release, a staging environment are just', + 'pointers to one of those hashes. Moving a pointer copies nothing.', + ], + [ + 'An import, an install, a lockfile, node_modules are just', + 'an address in an import map, written into a file you can read.', + ], + [ + 'A mutable package is', + 'already a one-package repository. No initialization file is required.', + ], + ['A branch is', 'a named ref inside the same owner and repository.'], + [ + 'A fork is', + 'a mutable package under another publisher, sharing unchanged objects' + + ' and recording exact ancestry when the source is mutable.', + ], + [ + 'A Review is', + 'durable source-to-target metadata bound to exact Checkpoints and an' + + ' append-only event history.', + ], + [ + 'A preview is', + 'the exact proposed Repository projected into package Versions and run' + + ' through the ordinary resolver. ?with= remains the' + + ' temporary substitution mechanism, not Review identity.', + ], + [ + 'A merge is', + 'a three-way merge of base, target and source Repositories, followed by' + + ' one compare-and-swap move of the target branch.', + ], + [ + 'A revert, a rollback, a checkout are just', + 'reading a different address. Nothing on disk changes.', + ], + [ + 'A build, a bundle, a deploy, a CDN are just', + 'absent — except the bundle, which is another version derived from the' + + ' first, at its own address.', + ], ] .map(([term, is]) => `
    ${term}
    ${is}
    `) .join('') + `
    ` + - `

    None of those is really a mechanism. They exist because - three systems each keep their own address space, and something has to - translate between them. With one address space, committing, installing - and deploying are not three things you do to code — they are three names - for pointing at it.

    ` + + `

    The workflow nouns remain useful because they express + ownership, intent and mergeability. Deck unifies their storage mechanics, + not their meaning. A branch and a fork are both cheap refs over shared + content, but only a fork crosses ownership. A Review uses runnable content, + but it remains durable coordination rather than a query parameter.

    ` + `

    What Deck specifies is small: the grammar of the address, the bytes that the address supplies, and how long a client may keep them. Twelve laws state it, and a test suite examines them. This server and its command-line tool are one implementation. If a different implementation obeys the test suite, that implementation is also Deck.

    ` + - `

    The three lenses at the top of the page ask three - questions about one address: what the address is, what it does, and what - changed. The lenses are decks. This server supplies them from the store - that they show to you.

    ` + + `

    The four working views ask different questions about the + same state. Browse asks what exists and who governs it. Run asks what an + exact graph does—and, for a library, which page makes it visible. Track + asks what happened while it changed. Review asks what is proposed, what + exact state was examined, and whether it merged. The views are decks + supplied from the store they inspect.

    ` + statLine(state) + `
    ` + sections @@ -1207,12 +1542,21 @@ async function fillNow() { } return; } - let stats = app.querySelector('[data-role="stats"]'); - if (stats) stats.outerHTML = statLine(state); - let slot = app.querySelector('[data-role="now"]'); - if (slot) slot.innerHTML = depotTable(state) + activeNote(state); - // The depot table is new HTML, so it arrives without a scroll port. - wrapTables(app); + let paint = () => { + let stats = app.querySelector('[data-role="stats"]'); + if (stats) stats.outerHTML = statLine(state); + let slot = app.querySelector('[data-role="now"]'); + if (slot) slot.innerHTML = depotTable(state) + activeNote(state); + wrapTables(app); + }; + // Packages first — table on screen — then claims without blocking. + paint(); + try { + await enrichNow(state); + paint(); + } catch { + // Claims are decoration; the table already answered "what is mounted". + } } addEventListener('keydown', (event) => { @@ -1225,7 +1569,7 @@ addEventListener('keydown', (event) => { // NOT ON EVERY CHANGE. This page is the expensive one. // // A render here is thirteen-odd requests — the catalog, activity, and one -// Timeline per depot — and `onLiveChange` fires about once a second while +// History per depot — and `onLiveChange` fires about once a second while // somebody is editing. Wiring those together meant a render was always in // progress, the next one queued before it finished, and the page never // settled: permanently "loading…", server at nothing, nothing thrown. The @@ -1248,6 +1592,8 @@ async function refresh() { } paint(); +mountTourButton(app); +resumeHubTour({ waitMs: 120 }); refresh(); setInterval(refresh, REFRESH_MS); @@ -1256,155 +1602,178 @@ setInterval(refresh, REFRESH_MS); // reader has opened or closed it themselves, that is their answer and resizing // a window is not a request to overrule it. let touched = false; -app.addEventListener('toggle', (event) => { - if (event.target.matches?.('[data-role="toc"]')) touched = true; -}, true); +app.addEventListener( + 'toggle', + (event) => { + if (event.target.matches?.('[data-role="toc"]')) touched = true; + }, + true, +); WIDE.addEventListener('change', (event) => { if (touched) return; let toc = app.querySelector('[data-role="toc"]'); if (toc) toc.open = event.matches; }); -// IN THE HOUSE STYLE, WHICH MEANS ADDING ALMOST NOTHING. -// -// The theme's rules are one typeface at one size doing every job, colour only -// where colour means something, measurement in `ch` and whole rows, and -// structure from alignment rather than from boxes. A page of prose is exactly -// where those get broken — the reflex is a bigger heading and a bordered code -// block — so this sets no font sizes at all and draws one hairline per -// section. Hierarchy comes from the numbered inverse marker the nav already -// uses, and from the measure. +// Same lattice as the four working views. One face, one size, colour only where it +// means something. The mark is ASCII because this is a terminal; the Deckbook +// door is inverse because that is how this surface says "go here" — the same +// fill as the current nav tab. const START_CSS = ` -/* Page-specific layout only. Everything typographic — the two faces, the - scale, the rhythm, the measure, the tables — lives in the .prose block in - theme.css and is specified in docs/deck-hub-typography.md. If a rule here - is about type, it is in the wrong file. */ - -.hero { padding: var(--s2) 0 var(--s4); } -/* "Deck is" — THE SAME MARK AS THE ONE IN THE NAV. - The name appeared twice on the first screen in two different typefaces: - Commit Mono in the corner, the prose sans in the sentence. Two faces for - one word is two marks, and a reader has to decide whether they refer to - the same thing. So the name takes the vendored face wherever it appears, - at the same weight, in full-strength foreground and never in a hue from - the address palette — those hues mean depot, publisher and package, and - spending one on the product name makes the name read as a segment. - - Tracking is lighter than the nav's .22em: that value is for four capitals - at 13px, and the same value at 31px pulls the word apart. */ -.hero h1 b { - font-family: var(--mono); font-weight: 700; - letter-spacing: .1em; color: var(--fg); - /* The mark is set in capitals wherever it appears, so the case is a rule - here and not a decision the copy makes. Tracking sits between the nav's - .22em and none: that value is tuned for 13px, and the same figure at - 31px pulls four letters into four separate letters. */ - text-transform: uppercase; - margin-right: .15em; +.start { display: block; max-width: 108ch; } +.start h2, .start h3 { + font-size: 1em; font-weight: 600; color: var(--fg); + margin: calc(var(--row) * 1.2em) 0 calc(var(--row) * .3em); +} +.start h2:first-child, .start h3:first-child { margin-top: 0; } +.start .lede { color: var(--dim); max-width: 88ch; } +.start pre.term { + background: var(--panel); padding: calc(var(--row) * .4em) 1.5ch; + overflow-x: auto; margin: calc(var(--row) * .8em) 0; +} +.start td.says, .start td.ok, .start td.todo { white-space: normal; max-width: 64ch; } +.start td.ok { color: var(--green); font-weight: 600; } + +.hero { padding: calc(var(--row) * .6em) 0 calc(var(--row) * 1.4em); } +.deck-mark { + margin: 0 0 calc(var(--row) * .8em); + font-family: var(--mono); font-size: 15px; line-height: 1.05; + color: var(--fg); overflow-x: auto; user-select: none; + white-space: pre; +} +.hero .prompt { padding-top: 0; color: var(--dim); } +.hero h1 { + font-size: 1em; font-weight: 400; color: var(--fg); + max-width: none; margin: 0 0 calc(var(--row) * .8em); + line-height: var(--row); +} + +/* Same gesture as .deck-nav-here — filled chip, package hue, no box. */ +.doors { + display: flex; flex-wrap: wrap; align-items: baseline; gap: 0; + margin: 0 0 calc(var(--row) * 1em); +} +.doors .here, .doors button { + font: inherit; padding: 0 1ch; margin-right: 1ch; +} +.doors .here { + background: var(--a-package); color: var(--bg); font-weight: 600; + text-decoration: none; +} +.doors .here:hover { + background: var(--fg); color: var(--bg); text-decoration: none; +} +.doors button { + cursor: pointer; background: none; color: var(--dim); border: 0; +} +.doors button:hover { color: var(--fg); background: var(--sel); } + +.start .isjust { max-width: 88ch; margin: calc(var(--row) * .8em) 0; } +.start .isjust dt { font-weight: 600; margin-top: calc(var(--row) * .5em); } +.start .isjust dt:first-child { margin-top: 0; } +.start .isjust dd { margin: 0; color: var(--dim); } +@media (min-width: 60rem) { + .start .isjust { + max-width: none; + display: grid; + grid-template-columns: minmax(0, 42ch) minmax(0, 1fr); + column-gap: 3ch; align-items: baseline; + } + .start .isjust dt, .start .isjust dd { + margin-top: 0; padding: calc(var(--row) * .2em) 0; + border-top: 1px solid var(--line); + } + .start .isjust dt:first-of-type, + .start .isjust dt:first-of-type + dd { border-top: none; } } -.lede { opacity: .9; } -/* The counts. Doors, so each gets a door's hover; tabular figures so the - numbers sit in a row rather than jittering as they update. */ .stats { - display: flex; flex-wrap: wrap; gap: var(--s3); - padding: var(--s3) 0 0; margin-top: var(--s2); + display: flex; flex-wrap: wrap; gap: .2em 2.5ch; + padding: calc(var(--row) * .6em) 0 0; margin-top: calc(var(--row) * .6em); border-top: 1px solid var(--line); } .stat { display: flex; align-items: baseline; gap: .8ch; text-decoration: none; - font-size: var(--t-small); } .stat b { - color: var(--a-package); font-size: var(--t-h3); font-weight: 600; + color: var(--a-package); font-weight: 600; font-variant-numeric: tabular-nums; - font-family: var(--mono, ui-monospace, monospace); } .stat span { color: var(--dim); } .stat:hover b, .stat:hover span { color: var(--fg); } -/* A section is a band, and the rule is the band's edge — the space above a - heading does the separating, the rule only confirms it. */ -.doc { padding-top: var(--s6); border-top: 1px solid var(--line); } +.doc { padding-top: calc(var(--row) * 1.6em); border-top: 1px solid var(--line); } .doc:first-of-type { border-top: none; } .doc > h2 { margin-top: 0; } .doc-n { display: inline-block; min-width: 2.4ch; text-align: center; background: var(--fg); color: var(--bg); - font-size: var(--t-micro); font-weight: 600; + font-size: .82em; font-weight: 600; padding: .1em .5ch; margin-right: 1.5ch; - vertical-align: .25em; -} -.doc-sub { padding-top: var(--s2); } - -/* The benefit list: a claim, its explanation, and the place to check it. - Not a table — each item is a short essay, and a table would force the - explanation into a cell that wants to be a paragraph. */ -.bens { list-style: none; padding: 0; margin: 0; max-width: none; } -.ben { - padding: var(--s2) 0; border-bottom: 1px solid var(--line); - max-width: var(--measure); + vertical-align: .15em; } +.doc-sub { padding-top: calc(var(--row) * .6em); } + +.bens { list-style: none; padding: 0; margin: 0; } +.ben { padding: calc(var(--row) * .6em) 0; border-bottom: 1px solid var(--line); max-width: 88ch; } .ben:last-child { border-bottom: none; } -.ben > b { display: block; font-size: var(--t-body); margin-bottom: 4px; } -.ben p { color: var(--fg); opacity: .88; margin: 0 0 var(--s1); } -.ben a { font-size: var(--t-small); color: var(--a-package); } +.ben > b { display: block; margin-bottom: 4px; } +.ben p { color: var(--dim); margin: 0 0 calc(var(--row) * .3em); } +.ben a { color: var(--a-package); } .todo { color: var(--a-live); font-weight: 600; } -.prose td.ok { color: var(--green); font-weight: 600; } ul.plain { list-style: none; padding: 0; margin: 0; } ul.plain li { margin-bottom: 4px; } -/* ── the contents rail ─────────────────────────────────────────────────── - Wide screens get a sticky rail; narrow ones get a disclosure. One element - doing both, because two would be two states to keep in step and a - duplicate list for a screen reader to read twice. */ -.start { display: block; } - .toc summary { cursor: pointer; color: var(--dim); list-style: none; - padding: var(--s1) 0; font-size: var(--t-small); + padding: calc(var(--row) * .3em) 0; } .toc summary::-webkit-details-marker { display: none; } .toc summary::before { content: '▸ '; } .toc[open] summary::before { content: '▾ '; } -.toc nav { display: flex; flex-direction: column; padding-bottom: var(--s2); } +.toc nav { display: flex; flex-direction: column; padding-bottom: calc(var(--row) * .6em); } .toc a { display: flex; gap: 1.2ch; text-decoration: none; color: var(--dim); - padding: 5px 0; font-size: var(--t-small); line-height: 1.35; + padding: calc(var(--row) * .15em) 0; line-height: 1.35; } .toc a i { font-style: normal; opacity: .55; min-width: 2ch; font-variant-numeric: tabular-nums; - font-family: var(--mono, ui-monospace, monospace); } .toc a:hover { color: var(--fg); } .toc a[data-here='1'] { color: var(--fg); font-weight: 600; } .toc a[data-here='1'] i { opacity: 1; color: var(--a-package); } -/* A departure from this page, so it sits below a rule rather than in the - run of sections — the numbering stops and the arrow says why. */ + +/* Same fill as the current nav tab — a destination, not a footnote. */ .toc-out { - margin-top: var(--s2); padding-top: var(--s2) !important; - border-top: 1px solid var(--line); + margin-top: calc(var(--row) * .6em); + padding: 0 1ch !important; + align-self: start; + width: max-content; + background: var(--a-package); color: var(--bg) !important; font-weight: 600; + border-top: 0; +} +.toc-out:hover { + background: var(--fg); color: var(--bg) !important; text-decoration: none; } -.toc-out i { color: var(--a-package); opacity: 1 !important; } -.toc-out .dim { display: block; } @media (min-width: 62rem) { .start { - display: grid; grid-template-columns: 30ch minmax(0, 1fr); gap: 6ch; - align-items: start; + display: grid; grid-template-columns: 30ch minmax(0, 1fr); gap: 4ch; + align-items: start; max-width: none; } .toc { - position: sticky; top: var(--s3); + position: sticky; top: calc(var(--row) * .8em); max-height: 86vh; overflow-y: auto; } .toc summary { display: none; } } @media (max-width: 40rem) { - .stats { gap: var(--s2); } - .doc { padding-top: var(--s4); } + .deck-mark { font-size: 10px; } + .stats { gap: .2em 1.5ch; } + .doc { padding-top: calc(var(--row) * 1.1em); } } `; diff --git a/apps/hub/hub/app/track/importmap.json b/apps/hub/hub/app/track/importmap.json deleted file mode 100644 index 8c62665..0000000 --- a/apps/hub/hub/app/track/importmap.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "deck": { - "packages": { - "track": { - "version": "0.1.0", - "entry": "$DECK/track.js" - } - }, - "dependencies": { - "ui/nav": "live", - "@tanstack/table-core": "lib/tanstack-table-core@browser", - "@tanstack/query-core": "lib/tanstack-query-core@browser", - "ui/activity": "live" - } - }, - "imports": { - "ui/nav/": "/hub/ui/nav/", - "ui/nav": "/hub/ui/nav/nav.js", - "@tanstack/table-core/": "/hub/lib/tanstack-table-core@9.1.1-browser.72f09cd7/", - "@tanstack/table-core": "/hub/lib/tanstack-table-core@9.1.1-browser.72f09cd7/dist/index.js", - "@tanstack/query-core/": "/hub/lib/tanstack-query-core@5.101.4-browser.ffd390ff/", - "@tanstack/query-core": "/hub/lib/tanstack-query-core@5.101.4-browser.ffd390ff/build/modern/index.js", - "ui/activity/": "/hub/ui/activity/", - "ui/activity": "/hub/ui/activity/activity.js" - }, - "scopes": { - "/hub/lib/tanstack-table-core@9.1.1-browser.72f09cd7/": { - "@tanstack/store": "/hub/lib/tanstack-store@0.11.1/dist/index.js", - "@tanstack/store/": "/hub/lib/tanstack-store@0.11.1/" - } - } -} diff --git a/apps/hub/hub/app/track/index.html b/apps/hub/hub/app/track/index.html index e595043..1a0d656 100644 --- a/apps/hub/hub/app/track/index.html +++ b/apps/hub/hub/app/track/index.html @@ -16,6 +16,9 @@ addressable and deliberately not importable — the same shape the ProseMirror `src/` decks have, for the same reason. --> + + + + + -
    loading…
    + +
    + - diff --git a/apps/hub/hub/app/track/package.json b/apps/hub/hub/app/track/package.json new file mode 100644 index 0000000..9997a2b --- /dev/null +++ b/apps/hub/hub/app/track/package.json @@ -0,0 +1,13 @@ +{ + "name": "track", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./track.js" }, + "dependencies": { + "ui/nav": "workspace:*", + "@tanstack/table-core": "browser", + "@tanstack/query-core": "browser", + "ui/activity": "workspace:*", + "ui/tour": "workspace:*" + } +} diff --git a/apps/hub/hub/app/track/track.js b/apps/hub/hub/app/track/track.js index f0d411d..88951e3 100644 --- a/apps/hub/hub/app/track/track.js +++ b/apps/hub/hub/app/track/track.js @@ -13,7 +13,7 @@ // // So every row is an action, in the past tense: // -// saved a settled edit was sealed as a Step on the Timeline +// saved a settled edit was sealed as a Step on the History // auto-saved the watcher published that save as `-dev.N`, unasked // published somebody cut an immutable version on purpose // released a dist-tag moved onto one. No bytes copied; a name changed. @@ -43,6 +43,7 @@ // else here uses. import 'ui/nav'; import { installNavStyles, onLiveChange, wrapTables } from 'ui/nav'; +import { resumeHubTour } from 'ui/tour'; import { QueryClient } from '@tanstack/query-core'; import { constructTable, @@ -142,16 +143,38 @@ function parseAddress(at) { }; } -async function collect(scope) { +/** First seal page — enough for a screen; more arrives on scroll. */ +const HISTORY_PAGE = 40; +/** DOM window — do not paint thousands of entries at once. */ +const FEED_PAGE = 50; + +/** + * Collect change rows for the current scope. + * + * Seal logs are the expensive half. Unscoped `/track` used to fan out a full + * `jj log` across every demo depot and hang the Hub on multi-MB responses — + * so seals load only when `?at=` names a depot, and then only `historyLimit` + * newest seals (`/_history?limit=`). Scroll asks for more. + * + * @returns {{ rows: object[], sealsTruncated: boolean, historyLimit: number }} + */ +async function collect(scope, { historyLimit = HISTORY_PAGE } = {}) { let { data: depots } = await query(['packages'], '/_packages'); let wanted = depots.filter((depot) => !scope.depot || depot.name === scope.depot); let rows = []; + let sealsTruncated = false; await Promise.all( wanted.map(async (depot) => { // ── versions ────────────────────────────────────────────────────── for (let deck of depot.packages) { for (let [version, record] of Object.entries(deck.versions ?? {})) { + // Repository projections are transport addresses created so a + // Review can run an exact multi-package tree. They are not package + // publication decisions, so showing them here would make merely + // opening Review look like six releases happened. + if (isRepositoryProjection(version)) continue; + let previous = previousVersion(deck, version); rows.push({ // `vendored` is now sayable. It used to be guesswork, because // telling a vendored version from a hand-cut one meant reading @@ -187,17 +210,34 @@ async function collect(scope) { upstream: deck.upstream, treeHash: record.treeHash, at: `/${depot.name}/${deck.publisher}/${deck.name}@${version}`, - // A version's natural comparison is the one before it. - vs: previousVersion(deck, version), + // A Version's natural comparison is the one before it, but a + // release can deliberately give an existing tree a stable name. + // That is a real event, not a content change. Sending it through + // Browse's diff surface can only produce "no differences", so + // keep the ancestry visible while reserving `vs` for changed + // trees. + vs: + previous && previous.treeHash !== record.treeHash + ? previous.at + : '', + sameTreeAs: + previous?.treeHash === record.treeHash + ? previous.version + : undefined, }); } } // ── seals ───────────────────────────────────────────────────────── - if (depot.history) { - let seals = await query([depot.name, 'history'], `/${depot.name}/${HISTORY}`) - .then((body) => body.data) - .catch(() => []); + // Scope is the point: without a depot, skip the seal fan-out. Versions + // and offers still paint a useful river from `/_packages` alone. + if (depot.history && scope.depot) { + let body = await query( + [depot.name, 'history', historyLimit], + `/${depot.name}/${HISTORY}?limit=${historyLimit}`, + ).catch(() => ({ data: [], meta: {} })); + if (body.meta?.truncated) sealsTruncated = true; + let seals = body.data ?? []; seals.forEach((seal, index) => { let attrs = seal.attributes; // jj reports `A path` / `M path` / `D path` / `R old => new`, and @@ -213,7 +253,7 @@ async function collect(scope) { }) // Machinery is not content, wherever it turns up. // - // `.deck/` holds the store; `.jj` / `.jj.main-orphan` are Timeline + // `.deck/` holds the store; `.jj` / `.jj.main-orphan` are History // stubs and jj workspace leftovers. DEFAULT_IGNORE / `.jj.*` exist // so they never enter a tree — but older seals may still list them. // Filtering here keeps /track honest: a change to machinery is not @@ -245,6 +285,8 @@ async function collect(scope) { publisher: touched[0]?.split('/')[0] ?? '', pkg: touched[0]?.split('/')[1] ?? '', id: attrs.changeId, + summary: attrs.description, + actor: attrs.author, what: decks.length === 1 ? decks[0] @@ -296,7 +338,8 @@ async function collect(scope) { publisher: publisher ?? '', pkg: pkg?.split('@')[0] ?? '', id: attrs.offer, - what: `${attrs.state ?? 'open'} · against ${attrs.upstream ?? ''}`, + what: `${attrs.state === 'unresolvable' ? 'merge base missing' : attrs.state ?? 'open'} · against ${attrs.upstream ?? ''}`, + detail: attrs.detail, state: attrs.state, when: '', at: `/${depot.name}/${attrs.offer}`, @@ -307,15 +350,34 @@ async function collect(scope) { }), ); - return rows.filter((row) => inScope(row, scope)); + return { + rows: rows.filter((row) => inScope(row, scope)), + sealsTruncated, + historyLimit, + }; } function previousVersion(deck, version) { - let all = Object.keys(deck.versions ?? {}).sort(); - let index = all.indexOf(version); - return index > 0 - ? `/${deck.id.split('/')[0]}/${deck.publisher}/${deck.name}@${all[index - 1]}` - : ''; + let all = Object.entries(deck.versions ?? {}) + .filter(([candidate]) => !isRepositoryProjection(candidate)) + .sort( + ([, left], [, right]) => + (Date.parse(left.publishedAt ?? '') || 0) - + (Date.parse(right.publishedAt ?? '') || 0), + ); + let index = all.findIndex(([candidate]) => candidate === version); + if (index <= 0) return; + + let [previous, record] = all[index - 1]; + return { + version: previous, + treeHash: record.treeHash, + at: `/${deck.id.split('/')[0]}/${deck.publisher}/${deck.name}@${previous}`, + }; +} + +function isRepositoryProjection(version) { + return /-repository\.[0-9a-f]{12}$/.test(version); } /** @@ -584,7 +646,7 @@ function controls(all, kinds) { let noise = all.filter((row) => NOISE.includes(row.kind)).length; let showing = kinds.includes('auto-published'); return ( - `
    ` + + `
    ` + (noise ? `` @@ -719,6 +781,7 @@ function entry(row) { if (row.count > 1) return groupEntry(row); let fresh = freshness(row.when); let browse = row.vs ? `/browse?at=${row.at}&vs=${row.vs}` : `/browse?at=${row.at}`; + let headline = row.summary || row.what; let detail = row.changes?.length ? row.changes @@ -733,16 +796,20 @@ function entry(row) { ? ` +${row.changes.length - 3}` : '') : esc(row.detail ?? ''); + let sameTree = row.sameTreeAs + ? `same tree as ${esc(row.sameTreeAs)} · new name, zero bytes copied` + : ''; return ( `
  • ` + `
    ${esc(ago(row.when)) || '—'}
    ` + `
    ` + `
    ` + `${MARK[row.kind] ?? '·'}${esc(row.kind)} ` + - `${esc(row.what)}` + + `${esc(headline)}` + + (row.actor ? ` by ${esc(row.actor)}` : '') + `
    ` + `
    ` + `${esc(row.depot)}` + @@ -752,7 +819,9 @@ function entry(row) { (row.pkg ? `/${esc(row.pkg)}` : '') + + (row.summary ? ` ${esc(row.what)}` : '') + (detail ? ` ${detail}` : '') + + (sameTree ? ` ${sameTree}` : '') + // Provenance sits on the second line because it is a property of the // thing, not the event: `npm:three · published 2y ago` answers "what did // we take on", while the row's own timestamp answers "when did we". @@ -837,33 +906,137 @@ function sortBar(current) { ); } +/** Feed state for infinite scroll — survives appends, resets on full render. */ +let feedState = { + all: [], + rows: [], + visible: 0, + historyLimit: HISTORY_PAGE, + sealsTruncated: false, + loadingMore: false, + observer: undefined, +}; + +function disconnectFeedObserver() { + feedState.observer?.disconnect(); + feedState.observer = undefined; +} + +function paintFeedWindow() { + let feed = app.querySelector('[data-tour="feed"]'); + let more = app.querySelector('[data-role="feed-more"]'); + if (!feed || feed.tagName !== 'UL') return; + let source = grouped() ? groupRuns(feedState.rows) : feedState.rows; + let slice = source.slice(0, feedState.visible); + feed.innerHTML = slice.map(entry).join(''); + let canDom = feedState.visible < source.length; + let canFetch = feedState.sealsTruncated && Boolean(parseAddress(currentAt()).depot); + if (more) { + more.hidden = !(canDom || canFetch); + more.dataset.state = feedState.loadingMore + ? 'loading' + : canFetch && !canDom + ? 'fetch' + : 'more'; + more.textContent = feedState.loadingMore + ? 'loading…' + : canFetch && !canDom + ? 'loading older saves…' + : 'scroll for more'; + } +} + +async function loadMoreFeed() { + if (feedState.loadingMore) return; + let sourceLen = (grouped() ? groupRuns(feedState.rows) : feedState.rows).length; + if (feedState.visible < sourceLen) { + feedState.visible = Math.min(sourceLen, feedState.visible + FEED_PAGE); + paintFeedWindow(); + return; + } + if (!feedState.sealsTruncated) return; + let scope = parseAddress(currentAt()); + if (!scope.depot) return; + feedState.loadingMore = true; + paintFeedWindow(); + try { + let nextLimit = feedState.historyLimit + HISTORY_PAGE; + let collected = await collect(scope, { historyLimit: nextLimit }); + let kinds = currentKinds(); + let filtered = collected.rows.filter( + (row) => kinds.includes(row.kind) && !ATTENTION.includes(row.kind), + ); + let { rows } = sortRows(filtered); + feedState.all = collected.rows; + feedState.rows = rows; + feedState.historyLimit = collected.historyLimit; + feedState.sealsTruncated = collected.sealsTruncated; + feedState.visible = Math.min( + (grouped() ? groupRuns(rows) : rows).length, + feedState.visible + FEED_PAGE, + ); + } finally { + feedState.loadingMore = false; + paintFeedWindow(); + } +} + +function watchFeedScroll() { + disconnectFeedObserver(); + let sentinel = app.querySelector('[data-role="feed-more"]'); + if (!sentinel || typeof IntersectionObserver !== 'function') return; + feedState.observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) loadMoreFeed(); + }, + { root: null, rootMargin: '240px', threshold: 0 }, + ); + feedState.observer.observe(sentinel); +} + async function render() { let at = currentAt(); let scope = parseAddress(at); let kinds = currentKinds(); + disconnectFeedObserver(); app.innerHTML = `

    collecting…

    `; try { - let all = await collect(scope); - let filtered = all.filter( + let collected = await collect(scope, { historyLimit: HISTORY_PAGE }); + let filtered = collected.rows.filter( (row) => kinds.includes(row.kind) && !ATTENTION.includes(row.kind), ); - let { rows, sorting } = sortRows(filtered); + let { rows } = sortRows(filtered); + feedState = { + ...feedState, + all: collected.rows, + rows, + visible: Math.min(FEED_PAGE, (grouped() ? groupRuns(rows) : rows).length), + historyLimit: collected.historyLimit, + sealsTruncated: collected.sealsTruncated, + loadingMore: false, + }; + let unscopedNote = !scope.depot + ? `

    Saves load when you scope a depot — ` + + `open one from /browse or add ?at=/depot.

    ` + : ''; app.innerHTML = scopeBar(scope, at) + `
    ` + - controls(all, kinds) + - attention(all) + + controls(collected.rows, kinds) + + attention(collected.rows) + + unscopedNote + (rows.length >= 25 ? `
    filter ` + `
    ` : '') + (rows.length - ? `
      ${(grouped() ? groupRuns(rows) : rows) - .map(entry) - .join('')}
    ` - : `

    nothing here

    `) + + ? `
      ` + + `` + : `

      nothing here

      `) + `
      `; + paintFeedWindow(); + watchFeedScroll(); } catch (error) { app.innerHTML = `
      ${esc(error.code ?? 'error')} — ` + @@ -872,12 +1045,20 @@ async function render() { cursor = -1; wrapTables(app); keys.keys = bindings(); + maybeResumeTour(); } // --------------------------------------------------------------------------- // Keyboard // --------------------------------------------------------------------------- +let tourResumed = false; +function maybeResumeTour() { + if (tourResumed) return; + tourResumed = true; + resumeHubTour({ waitMs: 80 }); +} + let cursor = -1; const rows$ = () => [...app.querySelectorAll('.entry:not([hidden])')]; @@ -1015,11 +1196,16 @@ app.addEventListener('input', (event) => { // announces every save on the SSE channel; the query cache just was not // listening. // -// Invalidate rather than refetch: query-core then only re-fetches what is -// actually mounted, and the seal log is the one query that grows. +// Debounced: a save-storm used to call render() once a second and restack +// multi-megabyte history fetches until the page never left "collecting…". +let liveRefreshTimer; onLiveChange(() => { client.invalidateQueries(); - render(); + if (liveRefreshTimer) return; + liveRefreshTimer = setTimeout(() => { + liveRefreshTimer = undefined; + if (!document.hidden) render(); + }, 1500); }); // The clock keeps moving even when the depot does not, and every row is @@ -1027,7 +1213,7 @@ onLiveChange(() => { // stays on screen for an hour. A minute is finer than the smallest unit the // feed prints, so nothing ever displays a time that has gone stale. setInterval(() => { - if (!document.hidden) render(); + if (!document.hidden) paintFeedWindow(); }, 60_000); render(); diff --git a/apps/hub/hub/importmap.json b/apps/hub/hub/importmap.json new file mode 100644 index 0000000..ff8ba04 --- /dev/null +++ b/apps/hub/hub/importmap.json @@ -0,0 +1,36 @@ +{ + "imports": { + "@tanstack/query-core": "/hub/lib/tanstack-query-core@browser/build/modern/index.js", + "@tanstack/query-core/": "/hub/lib/tanstack-query-core@browser/", + "@tanstack/table-core": "/hub/lib/tanstack-table-core@browser/dist/index.js", + "@tanstack/table-core/": "/hub/lib/tanstack-table-core@browser/", + "diff": "/hub/lib/diff@9.0.0/libesm/index.js", + "diff/": "/hub/lib/diff@9.0.0/", + "driver.js": "/hub/lib/driver-js@1.8.0/dist/driver.js.mjs", + "driver.js/": "/hub/lib/driver-js@1.8.0/", + "highlight-js": "/hub/lib/highlight-js@esm/lib/core.js", + "highlight-js/": "/hub/lib/highlight-js@esm/", + "ui/activity": "/hub/ui/activity/activity.js", + "ui/activity/": "/hub/ui/activity/", + "ui/nav": "/hub/ui/nav/nav.js", + "ui/nav/": "/hub/ui/nav/", + "ui/history": "/hub/ui/history/history.js", + "ui/history/": "/hub/ui/history/", + "ui/tour": "/hub/ui/tour/tour.js", + "ui/tour/": "/hub/ui/tour/" + }, + "scopes": { + "/hub/lib/tanstack-table-core@9.1.1-browser.05cec605/": { + "@tanstack/store": "/hub/lib/tanstack-store@0.11.1/dist/index.js", + "@tanstack/store/": "/hub/lib/tanstack-store@0.11.1/" + } + }, + "integrity": { + "/hub/lib/diff@9.0.0/libesm/index.js": "sha256-sfQA/STaRDd4Z+3nq4R6NJQEI/i78/xUddjHZHdZoqo=", + "/hub/lib/driver-js@1.8.0/dist/driver.js.mjs": "sha256-kPXgQwWi0N8WpfYAC/frXTZstq7tG/mlw79cwruArTI=", + "/hub/lib/highlight-js@esm/lib/core.js": "sha256-QS/RqAp7ztxq601uV69sqNuIxesdxMutC+0NE1jy218=", + "/hub/lib/tanstack-query-core@browser/build/modern/index.js": "sha256-iisviX8WZHasNxVEyoDNJd812vDLVIdHjENBgPI30BM=", + "/hub/lib/tanstack-store@0.11.1/dist/index.js": "sha256-vGy00gQx1KEuoHPrJ7SYx/jxMBzBVkdle9jPX2yviyI=", + "/hub/lib/tanstack-table-core@browser/dist/index.js": "sha256-d0tlBKsEdSD6Qp0oKtfCO5YB8CbjqWfhOE3b0DHtw/g=" + } +} diff --git a/apps/hub/hub/theme/latte/importmap.json b/apps/hub/hub/theme/latte/importmap.json deleted file mode 100644 index d6d73c9..0000000 --- a/apps/hub/hub/theme/latte/importmap.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "deck": { - "packages": { - "latte": { - "version": "1.0.0" - } - } - } -} diff --git a/apps/hub/hub/theme/latte/package.json b/apps/hub/hub/theme/latte/package.json new file mode 100644 index 0000000..7397e90 --- /dev/null +++ b/apps/hub/hub/theme/latte/package.json @@ -0,0 +1,4 @@ +{ + "name": "latte", + "version": "1.0.0" +} diff --git a/apps/hub/hub/theme/mocha/importmap.json b/apps/hub/hub/theme/mocha/importmap.json deleted file mode 100644 index eb43b71..0000000 --- a/apps/hub/hub/theme/mocha/importmap.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "deck": { - "packages": { - "mocha": { - "version": "1.0.0" - } - } - } -} diff --git a/apps/hub/hub/theme/mocha/package.json b/apps/hub/hub/theme/mocha/package.json new file mode 100644 index 0000000..5205a1c --- /dev/null +++ b/apps/hub/hub/theme/mocha/package.json @@ -0,0 +1,4 @@ +{ + "name": "mocha", + "version": "1.0.0" +} diff --git a/apps/hub/hub/theme/tokyo-night-light/importmap.json b/apps/hub/hub/theme/tokyo-night-light/importmap.json deleted file mode 100644 index 534b444..0000000 --- a/apps/hub/hub/theme/tokyo-night-light/importmap.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "deck": { - "packages": { - "tokyo-night-light": { - "version": "1.0.0" - } - } - } -} diff --git a/apps/hub/hub/theme/tokyo-night-light/package.json b/apps/hub/hub/theme/tokyo-night-light/package.json new file mode 100644 index 0000000..5b7a6fd --- /dev/null +++ b/apps/hub/hub/theme/tokyo-night-light/package.json @@ -0,0 +1,4 @@ +{ + "name": "tokyo-night-light", + "version": "1.0.0" +} diff --git a/apps/hub/hub/ui/activity/activity.js b/apps/hub/hub/ui/activity/activity.js index 8a215a5..883f763 100644 --- a/apps/hub/hub/ui/activity/activity.js +++ b/apps/hub/hub/ui/activity/activity.js @@ -26,7 +26,8 @@ let REPORT = { now: Date.now(), scores: {}, last: {} }; let listeners = new Set(); /** - * Fetch the report. ~5 KB, cached on the server for a few seconds. + * Fetch the report. Package grain — enough to decorate depot/deck lists. + * File lists inherit the enclosing package's warmth (see `scoreOf`). * * Never throws: a Hub that cannot report activity should lose the decoration * and keep the list. Everything below degrades to zero, which renders as an @@ -50,7 +51,7 @@ export async function loadActivity() { async function fetchActivity() { try { - let response = await fetch('/_activity?top=400&grain=file', { + let response = await fetch('/_activity?top=200&grain=package', { headers: { accept: 'application/json' }, }); if (response.ok) { @@ -110,6 +111,24 @@ export function scoreOf(prefix) { if (path !== clean && !path.startsWith(`${clean}/`)) continue; claims.push(...list); } + // Package-grain reports have no file leaves. A file row still wants a + // colour: use the longest scored prefix that contains it (usually the deck). + if (score === 0 && files === 0) { + let best = ''; + for (let path of Object.keys(REPORT.scores ?? {})) { + if (clean === path || clean.startsWith(`${path}/`)) { + if (path.length > best.length) best = path; + } + } + if (best) { + score = REPORT.scores[best] ?? 0; + last = REPORT.last?.[best] ?? 0; + files = 1; + } + for (let [path, list] of Object.entries(REPORT.claims ?? {})) { + if (clean === path || clean.startsWith(`${path}/`)) claims.push(...list); + } + } return { score, last, files, claims }; } diff --git a/apps/hub/hub/ui/activity/importmap.json b/apps/hub/hub/ui/activity/importmap.json deleted file mode 100644 index 0f8daec..0000000 --- a/apps/hub/hub/ui/activity/importmap.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "deck": { - "packages": { - "activity": { - "version": "0.1.0", - "entry": "$DECK/activity.js" - } - } - } -} diff --git a/apps/hub/hub/ui/activity/package.json b/apps/hub/hub/ui/activity/package.json new file mode 100644 index 0000000..7e787d6 --- /dev/null +++ b/apps/hub/hub/ui/activity/package.json @@ -0,0 +1,6 @@ +{ + "name": "activity", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./activity.js" } +} diff --git a/apps/hub/hub/ui/timeline/timeline.js b/apps/hub/hub/ui/history/history.js similarity index 95% rename from apps/hub/hub/ui/timeline/timeline.js rename to apps/hub/hub/ui/history/history.js index ef5705c..bcb9f20 100644 --- a/apps/hub/hub/ui/timeline/timeline.js +++ b/apps/hub/hub/ui/history/history.js @@ -1,8 +1,8 @@ -// The Timeline — a horizontal scrubber over Steps. +// The History — a horizontal scrubber over Steps. // // Vocabulary from docs/deck-step-version-vocabulary.md: a **Step** is one // settled save on the depot's every-save tape, a **Version** is an immutable -// published tree, and the **Timeline** is the tape itself. +// published tree, and the **History** is the tape itself. // // WHY A SCRUBBER AND NOT A DROPDOWN. A dropdown of 138 change ids answers // "which one" and refuses to answer the question people actually have, which @@ -17,8 +17,8 @@ // run steps that touched ANYTHING IN THE you are watching the // PAGE'S CLOSURE assembled thing // -// A palette change is a step on the run timeline for a canvas page — it -// changes what renders — and is not a step on the browse timeline for +// A palette change is a Step in the run History for a canvas page — it +// changes what renders — and is not a Step in the browse History for // canvas.js, because those bytes never moved. Same depot, same instant, two // correct answers, and the difference is exactly what the two lenses mean. // @@ -75,7 +75,7 @@ export function stepsFor(steps, prefixes) { * * This folded by elapsed time and that was wrong twice over. At ninety * seconds it swallowed a whole working session — forty-five Steps became two - * notches, and a timeline with two notches is not a timeline. Shrinking the + * notches, and History with two notches is not useful History. Shrinking the * window did not fix the idea. * * THE IDEA WAS WRONG FOR AGENTS. Time-folding assumes rapid saves are one @@ -179,10 +179,10 @@ export function stepBy(steps, current, delta) { * and none on the work. This is an index, not a clock; the clock is printed * beside it. */ -export function timelineHtml({ steps, current, label = 'timeline', busy }) { +export function historyHtml({ steps, current, label = 'history', busy }) { if (!steps.length) { return ( - `
      ` + + `
      ` + `${esc(label)}` + `no steps here yet
      ` ); @@ -227,7 +227,7 @@ export function timelineHtml({ steps, current, label = 'timeline', busy }) { .join(''); return ( - `
      ` + + `
      ` + `${esc(label)}` + `` + `` + @@ -407,7 +407,7 @@ export function attachScrub(root, { onScrub, onCommit } = {}) { /** * HOW FAST WAS THAT — measured, not claimed. * - * Scrubbing a timeline is pleasant rather than painful because every Step + * Scrubbing History is pleasant rather than painful because every Step * address is immutable, so the whole closure is `immutable`-cached after the * first visit. That is a real property of the protocol and it is invisible * unless somebody prints it, so this prints it: wall clock for the frame, how @@ -444,7 +444,7 @@ export function measureFrame(frame, into) { ); } -export const TIMELINE_CSS = ` +export const HISTORY_CSS = ` .tl { display: flex; align-items: center; gap: 1ch; padding: calc(var(--row, 1.5) * .3em) 0; @@ -465,7 +465,7 @@ export const TIMELINE_CSS = ` are three wide bars and ninety are ninety thin ones — the tape is always the same length and only its resolution changes, which is what makes the position of the marker mean something at a glance. An earlier version - capped notch width and right-aligned them, so a short timeline was a few + capped notch width and right-aligned them, so short History was a few small marks adrift in an empty rule, and read as broken. */ .tl-track { flex: 1; display: flex; align-items: stretch; @@ -473,7 +473,7 @@ export const TIMELINE_CSS = ` border-bottom: 1px solid var(--line); } /* Capped, so twelve steps read as a tape rather than a bar chart. Without a - maximum, a short timeline stretches each notch to 80px and the row stops + maximum, short History stretches each notch to 80px and the row stops looking like time at all. */ .tl-notch { flex: 1 1 0; min-width: 2px; padding: 0; @@ -511,10 +511,10 @@ export const TIMELINE_CSS = ` .tl-pop-id { margin-top: .2em; } `; -export function installTimelineStyles(doc = document) { - if (doc.querySelector('style[data-deck-timeline]')) return; +export function installHistoryStyles(doc = document) { + if (doc.querySelector('style[data-deck-history]')) return; let style = doc.createElement('style'); - style.dataset.deckTimeline = 'v1'; - style.textContent = TIMELINE_CSS; + style.dataset.deckHistory = 'v1'; + style.textContent = HISTORY_CSS; doc.head.append(style); } diff --git a/apps/hub/hub/ui/history/package.json b/apps/hub/hub/ui/history/package.json new file mode 100644 index 0000000..245e9b2 --- /dev/null +++ b/apps/hub/hub/ui/history/package.json @@ -0,0 +1,6 @@ +{ + "name": "history", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./history.js" } +} diff --git a/apps/hub/hub/ui/nav/importmap.json b/apps/hub/hub/ui/nav/importmap.json deleted file mode 100644 index 6e6dbb9..0000000 --- a/apps/hub/hub/ui/nav/importmap.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "deck": { - "packages": { - "nav": { - "version": "0.1.0", - "entry": "$DECK/nav.js" - } - } - } -} diff --git a/apps/hub/hub/ui/nav/nav.js b/apps/hub/hub/ui/nav/nav.js index ef8ce0c..e4b9810 100644 --- a/apps/hub/hub/ui/nav/nav.js +++ b/apps/hub/hub/ui/nav/nav.js @@ -1,6 +1,6 @@ // and — the chrome every Hub app carries. // -// Four apps, four documents, so moving between them is a link and a page +// Five apps, five documents, so moving between them is a link and a page // load. There is no router here and there does not need to be one: the // browser has always been able to do this part. // @@ -14,12 +14,12 @@ // rather than underlined, and the keys that work are listed along the bottom // where you can see them rather than discovered by accident. // -// `1`…`4` still switch apps. They are not printed beside the names — a row of -// ordinals is noise once the four words are the four words — and the legend +// `1`…`5` still switch apps. They are not printed beside the names — a row of +// ordinals is noise once the five words are the five words — and the legend // at the foot of every page carries them instead, which is where every other // binding is documented anyway. -// FOUR APPS, BUT ONLY THREE OF THEM ARE LENSES. +// FIVE APPS, BUT ONLY THREE OF THEM ARE ADDRESS LENSES. // // `start` is about the machine. The other three are three questions about one // address: what is it, what does it do, what happened to it. That is not a @@ -36,6 +36,7 @@ const ROUTES = [ ['browse', 'the depot at rest'], ['run', 'the assembled, executing thing'], ['track', 'what changed, and where'], + ['review', 'proposed changes and merge'], ]; /** The focused address, or ''. The one piece of state the lenses share. */ @@ -44,7 +45,7 @@ export function focus() { } /** - * ONE EventSource PER DOCUMENT. Not one per feature. + * ZERO EventSources IN HUB CHROME. Live requests must release their socket. * * An SSE stream is a connection held open forever, and HTTP/1.1 allows six * per origin. The nav opens one for the live badge; heat wanted one; /track @@ -54,11 +55,23 @@ export function focus() { * for ever, because its requests were queued behind connections that never * close. Starvation does not throw. * - * So there is exactly one stream, and anything that cares subscribes to it. - * Opened lazily, so a page that never subscribes never opens it at all. + * One stream per document fixed the iframe case but not five ordinary Hub + * tabs: five permanent requests still leave one HTTP/1.1 slot for every + * navigation, module and API response. The fifth tab can sit blank with no + * error because its redirect is queued behind the live streams. + * + * Hub chrome therefore does not use EventSource. It polls a tiny moving + * sequence at `/_live/state`; requests finish immediately and release their + * connection. Running pages use the same moving snapshot for HMR, so neither + * Hub chrome nor a stage holds a connection open. */ -let liveSource; const liveListeners = new Set(); +const liveStatusListeners = new Set(); +const LIVE_POLL_MS = 1500; +let livePollTimer; +let livePolling = false; +let liveSequence; +let liveStatus = { state: '?', text: 'live' }; /** * A FRAMED PAGE IS A SUBJECT, NOT AN OBSERVER. @@ -106,23 +119,84 @@ const LIVE_QUIET_MS = 1000; let liveTimer; let liveLatest; +function deliverLive(data) { + liveLatest = { data }; + if (liveTimer) return; + liveTimer = setTimeout(() => { + liveTimer = undefined; + for (let each of liveListeners) each(liveLatest); + }, LIVE_QUIET_MS); +} + +function deliverLiveStatus(status) { + if (liveStatus.state === status.state && liveStatus.text === status.text) return; + liveStatus = status; + for (let each of liveStatusListeners) each(status); +} + +async function pollLive() { + if (!livePolling || FRAMED) return; + try { + let response = await fetch('/_live/state', { cache: 'no-store' }); + if (!response.ok) throw new Error(String(response.status)); + let snapshot = (await response.json()).data; + deliverLiveStatus({ state: 'on', text: 'live' }); + // The first answer establishes position. Replaying the last event on + // every navigation would make a quiet page redraw for old work. + if ( + liveSequence !== undefined && + snapshot.sequence > liveSequence && + snapshot.event + ) { + deliverLive(JSON.stringify(snapshot.event)); + // Reload the Hub's own module edits from their no-store addresses. + // Repository/fork events are data, so subscribers repaint them in place. + if (/^(app|ui|theme)\//.test(snapshot.event.name)) { + setTimeout(() => location.reload(), 80); + return; + } + } + liveSequence = snapshot.sequence; + } catch { + deliverLiveStatus({ state: 'off', text: 'live: disconnected' }); + } + if (livePolling) livePollTimer = setTimeout(pollLive, LIVE_POLL_MS); +} + +function startLivePolling() { + if (livePolling || FRAMED) return; + livePolling = true; + pollLive(); +} + +addEventListener('pagehide', () => { + livePolling = false; + clearTimeout(livePollTimer); +}); + +// A document restored from the back-forward cache keeps its subscribers but +// pagehide stopped its timer. Rejoin without subscribing every feature twice. +addEventListener('pageshow', () => { + if (!livePolling && (liveListeners.size || liveStatusListeners.size)) { + startLivePolling(); + } +}); + export function onLiveChange(listener) { if (FRAMED) return () => {}; liveListeners.add(listener); - if (!liveSource) { - liveSource = new EventSource('/_live/events'); - liveSource.addEventListener('change', (event) => { - liveLatest = event; - if (liveTimer) return; - liveTimer = setTimeout(() => { - liveTimer = undefined; - for (let each of liveListeners) each(liveLatest); - }, LIVE_QUIET_MS); - }); - } + startLivePolling(); return () => liveListeners.delete(listener); } +function onLiveStatus(listener) { + if (FRAMED) return () => {}; + liveStatusListeners.add(listener); + listener(liveStatus); + startLivePolling(); + return () => liveStatusListeners.delete(listener); +} + /** * Where a lens tab points. * @@ -169,22 +243,16 @@ const THEMES = [ const THEME_KEY = 'deck.theme'; /** - * The stored choice, or the default for the operating system's preference. + * The stored choice, or mocha. * - * Tokyo Night is the light default. It is the one with enough contrast for a - * page of prose — latte sets its body grey at about 3:1 against the - * background, which is fine for a filename and under the WCAG AA floor for a - * paragraph, and /start is mostly paragraphs. - * - * A stored choice always wins. This only decides what somebody sees before - * they have had an opinion. + * Mocha is the Hub default — dark shell, Catppuccin. Latte and Tokyo Night + * stay available from the theme picker. A stored choice always wins; this + * only decides what somebody sees before they have had an opinion. */ function theme() { let stored = localStorage.getItem(THEME_KEY); if (stored && THEMES.some(([name]) => name === stored)) return stored; - return matchMedia('(prefers-color-scheme: light)').matches - ? 'tokyo-night-light' - : 'mocha'; + return 'mocha'; } export function applyTheme(name = theme()) { @@ -361,7 +429,7 @@ export class DeckNav extends HTMLElement { let paint = async () => { if (painting) return; painting = true; - let claims = await fetch('/_activity?top=200&grain=file') + let claims = await fetch('/_activity?top=200&grain=package') .then((response) => (response.ok ? response.json() : { foci: [] })) .then((body) => (body.foci ?? []).flatMap((focus) => @@ -411,6 +479,10 @@ export class DeckNav extends HTMLElement { // `1`…`4` go to a section, the way the numbers in the nav imply. Ignored // while a field has focus, or the filter box would be unusable. + // + // Activate the real tab link (do not assign location.href): cross-document + // View Transitions only run for user-activated navigations, and a synthetic + // click on an keeps that activation. Scripted location changes flash. #numberKeys() { addEventListener('keydown', (event) => { if (event.metaKey || event.ctrlKey || event.altKey) return; @@ -419,7 +491,9 @@ export class DeckNav extends HTMLElement { let index = Number(event.key) - 1; let route = ROUTES[index]?.[0]; if (route && route !== this.getAttribute('app')) { - location.href = `/${route}`; + let link = this.querySelector(`a[data-route="${route}"]`); + if (link) link.click(); + else location.href = `/${route}`; } }); } @@ -449,8 +523,7 @@ export class DeckNav extends HTMLElement { clearTimeout(this.#settle); this.#settle = setTimeout(() => set('on', 'live'), 4000); }); - liveSource?.addEventListener('open', () => set('on', 'live')); - liveSource?.addEventListener('error', () => set('off', 'live: disconnected')); + onLiveStatus(({ state, text }) => set(state, text)); } #settle; @@ -526,6 +599,12 @@ export class DeckSubject extends HTMLElement { * need no shared clearing protocol between them. */ clear() { + // Same as number-keys: prefer a real navigation so View Transitions run. + let here = document.querySelector('deck-nav a[aria-current="page"]'); + if (here && here.getAttribute('data-inert') === '0') { + here.click(); + return; + } location.href = location.pathname; } @@ -553,10 +632,15 @@ export class DeckSubject extends HTMLElement { * key names. Every shortcut the page implements appears here, always, and * nothing appears here that the page does not implement. A legend that drifts * from the bindings is worse than no legend. + * + * While a Hub tour is running, `.tour` replaces the legend with chapter + * chrome (Prev / Next / Exit) plus a one-line protocol claim. The bar lights + * an accent; claims stay here so the exhibit is never covered by a popover. */ export class DeckKeys extends HTMLElement { #keys = []; #shown = localStorage.getItem('deck.keys') !== 'hidden'; + #tour = null; set keys(value) { this.#keys = value; @@ -567,32 +651,104 @@ export class DeckKeys extends HTMLElement { return this.#keys; } + /** @param {null | { + * chapter: string, + * chapterIndex: number, + * chapters: number, + * step: number, + * steps: number, + * label?: string, + * claim?: string, + * onPrev?: () => void, + * onNext?: () => void, + * onExit?: () => void, + * }} value */ + set tour(value) { + this.#tour = value; + this.render(); + } + + get tour() { + return this.#tour; + } + connectedCallback() { this.render(); // `h` hides and shows, and the choice sticks. Someone who has learned // the keys should not have to keep dismissing the reminder, and someone // who has not should not have to know it exists to find it — so when it // is hidden a single `?` remains, which is a smaller promise than a full - // bar and still an obvious one. + // bar and still an obvious one. Tour mode always shows; `h` is ignored. addEventListener('keydown', (event) => { if (event.metaKey || event.ctrlKey || event.altKey) return; let tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return; + if (this.#tour) { + if (event.key === 'Escape') { + event.preventDefault(); + this.#tour.onExit?.(); + return; + } + if (event.key === 'ArrowRight' || event.key === 'Enter') { + event.preventDefault(); + this.#tour.onNext?.(); + return; + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + this.#tour.onPrev?.(); + return; + } + return; + } if (event.key !== 'h' && event.key !== '?') return; this.toggle(); }); this.addEventListener('click', (event) => { if (event.target.closest('[data-role="keys-toggle"]')) this.toggle(); + if (!this.#tour) return; + if (event.target.closest('[data-role="tour-prev"]')) this.#tour.onPrev?.(); + if (event.target.closest('[data-role="tour-next"]')) this.#tour.onNext?.(); + if (event.target.closest('[data-role="tour-exit"]')) this.#tour.onExit?.(); }); } toggle(next = !this.#shown) { + if (this.#tour) return; this.#shown = next; localStorage.setItem('deck.keys', next ? 'shown' : 'hidden'); this.render(); } render() { + if (this.#tour) { + let t = this.#tour; + this.innerHTML = + `
      ` + + `
      ` + + `` + + `` + + `` + + `ch ${t.chapterIndex}/${t.chapters} ` + + `${esc(t.chapter)}` + + `${t.step}/${t.steps}` + + `` + + `` + + `
      ` + + (t.claim + ? `
      ` + + (t.label + ? `${esc(t.label)}` + : '') + + `${esc(t.claim)}
      ` + : '') + + `
      `; + return; + } if (!this.#shown) { this.innerHTML = `
      ` + @@ -738,8 +894,51 @@ export const NAV_CSS = ` padding: calc(var(--row, 1.5) * .5em) var(--gut, 2ch); background: var(--bg); border-top: 1px solid var(--line); font-size: 12px; + z-index: 40; } .deck-keys[data-collapsed] { border-top-color: transparent; background: none; } +.deck-keys[data-tour="1"] { + flex-direction: column; + align-items: stretch; + gap: .45em; + z-index: 1000000010; /* above driver.js overlay so the claim stays readable */ + border-top: 2px solid var(--a-live, #df8e1d); + background: color-mix(in srgb, var(--a-live, #df8e1d) 14%, var(--bg)); + box-shadow: 0 -8px 24px color-mix(in srgb, var(--a-live, #df8e1d) 18%, transparent); +} +.deck-keys[data-tour="1"] .deck-tour-row { + display: flex; flex-wrap: wrap; align-items: baseline; gap: .2em 2.5ch; +} +.deck-keys[data-tour="1"] .deck-tour-chapter b { + color: var(--a-live, #df8e1d); + font-weight: 700; + letter-spacing: .04em; +} +.deck-keys[data-tour="1"] .deck-tour-claim { + display: flex; flex-wrap: wrap; align-items: baseline; gap: .35em 1.2ch; + font-size: 13.5px; + line-height: 1.4; + max-width: 96ch; +} +.deck-keys[data-tour="1"] .deck-tour-label { + flex: 0 0 auto; + font-family: var(--mono, monospace); + font-size: 11px; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--a-live, #df8e1d); + opacity: .9; +} +.deck-keys[data-tour="1"] .deck-tour-claim b { + flex: 1 1 36ch; + min-width: 0; + font-weight: 500; + letter-spacing: .01em; + white-space: normal; +} +.deck-keys[data-tour="1"] button:disabled { + opacity: .35; cursor: default; +} .deck-keys-item, .deck-keys-peek { display: inline-flex; align-items: baseline; gap: .6ch; background: none; border: 0; padding: 0; font: inherit; color: inherit; @@ -772,12 +971,57 @@ export function wrapTables(root = document) { } } +/** + * Boot status (`#boot`) is hidden by default (see HTML + boot.js). This only + * dismisses it once `#app` has real content — it must never re-show the + * message after a View Transition, or every Hub/tour navigation flashes + * "connecting…". + */ +function installBoot(doc = document) { + let boot = doc.getElementById('boot'); + let app = doc.getElementById('app'); + if (!boot || !app || boot.dataset.deckBoot === '1') return; + boot.dataset.deckBoot = '1'; + + let appReady = () => { + let text = (app.textContent || '').trim(); + return app.childElementCount > 0 || (text.length > 0 && text !== 'loading…'); + }; + let dismiss = () => { + boot.hidden = true; + boot.dataset.dismissed = '1'; + app.removeAttribute('aria-busy'); + }; + let sync = () => { + if (appReady()) dismiss(); + }; + + addEventListener('pagereveal', (event) => { + let vt = event.viewTransition; + if (!vt) return; + boot.hidden = true; + // Stay hidden after the morph — boot.js owns the slow-load reveal. + vt.finished.finally(() => { + if (appReady()) dismiss(); + else boot.hidden = true; + }); + }); + + new MutationObserver(sync).observe(app, { + childList: true, + characterData: true, + subtree: true, + }); + sync(); +} + export function installNavStyles(doc = document) { - if (doc.querySelector('style[data-deck-nav]')) { - return; - } + let existing = doc.querySelector('style[data-deck-nav]'); + if (existing?.dataset.deckNav === 'v5') return; + existing?.remove(); let style = doc.createElement('style'); - style.dataset.deckNav = 'v1'; + style.dataset.deckNav = 'v5'; style.textContent = NAV_CSS; doc.head.append(style); + installBoot(doc); } diff --git a/apps/hub/hub/ui/nav/package.json b/apps/hub/hub/ui/nav/package.json new file mode 100644 index 0000000..acf5221 --- /dev/null +++ b/apps/hub/hub/ui/nav/package.json @@ -0,0 +1,6 @@ +{ + "name": "nav", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./nav.js" } +} diff --git a/apps/hub/hub/ui/theme/boot.css b/apps/hub/hub/ui/theme/boot.css new file mode 100644 index 0000000..82e3a5a --- /dev/null +++ b/apps/hub/hub/ui/theme/boot.css @@ -0,0 +1,37 @@ +/* Critical shell colours — linked in before any module runs. + * + * Cross-document View Transitions snapshot the NEW document as soon as it + * exists. If `--bg` is still unset (palette.css arrives via JS), the + * viewport is browser-white and the default root crossfade shows it. + * These fallbacks match the three Hub palettes / applyTheme() defaults. + */ + +:root { + --hub-flash: #1e1e2e; /* mocha — Hub default */ + color-scheme: dark; +} + +html[data-theme='mocha'] { + --hub-flash: #1e1e2e; + color-scheme: dark; +} +html[data-theme='latte'] { + --hub-flash: #eff1f5; + color-scheme: light; +} +html[data-theme='tokyo-night-light'] { + --hub-flash: #e1e2e7; + color-scheme: light; +} + +html, +body, +#app { + background-color: var(--bg, var(--hub-flash)); +} + +/* The VT layer sits above the document. Without a fill, opacity crossfades + of ::view-transition-old/new(root) punch through to white. */ +::view-transition { + background-color: var(--bg, var(--hub-flash)); +} diff --git a/apps/hub/hub/ui/theme/boot.js b/apps/hub/hub/ui/theme/boot.js new file mode 100644 index 0000000..378a350 --- /dev/null +++ b/apps/hub/hub/ui/theme/boot.js @@ -0,0 +1,52 @@ +// Sync, classic script in — runs before first paint / VT snapshot. +// Mirrors applyTheme() defaults in ui/nav so the palette link exists before +// the module graph. Must stay non-module so it is not deferred. +// +// `#boot` stays hidden in HTML on purpose. Showing "connecting…" on every MPA +// navigation painted a diagnostic into the View Transition and the tour. +// Only reveal after a real stall — never on the happy path. +(function () { + var KEY = 'deck.theme'; + var known = { mocha: 1, latte: 1, 'tokyo-night-light': 1 }; + var SLOW_MS = 1600; + var name = null; + try { + name = localStorage.getItem(KEY); + } catch (_) { + /* private mode */ + } + if (!known[name]) { + name = 'mocha'; + } + document.documentElement.dataset.theme = name; + if (!document.querySelector('link[data-role="palette"]')) { + var link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/hub/theme/' + name + '/palette.css'; + link.dataset.role = 'palette'; + document.head.appendChild(link); + } + + function appHasContent() { + var app = document.getElementById('app'); + if (!app) return true; + var text = (app.textContent || '').trim(); + return app.childElementCount > 0 || (text.length > 0 && text !== 'loading…'); + } + + function armSlowBoot() { + var boot = document.getElementById('boot'); + if (!boot) return; + boot.hidden = true; + setTimeout(function () { + if (appHasContent() || boot.dataset.dismissed === '1') return; + boot.hidden = false; + }, SLOW_MS); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', armSlowBoot); + } else { + armSlowBoot(); + } +})(); diff --git a/apps/hub/hub/ui/theme/importmap.json b/apps/hub/hub/ui/theme/importmap.json deleted file mode 100644 index 0e34069..0000000 --- a/apps/hub/hub/ui/theme/importmap.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "deck": { - "packages": { - "theme": { - "version": "0.1.0" - } - } - } -} diff --git a/apps/hub/hub/ui/theme/package.json b/apps/hub/hub/ui/theme/package.json new file mode 100644 index 0000000..fb5a64c --- /dev/null +++ b/apps/hub/hub/ui/theme/package.json @@ -0,0 +1,4 @@ +{ + "name": "theme", + "version": "0.1.0" +} diff --git a/apps/hub/hub/ui/theme/theme.css b/apps/hub/hub/ui/theme/theme.css index d35f614..c6ee45f 100644 --- a/apps/hub/hub/ui/theme/theme.css +++ b/apps/hub/hub/ui/theme/theme.css @@ -68,10 +68,89 @@ a.s-file { color: var(--fg); } * { box-sizing: border-box; } ::selection { background: var(--sel); } +/* ── Cross-document View Transitions (MPA tab strip) ───────────────────── + Opt-in `@view-transition { navigation: auto }` lives inline in each app's + HTML so the browser can start the transition before the stylesheet lands. + Names + nested groups live here so every page shares one chrome map. + + NO FADES. The UA default fades old↓ and new↑; at mid-transition the page + dims (and flashes white). Goal: the outgoing page (browse, run, …) stays + fully opaque on screen until the next document is ready, then cuts. */ +@media (prefers-reduced-motion: no-preference) { + body { + view-transition-name: hub-shell; + } + deck-nav { + view-transition-name: hub-nav; + view-transition-group: hub-shell; + } + deck-subject { + view-transition-name: hub-subject; + view-transition-group: hub-shell; + } + #app { + view-transition-name: hub-main; + view-transition-group: hub-shell; + } + deck-keys { + view-transition-name: hub-keys; + view-transition-group: hub-shell; + } + .hub-boot { + view-transition-name: none; + } + + /* Instant cut — no morph timing that could interpolate opacity/geometry. */ + ::view-transition-group(*) { + animation-duration: 0s; + animation-timing-function: step-end; + } + + /* Outgoing snapshot stays full strength; incoming stays invisible until the + cut. Never run the UA fade keyframes. */ + ::view-transition-old(*) { + animation: none !important; + opacity: 1 !important; + mix-blend-mode: normal; + z-index: 2; + } + ::view-transition-new(*) { + animation: none !important; + opacity: 0 !important; + mix-blend-mode: normal; + z-index: 1; + } +} + +/* Hold the main region's height so an empty `#app` during a view transition + does not collapse the shell (nav + keys stay put). */ +#app { + min-height: 50vh; +} + +/* Outside every named transition group — corner status, not a page flash. */ +.hub-boot { + position: fixed; + left: var(--gut); + bottom: 3.6em; + z-index: 4; + margin: 0; + color: var(--dim); + pointer-events: none; + letter-spacing: 0.04em; +} +.hub-boot[hidden] { + display: none; +} + +html { + background-color: var(--bg, var(--hub-flash, #1e1e2e)); +} + body { margin: 0; padding: 0 var(--gut) 6em; - background: var(--bg); + background: var(--bg, var(--hub-flash, #1e1e2e)); color: var(--fg); font-family: var(--mono); font-size: 13px; @@ -245,7 +324,13 @@ pre.blob:not(.diff) .ln::before { text-align: right; color: var(--dim); position: sticky; left: 0; background: var(--bg); } -pre.blob .ln:target { background: var(--sel); } +pre.blob a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: var(--line); +} +pre.blob a:hover { text-decoration-color: currentColor; } /* ── syntax ───────────────────────────────────────────────────────── Our own mapping of highlight.js classes onto the palette, rather than @@ -504,6 +589,13 @@ td.indent { padding-left: 2ch; } .sortbar label { color: var(--dim); margin-right: .6ch; } .feed { list-style: none; margin: 0; padding: 0; } +.feed-more { + text-align: center; + padding: calc(var(--row) * .8em) 0 calc(var(--row) * 1.2em); + letter-spacing: .06em; +} +.feed-more[data-state='loading'], +.feed-more[data-state='fetch'] { color: var(--a-live, var(--amber)); } /* RECENCY IS BRIGHTNESS. `--fresh` runs 1 → 0 over a day, and the entry fades with it, so "something just happened" reads from across the room and stops @@ -770,6 +862,40 @@ td.indent { padding-left: 2ch; } .pick li a { color: var(--a-package); } .pick li .dim { padding-left: 1.5ch; } +/* A library is executable context, not an error. Run follows the reverse + import edge to the page where the package becomes visible, then makes the + selected Version a no-store trial. */ +.use-view { max-width: 90rem; } +.use-explainer { + max-width: 76ch; + margin: calc(var(--row) * .8em) 0 calc(var(--row) * 1.2em); + padding-left: var(--gut); + border-left: 2px solid var(--blue); +} +.use-explainer h1 { + margin: 0 0 calc(var(--row) * .25em); + color: var(--fg); + font-size: 1rem; +} +.use-explainer p { margin: calc(var(--row) * .35em) 0; } +.use-flow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 1ch; + margin: calc(var(--row) * .7em) 0; +} +.use-flow span, .use-flow strong { + padding: .25em .7em; + border: 1px solid var(--line); + background: var(--panel); + font-weight: 500; +} +.use-flow strong { color: var(--a-package); } +.use-flow i { color: var(--dim); font-style: normal; } +.run-action { white-space: nowrap; font-weight: 600; } +.use-fallback { margin-top: calc(var(--row) * .8em); } + /* The set-aside control and its way back. Both are quiet: the band already has the reader's attention, and a loud dismiss button competes with the thing it is dismissing. */ @@ -786,3 +912,186 @@ td.indent { padding-left: 2ch; } color: var(--fg); border-color: var(--dim); } button.aside { margin-left: 1.5ch; align-self: center; } + +/* ── repository collaboration ─────────────────────────────────────────── */ +.repository { max-width: 112ch; margin: 0 auto; padding-top: calc(var(--row) * .8em); } +.repo-title { display: flex; align-items: baseline; gap: .2ch; font-size: 1.15em; } +.repo-title strong { font-size: 1em; } +.visibility, .status { display: inline-block; border: 1px solid var(--line); border-radius: 999px; padding: 0 .8ch; font-size: .9em; line-height: 1.6; } +.visibility { color: var(--dim); margin-left: 1ch; } +.repo-tabs { display: flex; gap: 3ch; margin: calc(var(--row) * .7em) 0 0; border-bottom: 1px solid var(--line); overflow-x: auto; } +.repo-tabs a { padding: calc(var(--row) * .35em) 0; white-space: nowrap; } +.repo-tabs a[aria-current='page'] { color: var(--fg); border-bottom: 2px solid var(--orange); } +.repo-section { padding: calc(var(--row) * 1.1em) 0; border-bottom: 1px solid var(--line); } +.repo-section > header { display: flex; justify-content: space-between; align-items: center; gap: 2ch; margin-bottom: calc(var(--row) * .7em); } +.repo-section > header > div { display: flex; align-items: baseline; gap: 1.5ch; } +.repo-section h1 { color: var(--fg); font-weight: 600; margin: 0; } +.section-index { color: var(--dim); } +.repo-table { border-collapse: collapse; width: 100%; } +.repo-table th { text-align: left; border-bottom: 1px solid var(--fg); padding: .35em 2ch .35em 0; } +.repo-table td { border-bottom: 1px solid var(--line); padding: .6em 2ch .6em 0; } +.repo-table tr:last-child td { border-bottom: 0; } +.branch-mark { color: var(--green); margin-right: .7ch; } +.branch-mark.branch-feature { color: var(--a-publisher); } +.status-main { color: var(--dim); } +.status-open { color: var(--green); border-color: color-mix(in srgb, var(--green) 55%, var(--line)); } +.status-merged { color: var(--violet); border-color: color-mix(in srgb, var(--violet) 55%, var(--line)); } +.section-note { color: var(--dim); margin: calc(var(--row) * .5em) 0 0; max-width: 78ch; } +.section-note strong { color: var(--fg); font-weight: 400; } +.action { display: inline-block; font: inherit; color: var(--fg); background: var(--panel); border: 1px solid var(--line); border-radius: 2px; padding: .35em 1.2ch; cursor: pointer; } +.action:hover, .action:focus-visible { background: var(--sel); text-decoration: none; } +.action.primary { color: var(--bg); background: var(--green); border-color: var(--green); font-weight: 600; } +.action.primary:hover { filter: brightness(1.08); } +.action.quiet { color: var(--dim); background: transparent; } +.action:disabled { opacity: .55; cursor: not-allowed; } +.pull-summary { border-left: 2px solid var(--green); padding-left: 2ch; } +.pull-summary:has(.status-merged) { border-left-color: var(--violet); } +.pull-heading { display: flex; align-items: flex-start; gap: 1.5ch; } +.pull-heading strong { font-size: 1.08em; } +.pull-heading p { margin: .25em 0 0; color: var(--dim); } +.merge-line { display: flex; gap: 1ch; align-items: center; margin: calc(var(--row) * .8em) 0; color: var(--dim); } +.merge-line code { color: var(--fg); background: var(--panel); padding: .1em .7ch; } +.handoff { color: var(--a-package); } +.handoffs { display: flex; align-items: center; flex-wrap: wrap; gap: 2ch; margin-top: calc(var(--row) * .8em); } +.empty-pull { color: var(--dim); border-left: 2px solid var(--line); padding-left: 2ch; } +.protocol-object { border-top: 1px solid var(--line); } +.protocol-object:last-child { border-bottom: 1px solid var(--line); } +.protocol-object summary { display: grid; grid-template-columns: 25ch minmax(0, 1fr); gap: 2ch; padding: .6em 0; cursor: pointer; } +.protocol-object summary:hover { background: var(--panel); } +.protocol-object summary code { color: var(--dim); overflow-wrap: anywhere; } +.protocol-object pre { margin: 0 0 calc(var(--row) * .7em); padding: 1em 2ch; background: var(--panel); overflow: auto; } +@media (max-width: 44rem) { + .repository { max-width: none; } + .protocol-object summary { grid-template-columns: 1fr; gap: 0; } + .repo-section > header { align-items: flex-start; } +} + +/* Branches are an address choice, not a second application inside Browse. */ +.repo-context { position: relative; z-index: 4; display: flex; align-items: center; justify-content: space-between; gap: 2ch; min-height: 2.4rem; padding-bottom: .65rem; margin-bottom: .65rem; border-bottom: 1px solid var(--line); } +.repo-identity, .repo-controls { display: flex; align-items: center; gap: .7ch; min-width: 0; } +.repo-switcher { position: relative; } +.repo-switcher > summary { list-style: none; cursor: pointer; padding: .35em .8ch; border: 1px solid var(--line); background: var(--bg); color: var(--text); white-space: nowrap; } +.repo-switcher > summary::-webkit-details-marker { display: none; } +.repo-switcher[open] > summary { background: var(--panel); } +.repo-menu { position: absolute; right: 0; top: calc(100% + .35rem); min-width: 17rem; padding: .35rem; border: 1px solid var(--line-strong); background: var(--bg); box-shadow: 0 1rem 2.5rem rgb(0 0 0 / .35); } +.repo-option { display: flex; justify-content: space-between; gap: 2ch; padding: .45rem .6rem; color: var(--text); } +.repo-option:hover, .repo-option.selected { background: var(--panel); } +.repo-option code { color: var(--dim); } +.repo-empty { display: block; padding: .45rem .6rem; color: var(--dim); } +.repo-menu-new { margin-top: .35rem; padding-top: .35rem; border-top: 1px solid var(--line); } +.repo-menu-new b { display: block; padding: .25rem .6rem; color: var(--dim); font-size: .85em; font-weight: 500; } +.repo-menu-new button { display: block; width: 100%; padding: .45rem .6rem; border: 0; background: transparent; color: var(--text); text-align: left; font: inherit; cursor: pointer; } +.repo-menu-new button:hover { background: var(--panel); } +.repo-review { color: var(--accent); white-space: nowrap; } +.repo-new { border: 1px solid var(--line-strong); padding: .4em 1ch; background: var(--panel); color: var(--text); font: inherit; cursor: pointer; } +.repo-new.primary { border-color: var(--accent); color: var(--accent); } +.repo-context-readonly .visibility { margin-right: auto; } +.repo-action-dialog { width: min(31rem, calc(100vw - 2rem)); padding: 0; border: 1px solid var(--line-strong); background: var(--bg); color: var(--text); box-shadow: 0 1.5rem 4rem rgb(0 0 0 / .45); } +.repo-action-dialog::backdrop { background: rgb(20 22 30 / .55); backdrop-filter: blur(2px); } +.repo-action-dialog form { display: grid; gap: 1rem; padding: 1.1rem; } +.repo-action-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.repo-action-dialog header > div { display: flex; align-items: baseline; gap: .75rem; } +.repo-action-dialog h2 { margin: 0; font-size: 1rem; font-weight: 650; } +.repo-dialog-close { border: 0; background: transparent; color: var(--dim); font: inherit; font-size: 1.25rem; cursor: pointer; } +.repo-action-dialog label { display: grid; gap: .4rem; color: var(--dim); font-size: .85rem; } +.repo-action-dialog input { width: 100%; box-sizing: border-box; padding: .65rem .7rem; border: 1px solid var(--line-strong); background: var(--panel); color: var(--text); font: inherit; } +.repo-action-dialog input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } +.repo-action-dialog p { margin: 0; } +.repo-dialog-error:empty { display: none; } +.repo-action-dialog footer { display: flex; justify-content: flex-end; gap: .5rem; padding-top: .2rem; } +@media (max-width: 44rem) { + .repo-context { align-items: flex-start; } + .repo-identity { flex-wrap: wrap; } + .repo-menu { right: 0; min-width: min(17rem, 82vw); } +} +.review-context { padding-bottom: calc(var(--row) * .7em); border-bottom: 1px solid var(--line); } + +/* Reviews are an inbox first and a document second. A reviewer should be able + to triage a working day's queue without scrolling through one giant card. */ +.review-page { max-width: 128ch; } +.review-inbox { padding-top: calc(var(--row) * .65em); } +.queue-actions, .queue-filters { display: flex; align-items: center; gap: .5ch; } +.queue-filters { padding: .2em; border: 1px solid var(--line); background: var(--panel); } +.queue-filters button { + border: 0; padding: .22em .8ch; background: transparent; color: var(--dim); + font: inherit; cursor: pointer; +} +.queue-filters button[aria-pressed='true'] { background: var(--bg); color: var(--fg); } +.queue-filters b { color: inherit; font-weight: 500; } +.queue-summary { color: var(--dim); font-size: .88em; } +.review-queue { border-top: 1px solid var(--line); } +.review-queue-row { + display: grid; grid-template-columns: 16ch minmax(28ch, 1fr) 14ch 15ch 2ch; + align-items: center; gap: 1.5ch; width: 100%; min-height: 3.15rem; + padding: .42rem .75rem; border: 0; border-bottom: 1px solid var(--line); + border-left: 2px solid transparent; background: transparent; color: var(--fg); + text-align: left; font: inherit; cursor: pointer; +} +.review-queue-row:hover { background: var(--panel); } +.review-queue-row[aria-current='true'] { border-left-color: var(--orange); background: var(--sel); } +.queue-status { + display: inline-block; justify-self: start; padding: .08em .65ch; + border: 1px solid var(--line); border-radius: 999px; font-size: .8em; + white-space: nowrap; +} +.queue-status-approved { color: var(--green); border-color: color-mix(in srgb, var(--green) 55%, var(--line)); } +.queue-status-changes { color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, var(--line)); } +.queue-status-waiting, .queue-status-closed { color: var(--dim); } +.queue-status-merged { color: var(--violet); border-color: color-mix(in srgb, var(--violet) 55%, var(--line)); } +.queue-title { display: grid; min-width: 0; } +.queue-title b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; } +.queue-title small { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queue-author, .review-queue-row time { color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queue-open { color: var(--dim); } +.review-detail { padding-top: calc(var(--row) * .7em); } +.review-detail .pull-summary { padding-left: 1.3ch; } +.review-detail .pull-heading { align-items: baseline; } +.review-detail .pull-heading p { max-width: 78ch; } +.review-meta { + display: flex; align-items: center; flex-wrap: wrap; gap: .6ch 1ch; + margin: .55rem 0 .7rem; color: var(--dim); font-size: .9em; +} +.review-meta code { color: var(--fg); background: var(--panel); padding: .05em .5ch; } +.review-detail-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(26ch, 2fr); gap: 3ch; } +.review-detail-grid h2 { + margin: 0 0 .35rem; color: var(--dim); font-size: .78em; font-weight: 500; + letter-spacing: .12em; text-transform: uppercase; +} +.review-changes { border-top: 1px solid var(--line); } +.review-changes a { + display: flex; justify-content: space-between; gap: 2ch; + padding: .42rem .5rem; border-bottom: 1px solid var(--line); +} +.review-changes a:hover { background: var(--panel); text-decoration: none; } +.review-changes a > span:last-child { white-space: nowrap; } +.review-events, .review-history { margin: 0; padding: 0; list-style: none; } +.review-events { border-top: 1px solid var(--line); } +.review-events li { + display: grid; grid-template-columns: 15ch minmax(0, 1fr) auto; gap: 1ch; + padding: .42rem .5rem; border-bottom: 1px solid var(--line); font-size: .88em; +} +.review-events b { font-weight: 550; } +.review-events span, .review-events time { color: var(--dim); } +.review-events time { white-space: nowrap; } +.review-history { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 3ch; } +.review-history li { + display: grid; grid-template-columns: 13ch minmax(0, 1fr); gap: 1ch; + padding: .42rem 0; border-bottom: 1px solid var(--line); font-size: .9em; +} +.review-history li code { color: var(--dim); } +.review-history li strong { font-weight: 500; } +@media (max-width: 62rem) { + .review-queue-row { grid-template-columns: 14ch minmax(20ch, 1fr) 12ch 2ch; } + .review-queue-row time { display: none; } + .review-detail-grid, .review-history { grid-template-columns: 1fr; } +} +@media (max-width: 44rem) { + .review-inbox > header { flex-direction: column; } + .queue-actions { width: 100%; justify-content: space-between; } + .queue-summary, .queue-author { display: none; } + .review-queue-row { grid-template-columns: 15ch minmax(0, 1fr) 2ch; padding-inline: .35rem; } + .review-detail .pull-heading { display: grid; grid-template-columns: 15ch minmax(0, 1fr); align-items: start; } + .review-detail .pull-heading .status { justify-self: start; } + .review-events li { grid-template-columns: 13ch minmax(0, 1fr); } + .review-events time { display: none; } +} diff --git a/apps/hub/hub/ui/timeline/importmap.json b/apps/hub/hub/ui/timeline/importmap.json deleted file mode 100644 index b00573c..0000000 --- a/apps/hub/hub/ui/timeline/importmap.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "deck": { - "packages": { - "timeline": { - "version": "0.1.0", - "entry": "$DECK/timeline.js" - } - } - } -} diff --git a/apps/hub/hub/ui/tour/package.json b/apps/hub/hub/ui/tour/package.json new file mode 100644 index 0000000..1d8104d --- /dev/null +++ b/apps/hub/hub/ui/tour/package.json @@ -0,0 +1,9 @@ +{ + "name": "tour", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./tour.js" }, + "dependencies": { + "driver.js": "1.8.0" + } +} diff --git a/apps/hub/hub/ui/tour/tour.js b/apps/hub/hub/ui/tour/tour.js new file mode 100644 index 0000000..c5c2aa5 --- /dev/null +++ b/apps/hub/hub/ui/tour/tour.js @@ -0,0 +1,642 @@ +// ui/tour — Hub protocol stories (driver.js highlight + bottom-bar caption). +// +// Implements docs/deck-hub-tour.md. The exhibit stays visible: driver.js +// cuts a stage and paints a theme-coloured ring. The story lives on +// . Next / Prev / Exit are the bar (and ← → esc). +// +// Beats may `prepare` a real interaction: list-browse, open volatility, +// scrub History, scroll a feed, expand a review fold — show, don't narrate. +import { driver } from 'driver.js'; + +const STORAGE = 'deck.hub.tour'; +const TOUR_PARAM = 'tour'; + +/** @typedef {'none' | 'list-browse' | 'dropdown' | 'scroll' | 'scrub'} Interaction */ +/** @typedef {'open-vol' | 'expand-review' | 'scroll-feed' | 'list-browse' | 'scrub-history'} Prepare */ + +/** @typedef {{ + * chapter: number, + * title: string, + * page: string, + * element?: string, + * go: string, + * label: string, + * claim: string, + * interaction?: Interaction, + * prepare?: Prepare, + * }} TourStep */ + +/** Four stories — one use case each. Live paths over brittle @pins. */ +const CHAPTERS = [ + { + title: 'Eight desks, one board', + steps: /** @type {TourStep[]} */ ([ + { + chapter: 1, + title: 'Eight desks, one board', + page: 'browse', + element: 'main table tbody, main table, main', + go: '/browse?at=/two-agents', + label: 'catalog', + claim: + 'Each row is a publisher’s desk — agent-a, charts, ui, app. The board is not a branch; it is this list, composed at load.', + interaction: 'list-browse', + prepare: 'list-browse', + }, + { + chapter: 1, + title: 'Eight desks, one board', + page: 'run', + element: '[data-tour="stage"], [data-role="stage"], .stagewrap', + go: '/run?at=/two-agents/app/board/index.html', + label: 'board', + claim: + 'Review the swarm by watching the board. Pins across those desks are the composition — no integration step, because there was never a disintegration.', + }, + { + chapter: 1, + title: 'Eight desks, one board', + page: 'run', + element: '[data-tour="graph"], .runsplit-under', + go: '/run?at=/two-agents/app/board/index.html', + label: 'resolved map', + claim: + 'The resolved map is the merge. Freeze one pin and you quarantine one agent; the rest keep moving.', + }, + { + chapter: 1, + title: 'Eight desks, one board', + page: 'track', + element: '[data-tour="feed"], ul.feed, main', + go: '/track?at=/two-agents', + label: 'feed', + claim: + 'Every agent save is already a row with an address. The assembled thing existed the whole time those rows were written.', + }, + ]), + }, + { + title: 'Two currencies land in the same minute', + steps: /** @type {TourStep[]} */ ([ + { + chapter: 2, + title: 'Two currencies land in the same minute', + page: 'run', + element: '[data-tour="stage"], [data-role="stage"], .stagewrap, main', + go: '/run?at=/codelist/app/viewer/index.html', + label: 'exchange board', + claim: + 'A live exchange board, not a JSON table. When amendments land, rows appear and the count moves — a merge you can see from across the room.', + }, + { + chapter: 2, + title: 'Two currencies land in the same minute', + page: 'browse', + element: '[data-tour="vol"], select[data-role="vol"], .vol', + go: '/browse?at=/codelist/std/currencies@latest', + label: 'version', + claim: + 'Open the version control. @1.4.0 and @1.5.0 are immutable cuts; latest is a tag on a Version — never “whatever is on disk.”', + interaction: 'dropdown', + prepare: 'open-vol', + }, + { + chapter: 2, + title: 'Two currencies land in the same minute', + page: 'browse', + element: 'deck-subject, [data-tour="facts"], main', + go: '/browse?at=/codelist/std/currencies@1.5.0', + label: 'frozen cut', + claim: + 'Only the @segment moved. Same package path, frozen bytes forever. Consumers choose exposure per list.', + }, + { + chapter: 2, + title: 'Two currencies land in the same minute', + page: 'track', + element: '[data-tour="feed"], ul.feed, main', + go: '/track?at=/codelist', + label: 'feed', + claim: + 'Amendments show up as published rows when they seal. Publishing ceremony is not what forces every team to keep a patched copy.', + }, + ]), + }, + { + title: 'Try it before you take it', + steps: /** @type {TourStep[]} */ ([ + { + chapter: 3, + title: 'Try it before you take it', + page: 'run', + element: '[data-tour="stage"], [data-role="stage"], .stagewrap, main', + go: '/run?at=/contribute-to-codelist/app/picker/index.html', + label: 'picker', + claim: + 'Ana and Kim each patched the list. Side-by-side boards — “Try this one” is ?with= for one request, not a checkout.', + }, + { + chapter: 3, + title: 'Try it before you take it', + page: 'run', + element: '[data-tour="graph"], .runsplit-under, main', + go: '/run?at=/contribute-to-codelist/app/picker/index.html&with=std/currencies=ana/currencies', + label: 'trial map', + claim: + 'Only the rebound specifier moves. Everything else stays on its pins. A proposal is already a runnable URL.', + }, + { + chapter: 3, + title: 'Try it before you take it', + page: 'browse', + element: 'section.review, [data-role="fold"], main', + go: + '/browse?at=/contribute-to-codelist/ana/currencies@1.5.0-dev.1&vs=/contribute-to-codelist/std/currencies@1.4.0', + label: 'fork diff', + claim: + 'Publisher segment is the fork boundary. Expand the row: key-level truth, not a line conflict over two different additions.', + interaction: 'list-browse', + prepare: 'expand-review', + }, + ]), + }, + { + title: 'The hour you abandoned is still an address', + steps: /** @type {TourStep[]} */ ([ + { + chapter: 4, + title: 'The hour you abandoned is still an address', + page: 'browse', + element: '[data-tour="vol"], select[data-role="vol"], .vol', + go: '/browse?at=/vale-sale/vale/sale', + label: 'versions', + claim: + 'A real page, auto-published as -dev.N on every save. Open the stack — the interesting states live between releases.', + interaction: 'dropdown', + prepare: 'open-vol', + }, + { + chapter: 4, + title: 'The hour you abandoned is still an address', + page: 'run', + element: '[data-tour="stage"], [data-role="stage"], .stagewrap', + go: '/run?at=/vale-sale/vale/sale/index.html', + label: 'sale', + claim: + 'Past is not a backup — it is a URL you can load in this frame. Watch the page; History is underneath.', + }, + { + chapter: 4, + title: 'The hour you abandoned is still an address', + page: 'run', + element: '[data-role="history"], .tl', + go: '/run?at=/vale-sale/vale/sale/index.html', + label: 'history', + claim: + 'Scrub back one Step. The iframe follows /_history//…. Going back is reading and running an address.', + interaction: 'scrub', + prepare: 'scrub-history', + }, + { + chapter: 4, + title: 'The hour you abandoned is still an address', + page: 'track', + element: '[data-tour="feed"], ul.feed, main', + go: '/track?at=/vale-sale', + label: 'saved rows', + claim: + 'Saved rows deep-link into browse. The hour you abandon remains in History too — in case you were wrong.', + interaction: 'scroll', + prepare: 'scroll-feed', + }, + ]), + }, +]; + +const SCRIPT = CHAPTERS.flatMap((ch) => ch.steps); + +function ensureCss() { + if (document.querySelector('link[data-driver-css]')) return; + let href; + try { + href = import.meta.resolve('driver.js/dist/driver.css'); + } catch { + href = undefined; + } + if (!href) return; + let link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.dataset.driverCss = '1'; + document.head.appendChild(link); +} + +function ensureTourCss() { + let existing = document.querySelector('style[data-deck-tour-css]'); + if (existing) { + existing.textContent = TOUR_CSS; + return; + } + let style = document.createElement('style'); + style.dataset.deckTourCss = '1'; + style.textContent = TOUR_CSS; + document.head.appendChild(style); +} + +function loadState() { + try { + let raw = sessionStorage.getItem(STORAGE); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function saveState(state) { + if (!state || !state.active) { + sessionStorage.removeItem(STORAGE); + return; + } + sessionStorage.setItem(STORAGE, JSON.stringify(state)); +} + +function clearTourQuery() { + let url = new URL(location.href); + if (!url.searchParams.has(TOUR_PARAM)) return; + url.searchParams.delete(TOUR_PARAM); + history.replaceState(null, '', url); +} + +function withTourParam(href) { + let url = new URL(href, location.origin); + url.searchParams.set(TOUR_PARAM, '1'); + return url.pathname + url.search + url.hash; +} + +function currentApp() { + return document.querySelector('deck-nav')?.getAttribute('app') ?? ''; +} + +function firstSelector(selector) { + if (!selector) return undefined; + for (let part of selector.split(',').map((s) => s.trim())) { + if (document.querySelector(part)) return part; + } + return selector.split(',')[0]?.trim(); +} + +function chapterMeta(absoluteIndex) { + let step = SCRIPT[absoluteIndex]; + if (!step) return null; + let chapterSteps = CHAPTERS[step.chapter - 1].steps; + let stepInChapter = chapterSteps.indexOf(step) + 1; + return { + chapter: step.title, + chapterIndex: step.chapter, + chapters: CHAPTERS.length, + step: stepInChapter, + steps: chapterSteps.length, + label: step.label, + claim: step.claim, + }; +} + +function keysEl() { + return document.querySelector('deck-keys'); +} + +function paintTourBar(absoluteIndex) { + let meta = chapterMeta(absoluteIndex); + let el = keysEl(); + if (!el || !meta) return; + el.tour = { + ...meta, + onPrev: () => tourPrev(), + onNext: () => tourNext(), + onExit: () => abandonTour(), + }; +} + +function clearTourBar() { + let el = keysEl(); + if (el) el.tour = null; +} + +let activeDriver = null; +let tearingDown = false; +let cursor = 0; + +function destroyActive() { + cleanupPrepare(); + if (!activeDriver) return; + tearingDown = true; + try { + activeDriver.destroy(); + } finally { + tearingDown = false; + activeDriver = null; + } +} + +function finish() { + saveState(null); + clearTourQuery(); + clearTourBar(); + destroyActive(); +} + +function abandonTour() { + finish(); +} + +function revealExhibit(selector) { + if (!selector) return; + let el = document.querySelector(selector); + if (!el || typeof el.scrollIntoView !== 'function') return; + el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); +} + +/** Undo transient UI the prepare helpers opened. */ +function cleanupPrepare() { + let sel = document.querySelector('select[data-role="vol"][data-tour-open="1"]'); + if (sel) { + sel.size = 1; + delete sel.dataset.tourOpen; + sel.blur(); + } + for (let row of document.querySelectorAll('tbody tr[data-tour-pick="1"]')) { + delete row.dataset.tourPick; + if (row.dataset.here === 'tour') delete row.dataset.here; + } +} + +/** + * Exercise the interaction named on the step — dropdown open, feed scroll, + * catalog row pick, review fold expand. Fail soft if the exhibit is thin. + * @param {Prepare | undefined} kind + */ +function runPrepare(kind) { + cleanupPrepare(); + if (!kind) return; + if (kind === 'open-vol') { + let sel = document.querySelector('select[data-role="vol"]'); + if (!sel) return; + sel.focus(); + let n = Math.min(sel.options.length, 7); + if (n > 1) { + sel.size = n; + sel.dataset.tourOpen = '1'; + } + return; + } + if (kind === 'scroll-feed') { + let feed = + document.querySelector('ul.feed.attention') || + document.querySelector('[data-tour="feed"]') || + document.querySelector('ul.feed'); + if (!feed) return; + let entries = [...feed.querySelectorAll('li.entry')]; + if (!entries.length) { + feed.scrollIntoView({ block: 'start', behavior: 'smooth' }); + return; + } + let target = + entries[Math.min(entries.length - 1, Math.max(1, Math.floor(entries.length * 0.55)))]; + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return; + } + if (kind === 'list-browse') { + let rows = [...document.querySelectorAll('main table tbody tr')]; + if (!rows.length) return; + let preferred = + rows.find((row) => /agent-a|charts|ana\b|kim\b/i.test(row.textContent || '')) || + rows[Math.min(1, rows.length - 1)]; + preferred.dataset.tourPick = '1'; + preferred.dataset.here = preferred.dataset.here || 'tour'; + preferred.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return; + } + if (kind === 'expand-review') { + let section = document.querySelector('section.review'); + let fold = section?.querySelector('[data-role="fold"]'); + if (!fold) return; + if (section.dataset.open !== '1') { + fold.dispatchEvent(new MouseEvent('click', { bubbles: true })); + } + section.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return; + } + if (kind === 'scrub-history') { + let tape = document.querySelector('[data-role="history"]'); + if (!tape) return; + tape.scrollIntoView({ block: 'center', behavior: 'smooth' }); + // Click ◀ once the stage has painted — same control as keyboard `[`. + let back = tape.querySelector('[data-role="step-by"][data-by="-1"]'); + if (!back || back.disabled) return; + setTimeout(() => { + if (!document.contains(back) || back.disabled) return; + back.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }, 480); + } +} + +/** True when the current URL already carries this step's destination query. */ +function hrefMatchesStep(href) { + let want = new URL(href, location.origin); + let here = new URL(location.href); + for (let key of want.searchParams.keys()) { + if (key === TOUR_PARAM) continue; + if ((here.searchParams.get(key) ?? '') !== (want.searchParams.get(key) ?? '')) { + return false; + } + } + // Also require that *extra* significant params on here match when want set them + // (e.g. clearing `with=` when the next beat has no with). + for (let key of ['at', 'vs', 'with', 'kind']) { + let w = want.searchParams.get(key); + let h = here.searchParams.get(key); + if (w == null && h != null && key === 'with') return false; + if (w != null && w !== h) return false; + } + return true; +} + +function buildHighlight(absoluteIndex) { + ensureCss(); + ensureTourCss(); + let step = SCRIPT[absoluteIndex]; + if (!step || step.page !== currentApp()) return null; + let sel = firstSelector(step.element); + revealExhibit(sel); + // Prepare after a tick so layout/scroll settle under the cutout. + requestAnimationFrame(() => runPrepare(step.prepare)); + return driver({ + popoverClass: 'deck-tour-popover', + showProgress: false, + allowClose: false, + overlayOpacity: 0.48, + overlayClickBehavior: () => {}, + stagePadding: 10, + stageRadius: 2, + smoothScroll: false, + disableActiveInteraction: false, + steps: [ + { + element: sel, + popover: { + title: '', + description: '', + showButtons: [], + side: 'over', + align: 'center', + }, + }, + ], + onDestroyStarted: (_el, _step, { driver: d }) => { + if (tearingDown) d.destroy(); + }, + }); +} + +function showStep(absoluteIndex) { + if (absoluteIndex < 0 || absoluteIndex >= SCRIPT.length) { + finish(); + location.href = '/start'; + return; + } + cursor = absoluteIndex; + let step = SCRIPT[absoluteIndex]; + saveState({ active: true, index: absoluteIndex }); + paintTourBar(absoluteIndex); + + if (step.page !== currentApp() || !hrefMatchesStep(step.go)) { + location.href = withTourParam(step.go); + return; + } + destroyActive(); + activeDriver = buildHighlight(absoluteIndex); + activeDriver?.drive(0); +} + +function tourNext() { + cleanupPrepare(); + let next = cursor + 1; + if (next >= SCRIPT.length) { + finish(); + location.href = '/start'; + return; + } + showStep(next); +} + +function tourPrev() { + if (cursor <= 0) return; + cleanupPrepare(); + showStep(cursor - 1); +} + +export function startHubTour() { + saveState({ active: true, index: 0 }); + location.href = withTourParam(SCRIPT[0].go); +} + +export function resumeHubTour(options = {}) { + let params = new URLSearchParams(location.search); + let wanted = params.has(TOUR_PARAM) || loadState()?.active; + if (!wanted) return; + + let state = loadState(); + let index = state?.index ?? 0; + while (index < SCRIPT.length && SCRIPT[index].page !== currentApp()) { + index++; + } + if (index >= SCRIPT.length) { + finish(); + return; + } + // Same app, wrong subject — snap to this step's URL (showStep navigates). + saveState({ active: true, index }); + let waitMs = options.waitMs ?? 0; + let attempt = () => { + let step = SCRIPT[index]; + if (!hrefMatchesStep(step.go)) { + location.href = withTourParam(step.go); + return; + } + let sel = firstSelector(step.element); + if (sel && !document.querySelector(sel) && (options.retries ?? 0) < 40) { + setTimeout( + () => resumeHubTour({ ...options, retries: (options.retries ?? 0) + 1 }), + 100, + ); + return; + } + showStep(index); + }; + if (waitMs) setTimeout(attempt, waitMs); + else attempt(); +} + +export function mountTourButton(host) { + let root = host ?? document; + let btn = root.querySelector('[data-role="tour"]'); + if (!btn || btn.dataset.bound === '1') return; + ensureTourCss(); + btn.dataset.bound = '1'; + btn.addEventListener('click', () => startHubTour()); +} + +const TOUR_CSS = ` +/* Caption lives on — never cover the exhibit with a popover. */ +.deck-tour-popover.driver-popover, +.driver-popover.deck-tour-popover { + display: none !important; +} +.driver-overlay { + pointer-events: none !important; +} +/* Dim the room enough that the cutout reads as a spotlight. */ +body.driver-active .driver-overlay { + opacity: 0.52 !important; +} + +/* Theme-native ring — grammar colours, not driver.js blue. */ +@keyframes deck-tour-glow { + 0%, 100% { + box-shadow: + 0 0 0 2px var(--a-live, #f9e2af), + 0 0 0 7px color-mix(in oklab, var(--a-live, #f9e2af) 28%, transparent), + 0 0 28px color-mix(in oklab, var(--a-package, #89b4fa) 35%, transparent); + } + 50% { + box-shadow: + 0 0 0 2px var(--a-package, #89b4fa), + 0 0 0 10px color-mix(in oklab, var(--a-package, #89b4fa) 32%, transparent), + 0 0 36px color-mix(in oklab, var(--a-live, #f9e2af) 40%, transparent); + } +} +.driver-active-element, +#driver-highlighted-element-stage { + outline: none !important; + border-radius: 2px; + animation: deck-tour-glow 1.5s ease-in-out infinite; +} +/* Opened volatility list during the dropdown beat. */ +select[data-role="vol"][data-tour-open="1"] { + position: relative; + z-index: 1000000005; + background: var(--panel, #181825); + box-shadow: + 0 0 0 2px var(--a-live, #f9e2af), + 0 12px 32px color-mix(in oklab, var(--bg, #000) 55%, transparent); +} +tbody tr[data-tour-pick="1"] { + box-shadow: inset 3px 0 0 var(--a-live, #f9e2af) !important; + background: var(--sel, #45475a) !important; +} + +/* Room for the multi-line story bar while scrolling exhibits into view. */ +body:has(.deck-keys[data-tour="1"]) { + padding-bottom: 6.5rem; +} +`; diff --git a/apps/hub/repository-service.mjs b/apps/hub/repository-service.mjs new file mode 100644 index 0000000..86cd5d2 --- /dev/null +++ b/apps/hub/repository-service.mjs @@ -0,0 +1,742 @@ +// Realm-local Repository/Checkpoint/Review adapter for Deck Hub. +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { forkDeck } from '@cardstack/deck/fork'; +import { packageJsonFromPack } from '@cardstack/deck/import-map-pack'; +import { readForkedFrom } from '@cardstack/deck/offer'; +import { readObject, readTree, rebuildPack } from '@cardstack/deck/object-store'; +import { + canonicalJson, + changedMembers, + checkpoint, + hashProtocolObject, + repository, +} from '@cardstack/deck/repository'; +import { + readBranchHead, + readCheckpoint, + readRepository, + readRepositoryLock, + realmDeckPath, + realmDeckStoreDir, + storeCheckpoint, + storeRepository, +} from '@cardstack/deck/repository-store'; +import { + createRepositoryBranch, + ensureRepositoryMain, + readRepositoryConfig, + repositoryIdentity, +} from '@cardstack/deck/repository-workflow'; +import { + createBranchReview, + createReview, + mergeReview, + previewReview, + readReview, +} from '@cardstack/deck/review-store'; +import { reviewEventHash, reviewHash } from '@cardstack/deck/review'; +import { parseRRI } from '@cardstack/deck/rri'; +import { publishToStore, readStoreMeta } from '@cardstack/deck/store'; +import { writeTreeToDir } from '@cardstack/deck/tree'; + +const json = (res, status, value) => { + let bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': bytes.length, + }); + res.end(bytes); +}; +const short = (hash) => hash?.slice(0, 12); +const versionFor = (base, hash) => `${base}-repository.${short(hash)}`; + +async function bodyOf(req) { + let chunks = []; + for await (let chunk of req) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); +} + +async function namesBelow(directory, suffix = '.json', prefix = '') { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return []; + } + let names = []; + for (let entry of entries) { + let name = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + names.push(...(await namesBelow(join(directory, entry.name), suffix, name))); + } else if (entry.name.endsWith(suffix)) { + names.push(name.slice(0, -suffix.length)); + } + } + return names.sort(); +} + +async function valuesBelow(directory) { + let names = await namesBelow(directory); + return Promise.all( + names.map(async (name) => [name, await readFile(join(directory, `${name}.json`), 'utf8')]), + ); +} + +function identity(config) { + return repositoryIdentity(config); +} + +function identityParts(config) { + let { scope: owner, name } = parseRRI(config.roots[0]); + return { owner, name }; +} + +function implicitConfig(owner, name) { + let root = `@${owner}/${name}/`; + return { + schema: 'deck-repository-manifest-v2', + roots: [root], + members: { [root]: '.' }, + }; +} + +function storageName(entry, memberRRI) { + let path = entry.config.members[memberRRI]; + return path === '.' ? entry.repository : path; +} + +export async function createRepositoryService({ depots, log = console.log, onChange = () => {} }) { + let repositories = []; + let queues = new Map(); + + function addEntry(depot, realmDir, config, kind, forkedFrom) { + let repository = identity(config); + let existing = repositories.find( + (entry) => entry.depot.name === depot.name && entry.repository === repository, + ); + if (existing) { + if (forkedFrom) existing.forkedFrom = forkedFrom; + return existing; + } + let { owner, name } = identityParts(config); + let entry = { depot, realmDir, config, kind, forkedFrom, repository, owner, name }; + repositories.push(entry); + return entry; + } + + for (let depot of depots) { + let grouped = await readRepositoryConfig(depot.dir); + let groupedPaths = new Set(grouped ? Object.values(grouped.members) : []); + if (grouped) addEntry(depot, depot.dir, grouped, 'group'); + let publishers = await readdir(depot.dir, { withFileTypes: true }).catch(() => []); + for (let publisher of publishers) { + if (!publisher.isDirectory() || publisher.name.startsWith('.') || publisher.name.startsWith('_')) continue; + let packages = await readdir(join(depot.dir, publisher.name), { withFileTypes: true }).catch(() => []); + for (let pkg of packages) { + if (!pkg.isDirectory()) continue; + let localName = `${publisher.name}/${pkg.name}`; + if (groupedPaths.has(localName)) continue; + let realmDir = join(depot.dir, publisher.name, pkg.name); + try { + JSON.parse(await readFile(join(realmDir, 'package.json'), 'utf8')); + } catch { + continue; + } + let forkedFrom; + try { + forkedFrom = readForkedFrom(await readFile(join(realmDir, 'importmap.json'), 'utf8'), pkg.name); + } catch {} + addEntry(depot, realmDir, implicitConfig(publisher.name, pkg.name), 'package', forkedFrom); + } + } + } + + function serial(entry, action) { + let prior = queues.get(entry.realmDir) ?? Promise.resolve(); + let next = prior.then(action, action); + queues.set(entry.realmDir, next.catch(() => undefined)); + return next; + } + + async function project(entry, repositoryHash, value) { + let members = {}; + let rriMembers = {}; + let memberVersions = {}; + for (let [memberRRI, treeHash] of Object.entries(value.members)) { + let name = storageName(entry, memberRRI); + let bytes = await rebuildPack(realmDeckStoreDir(entry.realmDir), treeHash); + if (!bytes) throw new Error(`missing package tree ${treeHash}`); + let declaredVersion = packageJsonFromPack(bytes)?.version; + if (!declaredVersion) throw new Error(`${memberRRI} has no declared Version`); + let version = versionFor(declaredVersion, repositoryHash); + let existing = (await readStoreMeta(entry.depot.storeDir, name))?.versions?.[version]; + if (existing && existing.treeHash !== treeHash) { + throw new Error(`Repository projection collision: ${name}@${version}`); + } + if (!existing) { + try { + await publishToStore(entry.depot.storeDir, name, version, bytes); + } catch (error) { + // Several branches may point at the same immutable Repository and + // project it concurrently. The Version is already the desired fact + // when the winning writer stored this exact tree; anything else is + // still a real immutable-name collision. + existing = (await readStoreMeta(entry.depot.storeDir, name))?.versions?.[version]; + if (existing?.treeHash !== treeHash) throw error; + } + } + members[name] = `/${entry.depot.name}/${name}@${version}`; + rriMembers[memberRRI] = members[name]; + memberVersions[name] = version; + } + let rootName = storageName(entry, value.roots[0]); + let query = new URLSearchParams({ at: `${members[rootName]}/index.html` }); + for (let name of Object.keys(members).filter((name) => name !== rootName)) { + query.append('with', `${name}=${name}@${memberVersions[name]}`); + } + return { + version: memberVersions[rootName], + versions: memberVersions, + runUrl: `/run?${query.toString()}`, + members, + rriMembers, + }; + } + + async function branchState(entry, name) { + let head = await readBranchHead(entry.realmDir, name); + if (!head) return undefined; + let value = await readRepository(entry.realmDir, head.repositoryHash); + if (!value) throw new Error(`branch ${name} points at a missing Repository`); + let point = head.latestCheckpointHash + ? await readCheckpoint(entry.realmDir, head.latestCheckpointHash) + : undefined; + return { + name, + checkpointHash: head.latestCheckpointHash, + repositoryHash: head.repositoryHash, + historyHead: head.historyHead, + indexGenerationHash: head.indexGenerationHash, + generation: head.generation, + parents: point?.parents ?? [], + author: point?.author ?? { id: '@deck:save', name: 'Every-save History' }, + message: point?.message ?? 'Uncheckpointed saves', + createdAt: point?.createdAt ?? '', + projection: await project(entry, head.repositoryHash, value), + }; + } + + async function reviewNumbers(entry) { + return (await namesBelow(realmDeckPath(entry.realmDir, 'reviews', 'numbers'))) + .map(Number) + .filter((number) => Number.isSafeInteger(number) && number > 0) + .sort((a, b) => b - a); + } + + async function reviewState(entry, number) { + let stored = await readReview(entry.realmDir, number); + if (!stored) return undefined; + return { + number, + state: stored.ref.state, + title: stored.review.title, + body: stored.review.body, + source: stored.review.source, + target: stored.review.target, + base: stored.review.base, + author: stored.review.author, + createdAt: stored.review.createdAt, + events: stored.events.map((event) => ({ ...event, hash: reviewEventHash(event) })), + }; + } + + async function history(entry, branches) { + let queue = branches.map((branch) => branch.checkpointHash).filter(Boolean); + let names = new Map( + branches.filter((branch) => branch.checkpointHash).map((branch) => [branch.checkpointHash, [branch.name]]), + ); + let seen = new Set(); + let rows = []; + while (queue.length && rows.length < 50) { + let hash = queue.shift(); + if (seen.has(hash)) continue; + seen.add(hash); + let point = await readCheckpoint(entry.realmDir, hash); + if (!point) continue; + rows.push({ checkpointHash: hash, ...point, branches: names.get(hash) ?? [] }); + queue.push(...point.parents); + } + return rows.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))); + } + + async function state(entry, selectedNumber) { + let branchNames = await namesBelow(realmDeckPath(entry.realmDir, 'refs', 'heads')); + let branches = (await Promise.all(branchNames.map((name) => branchState(entry, name)))).filter(Boolean); + let main = branches.find((branch) => branch.name === 'main'); + let reviews = ( + await Promise.all((await reviewNumbers(entry)).map((number) => reviewState(entry, number))) + ).filter(Boolean); + let roots = entry.config.roots.map((root) => storageName(entry, root)); + let members = Object.keys(entry.config.members).map((member) => storageName(entry, member)); + let common = { + repository: entry.repository, + repositoryRRI: entry.config.roots[0], + kind: entry.kind, + forkedFrom: entry.forkedFrom, + owner: entry.owner, + name: entry.name, + depot: entry.depot.name, + roots, + memberRRIs: Object.keys(entry.config.members), + members, + }; + if (!main) return { ...common, initialized: false, branches: [], reviews: [], history: [], protocol: [] }; + let selectedReview = + (selectedNumber + ? reviews.find((item) => item.number === selectedNumber) + : undefined) ?? + reviews.find((item) => item.state === 'open') ?? + reviews[0]; + let preview = selectedReview?.state === 'open' + ? await previewReview(entry.realmDir, selectedReview.number) + : undefined; + let previewProjection = preview?.state === 'ready' + ? await project(entry, preview.repositoryHash, preview.repository) + : undefined; + let comparison; + if (selectedReview) { + let [basePoint, sourcePoint] = await Promise.all([ + readCheckpoint(entry.realmDir, selectedReview.base.checkpointHash), + readCheckpoint(entry.realmDir, selectedReview.source.checkpointHash), + ]); + let [baseRepository, sourceRepository] = await Promise.all([ + basePoint && readRepository(entry.realmDir, basePoint.repositoryHash), + sourcePoint && readRepository(entry.realmDir, sourcePoint.repositoryHash), + ]); + if (basePoint && sourcePoint && baseRepository && sourceRepository) { + comparison = { + names: preview?.state === 'ready' + ? preview.changedMembers + : changedMembers(baseRepository, sourceRepository), + before: await project(entry, basePoint.repositoryHash, baseRepository), + after: previewProjection ?? (await project(entry, sourcePoint.repositoryHash, sourceRepository)), + }; + } + } + let changes = comparison + ? comparison.names.map((memberRRI) => { + let name = storageName(entry, memberRRI); + return { + name: memberRRI, + browseUrl: `/browse?${new URLSearchParams({ + at: `${comparison.after.members[name]}/index.js`, + vs: `${comparison.before.members[name]}/index.js`, + })}`, + }; + }) + : []; + let protocol = []; + let seenCheckpoints = new Set(); + let seenRepositories = new Set(); + let seenLocks = new Set(); + let seenIndexes = new Set(); + for (let branch of branches) { + protocol.push({ + label: `${branch.name} branch head`, + path: `.deck/refs/heads/${branch.name}.json`, + value: await readBranchHead(entry.realmDir, branch.name), + }); + if (branch.checkpointHash && !seenCheckpoints.has(branch.checkpointHash)) { + seenCheckpoints.add(branch.checkpointHash); + protocol.push({ + label: `${branch.name} Checkpoint`, + path: `.deck/store/_checkpoints/${branch.checkpointHash.slice(0, 2)}/${branch.checkpointHash}.json`, + value: await readCheckpoint(entry.realmDir, branch.checkpointHash), + }); + } + if (!seenRepositories.has(branch.repositoryHash)) { + seenRepositories.add(branch.repositoryHash); + let repositoryValue = await readRepository(entry.realmDir, branch.repositoryHash); + protocol.push({ + label: `${branch.name} Repository`, + path: `.deck/store/_repositories/${branch.repositoryHash.slice(0, 2)}/${branch.repositoryHash}.json`, + value: repositoryValue, + }); + if (repositoryValue && !seenLocks.has(repositoryValue.lockHash)) { + seenLocks.add(repositoryValue.lockHash); + protocol.push({ + label: `${branch.name} RRI lock`, + path: `.deck/store/_locks/${repositoryValue.lockHash.slice(0, 2)}/${repositoryValue.lockHash}.json`, + value: await readRepositoryLock(entry.realmDir, repositoryValue.lockHash), + }); + } + } + if (!seenIndexes.has(branch.indexGenerationHash)) { + seenIndexes.add(branch.indexGenerationHash); + let indexPath = realmDeckPath( + entry.realmDir, + 'indexes', + branch.indexGenerationHash.slice(0, 2), + branch.indexGenerationHash, + 'manifest.json', + ); + let indexValue; + try { + indexValue = JSON.parse(await readFile(indexPath, 'utf8')); + } catch {} + protocol.push({ + label: `${branch.name} index generation`, + path: `.deck/indexes/${branch.indexGenerationHash.slice(0, 2)}/${branch.indexGenerationHash}/manifest.json`, + value: indexValue ?? { unavailable: true }, + }); + } + } + for (let item of reviews) { + let stored = await readReview(entry.realmDir, item.number); + if (!stored) continue; + let openingHash = reviewHash(stored.review); + protocol.push({ + label: `Review #${item.number} head`, + path: `.deck/reviews/numbers/${item.number}.json`, + value: stored.ref, + }); + protocol.push({ + label: `Review #${item.number} opening`, + path: `.deck/reviews/objects/${openingHash.slice(0, 2)}/${openingHash}.json`, + value: stored.review, + }); + for (let event of stored.events) { + let hash = reviewEventHash(event); + protocol.push({ + label: `Review #${item.number} ${event.type}`, + path: `.deck/reviews/events/${hash.slice(0, 2)}/${hash}.json`, + value: event, + }); + } + } + return { + ...common, + initialized: true, + main, + branches, + reviews, + review: selectedReview ?? null, + preview: preview ? { ...preview, projection: previewProjection } : undefined, + changes, + history: await history(entry, branches), + protocol, + }; + } + + async function settlement(entry, message, actor, repositoryHash = undefined) { + let backend = entry.depot.history; + if (!backend) throw new Error('deckd History is required to move a Repository branch'); + let historyHead = + (await backend.seal(entry.realmDir, message, { + name: actor?.name ?? actor?.id ?? 'Deck Hub', + })) ?? (await backend.head(entry.realmDir)); + if (!historyHead) throw new Error('deckd did not produce a History head'); + let manifest = { + schema: 'deck-file-index-generation-v1', + repository: entry.config.roots[0], + historyHead, + ...(repositoryHash ? { repositoryHash } : {}), + }; + let indexGenerationHash = hashProtocolObject(manifest); + let path = realmDeckPath(entry.realmDir, 'indexes', indexGenerationHash.slice(0, 2), indexGenerationHash, 'manifest.json'); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, canonicalJson(manifest)); + return { historyHead, indexGenerationHash }; + } + + async function ensureMain(entry, actor) { + let current = await readBranchHead(entry.realmDir, 'main'); + if (current?.latestCheckpointHash) return current.latestCheckpointHash; + let settled = await settlement(entry, `Initialize ${entry.repository}`, actor); + return ensureRepositoryMain({ + realmDir: entry.realmDir, + config: entry.config, + author: actor, + ...settled, + }); + } + + async function filesForTree(entry, treeHash) { + let storeDir = realmDeckStoreDir(entry.realmDir); + let tree = await readTree(storeDir, treeHash); + if (!tree) throw new Error(`missing tree ${treeHash}`); + let files = new Map(); + for (let item of tree.entries) { + let bytes = await readObject(storeDir, item.sha256); + if (!bytes) throw new Error(`missing object ${item.sha256}`); + files.set(item.path, bytes); + } + return files; + } + + async function applyRepository(entry, value) { + for (let [memberRRI, treeHash] of Object.entries(value.members)) { + let path = entry.config.members[memberRRI]; + if (!path) throw new Error(`Repository contains non-member ${memberRRI}`); + await writeTreeToDir(path === '.' ? entry.realmDir : join(entry.realmDir, path), await filesForTree(entry, treeHash)); + } + } + + async function changed(entry, action) { + let result = await serial(entry, action); + onChange({ name: `repository:${entry.repository}`, treeHash: String(Date.now()) }); + return result; + } + + return { + async list() { + return { + repositories: await Promise.all( + repositories.map(async (entry) => ({ + repository: entry.repository, + repositoryRRI: entry.config.roots[0], + kind: entry.kind, + forkedFrom: entry.forkedFrom, + owner: entry.owner, + name: entry.name, + depot: entry.depot.name, + roots: entry.config.roots.map((root) => storageName(entry, root)), + members: Object.keys(entry.config.members).map((member) => storageName(entry, member)), + initialized: Boolean(await readBranchHead(entry.realmDir, 'main')), + url: `/_hub/repositories/${entry.depot.name}/${entry.owner}/${entry.name}`, + })), + ), + }; + }, + async fingerprint() { + let parts = []; + for (let entry of repositories) { + parts.push([ + entry.depot.name, + entry.repository, + await valuesBelow(realmDeckPath(entry.realmDir, 'refs')), + await valuesBelow(realmDeckPath(entry.realmDir, 'reviews', 'numbers')), + ]); + } + return JSON.stringify(parts); + }, + async handle(req, res, path) { + if (path === '/_hub/repositories' && req.method === 'GET') { + json(res, 200, await this.list()); + return true; + } + if (path === '/_hub/forks' && req.method === 'POST') { + try { + let body = await bodyOf(req); + let depot = depots.find((candidate) => candidate.name === body.depot); + if (!depot) throw new Error(`depot not found: ${body.depot}`); + let [fromPublisher, fromPackage, ...fromRest] = String(body.from ?? '').split('/'); + let [toPublisher, toPackage, ...toRest] = String(body.to ?? '').split('/'); + if (!fromPublisher || !fromPackage || fromRest.length || !toPublisher || !toPackage || toRest.length) { + throw new Error('from and to must be owner/package'); + } + let sourceEntry = repositories.find( + (entry) => entry.depot === depot && entry.repository === `${fromPublisher}/${fromPackage}`, + ); + let baseCheckpointHash; + if (sourceEntry && !body.spec) { + baseCheckpointHash = await ensureMain(sourceEntry, { + id: body.actor ?? '@hub:local', + name: body.actorName ?? 'Deck Hub', + }); + } + let result = await forkDeck({ + depotDir: depot.dir, + storeDir: depot.storeDir, + from: { publisher: fromPublisher, package: fromPackage, ...(body.spec ? { spec: body.spec } : {}) }, + to: { publisher: toPublisher, package: toPackage }, + actor: body.actor ?? '@hub:local', + baseCheckpointHash, + }); + let realmDir = join(depot.dir, toPublisher, toPackage); + let entry = addEntry( + depot, + realmDir, + implicitConfig(toPublisher, toPackage), + 'package', + { + package: body.from, + ...(result.version ? { version: result.version } : {}), + treeHash: result.baseTreeHash, + ...(baseCheckpointHash ? { checkpointHash: baseCheckpointHash } : {}), + }, + ); + await ensureMain(entry, { + id: body.actor ?? '@hub:local', + name: body.actorName ?? 'Deck Hub', + }); + onChange({ name: `fork:${result.to}`, treeHash: result.baseTreeHash }); + log(`forked ${result.from} → ${result.to}`); + json(res, 201, { + ...result, + repository: `${toPublisher}/${toPackage}`, + browseUrl: `/browse?at=/${depot.name}/${toPublisher}/${toPackage}`, + }); + } catch (error) { + json(res, 409, { error: error.message }); + } + return true; + } + + let match = path.match(/^\/_hub\/repositories\/([^/]+)\/([^/]+)\/([^/]+)(.*)$/); + if (!match) return false; + let [, depotName, owner, name, tail] = match; + let entry = repositories.find( + (candidate) => candidate.owner === owner && candidate.name === name && candidate.depot.name === depotName, + ); + if (!entry) { + json(res, 404, { error: 'Repository not found' }); + return true; + } + try { + if (req.method === 'GET' && !tail) { + json(res, 200, await state(entry)); + return true; + } + let selected = tail.match(/^\/reviews\/(\d+)$/); + if (req.method === 'GET' && selected) { + json(res, 200, await state(entry, Number(selected[1]))); + return true; + } + let body = await bodyOf(req); + if (req.method === 'POST' && tail === '/branches') { + await changed(entry, async () => { + await ensureMain(entry, body.author ?? { id: '@hub:local', name: 'Deck Hub' }); + return createRepositoryBranch({ + realmDir: entry.realmDir, + branch: body.name, + from: body.from ?? 'main', + }); + }); + } else if (req.method === 'POST' && tail === '/reviews') { + let sourceIdentity = String(body.sourceRepository ?? entry.repository); + let sourceEntry = repositories.find( + (candidate) => candidate.depot === entry.depot && candidate.repository === sourceIdentity, + ); + if (!sourceEntry) throw new Error(`source Repository not found: ${sourceIdentity}`); + await changed(entry, async () => { + await ensureMain(entry, body.author ?? { id: '@hub:local', name: 'Deck Hub' }); + await ensureMain(sourceEntry, body.author ?? { id: '@hub:local', name: 'Deck Hub' }); + if (sourceEntry === entry) { + return createBranchReview({ + realmDir: entry.realmDir, + repository: entry.config.roots[0], + sourceBranch: body.source, + targetBranch: body.target ?? 'main', + title: body.title, + body: body.body ?? '', + author: body.author ?? { id: '@hub:local', name: 'Deck Hub' }, + createdAt: new Date().toISOString(), + }); + } + if ( + sourceEntry.forkedFrom?.package !== entry.repository || + !sourceEntry.forkedFrom.checkpointHash + ) { + throw new Error(`${sourceIdentity} has no mutable fork lineage from ${entry.repository}`); + } + let [targetHead, sourceHead] = await Promise.all([ + readBranchHead(entry.realmDir, body.target ?? 'main'), + readBranchHead(sourceEntry.realmDir, body.source), + ]); + if (!targetHead?.latestCheckpointHash || !sourceHead?.latestCheckpointHash) { + throw new Error('Review endpoints require Checkpoints'); + } + let [targetRepository, sourceRepository] = await Promise.all([ + readRepository(entry.realmDir, targetHead.repositoryHash), + readRepository(sourceEntry.realmDir, sourceHead.repositoryHash), + ]); + if (!targetRepository || !sourceRepository) throw new Error('Review Repository is missing'); + let targetMembers = Object.keys(targetRepository.members); + let sourceMembers = Object.keys(sourceRepository.members); + if (targetMembers.length !== 1 || sourceMembers.length !== 1) { + throw new Error('cross-owner Review currently requires one package on each side'); + } + let normalized = repository({ + roots: targetRepository.roots, + members: { [targetMembers[0]]: sourceRepository.members[sourceMembers[0]] }, + lockHash: targetRepository.lockHash, + }); + let repositoryHash = await storeRepository(entry.realmDir, normalized); + let sourcePoint = await readCheckpoint(sourceEntry.realmDir, sourceHead.latestCheckpointHash); + if (!sourcePoint) throw new Error('source Checkpoint is missing'); + let normalizedPoint = checkpoint({ + repositoryHash, + parents: [sourceHead.latestCheckpointHash], + historyHead: sourcePoint.historyHead, + indexGenerationHash: sourcePoint.indexGenerationHash, + author: body.author ?? { id: '@hub:local', name: 'Deck Hub' }, + message: `Map ${sourceIdentity} onto ${entry.repository}`, + createdAt: new Date().toISOString(), + }); + let normalizedCheckpointHash = await storeCheckpoint(entry.realmDir, normalizedPoint); + return createReview({ + realmDir: entry.realmDir, + base: { + repository: entry.config.roots[0], + branch: body.target ?? 'main', + checkpointHash: sourceEntry.forkedFrom.checkpointHash, + }, + target: { + repository: entry.config.roots[0], + branch: body.target ?? 'main', + checkpointHash: targetHead.latestCheckpointHash, + }, + source: { + repository: sourceEntry.config.roots[0], + branch: body.source, + checkpointHash: normalizedCheckpointHash, + }, + title: body.title, + body: body.body ?? '', + author: body.author ?? { id: '@hub:local', name: 'Deck Hub' }, + createdAt: new Date().toISOString(), + }); + }); + } else { + let merge = tail.match(/^\/reviews\/(\d+)\/merge$/); + if (!merge || req.method !== 'POST') { + json(res, 404, { error: 'Repository action not found' }); + return true; + } + let result = await changed(entry, async () => { + let number = Number(merge[1]); + let preview = await previewReview(entry.realmDir, number); + if (preview.state === 'conflicted') return preview; + await applyRepository(entry, preview.repository); + let settled = await settlement( + entry, + `Merge Review #${number}`, + body.actor ?? { id: '@hub:local', name: 'Deck Hub' }, + preview.repositoryHash, + ); + return mergeReview({ + realmDir: entry.realmDir, + number, + actor: body.actor ?? { id: '@hub:local', name: 'Deck Hub' }, + createdAt: new Date().toISOString(), + ...settled, + }); + }); + if (result.state === 'conflicted') { + json(res, 409, result); + return true; + } + log(`merged ${entry.repository}#${merge[1]} ${short(result.mergeCheckpointHash)}`); + } + json(res, 200, await state(entry)); + return true; + } catch (error) { + json(res, 409, { error: error.message }); + return true; + } + }, + }; +} diff --git a/apps/hub/setup.mjs b/apps/hub/setup.mjs index 33b2e89..a13fea6 100644 --- a/apps/hub/setup.mjs +++ b/apps/hub/setup.mjs @@ -44,7 +44,7 @@ // nothing imports them — they are loaded on demand, by extension, at // runtime, which is how "universal highlighting" costs one request per // language you actually look at instead of 2.6 MB up front. -import { mkdir } from 'node:fs/promises'; +import { mkdir, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { pack } from '@cardstack/deck/pack'; @@ -61,19 +61,30 @@ const storeDir = storeDirFor(depotDir); // meaningful minimumReleaseAge. See L11. const POLICY = { minimumReleaseAge: 0 }; +await rm(storeDir, { recursive: true, force: true }); await mkdir(storeDir, { recursive: true }); const json = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); +/** Lineage only — identity and ranges live in the pack's package.json. */ +function lineageMap(name, extra = {}) { + return json({ + imports: {}, + deck: { + packages: { + [name]: extra, + }, + }, + }); +} + /** * Publish, unless this exact version is already there. * * A published exact version never changes (L4) and the store refuses to - * overwrite one — correctly, and this script must not fight it. Setup has to - * be runnable twice, so it asks first. If the version exists it is left - * alone and its recorded treeHash is reported, which also means a second run - * silently VERIFIES the first: same inputs, same content hash, or the - * mismatch shows up in the printed digest. + * overwrite one — correctly. This is demo data: setup clears the store + * first, then publishes. A second run still asks first so a leftover + * version is left alone. */ async function publishOnce(_storeDir, name, version, bytes, options) { let meta = await readStoreMeta(storeDir, name); @@ -136,17 +147,7 @@ console.log('\n DECK HUB — vendoring the Hub\'s own dependencies\n'); let bytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - diff: { - version: '9.0.0', - entry: `$DECK/${vendored.entryPath}`, - vendoredFrom: vendored.vendoredFrom, - }, - }, - }, - }), + bytes: lineageMap('diff', { vendoredFrom: vendored.vendoredFrom }), }, ...vendored.files, ]); @@ -199,40 +200,60 @@ const ESM_LINE = 'export default highlight;'; .filter((path) => !path.endsWith('.js.js') && !path.endsWith('.min.js')) .sort(); - // The version is derived from the patch so that re-running this script - // republishes the same bytes under the same name. A demo that publishes - // different content at the same immutable URL teaches the browser a lie - // it will hold on to for a year. - const version = `${HLJS}-esm.${hashBytes(patched).slice(0, 8)}`; + // Upstream `exports["."].import` is `./es/index.js`, which re-exports + // `lib/index.js` — a file this fork does not carry (we ship core + + // on-demand languages only). Point the entry at the patched core, and + // rename the package to the specifier Hub already depends on. + let manifest = files.get('package.json'); + if (!manifest) { + throw new Error(`highlight.js@${HLJS} has no package.json`); + } + let pkgJson = JSON.parse(manifest.toString('utf8')); + pkgJson.name = 'highlight-js'; + pkgJson.type = 'module'; + pkgJson.exports = { + '.': './lib/core.js', + './lib/core.js': './lib/core.js', + './es/languages/*': './es/languages/*', + }; + delete pkgJson.main; + delete pkgJson.module; + // Version from the fork payload (patched core + rewritten exports), so a + // change to either publishes a new immutable URL rather than silently + // reusing a broken pack. Version is filled in after the hash so it cannot + // feed back into itself. + let forHash = Buffer.from( + JSON.stringify({ ...pkgJson, version: undefined }), + 'utf8', + ); + const version = `${HLJS}-esm.${hashBytes( + Buffer.concat([patched, forHash]), + ).slice(0, 8)}`; + pkgJson.version = version; + let manifestBytes = Buffer.from(`${JSON.stringify(pkgJson, null, 2)}\n`, 'utf8'); let bytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'highlight-js': { - version, - entry: '$DECK/lib/core.js', - vendoredFrom: vendored.vendoredFrom, - // What was changed and why, in the tree, at rest, where a - // reader of the deck finds it without being told to look. - forkedFrom: { - package: 'highlight.js', - version: HLJS, - changed: ['lib/core.js'], - reason: - 'lib/core.js is CommonJS (module.exports) behind an ESM ' + - 'shim, so the published package cannot be imported by a ' + - 'browser. One line changed: `module.exports = highlight` ' + - 'became `export default highlight`. Nothing else differs.', - }, - }, - }, + bytes: lineageMap('highlight-js', { + vendoredFrom: vendored.vendoredFrom, + forkedFrom: { + package: 'highlight.js', + version: HLJS, + changed: ['lib/core.js', 'package.json'], + reason: + 'lib/core.js is CommonJS (module.exports) behind an ESM ' + + 'shim, so the published package cannot be imported by a ' + + 'browser. One line changed: `module.exports = highlight` ' + + 'became `export default highlight`. package.json is rewritten ' + + 'so the Hub specifier `highlight-js` resolves to that core ' + + '(upstream exports point at es/index.js → lib/index.js, which ' + + 'this fork does not carry).', }, }), }, { path: 'lib/core.js', bytes: patched }, + { path: 'package.json', bytes: manifestBytes }, ...languages.map((path) => ({ path, bytes: files.get(path) })), ]); let { treeHash, reused } = await publishOnce( @@ -252,6 +273,9 @@ const ESM_LINE = 'export default highlight;'; console.log( ` forked lib/core.js: 1 line of 2597 — \`${CJS_LINE}\` → \`${ESM_LINE}\``, ); + console.log( + ` package.json entry → lib/core.js (specifier highlight-js)`, + ); } // --------------------------------------------------------------------------- @@ -307,17 +331,7 @@ const TABLE_DECK = 'lib/tanstack-table-core'; let storeBytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'tanstack-store': { - version: '0.11.1', - entry: `$DECK/${store.entryPath}`, - vendoredFrom: store.vendoredFrom, - }, - }, - }, - }), + bytes: lineageMap('tanstack-store', { vendoredFrom: store.vendoredFrom }), }, ...store.files, ]); @@ -358,27 +372,18 @@ const TABLE_DECK = 'lib/tanstack-table-core'; let tableBytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'tanstack-table-core': { - version: tableVersion, - entry: `$DECK/${table.entryPath}`, - vendoredFrom: table.vendoredFrom, - forkedFrom: { - package: '@tanstack/table-core', - version: '9.1.1', - changed: patched, - reason: - 'Nine files guard dev-only warnings with ' + - '`process.env.NODE_ENV === "development"`, which is not ' + - 'defined in a browser. The guard is folded to `false` — the ' + - 'same constant-fold a bundler performs — so the package ' + - 'runs without a build step or a `process` shim.', - }, - }, - }, - dependencies: { '@tanstack/store': `${STORE_DECK}@^0.11.0` }, + bytes: lineageMap('tanstack-table-core', { + vendoredFrom: table.vendoredFrom, + forkedFrom: { + package: '@tanstack/table-core', + version: '9.1.1', + changed: patched, + reason: + 'Nine files guard dev-only warnings with ' + + '`process.env.NODE_ENV === "development"`, which is not ' + + 'defined in a browser. The guard is folded to `false` — the ' + + 'same constant-fold a bundler performs — so the package ' + + 'runs without a build step or a `process` shim.', }, }), }, @@ -422,24 +427,16 @@ const TABLE_DECK = 'lib/tanstack-table-core'; let bytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'tanstack-query-core': { - version, - entry: `$DECK/${vendored.entryPath}`, - vendoredFrom: vendored.vendoredFrom, - forkedFrom: { - package: '@tanstack/query-core', - version: '5.101.4', - changed: patched, - reason: - '`process.env.NODE_ENV === "development"` guards folded to ' + - '`false`, the same constant-fold a bundler performs, so the ' + - 'package runs in a browser without a build step or a shim.', - }, - }, - }, + bytes: lineageMap('tanstack-query-core', { + vendoredFrom: vendored.vendoredFrom, + forkedFrom: { + package: '@tanstack/query-core', + version: '5.101.4', + changed: patched, + reason: + '`process.env.NODE_ENV === "development"` guards folded to ' + + '`false`, the same constant-fold a bundler performs, so the ' + + 'package runs in a browser without a build step or a shim.', }, }), }, @@ -519,18 +516,13 @@ const FACES = [ let bytes = pack([ { path: 'importmap.json', - bytes: json({ - deck: { - packages: { - 'commit-mono': { - version: FONT, - // NO ENTRY. Nothing imports a font; this deck is addressable - // and deliberately not importable (L11 as a shape). - vendoredFrom: vendored.vendoredFrom, - }, - }, - }, - }), + bytes: lineageMap('commit-mono', { vendoredFrom: vendored.vendoredFrom }), + }, + { + path: 'package.json', + bytes: + files.get('package.json') ?? + json({ name: '@fontsource/commit-mono', version: FONT }), }, { path: 'font.css', bytes: Buffer.from(`${css}\n`, 'utf8') }, { path: 'LICENSE', bytes: files.get('LICENSE') ?? Buffer.from('', 'utf8') }, @@ -551,9 +543,52 @@ const FACES = [ console.log(' no entry declared — a deck you can address and cannot import'); } +// --------------------------------------------------------------------------- +// 6. driver.js — the product tour engine (ESM + CSS, unmodified). +// +// The module walk only reaches JS. The popover skin is a CSS export +// (`./dist/driver.css`) that nothing imports, so it has to be carried +// explicitly — same shape as highlight.js languages. +// --------------------------------------------------------------------------- +{ + // One ESM file + CSS. Upstream lost the module boundary at publish time; + // an explicit entry opts into that shape instead of failing the tree probe. + let vendored = await vendorFromNpmSource({ + spec: 'driver.js@1.8.0', + entry: 'dist/driver.js.mjs', + policy: POLICY, + }); + let cssPaths = ['dist/driver.css', 'dist/hints.css'].filter((path) => + vendored.pkg.files.has(path), + ); + let bytes = pack([ + { + path: 'importmap.json', + bytes: lineageMap('driver.js', { vendoredFrom: vendored.vendoredFrom }), + }, + ...vendored.files, + ...cssPaths.map((path) => ({ + path, + bytes: vendored.pkg.files.get(path), + })), + ]); + let { treeHash, reused } = await publishOnce( + storeDir, + 'lib/driver-js', + '1.8.0', + bytes, + { tag: 'latest' }, + ); + console.log( + ` lib/driver-js@1.8.0 ${vendored.modules.length} modules · ` + + `${cssPaths.length} css · ` + + `${treeHash.slice(0, 12)}…${reused ? ' (already sealed)' : ''}`, + ); +} + let store = await inspectStore(storeDir); console.log( `\n store: ${store.objects ?? store.objectCount ?? '?'} objects · ` + `${((store.bytes ?? store.totalBytes ?? 0) / 1024 / 1024).toFixed(1)} MB\n`, ); -console.log(' next: deck lock --depot apps/hub/hub --publisher app --name browse\n'); +console.log(' next: deck lock --depot apps/hub/hub\n'); diff --git a/deckbook.html b/deckbook.html index bb3a1b9..7b93d5a 100644 --- a/deckbook.html +++ b/deckbook.html @@ -290,7 +290,7 @@

      Deckbook

    • 4. Three volatilities
    • 5. The live loop
    • 6. Store & packs
    • -
    • 7. History: Versions, or a Timeline
    • +
    • 7. History: every save, or Versions only
    • 8. Safe vendor
    • 9. Fork, lock, offers
    • 10. HTTP surface
    • @@ -316,13 +316,13 @@

      The Deckbook

      are all pointers to trees. Commit, install and deploy all do the same job: they move a copy to a second place that names things differently. Here there is no second place. This book teaches the running reference - implementation: the CLI, the store, the server, the Timeline, and the + implementation: the CLI, the store, the server, the History, and the workflows they open up.

      Reference impl built Versions always on - Timeline optional + Every-save History optional No framework required
      @@ -340,7 +340,7 @@

      1. The promise

      Instant feedbackVersion-less URL → working tree, Cache-Control: no-store Never lose workAuto-publish <base>-dev.N + optional per-save seals - Revert anytimedeck unpack a pack, or deck restore <changeId> (with a Timeline) + Revert anytimedeck unpack a pack, or deck restore <changeId> (with every-save History) Ship any versiondeck release --from dev — pointer, zero bytes copied Share a milestonedeck pack → a Deck Pack (.pack.zip, a standard zip) Use any librarydeck vendor hermetic CDN graph → your store @@ -364,7 +364,7 @@

      2. Vocabulary

      VersionA published tree that does not change. A semver number addresses it. (Was “sealed deck”.) Deck Pack.pack.zip — one tree and its packlist in one file. Any zip tool opens it. (Was “cardpack”.) treeHashContent identity of a whole tree (tree-hash-v1). - Step / changeIdOne capture of the working tree, one for each save. The Steps in order are the Timeline. Needs a history backend (jj / historyd). + Step / changeIdOne capture of the working tree, one for each save. The Steps in order are History. Needs a history backend (deckd). offerA fork that records its upstream base — a proposal you can run. @@ -418,7 +418,7 @@

      5. The live loop

      # → debounce (~500ms quiet) # → treeHash unchanged? stop # → packFromDir → publish <base>-dev.N, tag:dev -# → [with a Timeline] the save is recorded as a Step too +# → [with every-save History] the save is recorded as a Step too deck release --depot ./work/site --publisher acme --name palette \ --version 1.1.0 --from dev --tag latest
    • @@ -449,7 +449,7 @@

      6. Store & packs

      -

      7. History: Versions, or a Timeline

      +

      7. History: every save, or Versions only

      A depot has one of two answers, and the server reports which. These are not numbered levels — each one has a name that says what you get.

      @@ -461,7 +461,7 @@

      7. History: Versions, or a Timeline

      - + @@ -536,7 +536,7 @@

      10. HTTP surface (read-only server)

      - + @@ -1028,7 +1028,7 @@

      The conflict that is real, and resolving it deterministically

      Runnable: pnpm --filter @cardstack/deck-example-two-agents demo, or the - two-agents panel in the hub. It is also the demo that turns a Timeline + two-agents panel in the hub. It is also the demo that turns on every-save History history on, so every save there has a changeId and not just every publish.
      @@ -1089,7 +1089,7 @@

      14. Try it on this server

    • /_packages — JSON catalog + history capability block
    • /_live/hmr.js — drop-in HMR client source
    • /<depot>/_offers — the open proposals, re-merged on read
    • -
    • /<depot>/_history — the Timeline, when a history backend is attached
    • +
    • /<depot>/_history — every-save History, when a history backend is attached
    • And pnpm demo in the repo starts all seven examples at once — @@ -1126,7 +1126,7 @@

      15. Honest status

      - +
      Always on
      A TimelineEvery-save History Every save also becomes a Step at /<depot>/_history/<changeId>/…, with deck history and deck restore. A packlist can name the Step it was cut from. Implemented when --history auto|jj|historyd attaches
      ?metaMachine-readable listing (incl. seal when present)
      ?format=packFaithful stored pack bytes
      …/vendor.pack.zip!/inner.jsMount into a nested pack (depth-capped)
      GET /<depot>/_historyThe Timeline, when a history backend is attached
      GET /<depot>/_historyEvery-save History, when a history backend is attached
      GET /<depot>/_history/<id>/…File at any seal
      GET /_packagesCatalog + capability truth
      GET /_browse/…Directory / file JSON for the store browser
      Depot vs realm (backport prep)Ruled — docs/deck-depot-and-realm-backport.md (standalone = depot; Boxel realm stays placement-free)
      deck.land hostnamesRuled — docs/deck-land-hostnames.md (apex / local / cloud / cdn; hub is the app)
      Activity (/_activity)Proposed — docs/deck-activity-protocol.md (replaces /_heat; lean focus + story=1 canvas pack)
      UI vocabularyRuled — docs/deck-step-version-vocabulary.md (Version/Step/Timeline; Import/Pin/Entry/Leaf/Closure)
      UI vocabularyRuled — docs/deck-step-version-vocabulary.md (Version/Step/History; Import/Pin/Entry/Leaf/Closure)

      diff --git a/docs/artifacts/backport-gallery-scene.gts b/docs/artifacts/backport-gallery-scene.gts index e4d05be..be26349 100644 --- a/docs/artifacts/backport-gallery-scene.gts +++ b/docs/artifacts/backport-gallery-scene.gts @@ -4,8 +4,8 @@ import { tracked } from '@glimmer/tracking'; import { CardDef, Component, field, contains } from '@cardstack/base/card-api'; import StringField from '@cardstack/base/string'; // Bare import — answered by the realm decklist pin (see backport-remix-decklist.json). -// This module’s RRI is @workspace/acme/gallery/scene → three@0.169.0. -// @workspace/acme/gallery/legacy-viewer/scene can import 'three' too → 0.160.0 via scopes. +// This module’s RRI is @acme/gallery/scene → three@0.169.0. +// @acme/gallery/legacy-viewer/scene can import 'three' too → 0.160.0 via scopes. import * as THREE from 'three'; export class GalleryScene extends CardDef { diff --git a/docs/artifacts/backport-remix-decklist-browser.json b/docs/artifacts/backport-remix-decklist-browser.json new file mode 100644 index 0000000..5af12e9 --- /dev/null +++ b/docs/artifacts/backport-remix-decklist-browser.json @@ -0,0 +1,16 @@ +{ + "imports": { + "three": "https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js", + "three/addons/": "https://app.boxel.ai/catalog/lib/three@0.169.0/examples/jsm/", + "@cardstack/base/": "https://app.boxel.ai/base@1.12.0/" + }, + "scopes": { + "https://app.boxel.ai/workspace/acme/gallery/legacy-viewer/": { + "three": "https://app.boxel.ai/catalog/lib/three@0.160.0/build/three.module.js", + "three/addons/": "https://app.boxel.ai/catalog/lib/three@0.160.0/examples/jsm/" + } + }, + "deck": { + "extends": "https://app.boxel.ai/catalog/chris/gallery-board@2.1.0/" + } +} diff --git a/docs/artifacts/backport-remix-decklist-rri.json b/docs/artifacts/backport-remix-decklist-rri.json deleted file mode 100644 index 29753c5..0000000 --- a/docs/artifacts/backport-remix-decklist-rri.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "imports": { - "three": "@catalog/lib/three@0.169.0/build/three.module.js", - "three/addons/": "@catalog/lib/three@0.169.0/examples/jsm/", - "@cardstack/base/": "@cardstack/base@1.12.0/" - }, - "scopes": { - "@workspace/acme/gallery/legacy-viewer/": { - "three": "@catalog/lib/three@0.160.0/build/three.module.js", - "three/addons/": "@catalog/lib/three@0.160.0/examples/jsm/" - } - }, - "deck": { - "extends": "@catalog/chris/gallery-board@2.1.0/" - } -} diff --git a/docs/artifacts/backport-remix-decklist.json b/docs/artifacts/backport-remix-decklist.json index 5af12e9..5c619a7 100644 --- a/docs/artifacts/backport-remix-decklist.json +++ b/docs/artifacts/backport-remix-decklist.json @@ -1,16 +1,16 @@ { "imports": { - "three": "https://app.boxel.ai/catalog/lib/three@0.169.0/build/three.module.js", - "three/addons/": "https://app.boxel.ai/catalog/lib/three@0.169.0/examples/jsm/", - "@cardstack/base/": "https://app.boxel.ai/base@1.12.0/" + "three": "@catalog/three@0.169.0/build/three.module.js", + "three/addons/": "@catalog/three@0.169.0/examples/jsm/", + "@cardstack/base/": "@cardstack/base@1.12.0/" }, "scopes": { - "https://app.boxel.ai/workspace/acme/gallery/legacy-viewer/": { - "three": "https://app.boxel.ai/catalog/lib/three@0.160.0/build/three.module.js", - "three/addons/": "https://app.boxel.ai/catalog/lib/three@0.160.0/examples/jsm/" + "@acme/gallery/legacy-viewer/": { + "three": "@catalog/three@0.160.0/build/three.module.js", + "three/addons/": "@catalog/three@0.160.0/examples/jsm/" } }, "deck": { - "extends": "https://app.boxel.ai/catalog/chris/gallery-board@2.1.0/" + "extends": "@chris/gallery-board@2.1.0/" } } diff --git a/docs/deck-a-package-carries-its-own-name.md b/docs/deck-a-package-carries-its-own-name.md index 5bfc24e..d09d2f7 100644 --- a/docs/deck-a-package-carries-its-own-name.md +++ b/docs/deck-a-package-carries-its-own-name.md @@ -3,8 +3,8 @@ **Status: RULING** (2026-08-11). Companions: [`deck-a-package-resolves-through-its-own-map.md`](deck-a-package-resolves-through-its-own-map.md) (the same instinct applied to resolution), -[`deck-name-derivation-ruling.md`](deck-name-derivation-ruling.md) (how names -are derived when a fold is lossy), +the planned name-derivation ruling (how names are derived when a fold is +lossy), [`deck-multi-package-design.md`](deck-multi-package-design.md) (the design this corrects), [`deck-laws.md`](deck-laws.md) (paths are IDs). diff --git a/docs/deck-a-package-resolves-through-its-own-map.md b/docs/deck-a-package-resolves-through-its-own-map.md index e0e8285..6decb4d 100644 --- a/docs/deck-a-package-resolves-through-its-own-map.md +++ b/docs/deck-a-package-resolves-through-its-own-map.md @@ -1,5 +1,14 @@ # A package resolves through its own map +**Status: SUPERSEDED for the consuming depot** (2026-08-12) by +[`deck-package-json-and-depot-lock.md`](deck-package-json-and-depot-lock.md). +A package's `package.json` is a suggestion; the consuming realm's canonical +composition of root and package/app-local import maps is the only resolution +authority. Same pack, two depots, two programs — that +is the owner's jurisdiction. This file remains the record of the earlier +ruling and still describes sealed-map behaviour for trees that have no +depot lock (dual-lock fallback). + **Status: RULING** (2026-08-11). Companions: [`deck-multi-package-design.md`](deck-multi-package-design.md) (the map format, and the design this narrows), @@ -93,10 +102,10 @@ tune the depot. - ~~**Whether a depot may ever override a sealed pin.**~~ **Settled** by [`deck-who-may-intervene.md`](deck-who-may-intervene.md). A sealed pin may - be advanced *within the range the package itself declared* — permission + be advanced _within the range the package itself declared_ — permission granted in writing at publish time, so it is not a depot second-guessing a package. Out-of-range remains open there. -- **What a packaged module's *unsealed* imports mean.** A `$DECK/` reference +- **What a packaged module's _unsealed_ imports mean.** A `$DECK/` reference is documented as the deliberate live escape hatch; whether a bare specifier with no sealed answer should refuse to resolve, or fall through to the depot, is open. Refusing is more honest and more annoying. diff --git a/docs/deck-activity-protocol.md b/docs/deck-activity-protocol.md index 1ac6116..fda6627 100644 --- a/docs/deck-activity-protocol.md +++ b/docs/deck-activity-protocol.md @@ -7,7 +7,7 @@ locator, commentary — without a second focus API. Companions: `deck-live-autopublish.md`, `deck-hub-brief.md` (`/track`, `/run`, `/browse`), `deck-land-hostnames.md`, `deck-laws.md`, -`deck-step-version-vocabulary.md` (Version / Step / Timeline). +`deck-step-version-vocabulary.md` (Version / Step / History). Supersedes: `deck-heat-protocol.md` (folded in here). --- diff --git a/docs/deck-at-rest.md b/docs/deck-at-rest.md index 0e41791..626cacf 100644 --- a/docs/deck-at-rest.md +++ b/docs/deck-at-rest.md @@ -1,4 +1,4 @@ -# Deck at rest +# Deck at rest inside a realm Written 2026-08-09. This document describes what a Deck depot looks like on disk, and what a second implementation must agree with. It uses ASD-STE100 @@ -14,8 +14,11 @@ are the agreement between two programs. `tree-hash-v1` and the Deck Pack are formats. The store layout in section 3, the command-line tool and the watcher are implementation: no law requires a server to store anything this way. -Deck is not a federation protocol. Offers and endorsements addressed by tree -hash are the parts that need two servers, and they are not finished. +Deck's package-serving laws do not require federation. The first durable +Repository, Checkpoint, and Ref layer is specified separately in +[`deck-collaboration-protocol.md`](deck-collaboration-protocol.md). Its local review +Request workflow and conditional object-store adapters are implemented; +hosted identity, policy, and cross-server discovery remain unfinished. **The laws are the contract, not this document.** `docs/deck-laws.md` states the twelve laws. `packages/conformance` examines them, and it also examines a @@ -30,76 +33,130 @@ laws and the conformance suite disagree, the suite is correct. The vocabulary changed in `docs/deck-step-version-vocabulary.md`. The important changes from v2: -| now | was | why | -| --- | --- | --- | -| **Version** | sealed deck, seal | "Seal" was a noun for a published version, a noun for a history checkpoint, and a verb. Three meanings for one word. | -| **Step** | seal, frame, tick | One capture of the working tree. One Step for each save. | -| **Timeline** | history, the tape | The Steps in order. | -| **Deck Pack** | cardpack | The file form is `.pack.zip`. The name `cardpack` belongs to the v0.2 implementation. | -| **depot** | realm, collection | A directory that is also a URL space. | -| **Import / Pin / Entry** | dependency, resolution | What a deck asks for, the exact address that answers, and the module a deck offers. | +| now | was | why | +| ------------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Version** | sealed deck, seal | "Seal" was a noun for a published version, a noun for a history checkpoint, and a verb. Three meanings for one word. | +| **Step** | seal, frame, tick | One capture of the working tree. One Step for each save. | +| **History** | timeline, the tape | The Steps in order. | +| **Deck Pack** | cardpack | The file form is `.pack.zip`. The name `cardpack` belongs to the v0.2 implementation. | +| **depot** | realm, collection | A directory that is also a URL space. | +| **Import / Pin / Entry** | dependency, resolution | What a deck asks for, the exact address that answers, and the module a deck offers. | -Words to avoid: *tier* for a level of history, *frame* or *tick* for a Step, -and *seal* alone. Write *Version*, *Step*, or *to seal*. +Words to avoid: _tier_ for a level of history, _frame_ or _tick_ for a Step, +and _seal_ alone. Write _Version_, _Step_, or _to seal_. --- ## 2. The layout -A depot is a directory. The layout of the directory is the URL space that the -server supplies. +A mutable Deck repository is rooted in one realm directory. A repository may +contain one package or several member packages, but all of its Deck machinery +is inside that realm. A host may aggregate many realms in one bucket or +process; it must not move their Deck objects into a parallel global tree. ``` -/ - //… the live tree — the files you edit +/ + /… default-branch files you edit + package.json conventional package metadata, where applicable + importmap.json optional root resolution input + apps//… conventional app/package locations + packages///… conventional package locations .deck/ + repository.json optional multi-package authoring manifest store/ _objects// one file's bytes, one time _trees//.json one tree's packlist, one time + _locks//.json one composed exact lock, one time + _repositories//.json one Repository state, one time + _checkpoints//.json one authored Checkpoint, one time + _revisions//.json legacy V1 objects; read-only //meta.json Versions and dist-tags + refs/heads/.json mutable branch heads + refs/published.json optional mutable published Release ref + reviews/ + objects//.json immutable Review openings + events//.json append-only Review events + numbers/.json mutable numbered Review refs + counter.json mutable number allocator + indexes///… completed index generations + releases/ + objects//.json immutable host Releases + branches// + source/ non-default branch materialization + history/repo/ branch-local jj History + transaction/ prepared recovery records + index-staging/ private index build files claims/.json declared work in progress policy.json the cooldown policy - .jj/ the Timeline, when a backend attached + history/ + repo/ default-branch every-save jj History + .jj/ thin jj workspace stub; not durable identity ``` -**Target (deckd):** Timeline moves under `.deck/timeline/` and derived build -cache under `.deck/build/` — **no top-level `.jj`**. See -[`deck-daemon.md`](deck-daemon.md). Until that lands, `.jj/` above remains -the reference layout. +This deliberately keeps the filesystem shape proven by Boxel's +`deck/at-rest-poc`: the default branch is the realm root, not +`branches/main/source` and not a generated checkout. Existing paths remain +valid. The examples above do not require every realm to contain `apps/`, +`packages/`, a root `package.json`, or a root `importmap.json`. + +`package.json` and `importmap.json` have different, composable jobs. +`package.json` lives at a conventional package boundary and carries identity, +exports, and dependency suggestions. A root import map and any applicable +nested import maps are authored resolution inputs. Deck resolves their +inheritance and scopes and stores the canonical composed result as the exact +lock object named by `lockHash`. A Checkpoint therefore closes over the +effective lock, not over an assumption that one root file controls the whole +realm. + +deckd stores default-branch History under `.deck/history/repo`; its small top-level +`.jj/` workspace stub points there because stock jj discovers that basename. +For a selected non-default branch, the realm view projects that branch's +physical History as `/.deck/history/repo` too. Agents never need the opaque +host path. +Derived build cache lives under `.deck/build/`. See +[`deck-daemon.md`](deck-daemon.md). Two rules make this work: -1. **The store path and the URL path have the same shape.** A package at - `///@/` is stored under - `/.deck/store///`. Neither path has to be +1. **The store path and the URL path have the same shape inside its realm.** A + package at `///@/` is stored under + `/.deck/store///`. Neither path has to be translated into the other. 2. **The machinery is invisible to the tree hash.** `DEFAULT_IGNORE` excludes `.deck`, `.jj`, `.git`, `node_modules` and `.DS_Store`. A caller can add to that set. A caller cannot remove from it, because two implementations would then hash one directory differently. -Copy the directory and you copy the live files, the Versions and the Timeline. +Copy the realm directory and you copy its live files, branches, Versions, +Reviews, indexes, and History. Creating a branch is narrower: clone the +authored realm tree while excluding `.deck`, `.jj`, `.git`, and other +implementation directories, then initialize the new branch's History and +index through their owning services. -**A depot is close to a monorepo**: many packages in one directory, one -history, one thing to clone, and a change that crosses several packages is -one change. Four differences are worth knowing. +**A multi-package realm is close to a monorepo**: many packages in one +repository boundary, one default branch, and a Checkpoint that can cross +several packages. Four differences are worth knowing. + +The architectural argument for keeping that coherent development experience +while replacing the monorepo as the storage and coordination boundary is in +[`deck-cas-packages-vs-monorepos.md`](deck-cas-packages-vs-monorepos.md). - Each deck has its own version line. A monorepo usually versions everything together, or needs a tool so that it does not have to. -- The directory layout *is* the URL space. A consumer imports one module from +- The directory layout _is_ the URL space. A consumer imports one module from one deck, and no build step extracts it first. - The publisher segment lets two people hold the same package name in one depot. That is a fork inside the repository, and it costs nothing until the bytes differ. -- There is no workspace file and no link step. A deck names another deck by - address, and `deck lock` turns the range into a pin. +- There is no separate linking manifest or link step. A deck names another + deck by address, and `deck lock` turns the range into a pin. --- ## 3. The store The store is **content-addressed for each file**, and the objects belong to -the whole store, not to one package. +the realm, not to one package and not to a host-global parallel tree. - A Version is a **pointer**: a version number, and the tree hash it names. - A tree is a **packlist**: the paths in the tree, and the hash of each file. @@ -161,31 +218,31 @@ depending on when it was asked. The publisher segment is the fork boundary. A path segment that starts with `_` is reserved for the protocol. -| address | is | cache | -| --- | --- | --- | -| no version segment | the live tree | `no-store` | -| `@` | one Version | `immutable` | -| `@` or `@` | a name for a Version | one redirect to the exact address | -| `/_history//…` | one Step | `immutable` | +| address | is | cache | +| ------------------------ | -------------------- | --------------------------------- | +| no version segment | the live tree | `no-store` | +| `@` | one Version | `immutable` | +| `@` or `@` | a name for a Version | one redirect to the exact address | +| `/_history//…` | one Step | `immutable` | A range never matches a prerelease. `latest` is a dist-tag on a stable Version, and `latest` is not live. --- -## 5. The Timeline +## 5. The History A depot has one of two answers, and the server **reports** which one. It does not pretend. Do not number these, and do not call them tiers: the two answers have names. -- **Versions only.** Each settled save becomes an immutable `-dev.N` Version. - The Versions are the history. Always available. -- **A Timeline.** Each save also becomes a Step, at +- **Version history only.** Each settled save becomes an immutable `-dev.N` + Version. The Versions are coarse-grained history. Always available. +- **Every-save History.** Each save also becomes a Step, at `//_history//…`, and `deck history` and `deck restore` work. Available when a history backend is attached. -A backend that is missing or has stopped leaves the depot with Versions only. +A backend that is missing or has stopped leaves the depot with Version history only. The server prints the reason and does not stop the live loop. `/_packages` carries the answer for each depot, so a client asks instead of assuming. @@ -228,7 +285,7 @@ after the fact**: Three decisions already constrain the answer: 1. **There is no unpublish** (L4). A Version cannot be altered or removed, so - the only way to say it is bad is to say so *about* it. That is the same + the only way to say it is bad is to say so _about_ it. That is the same shape as an endorsement, and it is the correct shape. 2. **A statement is addressed to the tree hash** (L9), so a revocation reaches every publisher that holds those bytes, exactly as an endorsement diff --git a/docs/deck-boxel-backport-for-team.md b/docs/deck-boxel-backport-for-team.md deleted file mode 100644 index 4a9e71c..0000000 --- a/docs/deck-boxel-backport-for-team.md +++ /dev/null @@ -1,416 +0,0 @@ -# Bringing Deck into Boxel — team brief - -**Audience:** Boxel / Cardstack engineers who have not worked in the Deck repo. -**Purpose:** Plain-English plan for what we gain, what lands where in Boxel, and in what order. -**Assumption:** We start from a clean Boxel `main`, add a tight **Deck Core** package under something like `packages/deck`, and wire Boxel to it (no Hub UI, no Deck demos). - -Internal numbering from our planning docs is kept in parentheses so you can match longer write-ups later. You do not need those docs to follow this. - -| Shorthand | Means | -| --- | --- | -| **K1–K14** | The fourteen product benefits we want from Deck in Boxel | -| **P1–P4** | Four technical prerequisites that unlock most of those benefits | -| **PR 0–14** | The stack in [`deck-boxel-backport-changelist-mockup.md`](deck-boxel-backport-changelist-mockup.md). **The only numbering.** This doc groups those PRs; it does not number anything itself. | -| **Deck Core** | Protocol library: identity, packs, store, merge, serve grammar, optional timeline — not the Deck Hub app | - -**Two living docs for execution** (everything else is appendix): - -| Where | Doc | Role | -| --- | --- | --- | -| **Deck repo** | [`deck-work-plan-for-backport.md`](deck-work-plan-for-backport.md) | Protocol questions closed *here* so Boxel PRs are wiring | -| **Boxel work** | [`deck-boxel-backport-changelist-mockup.md`](deck-boxel-backport-changelist-mockup.md) | Stacked PRs, blast radius, agent prompts | - -Appendix (why / deep dives): `deck-boxel-monorepo-change-register.md`, `deck-boxel-backport-plan.md`, `deck-depot-and-realm-backport.md`, `deck-core-backport-split.md`, [`deck-daemon.md`](deck-daemon.md) (deckd / `.deck`-only Timeline + build). Sample decklist + `.gts` under `docs/artifacts/`. - ---- - -## How we execute (campaign, not the benefit list) - -The K1–K14 table is the destination. The **campaign** is shorter and stricter: - -1. **Deck-side gate before Boxel PR 0.** `extends`, classifier, provenance, bare/hermetic packs, lean Core cone, and `deck conform` against a foreign URL — settled in the Deck work plan. Monorepo PRs must stay “call this / mount that,” not reopen design. -2. **Prove the decklist in product early.** After importer-aware resolve, ship a **thin** install / remix / use against a fixture LibSpec — before Timeline, before Matrix, before catalog authority theater. -3. **Slice the Timeline hinge.** Steps on one write path → restore API → one host restore surface → agent claims → CLI 3-way. Refuse Timeline PRs that reimplement merge/store in realm-server. -4. **Three tracks, different merge bars.** - -| Track | Owns | May lag | -| --- | --- | --- | -| **A — Map** | Resolver, decklist, thin verbs, LibSpec fixtures | Matrix, packs polish | -| **B — Tape** | Timeline / Steps / Versions, restore, CLI merge | Catalog CDN | -| **C — Carriage** | Classifier wiring, packs, GitHub share/gather interim | Base realm | -| **D — Build** | **deckd**: Timeline under `.deck` only; esbuild/AMD derived plane ([`deck-daemon.md`](deck-daemon.md)) | Not on Map/Tape critical path; D0 layout can trail Timeline attach | - -5. **Program gates** (falsifiable — not fourteen local Done whens): - - Monorepo PRs are wiring, not design debates. - - Algorithms imported from `@cardstack/deck`, not forked. - - `deck conform` against a deck-enabled realm URL is green. - - **Author gate:** remix of a multi-file package does not duplicate unchanged files. - -**Demote / do not staff on the critical path:** negotiated prune; full Matrix submission (use GitHub share/gather + Base-Version until A+B are real); review-tools product (K13); minify gold-plating (K9); bulk vendor corpus and “catalog on CDN” as blockers for Map/Tape. - -**Spines if you cut hard:** - -- **Product:** resolver → versioned serve door → thin decklist verbs → Timeline restore -- **Infra:** resolver → Timeline write path → CLI 3-way - ---- - -## What Deck is (one paragraph) - -Deck is a **versioning and serving protocol for module trees** (and JSON): immutable published **Versions**, a working “live” tree, content-addressed storage, 3-way merge, and a **Timeline** of every-save **Steps**. In Boxel terms: a **realm can become deck-enabled** — same realm product word, with Deck’s versioning and serve rules underneath. Boxel stays the product; Deck Core is the library the realm server and host import. - -Deck Core is a library drop, not the whole Deck teaching repo (no Hub, no demos). - ---- - -## Versions vs Timeline — what Boxel should turn on - -Deck can run **Versions-only** (publish/restore named cuts, no every-save tape). That is fine for some catalog mirrors. **It is not the product default for editable Boxel realms.** - -For Boxel we generally want **Timeline on**: - -| Who is writing | What we want recorded | Why | -| --- | --- | --- | -| **Human in the UI** (card instances, fields, layout, code in the host) | A **Step** on every settled save | Undo/restore, “what did the canvas look like an hour ago,” safe experimentation | -| **Agent-based updates** (especially **definitions** — `.gts` / card defs — and any agent write into the realm) | A **Step** per settled write batch; **claims** for “I am working here” | Audit what the agent changed, rewind a bad turn, parallel agents don’t erase each other’s trail | -| **Publish / milestone / catalog cut** | A **Version** (immutable `@exact`) | What others pin, cache, and remix against — not every keystroke | - -```text -UI edit or agent write → live tree changes → Step (Timeline) - └─ when you mean it → Version (store) -``` - -- **Steps** answer “what happened while we were editing.” -- **Versions** answer “what is the named release others depend on.” -- Restoring a Step does **not** rewrite published Versions. -- Agent **claims** (intent) and **touch/activity** (observed heat) are separate from Steps — see K12; don’t conflate “working here” with “captured a Step.” - -The Timeline hinge is therefore not “optional history if someone wants undo.” It is **Timeline + Steps for interactive and agent edits**, with Versions as the publish layer on top. Catalog read-only mirrors may stay Versions-only; personal and team working realms should ship with Timeline. - ---- - -## An app is a decklist (import map) - -This is easy to understate and expensive to miss. - -In Deck terms, a running **app is not a folder of copied dependencies**. It is -a **decklist** — an import map (plus optional `scopes` and `extends`) that -says which **RRI / Version** answers each bare name. The cards and modules -you author sit in the live tree; everything else is a pin. - -| Verb | What actually changes | -| --- | --- | -| **Use** | Create an instance whose `adoptsFrom` is an exact versioned RRI | -| **Install** | Add pins to *my* decklist (manage upgrades later) | -| **Remix** | `extends` a parent Version + override only what I change — **not** a tree copy | -| **Upgrade** | Repoint a pin / parent and re-resolve | - -So K5 and K8 are not four product features and a performance tip. They are -consequences of “the app *is* the map.” If Boxel keeps teaching remix as -copy-the-package, we have not backported the model — only some plumbing. - -Authors still write normal imports (`three`, `@cardstack/base/card-api`). -The decklist is where versions and coexistence live. See the sample in -`docs/artifacts/backport-remix-decklist.json` and PR 1 in the changelist. - -**Naming: fully-qualified URLs now, RRI aliases later.** Boxel's RRI grammar -today is a scoped identifier (`@cardstack/base/card-api`) or a full URL — -there is no version segment, and no `@catalog/` or `@workspace/` prefix. We -are not waiting for one. The version lives in the **path** -(`https://app.boxel.ai/catalog/lib/three@0.169.0/…`), which is Deck's own -serve grammar, so the backport builds and runs on what exists. When the -[RRI migration](https://linear.app/cardstack/project/cardstackbase-defea0d67319) -adds the aliases, the swap is a rewrite of decklist values — one -`importmap.json` per deck, using the classifier we already need for publish. -Deck's `extends` accepts both forms today, so nothing there has to change. - -The cost of the ruling is one sequencing change: **PR 3 moves onto the -spine**, because a pin needs a servable `@version` address to point at. - ---- - -## What we are trying to buy (the benefits) - -| # | Benefit in plain English | -| --- | --- | -| **K1** | **Timeline + Steps** behind UI and agent edits (restore/undo), plus **Versions** for publish/checkpoint — not Versions-only | -| **K2** | Immutable, cacheable module URLs (CDN-grade caching when pinned to an exact version) | -| **K3** | Our own unpkg-style vendoring of third-party libs, with policy, browsable as catalog specs | -| **K4** | One checkpoint/milestone story; sync uses **3-way merge** instead of “pick local or remote” | -| **K5** | Honest catalog verbs on a **decklist**: install / upgrade / remix / use (the app *is* the map) | -| **K6** | Catalog is the authority; GitHub is a read-only mirror (plus a safe intake path when needed) | -| **K7** | Submit / review / vote across realms (not “open a GitHub PR” as the product workflow) | -| **K8** | Remix = decklist `extends` + overrides — **without** copying the whole tree | -| **K9** | Published releases can be minified and gzipped (safe because exact versions never change) | -| **K10** | Versioned realm infra that can replace the github + npm + esm.sh combo for card apps/libs | -| **K11** | Base realm (card-api, etc.) distributed via Deck import maps instead of a special snowflake | -| **K12** | Live agent awareness / activity visible in preview while work happens | -| **K13** | Richer review / patch tools over protocol data (versions, diffs, claims, offers) | -| **K14** | Realm download / move as a real **Deck Pack** (with provenance); full “negotiate what to prune” later | - -**K10** is the destination (the sum of the above), not a separate ticket. - ---- - -## Where things land in the Boxel monorepo - -Think in subsystems, not Deck package names: - -| Boxel area | What Deck asks it to do | -| --- | --- | -| **`packages/deck` (new)** | Vendor Deck Core. Host imports the **browser-safe** surface (resolution). Realm-server imports the **Node** surface (store, pack, history helpers, serve helpers). | -| **`runtime-common`** | Resolver takes the *importer* (so two versions of the same library can coexist); realm write path records **Steps** (and publishes **Versions** when appropriate); classification of absolute URLs imported from Core — not reinvented. | -| **`realm-server`** | Serve Deck-shaped addresses and cache headers; **Timeline attached for editable realms**; activity/claims read path; pack download/intake; later mirror job; ACL around offers. | -| **`host` (Ember app)** | Remix / install / use UX; preview shows activity; restore/browse Steps from UI edits and agent turns; eventually base-realm URLs via Deck. | -| **`packages/boxel-cli`** | Local checkpoints align with Timeline/Steps; sync conflicts become 3-way merges against a Version (or Step) base. **The standalone `cardstack/boxel-cli` is retired** — this is the only CLI to migrate. | -| **Catalog / realm content** | LibSpec cards, four consumption verbs, submission workflow cards, review tools — much of K5/K7/K13 is **content**, not core TS. | -| **Outside the monorepo (early)** | Hosted Deck / vendor pipeline / corpus migration for third-party libs (K3 bulk). | - -Boxel keeps the word **realm**. “Depot” is Deck-standalone vocabulary; a deck-enabled realm *plays the role* of a depot for the protocol. - ---- - -## Order of operations - -Order and dependencies matter more than calendar guesses. What sets the pace -is **review risk**, **blast radius** (especially base realm), and refusing -to reopen protocol design inside Boxel PRs. Sections below are the **campaign -order** — not “list every K benefit once.” - -### Deck work plan complete — the gate before PR 0 - -Before PR 0: `extends`, classifier, provenance, bare/hermetic, -lean Core, foreign `deck conform`, scopes proof — see -[`deck-work-plan-for-backport.md`](deck-work-plan-for-backport.md). If a -Boxel PR needs a design debate, the work returns here. - ---- - -### Lean Core into the monorepo — PR 0 - -**Add:** `packages/deck` — protocol + serve grammar + history helpers + -conform surface. Browser entry for host / `runtime-common`; `/node` for -realm-server. - -**Do not add:** Hub, examples, teaching CLI, npm vendor pipeline, minify -derive, rebase-daemon (Tools until a customer PR needs them). - -**Gate:** install/build green; host graph pulls no `node:fs`; no realm -behavior change. - ---- - -### Resolver that knows who is importing — PR 1 (**Track A**) - -**Change in:** `runtime-common` (virtual network / loader). - -**What we add:** importer context, longest-prefix, import-map **scopes**. - -| Benefit unlocked | Why | -| --- | --- | -| **K8** Remix without copying | One card on A@new, another path on A@old, both live | -| Path to **K5**, **K3**, **K11** | All need scoped resolution | - -**Shape:** small. **First monorepo behavior PR.** - ---- - -### Thin decklist verbs — PR 1b (**Track A**, early product win) - -**Change in:** `runtime-common` decklist helpers; minimal host actions; -**fixture** LibSpec (not full catalog redesign). - -**What we add:** install / remix / use (upgrade can be stubby) that mutate -the app **decklist** — `extends` + pins — and do **not** full-copy trees. - -| Benefit unlocked | Why | -| --- | --- | -| Early **K5** + author-visible **K8** | Proves “app = decklist” in the product, not only in unit tests | - -**Shape:** small–medium. **Do this before staffing Timeline.** Full catalog -polish and package-page affordances can trail (PR 12 verb polish). - ---- - -### Classifier on publish — PR 2 (**Track C**, parallel OK) - -**Change in:** `realm-server` publish handler; **import** -`@cardstack/deck/classify` (do not fork). - -| Benefit unlocked | Why | -| --- | --- | -| Publish hygiene / **K14** prep | leave / rebind / rewrite; assets never become pins | - -**Shape:** small; parallel with PR 1 / 1b. Not on the product spine. - ---- - -### Versioned serve addresses — PR 3 (**on the spine**) - -**Was "optional early"; the URL-first naming ruling moved it.** The realm -server has to answer `…/three@0.169.0/…` before anything can pin a version, -so PR 1b has nothing to install from until this lands. The serve door is -small — a read-only handler plus a thin registry gate. - -**PR 3 also starts the `deck conform` CI job** (decided 2026-08-09). It is -the first Deck-shaped HTTP surface, so the program gate begins running here -rather than arriving finished and late. Most laws will report *not -applicable* at first; the job tightens as PR 6 and PR 4b land. - -What stays optional is the **corpus**: the npm crawl and bulk vendoring for -third-party libs (**K3**, catalog **K2**/**K9**) can live on a hosted Deck -outside the monorepo and must **not** block Track A or B. Staff the door now; -staff the corpus when someone owns it. - ---- - -### Timeline hinge — PRs 4a–4d, then 5 (**Track B**) — *sliced*, not one epic - -**Runtime: the `jj` binary in the realm-server image** (decided 2026-08-09). -The faster `historyd` backend is a later swap behind an identical contract; -shipping a binary avoids a second process to deploy and supervise, and Steps -are debounced so the per-call cost stays off the hot path. It is a deployment -task with an owner. A realm whose Timeline cannot start degrades to -Versions-only with a printed reason — it never fails writes. - -Do **not** ship editable realms as Versions-only and call K1 done. -Do **not** land write path + host UI + claims + CLI sync in one PR. - -| Slice | Ship | Explicit non-goal | -| --- | --- | --- | -| **4a** | Steps on *one* write path (definition saves or batched writes) via Deck façade | No host restore UI | -| **4b** | List/restore HTTP through normal `Realm.write` | No agent claims UX | -| **4c** | One host history list + restore | No CLI rewrite | -| **4d** | Agent writes → same Step path + claims; activity read if cheap | No 3-way sync | -| **5** | CLI checkpoints + `mergeTrees` 3-way (**K4**) | No minify / CDN | - -Each slice: wire Core; **refuse** reimplementing merge/store in realm-server. -Gate with integration tests and, when the realm speaks enough HTTP, -`deck conform`. - -| Benefit unlocked | Why | -| --- | --- | -| **K1** | Steps for edits; Versions when you publish a cut | -| **K4** (PR 5) | Real common ancestor for sync | -| Foundation for **K12** / **K13** | Claims and review read the same tape | - ---- - -### Base realm via Deck — PR 8 (**K11**) - -**Highest blast radius** — every realm depends on base. Only after **PR 4c** -(restore works) so a bad base Version is recoverable. - ---- - -### Cache classes, then activity — PRs 6 and 9 (**K2**, **K12**; PR 7 / **K9** demoted) - -Exact Version → immutable cache; live → no-store (**K2**). Agent activity in -preview (**K12**) when PR 4d data exists. **Minify/gzip derivatives (K9)** only -after cache classes are trusted — easy to gold-plate; not a spine item. - ---- - -### Catalog authority and collaboration — PR 11, then 13 (**K6**, then **K7**) - -GitHub as **mirror** (**K6**). **Caveat:** `share` / `gather` exist only in -the retired standalone CLI, so the "interim truck" is currently the mirror -export plus `Base-Version` trailers — no CLI verbs — unless someone scopes -share/gather as new commands in `packages/boxel-cli`. Full Matrix submission / retire GH-PR-as- -product (**K7**) **after** Tracks A+B are real — do not staff Matrix early. - -Catalog verb *polish* (pretty package pages, upgrade UX) lands here if not -finished in PR 1b. - ---- - -### Packs and review tools — PRs 10 and 14 (**K14**, **K13**) - -Bare/hermetic packs with provenance (**K14**). Negotiated prune stays -undesigned. Review tools (**K13**) are content after Steps exist — trail, -don’t block. - ---- - -## Dependency picture (campaign) - -```text -−1 Deck work plan gates → Boxel PRs stay wiring - 0 lean Core drop - 1 resolver → K8 -1b thin decklist verbs → early K5 (product spine) ← prove the model - 2 classifier on publish → parallel (Track C) - 3 versioned serve door → spine: a pin needs an address ← prereq for 1b - 3′ vendor corpus / CDN → optional; must not block A/B -4a–4d, 5 Timeline slices → K1, then K4 ← hinge, sliced - 8 base via Deck → K11 (after PR 4c restore) - 6 cache, then 9 activity → K2, K12; PR 7 / K9 later -11 mirror; 13 Matrix later → K6, then K7 -10 packs; 14 review trails → K14, K13 -``` - ---- - -## What is *not* “port the Deck Hub” - -| Stays out of the first Boxel drop | Why | -| --- | --- | -| Deck Hub UI | Inspiration only; Boxel has host + catalog UX | -| Deck example demos | Teaching artifacts | -| Full prune-negotiation protocol | Undesigned; ship bare/hermetic first | -| Treating GitHub as the registry | Opposite of K6 | -| Matrix submission as day-one work | Interim = share/gather + Base-Version | -| Fat Core (vendor-npm, minify, rebase-daemon) | Tools until a PR needs them | - -Collaboration across private realms long-term: **Matrix** (identity + room + -message of intent) + optional Deck Pack; realm ACL for offer/rebase. Until -Tracks A+B ship, the GitHub mirror is the truck — not the authority. (The -`share`/`gather` CLI verbs that used to carry this live in the retired -standalone repo; see PR 11.) - ---- - -## Practical “start Monday” checklist - -1. Confirm the Deck work-plan gates — do not open Boxel design debates. -2. Clean Boxel `main`; add **lean** `packages/deck` (PR 0). -3. PR 1 resolver; author gate: **remix without copying**. -4. PR **3** serve door — versioned addresses, so a pin has a target. -5. PR **1b** thin install/remix/use on a fixture — **before** Timeline. -6. Parallel (optional owners): classifier publish (2), vendor corpus (3′). -7. Staff Timeline as **4a → 4d → 5** slices with restore before base realm. -8. Base realm (PR 8) only after Step restore works. -9. Mirror / share-gather (PR 11); Matrix retirement of GH submission last (PR 13). -10. Packs (PR 10) + review tools (PR 14) without blocking Map/Tape wins. - ---- - -## Cheat sheet - -| PR | Main Boxel touchpoints | Benefits | Shape | -| --- | --- | --- | --- | -| — Deck gates | Deck repo work plan | — | **hard gate** | -| 0 Lean Core | `packages/deck` | — | dependency drop | -| 1 Resolver | `runtime-common` | K8 | small — first behavior | -| **1b Thin verbs** | host + helpers + fixture | **early K5** | **product spine** | -| 2 Classifier | `realm-server` | publish / K14 prep | parallel OK | -| **3 Serve door** | `realm-server` | **address space** | **spine — prereq for 1b** | -| 3′ Vendor corpus | outside + content | K3, catalog K2 | optional early | -| **4a–4d, 5 Tape** | realm write, HTTP, host, claims, CLI | **K1, K4** | **hinge — sliced** | -| 8 Base realm | `runtime-common`, `host` | K11 | highest blast radius | -| 6 Cache, 9 activity | `realm-server`, `host` | K2, K12 (K9 later) | medium | -| 11 Mirror; 13 Matrix later | content + jobs | K6, then K7 | process | -| 10 Packs; 14 review trails | content + `realm-server` | K14, K13 | after tape | - -Commit-level Touch / Do / Don’t: `deck-boxel-backport-changelist-mockup.md`. - ---- - -## One-sentence summary for stakeholders - -We finish protocol decisions in Deck, drop a lean Core into Boxel, treat an -**app as a decklist** addressed by fully-qualified URLs until the RRI -migration hands us aliases, prove remix/install without copying **before** we -attach a Timeline of Steps (Versions on publish), then serve/cache correctly -and move registry authority into the catalog — with GitHub share/gather as -interim collaboration and Matrix later, never as the source of truth. diff --git a/docs/deck-cas-packages-vs-monorepos.md b/docs/deck-cas-packages-vs-monorepos.md new file mode 100644 index 0000000..79a6413 --- /dev/null +++ b/docs/deck-cas-packages-vs-monorepos.md @@ -0,0 +1,761 @@ +# The monorepo is a legacy storage model + +**Status: THESIS + ARCHITECTURAL REQUIREMENTS.** This document explains why +Deck treats independently addressable, content-addressed packages as the +primary unit of software rather than treating one physically co-located +repository as the unit of identity, history, review, release, and delivery. + +Companions: [`deck-laws.md`](deck-laws.md), +[`deck-at-rest.md`](deck-at-rest.md), +[`deck-package-json-and-depot-lock.md`](deck-package-json-and-depot-lock.md), +[`deck-catalog-cdn-delivery.md`](deck-catalog-cdn-delivery.md), and +[`deck-fork-merge-pr-ux.md`](deck-fork-merge-pr-ux.md). + +--- + +## 0. The claim + +> **A monorepo preserves coherence by putting everything in one mutable +> place. Deck preserves coherence by naming every immutable thing and naming +> the exact Repository that uses it.** + +The monorepo is not a bad organizational idea. One product still needs one +place to search, refactor, test, review, and understand. What is legacy is +using **physical co-location as the mechanism that creates that coherence**. + +A monorepo makes these facts coincide: + +- where source files happen to sit; +- what changed together; +- what may be reviewed together; +- what may be built together; +- what may be deployed together; +- what may be versioned together; +- what a branch copies or isolates; +- what CI must consider. + +Those are different facts. They became one because filesystems and Git had +one convenient boundary: the repository. + +Deck splits them without losing the useful experience: + +- a **package tree** says what one package's bytes are; +- a **Checkpoint** says how one line of work reached those bytes; +- a **Version** says what compatibility promise those bytes carry; +- a **depot lock** says which exact package trees execute together; +- a **Repository Checkpoint** says which package changes are atomic; +- a **pull request** says which work should enter which target; +- an **HTTP URL** serves the exact ESM file the browser executes. + +The result should still feel as coherent as a monorepo. It no longer needs to +be stored, copied, invalidated, released, or deployed as one. + +--- + +## 1. Why monorepos won + +Any replacement that starts by mocking monorepos has misunderstood the job. +Monorepos won because they repair the fragmentation produced by traditional +package registries and multi-repository development. + +```text +MANY REPOSITORIES MONOREPO + +repo-a ── publish ──┐ ┌──────────────────────────────┐ + ├─ install ─ app │ app/ │ +repo-b ── publish ──┤ │ packages/a/ │ + │ │ packages/b/ │ +repo-c ── publish ──┘ │ packages/c/ │ + └──────────────────────────────┘ +version choreography one checkout +cross-repo PR choreography one change +registry round trips one search +dependency drift one CI graph +``` + +The monorepo gives teams real advantages: + +1. **Atomic cross-package changes.** Change producer and consumers together. +2. **Global discovery.** Search and navigation see the whole product. +3. **Mechanical refactors.** One tool can rewrite every affected caller. +4. **One review object.** A pull request can explain a product-level change. +5. **One reproducible checkout.** A commit identifies a coherent repository. +6. **Shared policy.** Formatting, tests, ownership, and CI can be centralized. +7. **Local source dependencies.** Packages need not be published before they + can be used by siblings. + +These are product requirements. Deck should preserve every one of them. + +The mistake is assuming they require one Git repository forever. + +--- + +## 2. The hidden bargain + +The monorepo recovers coherence by accepting coupling at the storage and +coordination layer. + +```text + ONE REPOSITORY + │ + ┌────────────────┼────────────────┐ + │ │ │ + identity history delivery + │ │ │ + one commit DAG one branch set build extracts + for all source for all teams deployable pieces +``` + +That bargain creates costs which become sharper under agent-scale work. + +### 2.1 The repository is the invalidation boundary + +A two-line change can cause repository-wide dependency analysis, CI +scheduling, indexing, checkout, and cache-key work. Good build systems reduce +the blast radius, but only after reconstructing package boundaries from the +large mutable tree. + +### 2.2 Branches isolate too much and too little + +A Git branch cheaply names another commit, but a usable branch usually needs +a checkout or worktree of the repository. It isolates the entire repository +even when an agent changes one package, while still allowing that agent to +accidentally edit anything in the checkout. + +At human cadence this is manageable. Hundreds of agents turn the shared +index, worktree machinery, CI graph, merge queue, and review surface into the +bottleneck. + +### 2.3 Package boundaries are advisory until build time + +`packages/a` looks independent, but its runtime identity is usually produced +later by a build, package, or deploy pipeline. The source tree, npm artifact, +bundle, container, and CDN output occupy different address spaces. Tooling +must keep proving that each is a faithful copy of the previous one. + +### 2.4 History and release have the wrong shared boundary + +Some packages want independent semver. Some changes span several packages. +Monorepo release tools must infer which package versions to bump, coordinate +them, generate manifests, and preserve the relationship between a repository +commit and many registry artifacts. + +The repository had one history; the published world receives many partial +translations of it. + +### 2.5 Ownership is layered onto location + +Teams use path rules such as `CODEOWNERS` because the repository boundary is +too broad. The ownership model is metadata saying that particular subtrees +were always the real units of authority. + +### 2.6 Physical completeness becomes expensive + +Every checkout presents the whole repository as ordinary files even when a +task needs one component and a handful of exact dependencies. Sparse +checkout, virtual filesystems, partial clone, build graphs, and remote caches +all exist to recover demand-loading after choosing an eagerly complete +logical unit. + +These tools are impressive. Collectively, they are evidence that the storage +boundary is no longer the product boundary. + +### 2.7 The agent-era tension: global context, bounded mutation + +The strongest argument for a monorepo in an agent system is navigation. An +agent can search once, follow imports without negotiating credentials across +repositories, read shared conventions, and discover code it did not know to +ask for. Breaking a product into ordinary independent repositories often +makes remote work worse: every ephemeral machine must discover, clone, +authenticate, install, align, and update the right set before useful work can +begin. + +That does not imply that every agent should receive one globally writable +tree. + +Agents need two views with different widths: + +```text + ORGANIZATION CONTEXT + code · docs · policy · issues · schemas + │ + broad, searchable, read-only + │ + ▼ + PLANNER + │ chooses task closure + ▼ + ┌────────────────────────────────┐ + │ bounded worker checkout │ + │ │ + │ writable: acme/editor branch │ + │ read-only: app + exact deps │ + │ context: relevant decisions │ + └────────────────────────────────┘ + │ + ▼ + new trees + Repository delta +``` + +The planner wants global legibility. The worker wants a small, explicit +authority boundary. Conflating those needs produces a checkout that is easy +to navigate but too easy to damage, expensive to materialize, and noisy to +validate. + +This reframes the question from “one repo or many repos?” to four separate +questions: + +1. **What may this agent discover?** Usually much broader than one package. +2. **What must this agent materialize?** The task closure, not all source. +3. **What may this agent mutate?** A named branch/fork and an explicit set of + packages. +4. **What must land atomically?** The resulting Repository delta, possibly + spanning several packages. + +The filesystem boundary answers none of these precisely. It is only one +possible projection of their answers. + +### 2.8 All code is not all knowledge + +Putting every source file in one repository still does not put the whole +organization in one context. Agents also need design decisions, tickets, +runtime evidence, schemas, ownership, security policy, prior reviews, and +the reasons a dependency is pinned. Vendoring all of that into a repository +would make the repository larger without making the relationships clearer. + +Deck should treat organizational context as an addressable graph around the +code graph: + +```text + decision record + │ +issue ─────▶ pull request ─────▶ Checkpoint ─────▶ treeHash + │ │ │ + │ │ └─ package files + │ └─ checks / reviews / authorship + └─ preview / discussion / acceptance + +Repository ──▶ exact package trees + lock ──▶ runtime evidence +``` + +One search surface can index this entire graph without pretending every node +belongs in one source checkout. “One place for context” is a product and +indexing requirement, not a mandate for one Git object database. + +### 2.9 Remote agents need cheap reconstruction, not permanent co-location + +Multi-repository development often feels acceptable on a long-lived laptop +because the machine quietly accumulates clones, credentials, caches, sibling +checkouts, and human memory. An ephemeral agent machine starts with none of +them. Reconstructing the implicit repository checkout becomes the task before the +task. + +CAS changes the economics: + +```text +task says: Repository 8f31… + writable acme/dashboard:add-myr + │ + ▼ +agent fetches one manifest ──▶ already has 91% of objects in shared cache + │ + ▼ +materialize exact closure ───▶ no repository discovery or version guessing +``` + +The unit sent to a remote worker is not “clone these seven repositories and +run their bootstrap scripts.” It is an immutable Repository identity plus a +write capability for one branch. Any machine reconstructs the same inputs; +shared objects arrive once; unrelated product source never needs to become +ordinary files. + +### 2.10 Version coordination moves to the Repository edge + +Splitting a monorepo into conventional repositories reintroduces version-bump +and submodule-pointer choreography. That is not the CAS model. + +During development, packages compose by exact tree or Checkpoint identity. +Changing a producer and three consumers creates one proposed Repository Checkpoint. +No semver bump is required merely to make the repository coherent. Semver is +assigned later, when somebody makes a compatibility promise to external +consumers. + +```text +DEVELOPMENT RELEASE + +tree A2 + tree B4 editor 3.0.0 ──▶ tree B4 + │ dashboard 8.2.0 ──▶ tree A2 + ▼ +Repository Checkpoint 91ce… +one atomic repository change +``` + +This retains independent package releases without making version numbers the +internal transport for every coordinated source edit. + +--- + +## 3. The Deck alternative: content-addressed packages + +Deck begins at the boundary monorepo tooling has to rediscover. + +```text +PACKAGE CONTENT + +acme/editor tree + editor.js ──sha256──▶ object 81c4… + toolbar.js ──sha256──▶ object a319… + package.json ──sha256──▶ object 0f72… + │ + ▼ + tree manifest 46bd… + path → object hash + │ + ▼ + treeHash = package identity +``` + +The store is global to the depot: + +```text + CONTENT-ADDRESSED STORE + +app/dashboard ──tree──┐ + │ ┌─ object A stored once +acme/editor ──tree────┼─ manifests ─┼─ object B stored once + │ └─ object C stored once +chris/editor ──tree───┘ + +same bytes ───────────────▶ same object +same package tree ─────────▶ same treeHash +new fork, no edits ─────────▶ zero new content +one-file edit ──────────────▶ one new object + one manifest +``` + +Package boundaries are no longer conventions inferred from paths. A package +has an immutable identity before release and remains directly addressable +after release. + +--- + +## 4. The lock replaces physical co-location + +A monorepo checkout answers “which versions work together?” by handing every +package the files that happened to coexist at one commit. + +Deck answers with an explicit lock: + +```json +{ + "imports": { + "app/dashboard": "/site/app/dashboard@3.2.0/index.js", + "acme/editor": "/site/acme/editor@2.4.1/editor.js", + "acme/theme": "/site/acme/theme@1.8.0/theme.js" + }, + "integrity": { + "/site/acme/editor@2.4.1/editor.js": "sha256-…" + } +} +``` + +This is not weaker than a checkout. It is more explicit: + +- every edge names the exact package and file that answers it; +- integrity is checkable; +- two applications can select different Versions without separate installs; +- an unchanged package keeps the same URL across every application update; +- the browser consumes the map directly. + +A useful compression of the idea is: + +> **A monorepo checkout is an implicit lockfile rendered as a directory. +> Deck stores the lock as data and renders a directory only when a tool needs +> one.** + +--- + +## 5. Atomic changes do not require one physical source tree + +This is the strongest monorepo argument and the place where a naive +“microrepos everywhere” answer fails. + +Updating package refs one at a time is not sufficient: + +```text +BAD + +editor head ──▶ new tree visible now +app head ──▶ old tree visible for 40 ms +app head ──▶ new tree visible later +``` + +Deck needs an immutable **Repository** object, named by a Checkpoint, that +records the complete member package set produced by one atomic change: + +```json +{ + "schema": "deck-repository-v2", + "roots": ["@acme/dashboard/"], + "members": { + "@acme/dashboard/": "tree-app-new", + "@acme/editor/": "tree-editor-new", + "@acme/theme/": "tree-theme-unchanged" + }, + "lockHash": "sha256-of-import-map" +} +``` + +The branch is one CAS-updated pointer: + +```text +immutable package trees written first + │ + ▼ +immutable Repository + Checkpoint written second + │ + ▼ +acme/dashboard:main ── atomic CAS ──▶ new Checkpoint +``` + +Readers see the complete old Repository or the complete new Repository, +never a half-landed refactor. This preserves the defining monorepo property +without making all package histories, releases, caches, or working trees one +object. + +This capability and Repository-level three-way merge are implemented and +exercised by the tour in +[`deck-repository-tour.md`](deck-repository-tour.md). + +--- + +## 6. Branches are zero-copy Repository forks + +The familiar UX can remain: + +```text +acme/dashboard:main +acme/dashboard:transaction-api +``` + +But the implementation does not need a second checkout of an entire product. +The branch is a real isolated fork of the Repository: + +```text +main + app/dashboard ─────────────── tree A + acme/editor ───────────────── tree B + acme/theme ────────────────── tree C + +transaction-api + app/dashboard ─────────────── tree A2 changed + acme/editor ───────────────── tree B2 changed + acme/theme ────────────────── tree C shared +``` + +The branch records different pointers for A and B. C is not copied, checked +out again, republished, or rebuilt. It is literally the same identity. + +For a team, this is displayed as a **branch** within `acme/dashboard`. When the +owner changes, the same primitive is displayed as a **fork**. The naming and +UX ruling is specified in +[`deck-fork-merge-pr-ux.md`](deck-fork-merge-pr-ux.md). + +--- + +## 7. Review the Repository, not only its diff + +A monorepo pull request primarily shows a repository diff. Reviewers infer +what that diff will become after build and deployment. + +Deck can show all three truths together: + +```text +PULL REQUEST #418 + +intent Add transaction API +content editor B → B2; dashboard A → A2 +resolution exact import-map change +execution [ Run proposed Repository ] +verification checks bound to source + target Checkpoints +merge one depot-head pointer move +``` + +The diff remains important. It is no longer forced to stand in for the +runtime result. + +Because every proposed package tree already has an address, a pull request +can execute the exact proposal with no checkout, integration branch, build, +or preview deployment. The preview is a different resolution of the same +address space. + +--- + +## 8. Release and deploy disappear into addressing + +In the traditional pipeline: + +```text +repository commit + │ build + ▼ +package artifact + │ publish + ▼ +registry version + │ install + bundle + ▼ +deployment artifact + │ deploy + ▼ +CDN/runtime bytes +``` + +Each arrow copies or transforms information into a new naming system. + +In Deck: + +```text +treeHash 46bd… + ├─ branch Checkpoint points here + ├─ Version 2.4.1 points here + ├─ pull-request preview points here + └─ exact HTTP module URL serves files from here +``` + +Release adds a compatibility name. Deployment changes a pointer or an import +map. Neither operation needs to manufacture a second copy of source. + +Derived bundles, minified trees, and compiled outputs may still exist, but +they are separately content-addressed derivations with provenance. They do +not retroactively become the identity of the source package. + +--- + +## 9. ESM makes packages the delivery unit + +Traditional package systems stop at distribution. Another tool must install, +link, transform, bundle, and serve them. + +Deck packages are ES modules at immutable HTTP addresses: + +```html + + +``` + +The package boundary survives into the browser. That produces compounding +cache behavior: + +- changing the app does not invalidate the editor URL; +- changing one editor module does not invalidate the theme; +- several apps share exact immutable module responses; +- a fork that changes one file downloads and stores what is genuinely new; +- a CDN can cache every exact URL forever. + +Modulepreload, HTTP/2 or HTTP/3, compression, and derived production variants +are required to make cold delivery competitive. CAS makes them optimizations +of an honest identity model rather than reasons to collapse identity into a +bundle. + +--- + +## 10. Comparison + +| Concern | Traditional monorepo | Deck CAS packages | +| ----------------------- | ---------------------------------------------------- | -------------------------------------------- | +| Primary identity | repository commit | Repository hash + member treeHashes | +| Package boundary | path/tool convention | immutable addressed object | +| Cross-package atomicity | one commit | one Repository Checkpoint pointer | +| Branch isolation | whole-repository worktree | Repository fork; unchanged objects shared | +| Unchanged content | repeated in checkouts and artifacts; cached by tools | shared by identity | +| Dependency selection | package-manager rules + lockfile | depot import map | +| Review | repository diff | package-grouped diff + exact running preview | +| Release | derive package artifacts from commit | Version pointer to existing tree | +| Deploy | build/copy/upload artifact | move Repository pointer | +| Browser delivery | usually bundle output | exact ESM URLs and derived variants | +| Independent versioning | release-tool concern | native per package | +| Global refactor | native directory-wide tooling | operate on materialized Repository | +| Search/index | repository-wide by default | catalog/index over addressed packages | +| Offline work | complete clone/worktree | explicit materialized working set required | +| Coordination | Git ref transaction + forge | CAS refs + durable Checkpoint/request metadata | +| Garbage collection | Git reachability | Versions + refs + requests + Checkpoints | + +--- + +## 11. What a directory becomes + +Deck does not abolish directories. Editors, compilers, grep, language +servers, and people are excellent at working with them. + +The change is ontological: + +> **The directory becomes a materialized repository view, not the source of +> identity.** + +```text +authoritative Repository + │ materialize + ▼ +/checkout + app/dashboard/ + acme/editor/ + acme/theme/ + │ edit with ordinary tools + ▼ +changed package trees + new Repository Checkpoint +``` + +On a laptop this can be ordinary local files. On AWS it can be an S3 Files +mount backed by S3. In both cases, sealing computes content identity and +publication moves CAS-protected metadata pointers. + +The checkout may look like a traditional monorepo because that is productive. +It no longer has to _be_ the unit of storage, history, release, or delivery. + +--- + +## 12. What Deck must solve honestly + +CAS is not a magic word. Moving the boundary creates real engineering work. + +### 12.1 Durable coordination + +Immutable objects are easy. Mutable heads, Reviews, approvals, and atomic realm +heads require authenticated compare-and-swap and durable event history. S3 +Files supplies the inspectable POSIX view, but not conditional coordination. +All objects remain below the owning realm's `.deck/`; agents may read them +there, while the realm service writes protocol-owned subtrees through direct +conditional S3 operations. + +### 12.2 Repository-scale atomicity + +Per-package refs are insufficient for product changes spanning several +packages. The Repository Checkpoint and its atomic ref update are required before +Deck can claim full monorepo parity. + +### 12.3 Materialization and offline work + +Tools still expect directories. Deck needs fast local materialization, +demand loading, an offline object cache, and a clear push/reconciliation +model. + +### 12.4 Search and refactoring + +Removing the physical monorepo must not remove global understanding. Indexes +must search a Repository or catalog, and refactoring tools must be able to +materialize and update a multi-package closure as one proposed depot +Checkpoint. + +### 12.5 Small-object economics + +Thousands of ESM files can create metadata, request, and cold-start costs. +Pack transport, local caches, S3 Files active-set caching, CDN delivery, and +preload closure computation remain necessary. + +### 12.6 Garbage collection + +Reachability now crosses Versions, Checkpoints, refs, pull requests, depot +Checkpoints, signatures, and retention policy. Deleting by “no Version points +here” would destroy branch-only work. + +### 12.7 Ecosystem compatibility + +Git hosting, npm, IDEs, CI systems, and security scanners exist. Deck needs +faithful projections and bridges during adoption; ideological purity is not +a migration strategy. + +These are not arguments for returning to the monorepo. They are the work +required to replace its implicit guarantees with explicit ones. + +--- + +## 13. Migration: make the monorepo a projection + +A credible transition does not begin by splitting every repository. + +### Phase 1 — identify package trees inside the monorepo + +- Compute a treeHash for each existing package boundary. +- Record package dependencies and exact depot lock pins. +- Preserve the Git repository and existing CI. +- Verify that a Git repository commit can reproduce one Deck Repository. + +### Phase 2 — serve immutable packages directly + +- Publish exact Deck URLs for package trees. +- Run applications through the depot import map. +- Keep GitHub as the write/review bridge. +- Compare built artifacts with Deck-served source or derivations. + +### Phase 3 — make branches Repository forks + +- Create durable Checkpoints and CAS refs. +- Present same-owner forks as branches and cross-owner forks as forks. +- Open pull requests against exact source and target Checkpoints. +- Materialize only the packages a change needs. + +### Phase 4 — make the Repository Checkpoint authoritative + +- Land cross-package changes with one atomic depot-head update. +- Generate a monorepo-shaped directory for legacy tools on demand. +- Mirror accepted Deck history back to Git while required. +- Stop treating the Git repository as the only source of truth. + +### Phase 5 — retire physical co-location as a requirement + +- Keep monorepo projections where they improve ergonomics. +- Scale storage, review, CI, release, and delivery by package identity. +- Allow one Repository to span local, organizational, and public + package catalogs without changing the collaboration model. + +--- + +## 14. The revised meaning of “monorepo” + +The useful part of a monorepo should survive: + +```text +one product view +one global context and search surface +one searchable Repository +one atomic cross-package change +one coherent review +one command to run everything +``` + +The legacy part should not: + +```text +one physical mutable tree as identity +one history graph for every package +one checkout boundary for every task +one invalidation boundary for every change +one build/deploy translation before code has an address +``` + +For agents, the replacement principle is: + +```text +one global map +many bounded writable checkouts +immutable, reconstructable input closures +one atomic Repository update when the work lands +``` + +So the durable claim is narrower and stronger than “monorepos are bad”: + +> **The monorepo is a valuable development experience implemented with a +> legacy storage boundary. Content-addressed packages let Deck retain the +> experience while replacing co-location with identity, explicit membership, +> and atomic pointers.** + +That is why Deck works better. It does not fragment the monorepo back into +the multi-repository world that caused monorepos to win. It makes the package +graph coherent without pretending the graph is one file tree. diff --git a/docs/deck-catalog-cdn-delivery.md b/docs/deck-catalog-cdn-delivery.md index ef56a00..8de8c50 100644 --- a/docs/deck-catalog-cdn-delivery.md +++ b/docs/deck-catalog-cdn-delivery.md @@ -4,7 +4,7 @@ [`deck-efficient-delivery-spec.md`](deck-efficient-delivery-spec.md) (minify variants, wire compression, modulepreload over H2/H3), [`deck-unpkg-comparison.md`](deck-unpkg-comparison.md), -[`deck-boxel-backport-plan.md`](deck-boxel-backport-plan.md) (K2, K5, K8, K9). +[`deck-boxel-backport-plan.md`](./plans/deck-boxel-backport-plan.md) (K2, K5, K8, K9). --- diff --git a/docs/deck-collaboration-protocol.md b/docs/deck-collaboration-protocol.md new file mode 100644 index 0000000..99e483b --- /dev/null +++ b/docs/deck-collaboration-protocol.md @@ -0,0 +1,438 @@ +# Deck collaboration protocol + +There is no separate kind of thing called a “Deck Repository.” A mutable Deck +package is already a repository: it can be forked, branched, reviewed, and +merged. The Checkpoint, ref, and review objects in this document are the durable +collaboration layer for packages. + +`.deck/repository.json` handles only the additional case where several packages +must advance atomically. That is Deck’s content-addressed equivalent of a +traditional monorepo—not an opt-in required for ordinary package work. + +Imported packages are immutable inputs. They do not acquire a writable branch +in place: forking materializes the chosen exact Version under a new package +identity, records its base tree, and initializes that mutable package's +`main`. Branch and fork therefore share ancestry and merge mechanics while +preserving different ownership boundaries. + +When the source is itself mutable, a fork also records the exact source +Checkpoint. Opening a cross-owner Review uses that Checkpoint as the three-way +merge base. The Review retains the fork endpoint as authored metadata, but +normalizes its one package tree into the upstream package member key before +preview and merge. Imported packages have no mutable upstream Checkpoint, so a +fork of an import begins an independent repository rather than advertising a +fake upstream Review. The Hub derives this base from the fork's stored lineage; +it does not trust a browser-supplied Checkpoint hash. + +**Status: RRI/CHECKPOINT PROTOCOL IS THE ONLY SUPPORTED COLLABORATION FORMAT.** +Deck makes a clean cutover before Boxel A0: durable member/root identities are +scoped RRIs and intentional collaboration points use `deck-checkpoint-v2`. +There is no V1 compatibility reader or legacy write surface. This document defines +the durable objects beneath Deck's familiar repository, branch, fork, Review, +and merge UX. The protocol deliberately introduces no new product +noun. A repository containing several member packages is simply a monorepo. + +Companions: [`deck-laws.md`](deck-laws.md), +[`deck-package-json-and-depot-lock.md`](deck-package-json-and-depot-lock.md), +[`deck-cas-packages-vs-monorepos.md`](deck-cas-packages-vs-monorepos.md), and +[`deck-fork-merge-pr-ux.md`](deck-fork-merge-pr-ux.md). The Boxel mapping—one +realm with multiple branch-specific source states, indexes, and Histories—is +specified in +[`deck-boxel-branch-index-backport.md`](./plans/deck-boxel-branch-index-backport.md). + +--- + +## 0. Ruling + +> **A Repository is the atomic collaboration boundary. A package remains the +> independently addressable execution and release boundary.** + +A Repository Checkpoint can therefore change one package or many packages. It +does not combine their source into one giant package and it does not make +their dependency identities disappear. + +```text +Branch ── mutable compare-and-swap head ──▶ Repository + │ │ + ├── historyHead ──▶ deckd/jj Step ├── root RRIs + └── latestCheckpoint ──▶ Checkpoint ────────┤ + ├── member package RRI → treeHash + └── lockHash + │ + ▼ + exact import map +``` + +The UI says Repository, Checkpoint, Branch, Fork, Review, and Merge. +`deck-repository-v2` is only a wire-format name. + +Runnable walkthrough: [`deck-repository-tour.md`](deck-repository-tour.md). + +The real Hub exposes Repository identity and branches in `/browse`, beside the +packages they govern. Proposed changes move to `/review`, where familiar forge +labels remain and **Underneath** reveals the exact objects and paths. A +preview is an immutable Version projection of the proposed Repository, handed +to the existing `/run` resolver with explicit dependency substitutions. A +review link hands the same projection to `/browse` with an exact base Version. +Neither surface reconstructs repository state independently. + +## 1. Existing identities remain intact + +The Repository layer is above Deck's existing package laws: + +- `treeHash` still identifies one package tree. +- a Version still points to a package `treeHash`. +- `package.json` still declares package identity and dependency ranges. +- the canonical composition of applicable import maps is still the exact + execution lock. +- blobs and package trees remain content addressed and shared. + +The new layer answers a different question: **which exact package trees and +lock were reviewed and landed together?** + +## 2. Exact lock object + +The lock object is the canonical composed resolution result for the authored +realm tree, encoded with Deck canonical JSON and addressed by SHA-256. Its +inputs may include a root `importmap.json`, nested package/app import maps, and +their declared inheritance. It includes the effective `imports`, `scopes`, +`integrity`, and Deck-owned lock policy. Exact Version RRIs are values; +importer RRIs are scope keys. `package.json` remains conventional package +metadata and dependency suggestion; it is not itself the exact lock. + +```text +lockHash = sha256(canonical-json(compose(authored import maps))) +``` + +This is not another dependency decision or a parallel lock. For native ESM, +Deck deterministically projects the same lock into a standard browser import +map whose targets and scopes are absolute transport URLs. The RRI lock is the +portable reviewed authority; the URL map is a derived delivery artifact. +Storing the canonical lock by hash makes exact resolution closed over by a +Repository and reconstructable on another machine without baking one host's +origin into authored history. + +Deck accepts standard authored import maps at the authoring boundary. Before +hashing, it removes the transport projector: a target such as +`/site/acme/theme@2.1.0/index.js` becomes +`@acme/theme@2.1.0/index.js`, relative targets resolve against the root RRI, +and URL-form values never enter the stored lock. Delivery later performs the +inverse projection for the serving authority. + +## 3. Repository membership + +A hosted forge may project repository membership into authenticated query +metadata. The canonical authoring manifest remains inside the owning realm at +`.deck/repository.json`, so an agent can inspect and move the realm without a +parallel service tree: + +```json +{ + "schema": "deck-repository-manifest-v2", + "roots": ["@acme/dashboard/"], + "members": { + "@acme/editor/": "acme/editor", + "@acme/theme/": "acme/theme", + "@acme/dashboard/": "app/dashboard" + } +} +``` + +The manifest maps canonical member RRIs to local authoring paths. Those paths +are placement intent, not durable identity. Sealing resolves each member to a +`treeHash` and the effective composed resolution state to a `lockHash`. + +## 4. `deck-repository-v2` + +```json +{ + "schema": "deck-repository-v2", + "roots": ["@acme/dashboard/"], + "members": { + "@acme/editor/": "", + "@acme/theme/": "", + "@acme/dashboard/": "" + }, + "lockHash": "" +} +``` + +- `roots` contains runnable or publishable package RRIs. Every root must be a + member. +- `members` maps independently addressable package RRI roots to exact package + trees. Membership means “may change and land atomically,” not “is imported + at runtime.” +- `lockHash` names the exact runtime closure. + +Member RRIs and roots are sorted in canonical form. A Repository must have at +least one member. Hashes are lowercase SHA-256 hex. + +Repository writers require scoped RRI roots. URL-form identities and bare +`owner/package` member keys are invalid protocol input. + +```text +dependency edge = this package is needed to run +member edge = this package may change and land with this repository +``` + +External dependencies occur in the lock but not in `members`. A repository +with one member is ordinary. A repository with several members is a monorepo. + +## 5. `deck-checkpoint-v2` + +```json +{ + "schema": "deck-checkpoint-v2", + "repositoryHash": "", + "parents": [""], + "historyHead": "", + "indexGenerationHash": "", + "author": { + "id": "@chris:example.test", + "name": "Chris" + }, + "message": "Add compact editor density", + "createdAt": "2026-08-20T00:00:00.000Z" +} +``` + +A Checkpoint is immutable and content addressed. Parent order is meaningful: +the first parent is the target line; the second parent of a merge is the +source line. Two authored events may point at the same Repository hash. + +This separation preserves both useful identities: + +- Repository hash: “are the executable bytes and resolution identical?” +- Checkpoint hash: “is this the same history event?” + +`historyHead` binds the intentional collaboration point to the exact +every-save History state that produced it. It is opaque to the collaboration +protocol. `indexGenerationHash` binds the exact Browse/Run projection reviewed +with that state. A normal save creates a deckd/jj Step and advances the branch +head; it does not write a Checkpoint. Opening a Review, explicitly naming a +milestone, and merging do write Checkpoints. + +The API and storage vocabulary is Checkpoint throughout: +`deck-checkpoint-v2`, `checkpointHash`, and `_checkpoints/`. There is no +Revision alias. + +## 6. Branches and forks + +A branch head is mutable metadata and is also the settlement point for source, +History, and the completed index: + +```json +{ + "schema": "deck-branch-head-v2", + "generation": 7, + "repositoryHash": "", + "historyHead": "", + "indexGenerationHash": "", + "latestCheckpointHash": "" +} +``` + +Ref address: + +```text +/.deck/refs/heads/.json +``` + +The realm root supplies owner/repository identity. A branch ref must not float +in a bucket-global namespace. A cross-owner fork gets a new destination realm +and therefore a new realm-local `.deck/` namespace. + +The update contract is compare-and-swap: + +```text +update(ref, expectedGeneration, nextHead) + +current generation == expected → write next head +current generation != expected → conflict; write nothing +``` + +Same owner and repository is presented as a branch. A different owner is +presented as a fork. Both use isolated refs and identical ancestry rules. + +For a single-package cross-owner Review, source and target package identities +differ by design. Deck records an immutable normalized source Checkpoint whose +member key is the target package and whose tree is the fork's current tree. +The Review still names the real source endpoint. Existing preview, +three-way merge, and target-ref compare-and-swap then operate unchanged. + +## 7. Merge + +A merge produces package trees first, then one Repository, then one Checkpoint: + +```text +base Repository ─┐ +target Repository ├─ merge changed package trees + lock +source Repository ┘ │ + ▼ + new Repository hash + │ + ▼ + Checkpoint(parents=[target, source]) + │ + ▼ + CAS update target branch ref +``` + +If two packages change, both new tree hashes enter one Repository object. The +branch moves once. A reader can observe the old repository or the new one, +never half of either. + +The package merge algorithm remains `mergeTrees`. Repository merge adds: + +1. member-set merge; +2. per-member tree merge where both sides changed; +3. lock merge or deterministic re-lock; +4. one final branch compare-and-swap. + +Conflicts are qualified by member RRI, for example +`@acme/editor/index.js`. A conflicted merge writes no Repository, Checkpoint, +branch ref, or Review state. Immutable package objects produced while +planning may already exist and are harmless because no ref reaches them. + +## 8. At-rest layout + +Each realm owns its protocol data. A host may physically aggregate realms in +one bucket, but no Deck resource floats in a parallel protocol tree: + +- For an ordinary one-package Repository, the package directory is the realm + root and its `.deck/` belongs to that package alone. +- For an explicit multi-package Repository, the directory containing + `.deck/repository.json` is the realm root and owns the shared atomic state. + +```text +/.deck/ +├── store/ +│ ├── _objects/ file blobs +│ ├── _trees/ package trees +│ ├── _locks//.json exact import maps +│ ├── _repositories//.json Repository objects +│ └── _checkpoints//.json Checkpoint objects +├── refs/heads/.json mutable branch heads +├── reviews/ +│ ├── objects//.json immutable Review openings +│ ├── events//.json append-only Review events +│ ├── numbers/.json mutable numbered Review refs +│ └── counter.json mutable number allocator +├── indexes///… completed index generations +├── releases/objects//.json immutable published Releases +└── history/repo/ default-branch deckd/jj History +``` + +Locks, repositories, and Checkpoints are immutable write-once objects. Mutable +branch heads, Review number refs/counters, and event heads need coordination. + +## 9. Local and S3 Files persistence + +### Local Deck + +- Content-addressed writes use temporary file plus rename. +- A branch update holds a per-ref exclusive directory lock, verifies the + expected generation, and atomically renames the new ref. +- A failed expectation writes nothing. + +The local daemon should be the normal ref writer. A leftover lock after an +unclean process exit is an operational condition to inspect, not something a +second writer silently steals. + +### Deck with S3 Files + +S3 Files is the hosted POSIX substrate for live package directories, index +staging, and deckd's jj repository at `.deck/history/repo`. It is not the +authority for mutable collaboration refs. The complete local/AWS split is +specified in [`deck-storage-backends.md`](deck-storage-backends.md). + +- Immutable blobs, trees, locks, repositories, Checkpoints, Reviews, and + completed index generations are written through direct S3 with + `If-None-Match: *`. S3 Files exposes them read-only for agent inspection. +- Branch and Review refs are read and written through the S3 object API. +- Immutable request openings, events, and numbered refs are created with + `If-None-Match: *`. +- Branch refs, the realm-local Review counter, and Review + event heads move with `If-Match: `. +- A precondition failure is a concurrency conflict. Number and event writers + reread and retry; a branch move reports the conflict to the merge caller. +- Agents may read these realm-local objects as files through S3 Files. Their + POSIX principal cannot write `store/`, `indexes/`, `refs/`, or `reviews/`; + only the realm service's direct-S3 credentials mutate those subtrees. +- Coordination reads use the direct object API. S3 Files reads are an + inspectable projection and may lag bucket synchronization. + +This is the small durable coordination layer Deck needs: realm-local immutable +objects plus conditional ref mutation. It is not a Git origin and does not +require a Git server or commit-shaped history service. + +## 10. Reviews + +A `deck-review-v2` opening durably names: + +- source repository ref and captured source Checkpoint; +- target repository ref and captured target Checkpoint; +- author, title, body, and creation time. + +It must not contain package bytes. Those are already named by Repository and +tree hashes. + +The opening is immutable and content addressed. A numbered mutable ref at +`.deck/reviews/numbers/.json` names the opening, the newest event, its +generation, and the visible state (`open`, `merged`, or `closed`). Events form +an immutable backward chain: + +```text +Review #1 ref + │ + ▼ +merged ──▶ merge-started ──▶ reviewed ──▶ source-updated +``` + +`source-updated` captures the new exact source Checkpoint. `reviewed` binds its +verdict to a Checkpoint. `merge-started` records the prepared two-parent merge +Checkpoint before the target branch moves. `merged` records completion. + +The two-event merge sequence makes local recovery idempotent: + +1. write the merged Repository and merge Checkpoint; +2. append `merge-started(mergeCheckpointHash)`; +3. compare-and-swap the target branch; +4. append `merged(mergeCheckpointHash)`. + +If the process stops after step 2 or 3, the next merge call reads +`merge-started`, completes the missing ref update, and appends `merged`. It +never creates a second merge Checkpoint. + +The API, paths, and wire objects use Review throughout. GitHub pull requests +may be produced later as a syndicated representation, but are not a Deck +protocol alias. + +Preview runs the same Repository merge engine as Merge, using the captured +target as base, the current target as “ours,” and the captured or explicitly +updated source as “theirs.” Therefore a clean preview and its immediate merge +name the same Repository hash. + +## 11. Implementation status + +| Layer | Status | +| -------------------------------------------------------- | ----------------------------------------------- | +| RRI-native Repository, Checkpoint, and branch head | Implemented and tested | +| Local compare-and-swap refs | Implemented and tested | +| Conditional object-store branch adapter for S3 | Implemented and tested | +| Multi-package Repository merge | Implemented and tested | +| Review opening/events/preview/merge | Implemented and tested locally | +| Crash recovery across branch + Review refs | Implemented | +| Realm-owned `.deck/repository.json` discovery | Implemented | +| Agent CLI: init, branch, checkpoint, Review, merge | Implemented and tested | +| Generic Hub Browse and Review projection | Implemented against current objects | +| Replay-built multi-package validation realm and tour | Implemented; current APIs recreate and assert it | +| Hosted authentication and authorization | Not implemented | +| Signed checks and required-review policy | Event shape exists; enforcement not implemented | +| Realm-local conditional Review numbering/opening | Implemented and tested | +| Conditional Review event-head adapter | Required by B1 | +| Production AWS object adapters and S3 Files/deckd wiring | Required by backport; not yet implemented | +| Hosted multi-user Hub authorization/policy UI | Specified, not implemented | + +The validation depot is the architectural test. UX must not invent state the protocol +cannot reproduce. diff --git a/docs/deck-competitive-gaps.md b/docs/deck-competitive-gaps.md index 9c1bb7e..f482ec0 100644 --- a/docs/deck-competitive-gaps.md +++ b/docs/deck-competitive-gaps.md @@ -326,7 +326,7 @@ and attestation**, in three parts. **Authorship (G4) — cheap, high payoff.** Every seal records the Matrix ID that caused it. jj already has an author field; the historyd backend declares no actor and should. Blame becomes real, and in a swarm it -answers "which agent did this" without a convention. (When a Timeline is attached, +answers "which agent did this" without a convention. (When every-save History is attached, also record the jj change id as optional seal metadata — stable across amends, never identity; same move Radicle is considering for patches.) diff --git a/docs/deck-daemon.md b/docs/deck-daemon.md index 463e172..e5365e9 100644 --- a/docs/deck-daemon.md +++ b/docs/deck-daemon.md @@ -1,8 +1,8 @@ -# deckd — Timeline + build daemon +# deckd — History + build daemon **Status: RULING + OPEN QUESTIONS** (2026-08-09). -Deck-owned long-lived process that attaches a **Timeline** to a depot and +Deck-owned long-lived process that attaches a **History** to a depot and (optionally) runs **derived build work** (esbuild, AMD, and friends) so the realm server / `deck serve` HTTP surface stays thin and read-only. @@ -11,14 +11,14 @@ Companions: [`deck-at-rest.md`](deck-at-rest.md) (layout), [`deck-laws.md`](deck-laws.md) (L12 surface), [`deck-efficient-delivery-spec.md`](deck-efficient-delivery-spec.md) (A2 minify), [`deck-embroider-lessons.md`](deck-embroider-lessons.md) (pain at seal time), -[`deck-boxel-backport-for-team.md`](deck-boxel-backport-for-team.md) (campaign). +[`deck-boxel-backport-for-team.md`](./plans/deck-boxel-backport-for-team.md) (campaign). jj-lib source used for spikes / dependency work: `/Users/chris/Projects/jj` (upstream [`jj-vcs/jj`](https://github.com/jj-vcs/jj) `lib/`). That tree is **not** where AMD or esbuild live. **Implementation:** [`packages/deckd/`](../packages/deckd/) — historyd-compatible -HTTP, Timeline under `.deck/timeline`, jj-lib in-process with a watchexec-shaped +HTTP, History under `.deck/history`, jj-lib in-process with a watchexec-shaped FS seal plane (`jj_cli` feature optional). --- @@ -27,15 +27,15 @@ FS seal plane (`jj_cli` feature optional). Today: -| Concern | Where it lives | -| --- | --- | -| Versions / claims | `/.deck/store`, `.deck/claims` | -| Timeline Steps | top-level `/.jj/` via jj CLI, or external **historyd** | -| Boxel edit history | often a separate `.boxel-history` git story | -| AMD / expensive transform | Boxel realm-server lifecycle (request-adjacent) | -| Minify derived releases | Tools/`derive`, **terser** (A2) | +| Concern | Where it lives | +| ------------------------- | ------------------------------------------------------------- | +| Versions / claims | `/.deck/store`, `.deck/claims` | +| History Steps | top-level `/.jj/` via jj CLI, or external **historyd** | +| Boxel edit history | often a separate `.boxel-history` git story | +| AMD / expensive transform | Boxel realm-server lifecycle (request-adjacent) | +| Minify derived releases | Tools/`derive`, **terser** (A2) | -Problems: two (or three) hidden directories; Timeline backend unsupervised; +Problems: two (or three) hidden directories; History backend unsupervised; compile cost on the wrong process; serve path tempted to grow Embroider-shaped compat forever. @@ -59,7 +59,7 @@ machinery directory, build pain at settle/seal time — not on GET. deckd may: -1. **Timeline** — ensure repo, seal Step, list, file-at, restore-plan +1. **History** — ensure repo, seal Step, list, file-at, restore-plan (today’s historyd surface, gaps closed — see §3). 2. **Build plane** — run named, versioned **derived** transforms (esbuild graph work, AMD, etc.), cache outputs under `.deck/build/`. @@ -77,36 +77,61 @@ deckd must **not**: Target layout: ```text -/ - //… live content (hashed + served) +/ + /… existing default-branch content (hashed + served) + package.json conventional package metadata where present + importmap.json optional root resolution input + apps//… existing app-local trees and maps + packages///… existing package-local trees and maps .deck/ + repository.json optional multi-package manifest store/ Versions (unchanged role) + refs/ branch/published heads + reviews/ Review objects and events + indexes/ completed index generations claims/ WIP claims policy.json optional - timeline/ - repo/ jj op store + internal git ← durable Timeline + history/ + repo/ jj op store + internal git ← durable History + branches// + source/ non-default branch materialization + history/repo/ branch-local jj History + index-staging/ derived private build state build/ reserved (derived plane later; unused for now) .jj/ jj workspace stub ONLY (not a symlink) - repo file: "../.deck/timeline/repo" + repo file: "../.deck/history/repo" working_copy/ jj WC state (jj requires this next to .jj) ``` -- **No** `.jj` → `.deck/timeline` symlink. -- **No** empty `.deck/timeline/.jj` nested-repo marker. -- Durable Timeline bytes live only under `.deck/timeline/repo/`. +The default branch stays at the realm root, matching Boxel's existing +`deck/at-rest-poc` layout. Only non-default branches require the new +`.deck/branches//` locations. Their `source/` directory clones the +authored realm tree without `.deck`, `.jj`, or `.git`; it preserves ordinary +package boundaries and all applicable import-map inputs. + +- **No** `.jj` → `.deck/history` symlink. +- **No** empty `.deck/history/.jj` nested-repo marker. +- Durable History bytes live only under `.deck/history/repo/`. - Top-level `.jj/` remains a thin stub because stock jj discovers the workspace by that basename; jj-lib can later root the stub under `.deck` - and delete it. The stub’s `repo` *file* is jj’s supported out-of-tree + and delete it. The stub’s `repo` _file_ is jj’s supported out-of-tree pointer (same idea as git’s `gitdir:`), not a Deck hack. -- No `.boxel-history` once Timeline is attached. -- No colocated `.git` in the depot (no-gitlink law). +- No `.boxel-history` once History is attached. +- No colocated `.git` in the realm (no-gitlink law). + +All Deck resources remain inside the owning realm. A selected non-default +branch view projects its physical History as `/.deck/history/repo`, so deckd, +agents, and UI keep one stable path even though the host uses an opaque branch +directory. On AWS, this realm tree is on S3 Files. Agents can inspect History; +only deckd's POSIX principal writes the jj repository. `DEFAULT_IGNORE` / `snapshot.auto-track` exclude `.deck` so the store is never sealed into itself; `file-list-at` / restore also filter machinery paths. **Migration:** `ensure` converts symlink or full-`.jj` layouts to the thin -stub. Prefer a **fresh depot** for deckd experiments — do not share Timeline -state with older demos. +stub. It also atomically renames a lone legacy `.deck/timeline/` directory to +`.deck/history/` and rewrites the pointer. If both directories exist, it +refuses to choose and preserves both for manual repair. ### R4 — Identity vs derived (AMD / esbuild) @@ -134,21 +159,21 @@ at most it waits on deckd or returns a clear not-ready. - HTTP Deck / realm **surface**: read-only for what a later GET returns. - deckd is a **sidecar** that may write under `.deck/` (and the live tree only - via restore replay rules already defined for Timeline). + via restore replay rules already defined for History). - Transforms are RPC to deckd (`/ensure-build`), not `PUT` handlers on the serve origin. ### R7 — Campaign fit Not inside lean Core drop, resolver, or thin decklist verbs (PR 1 / 1b). -Treat as **Track D — Build**, after or parallel to Timeline slices: +Treat as **Track D — Build**, after or parallel to History slices: -| Slice | Outcome | -| --- | --- | -| **D0** | Layout only: Timeline under `.deck/timeline`; kill top-level `.jj` / `.boxel-history` | -| **D1** | Own supervised deckd Timeline (historyd contract + gaps) | -| **D2** | Build plane later; until then identity JS only (main Hub pipeline) | -| **D3** | Editable realms default to deckd; Versions-only remains for RO catalog mirrors | +| Slice | Outcome | +| ------ | ----------------------------------------------------------------------------------- | +| **D0** | Layout only: History under `.deck/history`; kill top-level `.jj` / `.boxel-history` | +| **D1** | Own supervised deckd History (historyd contract + gaps) | +| **D2** | Build plane later; until then identity JS only (main Hub pipeline) | +| **D3** | Editable realms default to deckd; Versions-only remains for RO catalog mirrors | Boxel can keep shipping **jj CLI in-image** until D1; same `HistoryBackend` contract. @@ -157,36 +182,36 @@ contract. ## 2. RPC surface (v1) -Timeline (compat with today’s historyd client in +History (compat with today’s historyd client in `packages/history/src/historyd.ts`): -| Route | Body (sketch) | Result | -| --- | --- | --- | -| `POST /ensure` | `{ dir }` | `{}` — repo at `.deck/timeline` | -| `POST /seal` | `{ dir, message, actor? }` | `{ changeId }` | -| `POST /list` | `{ dir }` | `HistoryEntry[]` (incl. author when actor was set) | -| `POST /file-at` | `{ dir, revisionId, path }` | bytes or not found | -| `POST /file-list-at` | `{ dir, revisionId }` | paths — **close the CLI gap** | -| `POST /restore-plan` | `{ dir, revisionId }` | `{ writes, deletes }` | +| Route | Body (sketch) | Result | +| -------------------- | --------------------------- | -------------------------------------------------- | +| `POST /ensure` | `{ dir }` | `{}` — repo at `.deck/history` | +| `POST /seal` | `{ dir, message, actor? }` | `{ changeId }` | +| `POST /list` | `{ dir }` | `HistoryEntry[]` (incl. author when actor was set) | +| `POST /file-at` | `{ dir, revisionId, path }` | bytes or not found | +| `POST /file-list-at` | `{ dir, revisionId }` | paths — **close the CLI gap** | +| `POST /restore-plan` | `{ dir, revisionId }` | `{ writes, deletes }` | Build (deferred — **not** on the Hub serve path): -| Route | Body (sketch) | Result | -| --- | --- | --- | +| Route | Body (sketch) | Result | +| -------------------- | ------------------------------------------- | ------------------------------------------ | | `POST /ensure-build` | `{ dir, tip?, transforms[], toolchain_id }` | derived cache under `.deck/build/` (later) | -| `POST /artifact-at` | `{ dir, tip, path, … }` | derived bytes (later) | +| `POST /artifact-at` | `{ dir, tip, path, … }` | derived bytes (later) | **Near-term serve:** Hub / `deck serve` keep **main’s identity JS pipeline** (live ESM from the working tree / sealed packs). No esbuild, no -`?derived=amd` hook in Deck. deckd’s job for D0–D1 is Timeline under -`.deck/timeline` so Hub stops forking `jj` CLI. +`?derived=amd` hook in Deck. deckd’s job for D0–D1 is History under +`.deck/history` so Hub stops forking `jj` CLI. Default listen: `http://127.0.0.1:8787` (today’s `DECK_HISTORYD_URL`); `deck serve` stays on 8788. Env: `DECKD_URL` supersedes `DECK_HISTORYD_URL` when both set. With `DECKD_URL`, `createHistory` attaches **deckd** (not jj CLI). **Sealer cutover:** when `history.kind === 'deckd'`, Hub `watchAndPublish` -defaults `sealOnWatch: false` — Versions + HMR only. Timeline seals come from +defaults `sealOnWatch: false` — Versions + HMR only. History seals come from deckd’s FS watch plane (`notify` → debounce → jj-lib). jj / historyd keep Node-side note/flush. @@ -194,12 +219,12 @@ Node-side note/flush. ## 3. Gaps vs today’s historyd (must close in D1) -| Gap | Today | deckd | -| --- | --- | --- | -| `actor` on seal | dropped | persisted; `list` shows author | -| `fileListAt` | absent | `/file-list-at` | -| Supervision | unsupervised sibling | started/supervised by `deck serve` / realm-server | -| Repo path | implicit `.jj` at `dir` | **`.deck/timeline` only** | +| Gap | Today | deckd | +| --------------- | ----------------------- | ------------------------------------------------- | +| `actor` on seal | dropped | persisted; `list` shows author | +| `fileListAt` | absent | `/file-list-at` | +| Supervision | unsupervised sibling | started/supervised by `deck serve` / realm-server | +| Repo path | implicit `.jj` at `dir` | **`.deck/history` only** | --- @@ -260,10 +285,12 @@ appear in derivation meta when used. - No minify in deckd at all — leave `deck derive` in Tools forever (fine; D2 does not require absorbing A/B delivery workstreams). -### Q5 — One daemon for many depots, or one per realm? +### Q5 — One daemon for many realm branches, or one per realm? **Recommendation:** **one deckd per host**, many `{ dir }` arguments (historyd -model). Realm-server passes absolute depot paths. +model). Realm-server passes the selected branch workspace path. One +coordinated deckd writer owns each branch History at a time; hosted paths may +be S3 Files mounts. **Other options:** @@ -274,7 +301,7 @@ model). Realm-server passes absolute depot paths. ### Q6 — Seal source tip, projected tip, or both? -**Recommendation:** Timeline **Steps always capture the live author tree** +**Recommendation:** History **Steps always capture the live author tree** (what humans/agents edited). Build outputs live only under `.deck/build/` and are not Step content. A Version may point at source and optionally record `derivation` for a published derived tree. @@ -283,7 +310,7 @@ are not Step content. A Version may point at source and optionally record - Seal after projecting into the live tree — mutates what authors see; easy to confuse with identity; discouraged. -- Parallel Timeline for build artifacts — complexity with little product win. +- Parallel History for build artifacts — complexity with little product win. ### Q7 — Failure mode when build fails? @@ -297,14 +324,14 @@ failed” without losing restore. Never leave a half-written identity Version. for agent/debug restore. - Roll back live tree on build fail — hostile to authors. -### Q8 — Name of Timeline subdirectory? +### Q8 — Name of History subdirectory? -**Recommendation:** `.deck/timeline/` (product word matches UI: Timeline). +**Recommendation:** `.deck/history/` (product word matches UI: History). **Other options:** - `.deck/jj/` — honest about backend; leaks impl into paths. -- `.deck/history/` — clashes with “History: Versions vs Timeline” chrome. +- `.deck/history/` — clashes with “History: Versions vs History” chrome. --- @@ -312,31 +339,31 @@ failed” without losing restore. Never leave a half-written identity Version. ```text HistoryBackend (TS) - ├─ jj CLI → temporary Boxel/default until D1 - ├─ historyd client → today’s external daemon (D1 replaces) - └─ deckd client → Timeline + ensure-build (target) + ├─ jj CLI → legacy fallback only + ├─ historyd client → legacy external compatibility + └─ deckd client → official History + ensure-build backend ``` -Capability block grows: `{ jj, historyd, deckd, build? }`. Doctor copy: -`History: Timeline · via deckd` (not “via jj” once D1 is default). +Capability block is `{ jj, historyd, deckd, build? }`. Doctor copy: +`History: Every save · via deckd` (not “via jj” once D1 is default). --- ## 6. What stays out of deckd -| Stays elsewhere | Why | -| --- | --- | -| Hub UI / teaching demos | Tools | -| Offer rebase daemon | Tools; write policy separate | -| npm vendor corpus pipeline | Tools / ops | -| Negotiated pack prune | undesigned | -| Replacing import maps with a bundler identity | laws / at-rest | +| Stays elsewhere | Why | +| --------------------------------------------- | ---------------------------- | +| Hub UI / teaching demos | Tools | +| Offer rebase daemon | Tools; write policy separate | +| npm vendor corpus pipeline | Tools / ops | +| Negotiated pack prune | undesigned | +| Replacing import maps with a bundler identity | laws / at-rest | --- ## 7. Acceptance sketches -**D0:** Timeline store under `.deck/timeline/repo`; thin `.jj/repo` file +**D0:** History store under `.deck/history/repo`; thin `.jj/repo` file pointer (no symlink, no nested marker); sealed `file-list-at` has zero `.deck/**`; `tree-hash` unchanged; no-gitlink tests green; restore still replays forward. @@ -347,14 +374,14 @@ replays forward. **D2 (deferred):** derived build plane under `.deck/build/` when Boxel needs it; Hub continues identity ESM until then. -**D3:** new editable realm defaults to Timeline+deckd; RO catalog mount may +**D3:** new editable realm defaults to History+deckd; RO catalog mount may stay Versions-only. --- ## 8. One-sentence summary -**deckd** is Deck’s supervised sidecar: Timeline under **`.deck` only** +**deckd** is Deck’s supervised sidecar: History under **`.deck` only** (replacing Hub’s jj CLI forks and top-level `.jj`), so Boxel/Deck stop growing extra hidden git folders. Derived build plane is optional later; identity JS stays on the Hub. diff --git a/docs/deck-depot-and-realm-backport.md b/docs/deck-depot-and-realm-backport.md index 50853ed..85da952 100644 --- a/docs/deck-depot-and-realm-backport.md +++ b/docs/deck-depot-and-realm-backport.md @@ -4,11 +4,12 @@ That word is Boxel’s. This note freezes the standalone terms and how they map when Deck is backported onto a realm server. -Companions: `deck-at-rest.md` (still realm-shaped in places — -historical), `deck-laws.md`, `packages/server` URL grammar, +Companions: `deck-at-rest.md`, `deck-laws.md`, `packages/server` URL grammar, `deck-core-backport-split.md` (isolate backportable Core vs Tools), `deck-github-mirror-analysis.md` (depot↔GitHub monorepo mirror + intake), -`deck-in-motion.md` (§3.7 cross-depot offer/rebase + Boxel/Matrix plan). +`deck-in-motion.md` (§3.7 cross-depot offer/rebase + Boxel/Matrix plan), +`deck-boxel-branch-index-backport.md` (one realm, multiple branch indexes), +and `deck-storage-backends.md` (realm-contained Desktop/AWS persistence). --- @@ -19,11 +20,11 @@ module trees (and JSON). It must work with no Boxel, no cards, no realm server. Using “realm” in the reference implementation teaches the wrong boundary and makes backport sound like a rename instead of an embedding. -| Avoid in standalone Deck | Use instead | -| --- | --- | -| realm (mount / URL slot) | **depot** | -| RealmMount | DepotMount (or equivalent) | -| “the realm’s store” | **the depot’s store** | +| Avoid in standalone Deck | Use instead | +| ------------------------ | -------------------------- | +| realm (mount / URL slot) | **depot** | +| RealmMount | DepotMount (or equivalent) | +| “the realm’s store” | **the depot’s store** | Keep **realm** only in backport docs, meaning the Boxel construct. @@ -87,12 +88,12 @@ A **realm** on a Boxel realm server is a **placement-free address space**: So: -| | **Depot** (Deck standalone) | **Realm** (Boxel) | -| --- | --- | --- | -| Role | A **place** for sealed + live trees | A **dereferenceable** world of files/cards | -| Layout | Convenient `publisher/package` shape | Anything anywhere | -| Store | Depot-local CAS (`_objects` / `_trees` / meta) | Realm machinery + history + … | -| Protocol | Deck serve / publish / lock / pack | Full realm server + (after backport) Deck | +| | **Depot** (Deck standalone) | **Realm** (Boxel) | +| -------- | ---------------------------------------------- | ------------------------------------------ | +| Role | A **place** for sealed + live trees | A **dereferenceable** world of files/cards | +| Layout | Convenient `publisher/package` shape | Anything anywhere | +| Store | Depot-local CAS (`_objects` / `_trees` / meta) | Realm machinery + history + … | +| Protocol | Deck serve / publish / lock / pack | Full realm server + (after backport) Deck | **Depot ⊂ capabilities a realm can offer.** A depot does not need to become placement-free. A realm does not need to give up placement freedom to speak @@ -110,13 +111,13 @@ When Deck is backported: Mapping: -| Standalone Deck | On a deck-enabled realm | -| --- | --- | -| depot name | realm URL prefix / realm handle (one realm ↔ one Deck mount from the client’s view) | -| publisher/package | **one** way to lay out decks inside the realm — not the only way paths exist | -| `//…/@pkg@1.2.3/…` | same serve grammar, rooted at the realm | -| depot store | realm store / bookmarks as today, as long as L1–L12 hold | -| `deck conform` | black-box against the realm’s Deck HTTP surface | +| Standalone Deck | On a deck-enabled realm | +| ------------------------- | -------------------------------------------------------------------------------------------------------------- | +| depot name | Realm RRI authority plus its transport mapping; one Realm RRI ↔ one repository identity from the client’s view | +| publisher/package | **one** way to lay out decks inside the realm — not the only way paths exist | +| `//…/@pkg@1.2.3/…` | exact Version RRI projected through the realm’s Deck HTTP surface | +| depot store | realm-owned `.deck/store`, refs, Reviews, indexes, and deckd History | +| `deck conform` | black-box against the realm’s RRI resolver and Deck HTTP projection | ### 3.1 What backport must not do @@ -127,10 +128,21 @@ Mapping: ### 3.2 What backport must do - Preserve Deck identity (`tree-hash-v1`), immutability, and pin/verify laws. +- Keep every Deck resource below the owning realm's `.deck/`; authenticated + database rows may index/project those files but do not replace or relocate + them into a parallel global protocol tree. +- On hosted AWS, let agents inspect `.deck` through S3 Files while denying + POSIX writes to protocol-owned store/index/ref/Review paths. realm-server + writes those paths through direct conditional S3; deckd writes only the + branch-local jj History through S3 Files. - Expose the Deck URL grammar (exact / tag / live / pack / digests). - Allow decks to be addressed even when their files are not under a tidy publisher folder — resolution goes through the realm map / decklist, not through folder layout alone. +- Allow one realm to carry multiple branches. Each branch has its own mutable + source state, History, and cloned index while retaining the same Realm RRI, + transport mapping, ownership, and ACL. See + `deck-boxel-branch-index-backport.md`. ### 3.3 Cross-realm offer / rebase (in-motion on deck-enabled realms) @@ -139,22 +151,23 @@ Across depots (and therefore across deck-enabled realms) the same three carriages apply — normative detail in `deck-in-motion.md` §3.7: 1. **Public realm readable by both sides** — ordinary Deck GETs of offer - tip, upstream Version, digests. -2. **Out-of-band message of intent** — not wire protocol yet. + tip, upstream Version, digests. +2. **Out-of-band message of intent** — not wire protocol yet. 3. **If the tip cannot be public:** a **Deck Pack** attached to that message; receiver unpacks, then local offer/rebase. **Boxel plan for (2):** use **Matrix** as the signal — -| Piece | Role | -| --- | --- | -| Matrix **identity** | who is offering / who may accept | -| Matrix **room** | where the negotiation lives | -| Matrix **message** | message of intent (base Version, treeHash, why) | +| Piece | Role | +| ------------------------------------- | ------------------------------------------------- | +| Matrix **identity** | who is offering / who may accept | +| Matrix **room** | where the negotiation lives | +| Matrix **message** | message of intent (base Version, treeHash, why) | | optional **Deck Pack** on the message | case (3) when the offer tip is not world-readable | -Realms stay **depot-shaped** for Deck (one realm ↔ one Deck mount from the -client’s view). Realm **access control** decides who may **issue** an offer +Realms stay one ownership/repository boundary for Deck. A realm may expose +multiple branches with distinct indexes without pretending each branch is a +different realm. Realm **access control** decides who may **issue** an offer and who may **run rebase / accept** a pointer move; public catalog realms are case (1). GitHub share/gather remains an interim stand-in until Matrix negotiation lands (`deck-github-mirror-analysis.md` §5b). Synthesis vs @@ -181,14 +194,14 @@ new APIs say depot.** ## 5. One-line glossary -| Term | One line | -| --- | --- | -| **server** | Process that mounts depots and serves Deck HTTP | -| **depot** | Named place: live tree + store + URL prefix | -| **publisher** | Namespace / ownership segment under a depot | -| **deck / package** | Named module tree; versions point at treeHashes | -| **realm** | Boxel placement-free world; may become deck-enabled | -| **deck-enabled realm** | Realm that implements the Deck contract | +| Term | One line | +| ---------------------- | --------------------------------------------------- | +| **server** | Process that mounts depots and serves Deck HTTP | +| **depot** | Named place: live tree + store + URL prefix | +| **publisher** | Namespace / ownership segment under a depot | +| **deck / package** | Named module tree; versions point at treeHashes | +| **realm** | Boxel placement-free world; may become deck-enabled | +| **deck-enabled realm** | Realm that implements the Deck contract | --- diff --git a/docs/deck-embroider-lessons.md b/docs/deck-embroider-lessons.md index 1cfdbf0..1aaf22e 100644 --- a/docs/deck-embroider-lessons.md +++ b/docs/deck-embroider-lessons.md @@ -52,7 +52,7 @@ Stops casual “just make the server understand this package shape.” ### 3. Keep compat out of `@deck/server` All npm/CJS/`exports` normalization stays in vendor / CLI / Tools. -Serve only: live tree, `@exact`, tags, digests, maps, Timeline. +Serve only: live tree, `@exact`, tags, digests, maps, History. If a behavior needs “best effort like ember-auto-import,” it is a vendor flag with an explicit lossy report — never a silent serve path. diff --git a/docs/deck-fork-merge-pr-ux.md b/docs/deck-fork-merge-pr-ux.md new file mode 100644 index 0000000..7009818 --- /dev/null +++ b/docs/deck-fork-merge-pr-ux.md @@ -0,0 +1,682 @@ +# Deck fork, review, and merge UX + +**Status: UX DIRECTION — protocol-backed.** This document specifies the +familiar collaboration surface Deck should present over the implemented local +Repository, Checkpoint, branch-head, and Review protocol. It supersedes the UI +rule that Deck should avoid familiar branch/review/merge ceremony. Those +concepts are useful; the Review noun avoids pretending that Deck is moving Git +refs or pulling from a remote. + +Companions: [`deck-ui-spec.md`](deck-ui-spec.md), +[`deck-hub-brief.md`](deck-hub-brief.md), +[`deck-version-is-a-proposal.md`](deck-version-is-a-proposal.md), and +[`deck-in-motion.md`](deck-in-motion.md). Protocol facts are defined by +[`deck-collaboration-protocol.md`](deck-collaboration-protocol.md). + +--- + +## 0. Product ruling + +> **Use the GitHub grammar people know. Make the result Deck-native.** + +Deck should look immediately legible to a GitHub user: + +- **Browse** owns repositories, code, branches, Versions, and Activity; +- **Review** owns proposed changes; +- a person can **Fork**, choose a branch, open a **Review**, review + changes, see checks, and press **Merge**; +- a fork is private until its owner deliberately opens a review; +- conflicts and stale reviews have conventional, unmistakable states. + +Deck's advantage appears after orientation, not before it: + +- every review has a **Run this change** action; +- preview means executing the exact proposed Repository, not deploying an + approximation; +- dependency substitutions are visible; +- a clean merge moves a pointer and copies no content; +- hundreds of agent reviews collapse to the exceptional few that need + attention. + +The product should not ask a new user to learn _offer_, _trial_, _seal_, +_publisher boundary_, or _locus_ before completing their first contribution. +Those remain useful implementation and research concepts, not primary +navigation. + +--- + +## 1. Familiar outside, Deck inside + +| Familiar UI term | Deck mechanism | What the UI says | +| ------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------- | +| Repository | named collaboration boundary over one or more package trees | Repository | +| Owner | publisher | Owner | +| Branch | isolated fork ref presented inside the same owner/repository | Branch | +| Fork | the same isolated fork primitive under a different owner | Fork | +| Commit | durable Checkpoint | Checkpoint in detail; commit-compatible wording in import/export | +| Pull request | durable `deck-review-v2` source-head → target-head proposal | Review | +| Merge commit | Checkpoint with target and source parents | Merge | +| Release | Version pointing at a treeHash and Checkpoint | Version / Release | +| Actions/checks | signed results bound to exact source + target Checkpoints | Checks | +| Deployment preview | exact proposed Repository + lock | Preview | + +Two distinctions must remain explicit: + +1. A **fork is not a review**. People fork to experiment privately. +2. A **merge is not a release**. Merging changes a branch; releasing makes a + semver promise. + +### 1.1 One mechanism, two familiar names + +> **Same owner and repository: branch. Different owner: fork.** + +Internally, every non-default line of work is a real isolated fork: it has +its own head, ancestry, writable live tree, immutable snapshots, and Version +line. “Branch” is the team-workflow name for a fork that remains inside the +same repository boundary. + +```text +VISIBLE TO PEOPLE BACKED BY THE SAME PRIMITIVE + +acme/currencies:main ────────▶ ForkRef(owner=acme, repo=currencies) +acme/currencies:add-myr ────────▶ ForkRef(upstream=…:main) +acme/currencies:next ────────▶ ForkRef(upstream=…:main) +chris/currencies:add-myr ────────▶ ForkRef(upstream=acme/…:main) + ▲ ▲ + │ │ + called a fork because owner differs identical isolation semantics +``` + +This is primarily a naming and presentation rule. It gives teams the +workflow they expect without introducing a weaker shared-directory kind of +branch: + +| Source | New work line | UI calls it | Why | +| ----------------------- | ----------------------------- | ---------------- | -------------------------- | +| `acme/currencies:main` | `acme/currencies:add-myr` | branch | same owner/repository | +| `acme/currencies:main` | `chris/currencies:main` | fork | owner changed | +| `chris/currencies:main` | `chris/currencies:experiment` | branch in a fork | same fork owner/repository | + +The implementation must not make internal branches share one mutable live +directory. Each branch gets the same isolation, ancestry, merge behavior, +and zero-copy starting state as an external fork. + +--- + +## 2. The repository in Browse + +Repository identity is part of Browse, not a second site-wide shell. This is +what keeps the package tree, branch selection, Versions, and the Repository +that lands them together in one place. + +```text +┌──────────────────────────────────────────────────────────────────────────────┐ +│ DECK Search or jump to… + ◉ CS │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ acme / currencies main ▾ Review 12 │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ package tree, Version controls, and content │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +Design rules: + +- Owner and repository name are the strongest locator. +- Branch selection is a quiet address control beside package identity, not a + loud tab or repository dashboard. +- The selector's **New…** section offers **Branch here** and **Fork to my + scope**. They share one isolation primitive; ownership determines the noun. +- Imported packages are read-only. They show **Fork** only; the new package is + mutable and gains `main`, branches, commits, and Reviews. +- A fork made from another mutable Deck package quietly shows **fork of + owner/package** and **Review upstream**. A fork of an imported package has + no writable upstream in Deck, so it has no upstream Review action. +- Branch/Version volatility remains visible without duplicating the Version + control below it. +- **Run** and **Code** form Deck's persistent two-way lens. +- `treeHash`, storage savings, and canonical URLs are one click away, never + the headline a newcomer must decode. + +### Route shape + +```text +/browse?at=/// Repository / package code +/browse?at= branch package tree +/review proposed-change list +/review?repository=/&number=N Review +/review?repository=&source= propose fork main upstream +/track?at= ref and Checkpoint activity +``` + +These are Hub viewer routes. Raw ESM continues to use the existing Deck URL +grammar. The chrome never steals the canonical module address. + +--- + +## 3. Repository home / Code + +```text +┌─ acme / currencies ──────────────────────────────────────────────────────────┐ +│ [ Code ] Branches 3 Versions 34 Activity [ Review 12 ] [ Fork ]│ +├──────────────────────────────────────────────────────────────────────────────┤ +│ main ▾ latest: 2.4.1 dev: 2.5.0-dev.18 [ Run repository ] │ +│ │ +│ acme merged #418 from kim/add-myr 12 min ago │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ Name Last change Updated │ │ +│ │ data/ Add MYR currency 12m │ │ +│ │ index.js Export currencyByCode 3d │ │ +│ │ package.json Prepare 2.5 line 3d │ │ +│ │ README.md Document formatter 8d │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ README [ Preview data ] │ +│ A maintained ISO currency list… │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +The branch selector understands the three Deck address promises but presents +them without mixing them: + +```text +┌─ Switch branch or version ──────────────────────────────┐ +│ Find… │ +│ │ +│ BRANCHES │ +│ ● main updated 12m ago │ +│ next updated 2h ago │ +│ agent/fix-symbols updated 5h ago │ +│ │ +│ VERSIONS — immutable │ +│ 2.4.1 latest released 8d ago │ +│ 2.4.0 released 3w ago │ +│ │ +│ DEVELOPMENT — immutable snapshots │ +│ 2.5.0-dev.18 dev captured 12m ago │ +└────────────────────────────────────────────────────────┘ +``` + +Branches move. Versions do not. Development snapshots do not. The grouping +teaches that distinction without inventing a new control. + +### 3.1 Create a team branch + +The branch selector contains the familiar action: + +```text +┌─ Create a branch ────────────────────────────────────────────────────────────┐ +│ Branch name add-myr │ +│ Branch from main at 31c72d8 ▾ │ +│ │ +│ This creates an isolated work line inside acme/currencies. │ +│ It starts from the same content and uses no additional source storage. │ +│ │ +│ [ Cancel ] [ Create branch ] │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +After creation, the repository name does not change: + +```text +┌─ acme / currencies ──────────────────────────────────────────────────────────┐ +│ [ Code ] Branches 3 Versions 34 Activity [ Review 12 ] [ Fork ]│ +├──────────────────────────────────────────────────────────────────────────────┤ +│ add-myr ▾ branched from main · 0 ahead · 0 behind │ +│ [ Contribute ] [ Branch menu ▾ ] │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +The branch menu contains **Rename branch**, **Compare**, **Sync with main**, +and **Delete branch** according to permissions. “Fork” does not appear in +this internal team workflow even though isolation is implemented by the fork +primitive. + +--- + +## 4. Fork: an ordinary, private action + +The primary **Fork** button opens a familiar confirmation rather than +immediately enrolling the fork in an upstream queue. + +The fork records both its content base and, when the source is a mutable Deck +package, the exact upstream Checkpoint. **Review upstream** compares the fork's +current `main` against that recorded base. The Review keeps the fork's real +owner/package endpoint visible, while its package tree is mapped back onto the +upstream package identity for preview and merge. + +```text +┌─ Create a fork ──────────────────────────────────────────────────────────────┐ +│ A fork is your independent copy of acme/currencies. │ +│ It will not notify acme until you open a review. │ +│ │ +│ Owner chris ▾ │ +│ Repository currencies │ +│ Default branch main │ +│ │ +│ Storage No source bytes copied — both refs point at the same tree. │ +│ │ +│ [ Cancel ] [ Create fork ] │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +After creation: + +```text +┌─ chris / currencies ─────────────────────────────────────────────────────────┐ +│ forked from acme/currencies · no content changes yet │ +│ │ +│ [ Code ] Branches 1 Versions 0 Activity [ Review ] [ Sync fork ▾ ]│ +│ [ Contribute ]│ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +**Contribute** is the deliberate transition from private work to upstream +proposal. It appears only when the fork differs from upstream. + +### Branch versus fork in review labels + +The compare control follows the convention people already know: + +```text +INTERNAL TEAM BRANCH +base: acme/currencies:main ← compare: acme:add-myr + +EXTERNAL FORK +base: acme/currencies:main ← compare: chris:add-myr +``` + +The short `owner:branch` label is sufficient when repository names match. +The detail popover carries the full source repository and exact Checkpoint. + +--- + +## 5. Open a review + +```text +┌─ Open a review ────────────────────────────────────────────────────────┐ +│ base repository: acme/currencies base: main ▾ │ +│ head repository: acme/currencies compare: add-myr ▾ │ +│ │ +│ ✓ Able to merge 3 files +42 −6 │ +│ │ +│ Title Add Malaysian Ringgit formatting │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ Adds MYR metadata and locale-aware formatting. │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ REVIEW THE RUNNING RESULT │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ Preview URL /run?with=acme/currencies=acme/currencies:add-myr@ │ │ +│ │ [ Run proposed change ] [ Compare with main ] │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Reviewers Add… Draft ☐ [ Create review ]│ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +The exact source Checkpoint is captured when the request opens. A later source +update becomes an explicit review event and makes Checkpoint-bound checks +or approvals stale. + +--- + +## 6. Review overview + +The page should look conventional at first glance and unmistakably Deck on +the second. + +```text +┌─ Add Malaysian Ringgit formatting #418 ──────────────────────── Open ──────┐ +│ chris wants to merge 3 changed files into acme:main from acme:add-myr │ +│ │ +│ [ Conversation ] Changes 3 Preview Checks 4 │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ ┌─ RUNNABLE REVIEW ───────────────────────────────────────────────────────┐ │ +│ │ This preview executes source Checkpoint 9a4f2c1 against main 31c72d8. │ │ +│ │ [ Run proposed change ] [ Side-by-side with main ] [ Copy exact URL ] │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ chris opened this review 18m │ +│ Adds MYR metadata and locale-aware formatting. │ +│ │ +│ deck/check-links ✓ passed exact dependency closure │ +│ deck/data-shape ✓ passed 181 currency records │ +│ ui/preview ✓ passed artifact available │ +│ amy approved ✓ approved source Checkpoint 9a4f2c1 │ +│ │ +│ ┌─ Ready to merge ────────────────────────────────────────────────────────┐ │ +│ │ ✓ All required checks passed │ │ +│ │ ✓ Approved by amy │ │ +│ │ ✓ Branch has no conflicts with main │ │ +│ │ [ Merge ▾ ] │ │ +│ └─────────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +### The four tabs + +| Tab | Job | +| ------------ | ------------------------------------------------------------------- | +| Conversation | intent, review events, approvals, ref changes | +| Changes | file and semantic data diff | +| Preview | exact running result, import substitutions, side-by-side run | +| Checks | results bound to source Checkpoint + target Checkpoint + suite tree | + +GitHub makes the diff primary because source is what it can show reliably. +Deck should give **Preview** equal rank because running the exact proposal is +its strongest product advantage. + +--- + +## 7. Changes: code and data are both credible + +Code diff: + +```text +┌─ data/myr.js ─────────────────────────────────────────── +12 −0 Viewed ☐ ─┐ +│ 1 + export const MYR = { │ +│ 2 + code: 'MYR', │ +│ 3 + symbol: 'RM', │ +│ 4 + decimals: 2, │ +│ 5 + }; │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +JSON/data diff: + +```text +┌─ currencies.json ───────────────────────── Semantic diff · 1 key added ───┐ +│ + MYR │ +│ code "MYR" │ +│ name "Malaysian Ringgit" │ +│ symbol "RM" │ +│ decimals 2 │ +│ │ +│ [ View as JSON ] [ View raw diff ] │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +Review comments bind to `(sourceCheckpointHash, path, semantic key or line)`. If +the source moves and the location cannot be mapped confidently, the comment +stays visible as **Outdated** rather than floating onto unrelated bytes. + +--- + +## 8. Preview: Deck's signature interaction + +```text +┌─ Preview review #418 ─────────────────────────────────────────────────┐ +│ main 31c72d8 review 9a4f2c1 │ +│ ┌───────────────────────────────┐ ┌─────────────────────────────────────┐ │ +│ │ │ │ │ │ +│ │ RUNNING MAIN │ │ RUNNING REVIEW │ │ +│ │ │ │ │ │ +│ │ 180 currencies │ │ 181 currencies │ │ +│ │ │ │ MYR · Malaysian Ringgit · RM │ │ +│ └───────────────────────────────┘ └─────────────────────────────────────┘ │ +│ │ +│ Resolution change │ +│ currencies acme/currencies:main → acme/currencies:add-myr@9a4f2c1 │ +│ everything else unchanged │ +│ │ +│ [ Open PR source ] [ Inspect dependency closure ] [ Copy exact preview URL ] │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +The preview banner must always show: + +- review number; +- exact source Checkpoint; +- target Checkpoint it was resolved against; +- changed dependency bindings; +- one action to return to the unmodified target. + +A preview must never visually masquerade as production or ordinary live. + +--- + +## 9. Conflicts and source updates + +Conflicted request: + +```text +┌─ Merge blocked ──────────────────────────────────────────────────────────────┐ +│ main changed after this branch diverged. Deck found 2 real conflicts. │ +│ │ +│ ✕ package.json dependencies.currencies │ +│ ✕ data/currencies.json MYR │ +│ │ +│ 14 other upstream changes merge cleanly and need no attention. │ +│ │ +│ [ Update branch automatically ] [ Resolve locally ] │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +Deck should emphasize the exceptional paths, not display a generic “branch +is out of date” warning. If upstream changed 200 files but only two collide, +show two. + +Updating a branch produces durable ancestry. It does not silently rewrite a +working tree: + +```text +main ─────── A ───── B + ╲ ╱ +add-myr ─────── C ──M M = update merge on the fork +``` + +After a source update: + +```text +amy's approval is stale +Approved 9a4f2c1 · current source b71e833 [ Request review ] +``` + +--- + +## 10. Merge + +```text +┌─ Merge review #418 ───────────────────────────────────────────────────┐ +│ │ +│ Method ● Merge commit │ +│ ○ Fast-forward when possible │ +│ ○ Squash into one Checkpoint │ +│ │ +│ Result acme/currencies:main │ +│ Source acme/currencies:add-myr at 9a4f2c1 │ +│ Content 3 files changed · pointer update · 0 source bytes copied │ +│ │ +│ Create Version after merge ☐ Open a release proposal │ +│ │ +│ [ Cancel ] [ Confirm merge ] │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +Successful landing: + +```text +┌─ Merged ─────────────────────────────────────────────────────────────────────┐ +│ amy merged Checkpoint e42bc90 into acme/currencies:main just now │ +│ │ +│ [ Run merged result ] [ View merge Checkpoint ] [ Open release proposal ] │ +│ [ Delete branch ] │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +Do not make “zero bytes copied” the primary success message. It is valuable +supporting proof, not the user's job. The primary message is that the reviewed +change is now on the target branch and can be run. + +--- + +## 11. Review queue and agent scale + +Default list: + +```text +┌─ Review · Reviews ─────────────────────────────────────────────────────┐ +│ [ 12 Open ] 184 Closed Filters ▾ Sort: recently updated ▾ │ +│ │ +│ ✓ Add MYR formatting acme:add-myr Ready 18m │ +│ ● Refresh ISO numeric codes agent-41:iso Review 1h │ +│ ✕ Normalize symbols agent-09:symbols Conflict 2h │ +│ ◌ Add historic currencies kim:historic Draft 5h │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +Agent-scale triage view: + +```text +┌─ Review · Attention ─────────────────────────────────────────────────────────┐ +│ 203 open 187 merge cleanly 9 waiting for checks 7 need people │ +│ │ +│ NEED PEOPLE │ +│ ✕ #391 same JSON key changed differently 2 conflicts │ +│ ! #402 required security review missing opened by agent-14 │ +│ ! #417 source changed after approval re-review │ +│ │ +│ HEALTHY IN BACKGROUND │ +│ ✓ 187 continuously checked and mergeable [ Show all ] │ +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +This is where Deck should depart visually from GitHub. A queue of 200 agent +changes is summarized by state, with the seven exceptional requests expanded. +The product should not render automation success as 187 equally loud rows. + +--- + +## 12. Visual credibility + +Deck must look like a production forge, not a protocol inspector. + +### Hierarchy + +1. Repository and current branch. +2. Work state: open, draft, blocked, ready, merged. +3. Human intent: title, author, reviewers, discussion. +4. Runnable result and checks. +5. Files and semantic changes. +6. Content identity, canonical URLs, storage mechanics. + +### Density + +- Repository and PR pages use a 1200–1440 px content width. +- Primary reading columns stay approximately 760–880 px. +- Use bordered groups for files, checks, merge state, and preview state—not + a card around every sentence. +- Tables remain tables. Do not turn repository listings into card grids. +- A single green/red/amber state language is shared across list and detail. + +### Color + +| State | Treatment | +| ----------------------------- | --------------------------------------------- | +| open / neutral | cool blue or violet accent | +| ready / passed / merged | green | +| waiting / stale / draft | amber or neutral gray | +| failed / conflicted / blocked | red | +| preview / substituted runtime | strong violet banner distinct from production | + +Color never carries state alone; icon and text accompany it. + +### Typography + +- Repository, branch and file names use the product's mono face. +- Titles, descriptions and review prose use the reading face. +- Hashes are shortened to seven characters in ordinary chrome. +- Full hashes and canonical addresses live in copyable detail popovers. + +### What not to imitate + +- Do not reproduce GitHub's exact chrome, iconography, or color tokens. +- Do not bury Preview under an environment/deployment sidebar. +- Do not call every captured save a commit in the main UI. +- Do not make protocol metadata compete visually with contributor intent. + +Credibility comes from complete interaction states and consistent hierarchy, +not from tracing GitHub pixel for pixel. + +--- + +## 13. Build from protocol facts + +Build the UX in three slices. + +### Slice U0 — Repository fixture + +Add static, typed fixtures representing: + +- clean review; +- draft; +- source updated after review; +- real conflict with paths/JSON keys; +- failed check; +- merged request; +- 200-request agent queue summarized to exceptions. + +Build the repository shell, review list, detail, changes, preview, and +merge dialog against `examples/collaboration`. The same validation state must +round-trip through the collaboration implementation; the UI does not own a second +model of repository state. + +### Slice U1 — read protocol objects + +- Repository member, file, Checkpoint, branch, and Version data from Hub + endpoints over the protocol store. +- Existing forks and `forkedFrom` records projected as compatibility Reviews. +- Existing `mergeTrees` result projected into per-package ready/conflict state. +- the captured Repository and lock power the Preview tab; `?with=` remains a + temporary trial mechanism, not the review identity. +- Existing checks, signatures, and History facts displayed read-only. + +This slice proves the product with real Deck content and durable refs before +write actions are enabled. + +### Slice U2 — durable reviews + +The local core now has Review openings, append-only events, exact +preview, Repository merge, and crash-recoverable merge completion. Bind those +objects to the Hub. The conditional object-store adapter already covers S3 +branch refs, Review numbering, openings, and event heads. Hosted fork, +review, merge, and close actions still require the authenticated realm API. +The visual contract does not change. + +The executable terminal tour in +[`deck-repository-tour.md`](deck-repository-tour.md) is the behavioral fixture +for this slice. The visual UI must agree with its hashes and transitions. + +--- + +## 14. Acceptance walkthrough + +A first-time GitHub user should complete this without documentation: + +1. Open `acme/currencies` and recognize a repository. +2. See that **Fork** and **New branch** are separate, familiar actions, and + that neither opens a review. +3. As a team member, create an `acme/currencies:add-myr` branch; as an + outside contributor, create `chris/currencies` as a fork. +4. Understand that both work lines have identical isolation even though the + UI names them differently. +5. Press **Contribute** and compare base/head. +6. Open a review. +7. Run the exact proposal beside main. +8. Read code or semantic JSON changes. +9. See checks and approval state. +10. Merge, then run the merged result. +11. Optionally open a separate Version proposal. + +An experienced Deck user should additionally be able to prove: + +- which exact source and target Checkpoints were reviewed; +- which Repository hash and member treeHashes executed in Preview; +- which dependency bindings changed; +- why an approval became stale; +- that a merge copied no content bytes; +- that the released Version, if any, points at the merged tree. + +If the first journey requires explaining Deck vocabulary, the UX has failed. +If the second journey cannot reach the content-addressed proof, the UX has +become merely a GitHub imitation. diff --git a/docs/deck-frame-version-vocabulary.md b/docs/deck-frame-version-vocabulary.md index 801558a..344823c 100644 --- a/docs/deck-frame-version-vocabulary.md +++ b/docs/deck-frame-version-vocabulary.md @@ -1,4 +1,4 @@ -# Frame · Version · Timeline → Step +# Frame · Version · History → Step **Superseded** (2026-08-09). Frame → Tick → **Step**. diff --git a/docs/deck-github-mirror-analysis.md b/docs/deck-github-mirror-analysis.md index 182a736..2871ea6 100644 --- a/docs/deck-github-mirror-analysis.md +++ b/docs/deck-github-mirror-analysis.md @@ -8,7 +8,7 @@ Companions: `deck-depot-and-realm-backport.md`, `deck-laws.md` (L1/L3/L4), `deck-catalog-consumption-modes.md`, `packages/core/src/merge.ts` (3-way). Original decisions: Chris, 2026-08-07 (one-way mirror). Extended 2026-08-09 -with store-vs-Timeline clarity, monorepo confirmation, provenance carriers, +with store-vs-History clarity, monorepo confirmation, provenance carriers, and a **deterministic merge-back** path when a GitHub repo is allowed to be read/write — still **without AI**. @@ -20,11 +20,11 @@ read/write — still **without AI**. | --- | --- | --- | | **Version** (immutable cut, `@1.2.3`) | `.deck/store/` — `meta.json` → `treeHash` + CAS blobs | Projected as annotated tag + tree on `main` | | **Dist-tag** (`latest`, `dev`) | store `meta.json` `tags` | Projected as moving branch | -| **Timeline Step** (every save) | `.jj` (Git-compatible under the hood) | Optional `history` branch | +| **History Step** (every save) | `.jj` (Git-compatible under the hood) | Optional `history` branch | | **Live tree** | depot working directories `publisher/package/…` | Tree inside the monorepo | **Versions are not stored in jj/git.** The store is the npm+unpkg half. -jj is optional Timeline only. A depot can publish forever with +jj is optional History only. A depot can publish forever with `History: Versions` and no `.jj` at all. **GitHub is never the registry.** At best it is a projection (mirror) or @@ -56,7 +56,7 @@ to it.** Prefer `publisher/package@version` for catalogs with many authors. - **`main`:** one commit per *semantic* event (publish, yank, quarantine, migration batch, …). Deterministic rebuild from depot events. -- **`history`:** raw Timeline (colocated jj/git) pushed as-is. Opt-in for +- **`history`:** raw History (colocated jj/git) pushed as-is. Opt-in for “share an app”; on for catalog if you want every Step public. - Readers choose; nothing authoritative lives only on GitHub. @@ -106,7 +106,7 @@ tags for Versions. | --- | --- | | Version `@1.2.3` | annotated tag `publisher/package@1.2.3` | | Dist-tag `latest` / `dev` | branch `latest` / `dev` (or `tags/latest` — choose one scheme) | -| Timeline Steps | branch `history` (optional) | +| History Steps | branch `history` (optional) | | Semantic export tip | branch `main` | Identity for merges and audit is **`treeHash`**, not `git rev-parse`. @@ -143,14 +143,14 @@ tree bytes the store has. treeHash: <64 hex> Deck-Version: publisher/package@1.2.3 Base-Version: publisher/package@1.2.0 -Change-Id: +Change-Id: Actor: ``` - **`treeHash`:** L1 identity of that package tree (required on Version tags). - **`Base-Version`:** parent cut when exporting a tip that will accept GitHub PRs (required for intake repos). -- **`Change-Id`:** Step linkage when Timeline exists; omit if Versions-only. +- **`Change-Id`:** Step linkage when History exists; omit if Versions-only. Tag message body may repeat the same fields for humans browsing Releases. @@ -166,7 +166,7 @@ Tag message body may repeat the same fields for humans browsing Releases. | Dist-tag move | force-with-lease update of tag branch | | Publish | curated `main` commit + tag | | Yank / quarantine | tombstone commit; tag retained | -| Restore (Timeline) | ordinary commit on `history` (+ `main` if it was also a publish) | +| Restore (History) | ordinary commit on `history` (+ `main` if it was also a publish) | --- @@ -213,11 +213,11 @@ that fix that. The GitHub handshake is still work we have not built. | Idea behind share/gather | Deck today | Verdict | | --- | --- | --- | | “Server is SoT; git is a side channel” | Depot + store are SoT by law; GitHub is explicitly not the registry (§0) | **Enabled** — this is the whole point of the design | -| Share *WIP anytime*, not only a release | Live tree is first-class; Versions optional; Timeline optional | **Enabled in model** — tip can be `live` with `Base-Version` = last publish | +| Share *WIP anytime*, not only a release | Live tree is first-class; Versions optional; every-save History optional | **Enabled in model** — tip can be `live` with `Base-Version` = last publish | | Bring work back without clobbering | `mergeTrees` + `treeHash` already in Core | **Enabled in model** — gather is “run the merge,” not “copy files” | | Know what you are merging against | Store holds immutable `@x.y.z`; `?meta` exposes digests (incl. live) | **Enabled** — Base-Version is a real object, not a milestone nickname | | Package-scoped move (not whole realm dump) | Addressing is already `publisher/package` | **Enabled** — monorepo mirror matches the unit people share | -| Don’t invent a second history for collab | jj/Timeline ≠ Versions; share branches are disposable | **Enabled** — we finally have vocabulary legacy lacked | +| Don’t invent a second history for collab | jj/History ≠ Versions; share branches are disposable | **Enabled** — we finally have vocabulary legacy lacked | | Teammate sees your intent while you work | Claims (`deck claim`) on paths | **Partially** — coordination inside a depot, not a substitute for a PR | | Review / comment / CI social graph | None in Deck Core | **Not really** — still GitHub’s job (or a future Boxel CR) | | One command that “just shares” with no repo setup | No mirror, no `deck share` yet | **Not built** — design only | @@ -295,7 +295,7 @@ the same provenance offers already use (`treeHash` + base Version). Two git models were intentional (and correct): -1. **`.boxel-history/`** — local undo only (Deck: Timeline / jj Steps). +1. **`.boxel-history/`** — local undo only (Deck: History / jj Steps). 2. **GitHub share/gather** — team collaboration layer (Deck: this doc). Source of truth stayed the **server**, not git. That ruling carries over @@ -390,7 +390,7 @@ deck gather \ | `--into` | Effect | | --- | --- | -| **`live`** (default for humans) | `mergeTrees(base, ours=PR, theirs=current live)` → write working tree; optional Timeline Step; **claim** may be refreshed/cleared by actor | +| **`live`** (default for humans) | `mergeTrees(base, ours=PR, theirs=current live)` → write working tree; optional History Step; **claim** may be refreshed/cleared by actor | | **`publish`** | Same merge, then verify-links → `publishToStore` (new Version). Same as §6 intake success path | Never: copy-overwrite live; never: move an existing Version tag; never: @@ -500,7 +500,7 @@ mess. Details retained from 2026-08-07 catalog cleanup notes: | Piece | Status | | --- | --- | | Store Versions + dist-tags | **Built** | -| Timeline via jj | **Built** (optional) | +| History via jj | **Built** (optional) | | npm/CDN vendor into store | **Built** (not GitHub) | | Deck → GitHub export bot | **Not built** (this doc) | | `deck share` / `deck gather` | **Not built** (§5b; verbs kept) | @@ -555,7 +555,7 @@ milestone-as-default without an explicit `--from`. - Intake: conflicting edit on same file vs `onto` fails with path list. - Intake: clean PR publishes new Version; old Version tag unchanged; `@exact` URL serves new treeHash. -- Depot with **no** `.jj` still exports Versions (Timeline optional). +- Depot with **no** every-save History still exports Versions. ### 9.4 Non-goals for v1 @@ -580,7 +580,7 @@ milestone-as-default without an explicit `--from`. ## 10. Summary - **Monorepo per depot** matches depot layout; Versions stay in - `.deck/store`; jj is Timeline only. + `.deck/store`; jj is History only. - **Catalog mirrors are read-only**; contribution uses **intake** repos with declared `Base-Version` and Deck’s 3-way merge — deterministic, no AI. diff --git a/docs/deck-hub-brief.md b/docs/deck-hub-brief.md index 3583bac..6b441d7 100644 --- a/docs/deck-hub-brief.md +++ b/docs/deck-hub-brief.md @@ -2,17 +2,17 @@ Status: **decided.** This resolves the information architecture that [deck-ui-spec.md](deck-ui-spec.md) deliberately left open, and specifies the -four applications to build. +five applications to build. Read that document first for requirements (§3), jobs to be done (§5), invariants (§4), constraints (§6), scale targets (§11) and the states that must exist (§12). None of it is repeated here. This brief adds: which -direction won and why, the four apps, the journeys, the shared parts, the +direction won and why, the five apps, the journeys, the shared parts, the stack, and what has to be built on the server to support it. -Scope note: the nine demos under `examples/` are **out of scope** and are not -modified. They keep their own processes, ports and depots. Hub work is -additive. +The examples under `examples/` keep their own depots and standalone runners. +When the Hub mounts them, however, they are logical tenants of the Hub's one +listener rather than extra processes or ports. --- @@ -54,10 +54,10 @@ A two-position toggle is a control. A four-position one is a mode. --- -## 2. The four apps +## 2. The five apps -One depot named `hub`, four decks under the `app/` publisher. They are not -depots; they are four documents in one depot, which is why they can share +One depot named `hub`, five decks under the `app/` publisher. They are not +depots; they are five documents in one depot, which is why they can share components at ordinary deck addresses. | route | canonical address | centre of gravity | workloads | @@ -66,6 +66,15 @@ components at ordinary deck addresses. | `/browse` | `/hub/app/browse/` | the tree at rest | W2, W3 | | `/run` | `/hub/app/run/` | the assembled, executing thing | W1, W4 | | `/track` | `/hub/app/track/` | what changed, and where | W3, W4 | +| `/review` | `/hub/app/review/` | proposed changes and merge | W1, W2, W4 | + +Repository identity and branches live in `/browse`, alongside the package tree +they govern. A branch within an owner/repository uses the same isolated +Repository ref as a fork, but the UI calls it a branch. `/review` begins with a +proposed change: Review links lead back to exact `/browse` +comparisons, while Preview leads to `/run` with the proposed Repository's exact +package Versions. **Underneath** exposes the ref, Checkpoint, Repository, Pull +Request opening, and append-only events behind those familiar labels. **`/read` is gone; `/track` replaces it.** Two moves, one reason each. @@ -101,8 +110,8 @@ The first thing a person sees after `deck serve`. It answers J1, J2, J4. and the depots mounted with their honest on-disk paths. The layout *is* the address space, so showing the path is showing the truth, not a debug detail. -**Per depot.** Deck count, version count, store object count and size, history -history (a Timeline from jj colocated or historyd, or Versions only), and the +**Per depot.** Deck count, version count, store object count and size, History +(every-save History from deckd, or Version history only), and the most recent seal. **The river.** `/_live/events` as a stream of change events — deck name, @@ -191,22 +200,26 @@ and `.jj/`. --- -### 2.3 `/run` — the assembled thing, and the toggle +### 2.3 `/run` — the assembled thing and its package graph W1's home. J4, J9, J10, J12. -**What is runnable.** Any deck that declares an entry and any HTML page in a -depot, plus the nine demos. A deck with no declared entry — the ProseMirror -`src/` decks are the reference case — appears as **readable, not runnable**, -structurally, because the server refuses to import it. That refusal is L11 as -a shape and belongs in the interface as a state, not a warning. - -**The toggle.** - -- **run** — the app in an iframe, with its resolved import map alongside. -- **read** — the module graph of *exactly what is executing*: every node, the - volatility of every edge, and every node a link into `/browse` at the - address that answered. +**What is runnable.** HTML pages run directly. A library package does not +pretend to be a page, but it is not a dead end: `/run` follows resolved import +maps in reverse and lists the pages that consume it. Choosing one runs that +page with the selected package or Version supplied as a no-store `?with=` +trial. A package with no declared graph consumers explains that Run cannot +prove computed-URL relationships, then shows the import-map edge needed to +make it discoverable. This preserves L11's structural refusal without turning +most of a package graph into errors or guessing from source text. +Fork lineage participates in the lookup: a fork discovers pages that import +its upstream and offers the asymmetric trial +`?with=upstream/package=fork/package@Version`. + +**The view.** The app runs in an iframe and its resolved import map sits below +it: every node, the volatility of every edge, and every node a link into +`/browse` at the canonical Hub address that answered. Runtime-only `site` +mounts must never leak into those read links. The graph is the payoff. On any other package site "what am I actually running" is an inference; here it is a readout, because the import map the @@ -222,12 +235,13 @@ badge. `variations` demo already works and the pattern that makes A/B/C choosing possible without a deploy. -**Integration note.** The demos are separate processes on ports 8790–8798. -They currently default to HTTPS with a self-signed leaf, which costs a -click-through per port before an iframe will load. The Hub should launch them -with `DECK_DEMO_HTTP=1` so `/run` works on first load; the HTTP/2 comparison -stays available in `examples/three-js/bench.mjs`, where it is the subject -rather than an obstacle. +**One-port integration.** The Hub owns one listener. Each demo is a logical +`site` mount selected by a virtual localhost host, for example +`http://collaboration.localhost:8788/site/…`. This preserves the absolute +`/site/…` pins each depot was sealed with and preserves iframe origin +isolation, without one process or ephemeral port per demo. Hub chrome and the +HMR client both poll the short-lived `/_live/state` snapshot, so many tabs do +not exhaust the HTTP/1.1 connection pool. --- @@ -521,8 +535,9 @@ Small, and none of it changes the protocol surface. 3. **Module counts for the self-report.** `moduleClosure` already exists in `preload.ts`; expose the count per deck so `/start` can grade tree vs bundle without re-probing npm. *(small addition to `?meta`)* -4. **Demo aggregation.** The Hub already polls the nine demo processes for - `/api/demos`. Keep it; `/run` consumes it. +4. **Demo multiplexing — done.** One Node listener dispatches by Host to + unbound Deck server instances. The normal Hub host reads each depot by its + example name; `.localhost` runs the same directory as `site`. 5. **`tree` on `?meta` — done.** `files` was a list of paths, which answers *added* and *removed* and cannot answer *changed*. `tree` is the packlist verbatim (path, size, sha256), so a comparison is one request per side diff --git a/docs/deck-hub-tour.md b/docs/deck-hub-tour.md new file mode 100644 index 0000000..6d305cd --- /dev/null +++ b/docs/deck-hub-tour.md @@ -0,0 +1,156 @@ +# Hub tour — four stories (read-only) + +A walk of the **protocol** through live Hub UI — not a tutorial on using the +Hub. Each chapter is one use case from +[`deck-use-cases.md`](deck-use-cases.md). The UI is the exhibit; the bottom +bar holds the story caption. Highlights are theme-coloured rings, not +overlays that bury the stage. + +**In the Hub:** `/start` → **Walk the stories**, or `/start?tour=1`. The +bottom bar owns Next / Prev / Exit. Esc abandons. Nothing in this script +publishes, releases, locks, accepts, or restores. + +Companions: [`deck-hub-brief.md`](deck-hub-brief.md), +[`deck-demo-brief.md`](deck-demo-brief.md), +[`deck-use-cases.md`](deck-use-cases.md). + +--- + +## Bring the Hub up + +```bash +node apps/hub/setup.mjs # once — Hub demo packs +# demos under examples/*/ must have been run so their depot/ stores exist +DECKD_URL=http://127.0.0.1:8787 node apps/hub/hub.mjs +``` + +| App | Question | +| --- | --- | +| `/start` | What is this machine holding right now? | +| `/browse` | What are these bytes? | +| `/run` | What do they do when assembled? | +| `/track` | What changed? | + +**Rule:** if a control would write — skip it. Prefer subject, volatility, +History scrub, list browsing, and `?with=` trials. Prefer **live** paths +over brittle `@exact` pins when the tip is enough. + +--- + +## Product behaviour + +While the tour is active: + +- **Theme highlight** — a cutout plus a pulsing ring. No popover over the UI. +- **Caption in the bottom bar** — chapter · step · story line · exhibit. +- Interactions are *exercised*: **list-browse**, **dropdown**, **scroll**, + **History scrub**. +- Chapter boundaries navigate with `?tour=1` and session state. + +--- + +## Chapter map + +| # | Chapter | Use case | Exhibit | +| --- | --- | --- | --- | +| 1 | Eight desks, one board | §1 swarm | `two-agents` | +| 2 | Two currencies land in the same minute | §2 codelist | `codelist` | +| 3 | Try it before you take it | §3 review-by-running | `contribute-to-codelist` | +| 4 | The hour you abandoned is still an address | §4 high-frequency keep | `vale-sale` | + +Within each chapter, flip browse ↔ run ↔ track on the **same subject** — +that flip is the lesson. + +--- + +## Interaction choreography + +| Interaction | Where | What the tour does | +| --- | --- | --- | +| **List-browse** | Ch1 — `/browse?at=/two-agents` | Select a desk row (`agent-a` / `charts`) | +| **Dropdown** | Ch2 — currencies `@latest`; Ch4 — vale/sale | Open the version `