diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 6500995..0000000 --- a/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -.git -node_modules -dist -test-results -playwright-report -.env* diff --git a/AGENTS.md b/AGENTS.md index a9a72ee..6c55bf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,11 @@ # AGENTS.md -Build a small, browser-first Bitcoin super testnet from clearly attributed Supertestnet primitives. Keep v0 test-only: no mainnet funds, no hidden signing, no production custody. Prefer simple composable adapters over deep rewrites. Preserve upstream licenses and provenance. +Orgchain is lineage's app for creating and operating lineage holdings on Super Testnet's toy chain. + +- Test-only, always: no mainnet funds, no hidden signing, no custody, and nothing presented as a real key. +- Lineage semantics live in one place, `src/orgchain/lineage.js`. The UI never interprets events itself, and every act + is vetted by that reducer before it is signed. A new op means a reducer change, its tests and the README's table. +- The engine (`src/adapters`, `src/upstream`, `src/storage.js`, `src/miner.js`) is Super Testnet's. Compose with it + rather than rewriting it, and keep its tests passing. Preserve upstream licenses and provenance. +- The page makes no network requests; keep it that way (the deployed CSP sets `connect-src 'none'`). +- Before shipping: `npm test`, `npm run build`, `npm run test:e2e`. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 4477a7a..0000000 --- a/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM node:22-alpine AS build -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci -COPY index.html ./ -COPY src ./src -COPY public ./public -RUN npm run build - -FROM node:22-alpine -ENV NODE_ENV=production PORT=8080 -WORKDIR /app -COPY --from=build --chown=node:node /app/dist ./dist -COPY --chown=node:node server.js package.json ./ -USER node -EXPOSE 8080 -CMD ["node", "server.js"] diff --git a/ORGCHAIN.md b/ORGCHAIN.md deleted file mode 100644 index 82ae6e6..0000000 --- a/ORGCHAIN.md +++ /dev/null @@ -1,13 +0,0 @@ -# Orgchain (branch `orgchain`) - -Super Testnet restyled for lineage, served at https://lineage.yolopreneur.lol/orgchain/. - -- Same lab and code as `main`. The differences are a skin and copy: `src/lineage.css` (loads after `style.css` and - points its tokens at lineage's), a rehearsal guide and lineage branding in `index.html`, the lineage favicon, - relative paths (`vite.config.js` `base: './'`, the recursive preview, license links), the theme following the system - setting under its own storage key, and Inscriboor downloads linking to supertestnet.lol (the extension only connects - there, so the local test wallet is the path here). -- Build: `npm ci && npm run build`, then copy `dist/` to `ventures/lineage/orgchain/` on `teahubxyz/time` `gh-pages`. - Headers (CSP and the rest, matching supertestnet.lol's) come from `ventures/lineage/_headers`, scoped to - `/orgchain/*`. -- Keep in step with `main`: rebase this branch onto `main`, rebuild, recopy. diff --git a/README.md b/README.md index 3f06743..3d50cd7 100644 --- a/README.md +++ b/README.md @@ -1,148 +1,56 @@ -# Super Testnet v0 +# Orgchain -A small browser-local Bitcoin protocol lab: create a **test-only** wallet, mine fake BTC, sign a MIME inscription, see it in that wallet’s collectibles, and follow its transactions into a local block. +Lineage's app for creating and operating lineage holdings: https://lineage.yolopreneur.lol/orgchain/ -This is a private, single-browser simulation inspired by Supertestnet. It uses real Bitcoin transaction serialization, Schnorr signatures and Taproot commit/reveal envelopes. It is **not Bitcoin Core, public testnet, signet, regtest consensus, an Ord indexer, or production custody**. There is no mainnet mode and no external broadcast path. +Lineage gives an org its structure, a key tree with a place for every org and agent. Orgchain adds time. Every act is an +inscription on a private toy chain in your browser, and its parent inscription is the node that authorizes it, so the +chain itself records who did what, and when. -## Run locally +- **Found** a holding (a root: the LLC). **Birth** orgs and agents under any org. **Award** a belt (one skill, one + seat). **Decide** (a decision on a node's record). **Graduate** an org (its edge flips from derived to adopted). + **Close** a node (derived children close with it; graduated ones hold their own roots and stay open). +- **Time.** A beat is a block. At the default tempo each act is its own beat; at 10, 30 or 60 seconds, acts are cued and + land together on the next beat; on manual they wait for *Beat now*. The slider and *Replay* show a holding as it + stood at any beat. +- **One interpretation.** `src/orgchain/lineage.js` folds `lineage/v0` events into trees, and vets every act before + anything is signed. Events it rejects stay on the chain and show as rejected; they just don't change the tree. -Requires Node.js 20.19+ (Node 22 recommended) and npm. +Test coins only: one test wallet and one local chain, kept in this browser (`localStorage`, key `orgchain:lab:v1`). +Nothing here is a real key; minting a real root is the operator's call. -```sh +## Events + +`lineage/v0` is the ord metaprotocol (tag 7); the fields are CBOR metadata (tag 5); the body is a short text label. + +| op | parent inscription (spent to authorize) | metadata | +| --- | --- | --- | +| `found` | none | `name` | +| `birth` | the parent | `name`, `kind` (`org` or `agent`) | +| `award` | the issuing org or holding | `to`, `skill`, `belt` (`white`, `green`, `black`) | +| `decide` | the deciding node | `title`, `why` | +| `graduate` | the child's parent | `of` | +| `close` | the node itself | none | + +Names are domain labels (lowercase letters, digits, hyphens). Org and agent indices count separately and are never +reused. + +## Build and test + +``` npm ci -npm run dev -# Open http://localhost:5173 +npm test # engine, reducer and lab (node --test) +npm run build # dist/ is the site; relative paths, so it works at any sub-path +npm run test:e2e # Playwright on vite preview; PW_CHANNEL=chrome uses installed Chrome ``` -For the exact production server: +`ORGCHAIN_URL` (plus `PW_ARGS`, a JSON array of browser flags) points the same end-to-end spec at a deployed copy. -```sh -npm run build -npm start -# Open http://localhost:8080 -``` +Deploy: copy `dist/` to `ventures/lineage/orgchain/` on `teahubxyz/time` `gh-pages`. Its headers (a strict CSP with +`connect-src 'none'`: the app makes no network requests) come from `ventures/lineage/_headers`. -Use localhost or HTTPS: secure-context Web Crypto and Web Locks are required. Chromium is the verified browser; recent browsers supporting Web Locks and Array.toReversed should work. No API keys, Bitcoin node, faucet, CDN, database, account or environment file is needed. Runtime assets are local. Installing dependencies requires network access; the built app makes no third-party requests. It is not an installable/offline service-worker app. +## Built on Super Testnet -## Complete the loop - -1. **Create test wallet**, or load a previously saved Super Testnet wallet JSON. Wallet creation never signs a transaction. -2. **Mine now**: receive 50 fake BTC immediately, without coinbase maturity. -3. Choose text/plain, application/json, text/html, image/svg+xml, image/png, image/jpeg or image/webp. Edit text or upload a local file of up to **16,384 bytes (16 KiB)**. No remote fetching. Optionally select an owned inscription as its immutable parent. Add arbitrary JSON metadata independently of the body: it becomes real CBOR in repeated ord tag-5 fields, each at most 520 bytes. The app caps metadata at **2,048 CBOR bytes** (64 nesting levels); invalid JSON, non-finite numbers and oversized documents are rejected before signing. Blank metadata is omitted; JSON `null` is a present metadata value. **Review inscription** shows the payload and costs without signing. -4. **Sign & inscribe** explicitly signs a commit and a reveal locally. Fees total 1,000 fake sats; 1,000 fake sats remain in the inscription output. Both transactions enter the local mempool atomically. A child reveal also signs the parent input and returns its full value to the same wallet. After successful persistence the content, file, parent, metadata, review and byte counters reset; failures preserve the composer. -5. The inscription immediately appears as **pending** in this wallet’s collectibles. Mine again to confirm both transactions in one block. -6. Open the inscription, its reveal, its commit, or its block in the integrated explorer. Raw transaction hex and witnesses are visible. Reload to verify persistence. - -Wallet ownership is derived from the unspent reveal output’s script, not a disconnected UI list. Commits spend ordinary change. Child reveals explicitly spend and preserve an owned parent output, tracked at its current outpoint. Parent and children links support nested lineage, including pending ancestors. Loading another wallet on the same chain displays only that wallet’s collectibles. - -**Save test wallet** exports its tagged test key, not chain history. **Save complete lab** exports both wallet and chain, including pending transactions, and is the portable recovery option. Loading a complete lab replaces local state after an explicit prompt. Clearing site data/resetting the lab destroys local state unless you have a backup. Explorer hash links work only with the matching local chain; they are not public explorer links. A different origin (including localhost vs Fly) has separate browser storage; use a complete lab backup to move it. - -## Metadata, local indexes and API - -Collectible cards and details show metadata decoded from inscription witnesses. **Metadata is data; indexers are interpretation.** `MetadataIndex` reconstructs every inscription event from confirmed block raw transactions in chain order, followed by raw mempool transactions. Events include id, content type, status, height/block hash, metadata, presence flag and parent. No UI metadata sidecar is persisted. A top-level string `metadata.protocol` is the default discovery convention; other fields and values have no built-in semantics. The Indexes surface shows counts, discovered names and the ordered feed for a selected protocol. - -The optional `event-counter` reducer derives counts and ordered ids from only the selected protocol's events, displayed separately. Reducers are trusted application code registered above the base index, never downloaded or executed from inscriptions. Adding a reducer requires a source-code change; this is a seam for future metaprotocol experiments, not a sidechain consensus engine. Native ord metaprotocol tag 7 is supported as an independent inscription field. It is authoritative for protocol grouping; `metadata.protocol` remains a compatibility fallback when tag 7 is absent. - -The **LOCAL / IN-BROWSER** API explorer calls `LocalOrdAPI.request(path)` and displays/copies its `{status, contentType, body}` response. No HTTP request is made. Fly/the static server cannot read browser state. Available paths: - -- `/content/:id`: terminal delegate MIME and exact `Uint8Array` body; `inscriptionId` remains the requested id and `contentId` identifies the terminal source. The explorer renders bytes plus an escaped text preview, never executes content. -- `/r/inscription/:id`: decoded event, including JSON metadata, delegate/delegatedBy, synthetic sat, current location, reinscription position and ordered same-sat inscription ids. Metadata and identity always belong to the requested inscription. -- `/r/metadata/:id`: JSON string of CBOR hex, or `null` when absent. -- `/r/parents/:id`, `/r/children/:id`: `{ids}` for immediate lineage (all local results, no pagination). -- `/r/tx/:txid`: `{txid, hex, height}`; `/r/blockheight`: local height. -- `/r/blockhash/:height`, `/r/blockinfo/:height-or-hash`: local hash or synthetic block info with ordered transaction ids (timestamps in milliseconds). -- `/local/sat/:number`: synthetic sat location and ordered inscription stack; `/local/utxo/:txid:vout`: unspent value, half-open sat ranges and inscription ids. -- `/local/metadata-index`: stats and ordered events; `/local/protocols`: names and counts; `/local/protocol?name=...`: matching ordered events. URL-encode protocol names. - -Unknown paths/resources return 404; non-local paths return 400. This deliberately partial ord-like API is not wire-compatible with all ord schemas. Metadata encoding follows the [ord metadata convention](https://docs.ordinals.com/inscriptions/metadata.html); path naming and CBOR-hex metadata responses follow the [ord endpoint reference](https://docs.ordinals.com/inscriptions/recursion.html). HTML/SVG previews use the restricted local bridge described below. - -## Delegates, recursion and synthetic sats - -The optional delegate is ord tag 11, using the same reversed txid/minimal little-endian index as parent tag 3. Existing and syntactically valid future ids are accepted. `/content/:id` follows chains, returns 404 for an absent target and deterministic 508 for loops. Cards/details link both directions. Delegation changes only served bytes/type, never the requesting inscription's metadata, lineage, sat or self-reference. - -**Use recursive demo** fills a usable HTML template. Inline HTML/SVG scripts execute as an async worker program inside a sandboxed iframe. Use `await fetch('/r/blockheight')`, `fetch('/content/'+id)` and standard Response methods. `ord.id` and `ord.contentPath` refer to the requested inscription, including delegated previews; `ord.render(text)` writes escaped text to the preview. The MessageChannel bridge permits only the documented `/content` and `/r` read paths (no query strings, URLs, local administrative endpoints or mutations). CSP also blocks direct XHR, WebSocket and import requests. Workers cannot navigate or access any DOM; the iframe runs only the trusted renderer. General browser DOM scripts and automatic URL rewriting are deliberately unsupported. Scripts never receive wallet secrets or signing capabilities. - -Sat identity is **synthetic-local-fifo**, not a Bitcoin ordinal number. Height `h` issues `[ (h-1)*5,000,000,000, h*5,000,000,000 )`. Ordered input ranges flow into ordered outputs; leftover fee ranges retire instead of being paid to a miner. This replaceable range adapter is replayed from raw transactions, not saved UI state. New parent reveals put the parent's input and output first, preserving its exact sat offset; the new inscription uses a pointer to its own output. Legacy envelopes still decode and their sat positions follow their actual serialized FIFO flow. - -Select **Reinscribe an owned sat** to spend and return its current output while appending an inscription to that same sat. The original body and id remain, stack positions start at zero and ordering survives mining/replay/spends. A new 1,000-sat output is also created by the fixed commit/reveal template; reinscription targets the carried sat, not that new output. Parent and reinscription selection are mutually exclusive. The reinscription target determines its pointer. - -Advanced pointer tag 2 is an unsigned 64-bit zero-based position across all reveal outputs. Encoding is little-endian with trailing zero bytes omitted (zero is empty); a pointer beyond total output value falls back to the first output sat. There is **one inscription per reveal**. Multiple inscriptions on one sat come from separate reveals. Actual Bitcoin subsidy schedules, miner fee recycling, multiple envelopes/reveal, arbitrary DOM recursion and regtest/public backends are deferred. - -## Mining at your pace - -The miner defaults **OFF**, with 10s / 30s / 60s choices and **Mine now**. Preference and next deadline persist in lab state/backups; old backups default OFF. Auto and manual mining use the same Web Locks → read latest lab → mutate → atomic save path. Auto mining rechecks the persisted deadline inside the lock, so tabs sharing the origin cannot mine the same scheduled block. Manual mining resets the next deadline. Each block clears pending commit/reveal packages naturally. - -A tab must stay open; background throttling/suspension can delay mining. Reopening an overdue lab mines at most one block, then starts a fresh interval, without a catch-up burst. Auto mining signs nothing. A failed save publishes no partial state and the UI reports the error. - -## Planned backend progression - -**Local lab → regtest (Super Regtest) → signet/testnet → mainnet** is a future selector progression, not a list of implemented modes. Chain, wallet/signing, raw metadata index, trusted reducer and API adapters stay separate. A later network adapter must explicitly supply chain data and wallet capabilities; a hosted server cannot implicitly access browser storage. Regtest is not implemented. Public networks and mainnet need separate security and signing designs; this v0 has no production keys/funds, external broadcast or hidden signing. - -Native tag-7 compatibility, full ord indexing and public-network adapters remain future passes. Metadata supports the JSON data model encoded in CBOR, not every CBOR feature (no byte strings, tags, indefinite collections or arbitrary map keys). The narrow local envelope validator accepts the app's encoding, not arbitrary external ord transactions. - -## Architecture - -- `src/adapters/wallet.js`: test-wallet creation/loading, regtest-format `bcrt1p` address and explicit signing primitive. Only tagged v0 test backups can be imported; there is no arbitrary production-key input. -- `src/adapters/chain.js`: instant 50-BTC coinbase, UTXO set, package validation, mempool, synthetic blocks and replayable snapshots. Validates transaction shape, values, spent inputs, default Schnorr signatures and the supported Taproot script/control block. Exposes `mine`, `submitPackage`, `coins`, `balance`, `snapshot`. -- `src/adapters/inscription.js`: MIME/byte envelope with optional parent tag 3 and chunked CBOR metadata tag 5, commit/reveal construction, confirmation/ownership view. `prepare` does not sign; `inscribe` is invoked only after the user’s explicit signing action. -- `src/adapters/metadata.js`, `metadata-index.js`, `local-ord-api.js`: bounded JSON/CBOR codec, byte-derived raw index and trusted reducer boundary, in-process read API. -- `src/miner.js`: persisted schedule and deadline checks, invoked through `mutateLab` in storage for both mining modes. -- `src/upstream/primitives.js`: small attributed adaptations of Supertestnet’s input/output helpers. See [provenance](PROVENANCE.md). -- `LocalRPC`: in-process read facade for `getblockcount`, `getblockhash`, `getblock`, `getrawtransaction`. It is deliberately partial, not wire-compatible bitcoind JSON-RPC. A future Core or node_faker adapter can replace this boundary. -- `src/storage.js`: versioned `localStorage` snapshot; index/UTXO reconstruction with transaction validation on load. One atomic storage write per operation; failed validation/quota writes do not publish partial in-memory state. Web Locks serialize mutations across tabs; storage events refresh other tabs. -- `server.js`: dependency-free static file server with security headers and `/healthz`. **No signing, chain state, RPC, database or wallet secrets on the server.** - -## Exact v0 limitations - -- Blocks are custom JSON hashed with SHA-256, not Bitcoin block headers. No proof of work, difficulty, merkle tree, reorgs, peers, chain synchronization, coinbase maturity, halving or full consensus. Fixed 50-BTC rewards; fixed 500-sat fees per non-coinbase transaction are burned. -- Only this app’s single-input Taproot key-path and narrow `ord` MIME script-path templates, plus explicit two-input child/reinscription reveals, are accepted. No general Script interpreter, legacy/SegWit-v0, timelocks, arbitrary transactions, fee estimation, transfer UI or replace-by-fee. Regtest-format addresses do not make the chain compatible with Bitcoin Core. **Never fund these addresses elsewhere.** -- One inscription per reveal, 1,000-sat postage, 16 KiB body cap and 520-byte script pushes. Parent tag 3 encodes reversed txid bytes plus a minimal little-endian index; validation requires the referenced parent input, its signature, and preservation in the same wallet. Only one parent per child. Parent identity and lineage survive relocated outputs and cannot be edited. Signed key-path spends carry sat identities and stacks through FIFO ranges. Inscribed sats cannot be discarded into fees. No full ord indexing, BRC-20 execution, transfer UI or external Ord compatibility guarantee. -- Text/JSON previews use escaped text; JSON is not schema-validated. HTML/SVG markup uses a restrictive element/attribute allowlist in sandboxed frames. Inscription scripts run only in an isolated worker, with local fetch and text-only ord.render; no DOM, navigation, external imports or network requests. Rendering is intentionally lossy (no linked images, fonts or embedded frames). Reviews stay inert until explicit signing. Raster previews use local blobs; MIME declarations do not guarantee valid image data. Body bytes are preserved exactly even when previews cannot render them. -- One active wallet per origin. Test secret and chain are **unencrypted** in browser storage and JSON backups. No mnemonic, password, hardware wallet, key derivation, custody service or hardened security audit. Tagged JSON is an accidental-import guard, not proof a key has never been used elsewhere. Never put production secrets in these files. -- No persistent server database, volume or shared chain. Browser storage quotas apply; large histories replay synchronously and eventually become slow. A maximum of 1,000 pending transactions (500 inscriptions) per block; complete lab imports capped at 5 MB. Built for small experiments. -- Mining signs nothing; only **Sign & inscribe** signs the two displayed operations. Restoring state verifies signatures but does not create new ones. Exporting backups exposes the fake wallet key in the downloaded file. -- No network security or economic assurances: a user controlling their browser can rewrite their entire private lab. Validation is useful for testing this flow, not an adversarial consensus boundary. - -## Verification - -Verified for this pass: **30 adapter tests passed** (`npm test`), **26 desktop/mobile browser tests passed** (`npm run test:e2e`), and `npm run build` passed. Chromium, missing runtime libraries and fonts were staged under `/tmp`; no system packages or deployment were changed. Run: - -```sh -npm test -npx playwright install --with-deps chromium -npm run test:e2e -``` - -The adapter suite covers ownership, real signed commit/reveal construction, confirmation, RPC inspection, pending/confirmed restoration, malformed imports, MIME/byte round-trips and size limits, parent ownership/preservation, fraudulent tags, untagged parent spends, nested lineage, bad signatures, modified payload/control blocks, over-spending, atomic rollback, double-spending and collectible protection. - -Playwright tests the **production build/server**, at desktop and Pixel 7 mobile sizes: full loop, review-before-signing, pending persistence, transaction/block navigation, complete backup/reset/restore, wallet imports, invalid input, two-tab concurrent mining, composer reset and failure preservation, non-text uploads, parent navigation, sandbox preview safety, no third-party requests and no horizontal overflow. Additional tests cover CBOR vectors/chunking/caps, sidecar-independent metadata indexing, protocol grouping/reducer ordering, local API reads, miner persistence/countdown/manual reset and two-tab deadline races. Screenshots/traces are written to ignored `test-results/`. - -## Independent Fly deployment - -`Dockerfile` builds static assets and runs a non-root Node static server. `fly.toml` targets **only `supertestnet`**, one shared CPU, 256 MB RAM, HTTPS, health checks, scale-to-zero, and **no volume or database**. No Teahub app, service, secrets or network integration is configured. No custom domain is configured. - -From a worker with authorized Fly CLI/account access, create the new app in the intended organization, then deploy: - -```sh -fly auth whoami -fly apps create supertestnet --org YOUR_AUTHORIZED_ORG -fly deploy --app supertestnet --config fly.toml --ha=false -fly status --app supertestnet -``` - -Use `--ha=false` to avoid a redundant second machine. If the app name already exists, verify its ownership and purpose before deploying; do not overwrite an unrelated app. Expected URL after successful creation: `https://supertestnet.fly.dev`. - -Current worker deployment status: **not deployed**. Neither `fly` nor `flyctl` is installed/on PATH, no default `~/.fly` configuration is present, and no Fly credential environment variables are configured. Authorized account access could not be established; no app was created and no credentials were invented. Docker is also unavailable here, so the image was not built locally; the identical production Node server is exercised by Playwright. Fly app-name availability and deployment remain unverified. - -Configuration references: [Fly app configuration](https://docs.fly.io/reference/configuration), [Fly deploy options](https://docs.fly.io/flyctl/cmd/fly_deploy). - -## Attribution and licenses - -See [PROVENANCE.md](PROVENANCE.md) for upstream commit pins, exactly what was adapted, what was only inspiration, and the node_faker decision. License texts ship in `public/licenses/` and the app links them. No upstream UI, network relay, mainnet API or remote script loader was imported. - -## Prod (2026-10-14) - -https://supertestnet.lol is served by Teahub pages from this repository's `pages` branch: the built `dist/` plus a -`_headers` file carrying the same security headers `server.js` sends. `fly.toml` and `Dockerfile` describe the -earlier Fly deployment and are kept for reference. History before 2026-10-14 is `matcha/supertestnet` from the old -factory; the extension is `supertestnet/inscriboor`. +The chain, wallet, inscriptions, metadata and index (`src/adapters`, `src/upstream`, with `src/storage.js` and +`src/miner.js`) are [Super Testnet](https://supertestnet.lol)'s, unchanged and with their tests +(`tests/adapters.test.js`). See `PROVENANCE.md` and `public/licenses/`. The full lab (explorer, delegates, recursion and +the Inscriboor extension) lives at supertestnet.lol. diff --git a/fly.toml b/fly.toml deleted file mode 100644 index ca2b276..0000000 --- a/fly.toml +++ /dev/null @@ -1,28 +0,0 @@ -app = "supertestnet" -primary_region = "iad" - -[build] - dockerfile = "Dockerfile" - -[env] - PORT = "8080" - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = "stop" - auto_start_machines = true - min_machines_running = 0 - processes = ["app"] - - [[http_service.checks]] - grace_period = "10s" - interval = "30s" - method = "GET" - timeout = "3s" - path = "/healthz" - -[[vm]] - memory = "256mb" - cpu_kind = "shared" - cpus = 1 diff --git a/index.html b/index.html index 3805585..b5f23bc 100644 --- a/index.html +++ b/index.html @@ -1,69 +1,141 @@ - - - - Orgchain — rehearse an org on a small chain · Lineage - + + +Orgchain · Lineage + + + + + -
-
-
-

Orgchain

-

Rehearse an org on a small chain. Mint a root, give birth to children, award a badge, all as inscriptions on a private toy Bitcoin chain in this tab, with pretend money.

-
-
- Test coins - Private chain - Lineage ↗ - -
+
+
+
+

Lineage /

+

Orgchain

+

Found a holding, then birth its orgs and agents, award belts, record decisions, graduate and close. Each act is an inscription whose parent is the node that authorizes it, and each beat is a block. Lineage gives the structure; the chain adds time.

-
-
-

Rehearsal a lineage in four moves

  1. Root. Fund a wallet, then inscribe the org with metaprotocol lineage/v0 and metadata like {"lineage":"root","name":"yolopreneur"}.
  2. Birth. Inscribe a child with the root as its parent. Only the parent's holder can: the parent is spent to make the child, and the child carries the parent's ID on-chain (ord tag 3).
  3. Generations. Children can be parents too: time under the root, docscope under time, as deep as the tree goes.
  4. Badge. Inscribe a badge once, like {"lineage":"badge","skill":"launch","belt":"green"}, then mint each award as a child of it with the badge as delegate, so every award shows the same mark.

Not in this lab yet: handing an award to a seat, graduation (moving a venture's inscription to its own new root) and closing (burning a parent so no more children can be born). Those need transfers and burns, which this lab doesn't do.

- -
Choose Inscriboor or a local test wallet to start your first loop.
-
-
01

Your test wallet

NOT CREATED
-

One wallet. A whole chain to play with.

Looking for the extension…

Install Inscriboor

Inscriboor 0.2.4 connects on supertestnet.lol only. On Orgchain, use the local test wallet.

  1. Download and unzip Inscriboor.
  2. Open chrome://extensions (or Brave extensions).
  3. Turn on Developer mode.
  4. Choose Load unpacked and select the unzipped extension folder.
  5. Refresh this page. The button above will change from Get Inscriboor to Connect Inscriboor.
- - -
-
02

Make some blocks

Skip the faucet queue. Every block gives you 50 fake BTC, ready to use immediately.

BLOCK HEIGHT0
PENDING TXS0

Auto mining OFF

Keep a lab tab open; browser throttling may delay blocks. Mining also confirms your pending inscriptions.

-
03

Inscribe a root, a birth, a badge

INSCRIPTION LAB

Recursive HTML/SVG scripts use local fetch and ord.render(text) in an isolated worker; ord.id is the requested inscription. No DOM or network access.

0 / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only

Native ord metaprotocol identifier. Metadata and inscription data remain independent.

- -
-
-

04 / THE THINGS YOU MADE

Your inscriptions 0

Owned by this wallet

Your first inscription belongs here.
Fund your wallet, write some text, and make it yours.

-
-

05 / FOLLOW THE TRAIL

Chain explorer

-
-
MEMPOOLWaiting for the next block
commit + reveal transactions live here before mining
-
-
↓ mine
-
BLOCKSnewest first · click any block to inspect it
-
+
+ Test coins only + lineage/v0 + +
+
+
+
+
+

Time

+
+ Tempo + +
+

Beat 0 · nothing cued

+ +
+ + + Now · beat 0 + + +
+
+

+
+
+
+
+

Holdings

+
+
    +

    A holding is the root of a lineage tree: the LLC that births orgs and agents. Found one, or load the sample, the yolopreneur tree from the Lineage docs over six beats.

    +
    + +
    +

    Names become domains: lowercase letters, digits and hyphens.

    +
    +
    -
    -

    Indexes

    -

    Metadata is data; indexers are interpretation. Native metaprotocol tag 7 is authoritative; metadata.protocol remains a compatibility fallback. Raw events remain uninterpreted.

    -

    -

    Ordered raw events

    Confirmed blocks in chain order, then mempool order.

    - -

    Derived state (separate from raw events)

    No reducer selected.
    +
    +

    Tree

    +
    -

    API explorer LOCAL / IN-BROWSER

    -

    LocalOrdAPI reads this browser’s chain and indexes. These paths are not server URLs; the static host cannot access your lab.

    - - - -
    Choose a path and press Try.
    +
    +

    Timeline

    +
    +
      +
      -
      Under the hood & attribution

      Orgchain is Super Testnet in lineage's clothes: the same lab and code, restyled, with a rehearsal guide on top. Its lab state lives in this browser, separate from supertestnet.lol's.

      Real serialized Bitcoin transactions, Schnorr signatures and Taproot commit/reveal scripts, on a deliberately simplified local UTXO chain. No proof of work, Bitcoin consensus, peer network, public broadcast, or production custody. Synthetic local sats follow FIFO input-to-output ranges. Child creation and reinscription preserve the owned sat. Fees retire the input tail; this is not Bitcoin consensus.

      Built from and inspired by Supertestnet’s testnet_generator, inscriptions-online, and vanilla-js-browser-wallet. node_faker was evaluated for future RPC compatibility.

      Transaction library: tapscript by Christopher Scott (CC-BY-1.0). Supertestnet CC0 license · noble-curves license · noble-hashes license.

      - -
      - \ No newline at end of file +
      +
      +

      Act

      +
      +

      Select a holding, or a node in its tree, to act through it.

      + + +
      +
      +
      +

      Inspect

      +
      +
      +
      + + + + + diff --git a/package-lock.json b/package-lock.json index 26b1aa9..33e50d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "super-testnet", - "version": "0.1.0", + "name": "orgchain", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "super-testnet", - "version": "0.1.0", + "name": "orgchain", + "version": "1.0.0", "dependencies": { "@cmdcode/tapscript": "1.5.3", "@noble/curves": "1.9.7", diff --git a/package.json b/package.json index c1a7256..d7984ce 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { - "name": "super-testnet", - "version": "0.1.0", + "name": "orgchain", + "version": "1.0.0", + "description": "Create and operate lineage holdings on a toy chain in the browser: lineage's app, built on Super Testnet.", "private": true, "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview --host 0.0.0.0", - "test": "node --test tests/adapters.test.js", - "test:e2e": "npm run build && playwright test", - "start": "node server.js" + "test": "node --test tests/*.test.js", + "test:e2e": "npm run build && playwright test" }, "dependencies": { "@cmdcode/tapscript": "1.5.3", diff --git a/playwright.config.js b/playwright.config.js index 6d91572..ffb1531 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,7 +1,14 @@ import { defineConfig, devices } from '@playwright/test'; +// By default the spec runs against the built app on vite preview. ORGCHAIN_URL points it at a deployed copy instead; +// PW_CHANNEL=chrome uses the installed Chrome; PW_ARGS passes extra browser flags as a JSON array. +const url = process.env.ORGCHAIN_URL, channel = process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}; +const launchOptions = { args: JSON.parse(process.env.PW_ARGS || '[]') }; export default defineConfig({ - testDir: './tests', testMatch: '**/*.spec.js', fullyParallel: false, - use: { baseURL: 'http://127.0.0.1:4178', trace: 'retain-on-failure' }, - projects: [ { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile', use: { ...devices['Pixel 7'] } } ], - webServer: { command: 'node server.js', env: { PORT: '4178' }, url: 'http://127.0.0.1:4178/healthz', reuseExistingServer: false }, + testDir: './tests', testMatch: '**/*.spec.js', fullyParallel: false, workers: 1, timeout: 60_000, + use: { baseURL: url ?? 'http://127.0.0.1:4178/', trace: 'retain-on-failure' }, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'], ...channel, launchOptions } }, + { name: 'mobile', use: { ...devices['Pixel 7'], ...channel, launchOptions } }, + ], + webServer: url ? undefined : { command: 'npx vite preview --host 127.0.0.1 --port 4178 --strictPort', url: 'http://127.0.0.1:4178/', reuseExistingServer: false }, }); diff --git a/public/recursive-preview.html b/public/recursive-preview.html deleted file mode 100644 index fd2503b..0000000 --- a/public/recursive-preview.html +++ /dev/null @@ -1 +0,0 @@ -
      
      diff --git a/public/recursive-preview.js b/public/recursive-preview.js
      deleted file mode 100644
      index 6c0e98b..0000000
      --- a/public/recursive-preview.js
      +++ /dev/null
      @@ -1,16 +0,0 @@
      -// Trusted renderer only. Inscription JavaScript runs exclusively in the worker.
      -addEventListener('message', function start(event) {
      -  if (event.source !== parent || event.data?.type !== 'local-ord' || !event.ports[0]) return;
      -  removeEventListener('message', start);
      -  const port = event.ports[0];
      -  document.getElementById('markup').innerHTML = event.data.markup;
      -  const url = URL.createObjectURL(new Blob([event.data.worker], { type: 'text/javascript' }));
      -  const worker = new Worker(url);
      -  worker.onmessage = ({ data }) => {
      -    if (data.type === 'render') document.getElementById('recursive-output').textContent = String(data.text).slice(0, 65536);
      -    else if (data.type === 'fetch') port.postMessage(data);
      -  };
      -  worker.onerror = () => { document.getElementById('recursive-output').textContent = 'Recursive script failed.'; };
      -  port.onmessage = ({ data }) => worker.postMessage(data);
      -  addEventListener('pagehide', () => { worker.terminate(); port.close(); URL.revokeObjectURL(url); }, { once: true });
      -});
      diff --git a/public/theme.js b/public/theme.js
      new file mode 100644
      index 0000000..c5bb78e
      --- /dev/null
      +++ b/public/theme.js
      @@ -0,0 +1,2 @@
      +// Runs before first paint (a plain script, so the strict CSP still applies): apply a saved theme without a flash.
      +try { const t = localStorage.getItem('orgchain-theme'); if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t; } catch { /* storage blocked: follow the system */ }
      diff --git a/server.js b/server.js
      deleted file mode 100644
      index a064d13..0000000
      --- a/server.js
      +++ /dev/null
      @@ -1,27 +0,0 @@
      -// Static production server; no wallet, signing, chain state, RPC, DB, or secrets on the server.
      -import http from 'node:http';
      -import { readFile } from 'node:fs/promises';
      -import { resolve, extname, sep } from 'node:path';
      -const root = resolve('dist');
      -const types = { '.html':'text/html; charset=utf-8', '.js':'text/javascript; charset=utf-8', '.css':'text/css; charset=utf-8', '.svg':'image/svg+xml', '.txt':'text/plain; charset=utf-8', '.json':'application/json' };
      -const security = {
      -  'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
      -  'X-Content-Type-Options':'nosniff', 'Referrer-Policy':'no-referrer', 'X-Frame-Options':'DENY',
      -  'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
      -};
      -const previewSecurity = {
      -  'Content-Security-Policy': "default-src 'none'; script-src 'nonce-local-ord-preview'; worker-src blob:; style-src 'unsafe-inline'; connect-src 'none'; img-src data:; form-action 'none'; base-uri 'none'; frame-ancestors 'self'",
      -  'X-Frame-Options': 'SAMEORIGIN',
      -};
      -http.createServer(async (req,res) => {
      -  const send = (status, body, headers={}) => { res.writeHead(status, {...security,...headers}); res.end(req.method==='HEAD' ? undefined : body); };
      -  if (!['GET','HEAD'].includes(req.method)) return send(405,'Method not allowed',{'Allow':'GET, HEAD'});
      -  try {
      -    const path = decodeURIComponent(new URL(req.url,'http://localhost').pathname);
      -    if (path==='/healthz') return send(200,'ok',{'Content-Type':'text/plain'});
      -    const target=resolve(root, '.'+(path==='/' ? '/index.html' : path));
      -    if (!target.startsWith(root+sep)) return send(403,'Forbidden');
      -    const data=await readFile(target);
      -    send(200,data,{...(path === '/recursive-preview.html' ? previewSecurity : {}), 'Content-Type':types[extname(target)]??'application/octet-stream','Cache-Control':path.startsWith('/assets/')?'public, max-age=31536000, immutable':'no-cache'});
      -  } catch { send(404,'Not found'); }
      -}).listen(Number(process.env.PORT || 8080),'0.0.0.0',()=>console.log(`Super Testnet static server listening on ${process.env.PORT || 8080}`));
      diff --git a/src/lineage.css b/src/lineage.css
      deleted file mode 100644
      index 3f170c9..0000000
      --- a/src/lineage.css
      +++ /dev/null
      @@ -1,153 +0,0 @@
      -/* Orgchain: Super Testnet in lineage's clothes.
      -   Same lab, dressed as lineage's key-ceremony sheet: raw concrete, black rule, hazard yellow for the action and for
      -   anything live, everything in the system monospace. Restyle only: loads after style.css and repoints its tokens. */
      -:root {
      -  --concrete: #e4e3dd; --slab: #f6f5f1; --ink: #141414; --ink-2: #4a4944; --ink-3: #66655f;
      -  --signal: #ffc21a; --on-signal: #141414; --ok: #1d7a3a; --focus: #2346ff;
      -  --band: #141414; --band-fg: #f6f5f1; --band-muted: #b0afa8; --shadow: #141414;
      -  --mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", "DejaVu Sans Mono", monospace;
      -  --display: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      -  --t-xs: 0.75rem; --t-s: 0.8125rem; --t-m: 0.9375rem; --t-l: 1.25rem;
      -}
      -:root[data-theme="dark"] {
      -  --concrete: #151514; --slab: #1e1e1c; --ink: #ecebe4; --ink-2: #b9b8b0; --ink-3: #94938b;
      -  --ok: #62c784; --focus: #93a8ff; --band: #0b0b0a; --band-fg: #ecebe4; --band-muted: #9d9c95; --shadow: #4d4c47;
      -}
      -/* Super Testnet's own tokens, pointed at lineage's in both themes (style.css sets light values on [data-theme]) */
      -:root, :root[data-theme="light"], :root[data-theme="dark"] {
      -  --orange: var(--signal); --orange-2: var(--signal);
      -  --bg: var(--concrete); --panel: var(--slab); --panel-2: var(--concrete); --soft: var(--concrete);
      -  --text: var(--ink); --muted: var(--ink-2); --line: var(--ink);
      -  font-family: var(--mono);
      -}
      -:root, :root[data-theme="light"] { --danger: #b3261e; }
      -:root[data-theme="dark"] { --danger: #ff8a7f; }
      -
      -* { border-radius: 0 !important; }
      -html, body { background: var(--concrete); }
      -body { font: 400 var(--t-m)/1.5 var(--mono); color: var(--ink); transition: none; }
      -::selection { background: var(--signal); color: var(--on-signal); }
      -a { color: var(--ink); text-decoration-thickness: 2px; text-underline-offset: 3px; }
      -a:hover { background: var(--signal); color: var(--on-signal); }
      -code { font-family: inherit; font-weight: 700; }
      -button:focus-visible, a:focus-visible, summary:focus-visible, .file-button:focus-visible,
      -input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
      -
      -/* header slab, as on lineage's key tree */
      -header.band { max-width: none; margin: 0; padding: 0; display: block; border-bottom: 3px solid var(--ink); background: var(--band); color: var(--band-fg); }
      -.band-in {
      -  max-width: 1240px; margin: 0 auto; padding-block: 22px 20px;
      -  padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px));
      -  display: flex; flex-wrap: wrap; gap: 16px 32px; align-items: flex-end; justify-content: space-between;
      -}
      -.wordmark { margin: 0; color: var(--band-fg); font: 900 clamp(3.25rem, 11vw, 7rem)/0.82 var(--display); letter-spacing: -0.05em; text-transform: uppercase; }
      -.lede { margin: 12px 0 0; color: var(--band-muted); font-size: var(--t-s); max-width: 58ch; }
      -.lede b { color: var(--band-fg); }
      -.flags { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
      -.flag, button.flag {
      -  font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 9px 6px;
      -  border: 2px solid var(--band-fg); background: transparent; color: var(--band-fg); box-shadow: none; text-decoration: none;
      -}
      -.flag.hz { background: var(--signal); border-color: var(--signal); color: var(--on-signal); }
      -a.flag:hover, button.flag:hover:not(:disabled) { background: var(--band-fg); color: var(--band); border-color: var(--band-fg); transform: none; }
      -button.flag:active:not(:disabled) { transform: none; }
      -
      -/* blocks with ink tabs */
      -.blk { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; }
      -.blk > .panel { align-self: stretch; }
      -.tab {
      -  margin: 0; background: var(--ink); color: var(--slab); font: 700 var(--t-xs)/1 var(--mono);
      -  letter-spacing: 0.14em; text-transform: uppercase; padding: 8px 11px 7px; display: flex; gap: 12px; max-width: 100%;
      -}
      -.tab .sub { font-weight: 400; letter-spacing: 0.06em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      -.rehearse { margin: 40px 0 0; }
      -.moves { margin: 0; padding: 0 0 0 1.6em; display: grid; gap: 10px; font-size: var(--t-s); line-height: 1.6; max-width: 96ch; }
      -.moves li::marker { font-weight: 700; }
      -.moves b, .panel .later b { color: var(--ink); }
      -.panel .later { margin: 16px 0 0; padding: 10px 12px; border: 2px dashed var(--ink); font-size: var(--t-s); color: var(--ink-2); max-width: 96ch; }
      -
      -.notice { margin-top: 28px; background: transparent; border: 2px dashed var(--ink); padding: 10px 12px; font-size: var(--t-s); color: var(--ink-2); }
      -.notice strong { color: var(--ink); }
      -#status { color: var(--ink); font: 700 var(--t-s)/1.5 var(--mono); padding: 16px 0; }
      -#status::before { content: "\25B8  "; }
      -#status.error { color: var(--danger); }
      -
      -/* panels: slabs with a hard ink shadow */
      -.panel { background: var(--slab); border: 3px solid var(--ink); box-shadow: 8px 8px 0 var(--shadow); padding: 22px; }
      -.workspace { gap: 28px; }
      -.panel-title { gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
      -.panel-title h2, #indexes > h2, #api-explorer > h2 { font: 700 var(--t-m)/1.3 var(--mono); letter-spacing: 0.04em; text-transform: uppercase; margin: 0; }
      -.step { background: var(--ink); color: var(--slab); border: 0; padding: 7px 9px 6px; font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.1em; }
      -.tag { border: 2px solid var(--ink); color: var(--ink); font: 700 0.6875rem/1 var(--mono); letter-spacing: 0.1em; text-transform: uppercase; padding: 5px 7px 4px; }
      -.panel p { font-size: var(--t-s); color: var(--ink-2); }
      -.label, .chain-kicker { font: 700 var(--t-xs)/1.2 var(--mono) !important; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2); }
      -h3 { font: 700 var(--t-m)/1.3 var(--mono); text-transform: uppercase; letter-spacing: 0.04em; }
      -
      -/* form */
      -label { font: 700 var(--t-xs)/1.3 var(--mono); letter-spacing: 0.06em; color: var(--ink-2); margin: 16px 0 6px; }
      -input, textarea, select { background: var(--concrete); border: 2px solid var(--ink); color: var(--ink); font: 400 var(--t-s)/1.4 var(--mono); padding: 9px 10px; }
      -input:focus, textarea:focus, select:focus { border-color: var(--ink); outline: 3px solid var(--focus); outline-offset: 2px; }
      -input[type=file] { border-style: dashed; background: transparent; }
      -.file-button { color: var(--ink); font-weight: 700; text-decoration-thickness: 2px; }
      -.wallet-footer { border-top: 2px dashed var(--ink-3); }
      -.install-help summary { cursor: pointer; font-weight: 700; }
      -
      -/* buttons, as on the key tree */
      -button {
      -  font: 700 var(--t-s)/1 var(--mono); letter-spacing: 0.06em; text-transform: uppercase; padding: 10px 12px 9px;
      -  background: var(--slab); color: var(--ink); border: 2px solid var(--ink); box-shadow: 3px 3px 0 var(--shadow); transition: none;
      -}
      -button:hover:not(:disabled) { background: var(--concrete); border-color: var(--ink); transform: none; }
      -button:active:not(:disabled) { transform: translate(3px, 3px); box-shadow: none; }
      -button:disabled { opacity: 0.45; box-shadow: none; }
      -button.primary { background: var(--signal); color: var(--on-signal); border-color: var(--ink); }
      -button.primary:hover:not(:disabled) { background: var(--signal); filter: brightness(1.05); }
      -.quiet { background: transparent; font-size: var(--t-xs); box-shadow: 2px 2px 0 var(--shadow); }
      -button.danger, .danger { color: var(--danger); }
      -
      -/* numbers */
      -.balance { font: 700 2.4rem/1 var(--mono); letter-spacing: -0.02em; }
      -.balance small { color: var(--ink-2); font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.1em; }
      -.chain-stats strong { font: 700 2.5rem/1 var(--mono); }
      -pre { background: var(--concrete); border: 2px solid var(--ink); font: 400 var(--t-xs)/1.6 var(--mono); }
      -
      -/* sections */
      -.section-title { margin: 52px 0 18px; }
      -.eyebrow { display: inline-block; background: var(--ink); color: var(--slab); padding: 8px 11px 7px; font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.14em; margin: 0 0 10px; }
      -.section-title h2 { font: 900 clamp(1.6rem, 3.4vw, 2.4rem)/1 var(--display); letter-spacing: -0.03em; text-transform: uppercase; }
      -#collection-count { background: var(--signal); color: var(--on-signal); font: 700 var(--t-s)/1 var(--mono); padding: 5px 8px 4px; vertical-align: middle; }
      -.empty { border: 2px dashed var(--ink); color: var(--ink-2); font-size: var(--t-s); }
      -.collectible { background: var(--slab); border: 3px solid var(--ink); box-shadow: 6px 6px 0 var(--shadow); }
      -.collectible pre { font: 400 1rem/1.5 var(--mono); border: 0; background: transparent; }
      -.inscription-preview { border: 2px solid var(--ink); }
      -
      -/* chain board: pending is live, so it wears the signal */
      -.chain-board { background: var(--slab); border: 3px solid var(--ink); box-shadow: 8px 8px 0 var(--shadow); }
      -.chain-kicker { color: var(--ink); }
      -.mempool-empty { border: 2px dashed var(--ink); color: var(--ink-2); }
      -.mempool-card { background: var(--signal); color: var(--on-signal); border: 2px solid var(--ink); text-transform: none; letter-spacing: 0; }
      -.mempool-card strong { color: var(--on-signal); }
      -.mempool-card span { color: var(--on-signal); }
      -.chain-divider { border-left: 3px solid var(--ink); }
      -.chain-divider span { color: var(--ink-2); font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.1em; }
      -.block-card { background: var(--concrete); border: 2px solid var(--ink); text-transform: none; letter-spacing: 0; font-weight: 400; }
      -.block-card:before { border-top: 3px solid var(--ink); opacity: 1; }
      -.block-card .block-accent { color: var(--on-signal); background: var(--signal); display: inline-block; padding: 2px 5px; }
      -.inspector h2 { font: 700 var(--t-l)/1.25 var(--mono); }
      -#protocol-events article { border-bottom: 1px dashed var(--ink-3); }
      -#indexes, #api-explorer { margin-top: 2.5rem; }
      -
      -/* about + footer */
      -.about { border-top: 3px solid var(--ink); color: var(--ink-2); font-size: var(--t-s); }
      -.about summary { color: var(--ink); font: 700 var(--t-xs)/1.4 var(--mono); letter-spacing: 0.1em; text-transform: uppercase; }
      -footer { border-top: 3px solid var(--ink); font: 700 var(--t-xs)/1.4 var(--mono); letter-spacing: 0.12em; color: var(--ink); flex-wrap: wrap; gap: 12px; }
      -footer .made { color: var(--ink-2); font-weight: 400; letter-spacing: 0.04em; }
      -
      -@media (max-width: 700px) {
      -  .band-in { padding-block: 18px 16px; }
      -  .panel, .chain-board { box-shadow: 5px 5px 0 var(--shadow); padding: 18px; }
      -}
      -@media (prefers-reduced-motion: reduce) { button:active:not(:disabled) { transform: none; } }
      -
      -/* long ids and JSON wrap instead of widening the page */
      -.moves, .moves code, .collectible, .inspector, #review-panel, #status { overflow-wrap: anywhere; }
      diff --git a/src/main.js b/src/main.js
      index 09b2634..4c953bf 100644
      --- a/src/main.js
      +++ b/src/main.js
      @@ -1,417 +1,395 @@
      -import { RECURSIVE_DEMO } from './recursion.js';
      -import './style.css';
      -import './lineage.css';
      -import { encodeMetadata, parseMetadataJSON } from './adapters/metadata.js';
      -import { MetadataIndex, deriveProtocol } from './adapters/metadata-index.js';
      -import { LocalOrdAPI } from './adapters/local-ord-api.js';
      -import { mineLab, setMiner, minerStatus } from './miner.js';
      -import { TestWallet, ExtensionWallet, loadWalletBackup } from './adapters/wallet.js';
      -import { inscribeWithProvider } from './adapters/extension-inscription.js';
      -import { LocalRPC } from './adapters/chain.js';
      -import { InscriptionAdapter, MAX_BYTES, MIME_TYPES } from './adapters/inscription.js';
      -import { preview, clearPreviews } from './preview.js';
      -import { STORAGE_KEY, readLab, mutateLab, decodeLab, encodeLab } from './storage.js';
      +// Orgchain: create and operate lineage holdings. Lineage gives the structure; the chain adds time.
      +// Every change goes through the lab's lock (orgchain/store.js) and the reducer (orgchain/lineage.js); this file draws and asks.
      +import './orgchain.css';
      +import { MetadataIndex } from './adapters/metadata-index.js';
      +import { census, lineageEvents, reduce, rows, slug, summary, trail } from './orgchain/lineage.js';
      +import { LAB_KEY, beat, decodeLab, emptyLab, encodeLab, mutate, readLab, setTempo, tick } from './orgchain/store.js';
      +import { SAMPLE, act, seedSample } from './orgchain/actions.js';
       
       const $ = id => document.getElementById(id);
      -const btc = sats => (sats / 1e8).toFixed(8);
      -const short = id => `${id.slice(0, 12)}…${id.slice(-8)}`;
      -const THEME_KEY = 'orgchain-theme';
      -let fileBytes = null, fileLoading = false, fileRevision = 0;
      -let lab, busy = false, reviewed = null, failed = false;
      -let inscriboorProvider = null;
      -const INSCRIBOOR_RELEASE = { version: '0.2.4', url: 'https://supertestnet.lol/downloads/inscriboor-extension-v0.2.4.zip' };
      +function h(tag, attrs = {}, ...kids) {
      +  const el = document.createElement(tag);
      +  for (const [k, v] of Object.entries(attrs)) {
      +    if (v === null || v === undefined || v === false) continue;
      +    if (k === 'class') el.className = v;
      +    else if (k === 'text') el.textContent = v;
      +    else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
      +    else el.setAttribute(k, v === true ? '' : String(v));
      +  }
      +  for (const kid of kids.flat(Infinity)) if (kid !== null && kid !== undefined && kid !== false && kid !== '') el.append(kid instanceof Node ? kid : String(kid));
      +  return el;
      +}
      +const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
      +const at = when => (when === 'cued' ? 'cued' : `beat ${when}`);
      +const clockTime = ms => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
      +const json = value => JSON.stringify(value, (k, v) => (typeof v === 'bigint' ? String(v) : v), 2);
       
      -const message = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('error', error); };
      -const el = (tag, text, className) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; };
      -const link = (text, hash) => { const node = el('a', text); node.href = hash; return node; };
      -const byteSize = raws => Math.round(raws.reduce((sum, raw) => sum + raw.length / 2, 0));
      -const age = time => {
      -  const seconds = Math.max(0, Math.floor((Date.now() - time) / 1000));
      -  if (seconds < 5) return 'just now';
      -  if (seconds < 60) return `${seconds}s ago`;
      -  const minutes = Math.floor(seconds / 60);
      -  if (minutes < 60) return `${minutes}m ago`;
      -  return `${Math.floor(minutes / 60)}h ago`;
      -};
      -function txKind(record, chain) {
      -  if (record.coinbase) return 'COINBASE';
      -  if (record.inscription) return 'INSCRIPTION REVEAL';
      -  const reveal = [...chain.transactions.values()].find(tx => tx.inscription?.commitTxid === record.txid);
      -  return reveal ? 'INSCRIPTION COMMIT' : 'TRANSACTION';
      -}
      -function setTheme(theme, persist = false) {
      -  document.documentElement.dataset.theme = theme;
      -  $('theme-toggle').textContent = theme === 'dark' ? '☀ Light' : '☾ Dark';
      -  $('theme-toggle').setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
      -  if (persist) localStorage.setItem(THEME_KEY, theme);
      -}
      -function initTheme() {
      -  const saved = localStorage.getItem(THEME_KEY);
      -  setTheme(saved === 'light' || saved === 'dark' ? saved : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
      -}
      -function download(name, contents) {
      -  const url = URL.createObjectURL(new Blob([contents], { type: 'application/json' }));
      -  const anchor = link('', url); anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
      -}
      -function closeReview() {
      -  reviewed = null; $('review-panel').hidden = true;
      -  clearPreviews($('review-preview')); $('review-preview').replaceChildren();
      -  $('review-metadata').textContent = ''; $('review-text').textContent = ''; $('review-parent').textContent = '';
      -}
      -const composerBody = () => ({ contentType: $('mime-type').value, bytes: fileBytes ?? new TextEncoder().encode($('payload').value) });
      -function updateCount() { $('byte-count').textContent = `${composerBody().bytes.length} / ${MAX_BYTES} bytes`; }
      -function resetComposer() {
      -  fileRevision++; fileBytes = null; $('body-file').value = ''; $('payload').value = '';
      -  $('delegate').value = ''; $('pointer').value = ''; $('reinscribe').value = ''; $('metaprotocol').value = ''; $('metadata').value = ''; updateMetadataCount(); $('parent').value = ''; closeReview(); updateCount();
      -}
      -function lineage(item, chain) {
      -  const p = el('p', undefined, 'tx-links');
      -  if (item.delegate) p.append(link(`Delegate ${short(item.delegate)}`, `#inscription/${item.delegate}`));
      -  for (const record of chain.transactions.values()) if (record.inscription?.delegate === item.id)
      -    p.append(link(`Delegated by ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
      -  if (item.sat !== undefined) {
      -    p.append(el('span', `Synthetic sat ${item.sat} · stack position ${item.reinscriptionPosition} · ${JSON.stringify(chain.satLocation(item.sat))}`));
      -    for (const record of chain.transactions.values()) if (record.inscription?.sat === item.sat)
      -      p.append(link(`Sat history #${record.inscription.reinscriptionPosition}`, `#inscription/${record.inscription.id}`));
      -  }
      -  if (item.parent) p.append(link(`Parent ${short(item.parent)}`, `#inscription/${item.parent}`));
      -  for (const record of chain.transactions.values()) if (record.inscription?.parent === item.id)
      -    p.append(link(`Child ${short(record.inscription.id)}`, `#inscription/${record.inscription.id}`));
      -  return p;
      -}
      +// ui.beat: null means now (cued acts included); a number is that past beat, read-only.
      +const ui = { holding: null, node: null, event: null, beat: null, verb: 'birth', busy: false, replay: null, closing: null };
      +let lab = emptyLab(), index = new MetadataIndex(lab.chain), events = [], live = reduce([]), view = live;
      +const tip = () => lab.chain.blocks.length;
      +const cued = () => events.filter(e => e.status === 'pending').length;
      +const node = (id, state = live) => (id ? state.nodes.get(id) ?? null : null);
      +const select = next => Object.assign(ui, { event: null, closing: null }, next);
      +const pick = next => { select(next); render(); };
       
      +function say(message, bad = false) { const el = $('say'); el.textContent = message; el.classList.toggle('bad', bad); }
       
      -function updateMetadataCount() {
      -  try {
      -    const value = parseMetadataJSON($('metadata').value);
      -    $('metadata-count').textContent = `${value === undefined ? 0 : encodeMetadata(value).length} / 2048 CBOR bytes · ord tag 5 · JSON is authoring format only`;
      -  } catch (error) { $('metadata-count').textContent = `${error.message} Limit: 2048 CBOR bytes.`; }
      -}
      -function metadataView(item) {
      -  const node = el('div', undefined, 'metadata-document');
      -  if (item.metadata !== undefined) node.append(el('strong', 'Decoded metadata'), el('pre', JSON.stringify(item.metadata, null, 2)));
      -  if (item.metaprotocol) node.append(el('strong', `Metaprotocol: ${item.metaprotocol}`));
      -  return node;
      -}
      -function renderIndexes(chain) {
      -  if (!chain) return;
      -  const index = new MetadataIndex(chain), selected = $('protocol').value;
      -  const stats = index.stats;
      -  $('index-stats').textContent = `Metadata index: ${stats.events} events · ${stats.withMetadata} with metadata · ${stats.confirmed} confirmed · ${stats.pending} pending · ${stats.protocols.length} protocols`;
      -  $('protocol').replaceChildren(new Option('Select a protocol', ''));
      -  index.protocols.forEach(name => $('protocol').append(new Option(JSON.stringify(name), JSON.stringify(name))));
      -  if ([...$('protocol').options].some(o => o.value === selected)) $('protocol').value = selected;
      -  const protocol = $('protocol').value ? JSON.parse($('protocol').value) : null;
      -  const events = protocol === null ? [] : index.forProtocol(protocol);
      -  $('protocol-stats').textContent = `${events.length} events · ${events.filter(e => e.status === 'confirmed').length} confirmed · ${events.filter(e => e.status === 'pending').length} pending`;
      -  $('protocol-events').replaceChildren();
      -  for (const event of events) {
      -    const row = el('article'); row.append(link(short(event.id), `#inscription/${event.id}`), el('pre', JSON.stringify(event, null, 2)));
      -    $('protocol-events').append(row);
      -  }
      -  $('derived-state').textContent = !$('reducer').value ? 'No reducer selected.' : protocol === null ? 'Select a protocol first.' : JSON.stringify(deriveProtocol(index, protocol, $('reducer').value), null, 2);
      -}
      -$('protocol').onchange = $('reducer').onchange = () => renderIndexes(lab.chain);
      -const apiPresets = ['/r/blockheight', '/r/blockhash/1', '/r/blockinfo/1', '/r/blockinfo/:hash', '/content/:id', '/r/inscription/:id', '/r/metadata/:id', '/r/parents/:id', '/r/children/:id', '/r/tx/:txid', '/local/metadata-index', '/local/protocols', '/local/protocol?name=example'];
      -apiPresets.forEach(path => $('api-preset').append(new Option(path, path)));
      -$('api-preset').onchange = () => {
      -  const event = new MetadataIndex(lab.chain).events.at(-1);
      -  $('api-path').value = $('api-preset').value.replace(':id', event?.id ?? ':id').replace(':txid', event?.id.slice(0, -2) ?? ':txid').replace(':hash', lab.chain.blocks.at(-1)?.hash ?? ':hash');
      -};
      -$('api-try').onclick = () => {
      -  try {
      -    const response = new LocalOrdAPI(lab.chain).request($('api-path').value);
      -    const binary = response.body instanceof Uint8Array;
      -    $('api-response').textContent = JSON.stringify({ ...response, ...(binary ? { body: Array.from(response.body), encoding: 'byte array', textPreview: new TextDecoder().decode(response.body) } : {}) }, null, 2);
      -  } catch (error) { $('api-response').textContent = error.message; }
      -};
      -$('api-copy').onclick = async () => {
      -  try { await navigator.clipboard.writeText($('api-response').textContent); message('Local API response copied.'); }
      -  catch (error) { message(`Could not copy response: ${error.message}`, true); }
      -};
      -
      -function renderMempool(chain) {
      -  const pane = $('mempool-list');
      -  pane.replaceChildren();
      -  const pending = [...(chain?.transactions?.values?.() ?? [])].filter(record => !record.height && !record.coinbase);
      -  if (!pending.length) {
      -    pane.append(el('div', 'Mempool empty. Create an inscription and its commit + reveal transactions will wait here until you mine.', 'mempool-empty'));
      -    return;
      -  }
      -  for (const record of pending) {
      -    const button = el('button', undefined, 'mempool-card');
      -    button.append(el('strong', txKind(record, chain)));
      -    button.append(el('span', short(record.txid)));
      -    button.append(el('span', `${Math.round(record.raw.length / 2).toLocaleString()} bytes · 500 fake sat fee`));
      -    if (record.inscription) button.append(el('span', `${record.inscription.contentType} · ${record.inscription.bytes.length} bytes`));
      -    button.onclick = () => { location.hash = `tx/${record.txid}`; };
      -    pane.append(button);
      -  }
      -}
      -function renderBlocks(chain) {
      -  const pane = $('blocks');
      -  pane.replaceChildren();
      -  if (!chain?.blocks.length) {
      -    pane.append(el('p', 'No blocks yet. Mine your first one above.', 'muted'));
      -    return;
      -  }
      -  for (const block of chain.blocks.toReversed()) {
      -    const button = el('button', undefined, 'block-card');
      -    button.append(el('span', `HEIGHT ${block.height}`, 'block-accent'));
      -    button.append(el('strong', `${block.raws.length} tx`));
      -    button.append(el('span', `${byteSize(block.raws).toLocaleString()} bytes · ${age(block.time)}`));
      -    button.append(el('span', short(block.hash)));
      -    button.onclick = () => { location.hash = `block/${block.hash}`; };
      -    pane.append(button);
      -  }
      -}
      -
      -function render() {
      -  const wallet = lab?.wallet, chain = lab?.chain;
      -  const disabled = busy || failed;
      -  $('empty-wallet').hidden = !!wallet; $('active-wallet').hidden = !wallet;
      -  $('wallet-state').textContent = wallet ? (wallet.kind === 'extension-wallet' ? 'INSCRIBOOR / TEST' : 'LOCAL / TEST ONLY') : 'NOT CREATED';
      -  $('create-wallet').disabled = disabled; $('connect-inscriboor').disabled = disabled; $('load-wallet').disabled = disabled;
      -  const inscriboorNeedsUpdate = !!inscriboorProvider && inscriboorProvider.version !== INSCRIBOOR_RELEASE.version;
      -  $('connect-inscriboor').textContent = inscriboorNeedsUpdate ? 'Update Inscriboor ↓' : inscriboorProvider ? 'Connect Inscriboor ↗' : 'Get Inscriboor ↓';
      -  $('inscriboor-status').textContent = inscriboorNeedsUpdate ? `Inscriboor ${inscriboorProvider.version ?? 'older build'} detected · ${INSCRIBOOR_RELEASE.version} available.` : inscriboorProvider ? `Inscriboor ${inscriboorProvider.version ?? ''} detected · extension key stays outside this page.` : 'Inscriboor not detected. Install it, refresh, then connect.';
      -  $('mine').disabled = disabled || !wallet;
      -  $('miner-interval').disabled = disabled || !wallet;
      -  $('miner-interval').value = String(lab?.miner?.interval ?? 0);
      -  $('miner-status').textContent = minerStatus(lab);
      -  $('payload').disabled = disabled || !wallet || !!fileBytes || fileLoading || $('mime-type').value.startsWith('image/') && $('mime-type').value !== 'image/svg+xml';
      -  for (const id of ['mime-type', 'body-file', 'parent', 'metadata', 'metaprotocol', 'delegate', 'pointer', 'reinscribe', 'recursive-demo']) $(id).disabled = disabled || !wallet || fileLoading;
      -  $('review').disabled = disabled || !wallet || !chain?.coins(wallet.script).some(c => !c.inscription) || fileLoading || !composerBody().bytes.length;
      -  $('sign').disabled = disabled;
      -  $('export-wallet').disabled = disabled; $('export-lab').disabled = busy;
      -  $('height').textContent = chain?.blocks.length ?? 0;
      -  $('pending').textContent = chain?.mempool.length ?? 0;
      -  if (wallet) {
      -    $('address').value = wallet.address;
      -    $('balance').textContent = btc(chain.balance(wallet.script));
      -    const reserved = chain.coins(wallet.script).filter(c => c.inscription).reduce((n,c) => n+c.value,0);
      -    $('reserved').textContent = `${reserved.toLocaleString()} sats protected in collectibles · balance includes pending outputs`;
      -    $('export-wallet').textContent = wallet.kind === 'extension-wallet' ? 'Save wallet descriptor' : 'Save test wallet';
      -    $('signing-note').textContent = wallet.kind === 'extension-wallet' ? 'The next button opens Inscriboor for approval. The page never receives the extension key.' : 'The next button signs locally with this browser test wallet and submits commit + reveal.';
      -  }
      -  const items = wallet ? new InscriptionAdapter(chain, wallet).list() : [];
      -  const selectedParent = $('parent').value;
      -  $('parent').replaceChildren(new Option('No parent', ''));
      -  items.forEach(item => $('parent').append(new Option(short(item.id), item.id)));
      -  $('parent').value = selectedParent;
      -  const selectedSat = $('reinscribe').value;
      -  $('reinscribe').replaceChildren(new Option('New sat', ''));
      -  items.forEach(item => $('reinscribe').append(new Option(`Sat ${item.sat}: ${short(item.id)}`, item.id)));
      -  $('reinscribe').value = selectedSat;
      -  $('collection-count').textContent = items.length;
      -  clearPreviews($('collectibles'));
      -  $('collectibles').replaceChildren();
      -  if (!items.length) {
      -    const empty = el('p', 'Your first inscription belongs here.', 'empty');
      -    empty.append(el('br'), el('span', 'Fund your wallet, write some text, and make it yours.'));
      -    $('collectibles').append(empty);
      -  }
      -  for (const item of items.toReversed()) {
      -    const card = el('article', undefined, 'collectible');
      -    card.append(el('span', item.height ? `CONFIRMED · BLOCK ${item.height}` : 'PENDING · MINE TO CONFIRM', 'tag'), preview(item, new LocalOrdAPI(chain)));
      -    card.append(link(`Inscription ${short(item.id)}`, `#inscription/${item.id}`));
      -    const trail = el('p', undefined, 'tx-links');
      -    trail.append(link('View reveal transaction ↗', `#tx/${item.txid}`));
      -    if (item.height) trail.append(link(`View block ${item.height} ↗`, `#block/${chain.blocks[item.height - 1].hash}`));
      -    card.append(metadataView(item), lineage(item, chain), trail); $('collectibles').append(card);
      -  }
      -  renderIndexes(chain);
      -  renderMempool(chain);
      -  renderBlocks(chain);
      -  inspect(false);
      -}
      -
      -async function mutate(action, success, onSuccess = () => {}) {
      -  if (busy || failed) return;
      -  busy = true; render();
      -  try {
      -    const result = await mutateLab(action); lab = result.lab;
      -    if (result.changed) { closeReview(); onSuccess(); message(success); }
      -  } catch (error) { message(error.message, true); }
      -  finally { busy = false; render(); }
      -}
      -
      -$('theme-toggle').onclick = () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark', true);
      -$('create-wallet').onclick = () => mutate(next => {
      -  if (next.wallet) throw new Error('A wallet already exists in this browser. Reload to use it.');
      -  next.wallet = TestWallet.create();
      -}, 'Test wallet created. Next: mine a block to get 50 fake BTC.');
      -$('connect-inscriboor').onclick = async () => {
      -  try {
      -    const provider = inscriboorProvider ?? window.inscriboor;
      -    if (!provider?.request || provider.version !== INSCRIBOOR_RELEASE.version) { location.href = INSCRIBOOR_RELEASE.url; return; }
      -    const account = (await provider.request({ method:'bitcoin_getAccounts' }))?.[0];
      -    await mutate(next => {
      -      if (next.wallet) throw new Error('A wallet already exists in this browser. Reset or restore a different lab first.');
      -      next.wallet = new ExtensionWallet(account, provider);
      -    }, 'Inscriboor connected. Mine a block, prepare an inscription, then approve it in the extension.');
      -  } catch (error) { message(error.message, true); }
      -};
      -const mine = automatic => mutate(next => mineLab(next, automatic), 'Block mined. Pending transactions moved from the mempool into your newest block.');
      -$('mine').onclick = () => mine(false);
      -$('miner-interval').onchange = () => {
      -  const interval = Number($('miner-interval').value);
      -  mutate(next => setMiner(next, interval), interval ? `Auto miner set to ${interval}s.` : 'Auto mining OFF');
      -};
      -setInterval(() => {
      -  $('miner-status').textContent = minerStatus(lab);
      -  if (!busy && !failed && lab?.wallet && lab.miner.interval && Date.now() >= lab.miner.nextAt) mine(true);
      -}, 250);
      -$('metadata').oninput = () => { closeReview(); updateMetadataCount(); };
      -MIME_TYPES.forEach(type => $('mime-type').append(new Option(type, type)));
      -$('payload').oninput = () => { closeReview(); updateCount(); render(); };
      -$('parent').onchange = $('reinscribe').onchange = closeReview;
      -$('delegate').oninput = $('pointer').oninput = $('metaprotocol').oninput = closeReview;
      -$('recursive-demo').onclick = () => {
      -  fileRevision++; fileBytes = null; $('body-file').value = ''; $('mime-type').value = 'text/html';
      -  $('payload').value = RECURSIVE_DEMO; closeReview(); updateCount(); render();
      -};
      -$('mime-type').onchange = () => {
      -  fileRevision++; fileBytes = null; $('body-file').value = ''; closeReview(); updateCount(); render();
      -};
      -$('body-file').accept = MIME_TYPES.join(',');
      -$('body-file').onchange = async event => {
      -  const file = event.target.files[0], revision = ++fileRevision;
      -  closeReview(); fileBytes = null;
      -  if (!file) { updateCount(); render(); return; }
      -  fileLoading = true; render();
      -  try {
      -    if (file.size > MAX_BYTES) throw new Error(`Body must be at most ${MAX_BYTES} bytes.`);
      -    if (!MIME_TYPES.includes(file.type)) throw new Error('Unsupported file MIME type.');
      -    const bytes = new Uint8Array(await file.arrayBuffer());
      -    if (revision !== fileRevision) return;
      -    fileBytes = bytes; $('mime-type').value = file.type; $('payload').value = '';
      -  } catch (error) { $('body-file').value = ''; message(error.message, true); }
      -  finally { fileLoading = false; updateCount(); render(); }
      -};
      -$('review').onclick = () => {
      -  try {
      -    const payload = composerBody(), parent = $('parent').value || null, metadata = parseMetadataJSON($('metadata').value);
      -    const options = { delegate: $('delegate').value.trim() || null, metaprotocol: $('metaprotocol').value.trim() || null, reinscribe: $('reinscribe').value || null, ...($('pointer').value.trim() ? { pointer: $('pointer').value.trim() } : {}) };
      -    new InscriptionAdapter(lab.chain, lab.wallet).prepare(payload, parent, metadata, options);
      -    closeReview();
      -    reviewed = { payload, parent, metadata, options, address: lab.wallet.address };
      -    $('review-text').textContent = ['text/plain', 'application/json'].includes(payload.contentType) ? new TextDecoder().decode(payload.bytes) : `${payload.contentType} · ${payload.bytes.length} bytes`;
      -    if (!['text/plain', 'application/json'].includes(payload.contentType)) $('review-preview').append(preview(payload));
      -    $('review-parent').textContent = parent ? `Parent: ${parent}. Its output will be spent and its full value returned to this wallet. This relationship is immutable.` : 'No parent.';
      -    $('review-parent').textContent += ` Delegate: ${options.delegate ?? 'none'}. Reinscribe: ${options.reinscribe ?? 'none'}. Pointer: ${options.pointer ?? 'automatic'}. Metaprotocol: ${options.metaprotocol ?? 'none'}.`;
      -    $('review-metadata').textContent = metadata === undefined ? 'No metadata.' : `Metadata (CBOR, tag 5): ${JSON.stringify(metadata, null, 2)}`;
      -    $('review-panel').hidden = false;
      -    $('sign').focus(); message('Review the payload and fake-sat costs. Nothing has been signed yet.');
      -  } catch (error) { message(error.message, true); }
      -};
      -$('cancel-review').onclick = closeReview;
      -$('sign').onclick = () => {
      -  const consent = reviewed;
      -  if (!consent) return;
      -  mutate(async next => {
      -    if (next.wallet?.address !== consent.address) throw new Error('Wallet changed. Review again before signing.');
      -    if (next.wallet.kind === 'extension-wallet') {
      -      const provider = inscriboorProvider ?? window.inscriboor;
      -      if (!provider?.request) throw new Error('Reconnect the Inscriboor extension before signing.');
      -      next.wallet.attach(provider);
      -      await inscribeWithProvider(next.chain, next.wallet, consent.payload, consent.parent, consent.metadata, consent.options);
      -    } else new InscriptionAdapter(next.chain, next.wallet).inscribe(consent.payload, consent.parent, consent.metadata, consent.options);
      -  }, 'Inscription submitted. Its commit + reveal are now visible in the mempool; mine a block to confirm them.', resetComposer);
      -};
      -$('export-wallet').onclick = () => download('super-testnet-wallet.json', JSON.stringify(lab.wallet.backup(), null, 2));
      -$('export-lab').onclick = () => {
      -  try { download('super-testnet-lab.json', failed ? localStorage.getItem(STORAGE_KEY) ?? '{}' : encodeLab(lab)); }
      -  catch (error) { message(error.message, true); }
      -};
      -$('load-wallet').onchange = async event => {
      -  const file = event.target.files[0]; event.target.value = '';
      -  if (!file) return;
      -  if (file.size > 5_000_000) { message('Backup too large (maximum 5 MB).', true); return; }
      -  try {
      -    const raw = await file.text(), data = JSON.parse(raw);
      -    const restore = data.kind === 'test-lab';
      -    const loaded = restore ? decodeLab(raw) : loadWalletBackup(data);
      -    if (lab.wallet && !confirm(restore ? 'Replace this entire local lab with the backup? Save your current lab first.' : 'Switch the active test wallet? The local chain stays here. Save your current wallet first.')) return;
      -    await mutate(next => {
      -      if (restore) Object.assign(next, loaded); else next.wallet = loaded;
      -    }, restore ? 'Complete lab restored, including its wallet and chain.' : 'Test wallet loaded. Its collectibles on this local chain are shown below.');
      -  } catch (error) { message(`Backup not loaded: ${error.message}`, true); }
      -};
      -$('reset').onclick = async () => {
      -  if (busy || !confirm('Delete this browser’s local chain and test wallet? Export a complete lab first if you want to keep it.')) return;
      -  try { await navigator.locks.request(STORAGE_KEY, () => localStorage.removeItem(STORAGE_KEY)); location.hash = ''; location.reload(); }
      -  catch (error) { message(error.message, true); }
      -};
      -
      -function inspect(scroll = true) {
      -  const [kind, id] = location.hash.slice(1).split('/');
      -  const pane = $('inspector'); clearPreviews(pane); pane.replaceChildren(); pane.hidden = !kind;
      -  if (!kind || !lab) return;
      -  const chain = lab.chain, rpc = new LocalRPC(chain);
      -  const add = (label, value) => { const p = el('p'); p.append(el('strong', `${label}: `), el('code', String(value))); pane.append(p); };
      -  try {
      -    if (kind === 'block') {
      -      const block = rpc.call('getblock', [id]);
      -      const source = chain.blocks.find(candidate => candidate.hash === id);
      -      pane.append(el('h2', `Block ${block.height}`));
      -      add('Local block hash', block.hash);
      -      if (block.previousblockhash && block.height > 1) {
      -        const previous = chain.blocks[block.height - 2];
      -        const p = el('p'); p.append(el('strong', 'Previous block: '), link(short(block.previousblockhash), `#block/${previous.hash}`)); pane.append(p);
      -      } else add('Previous block', block.previousblockhash);
      -      add('Mined', new Date(block.time * 1000).toISOString());
      -      add('Transactions', block.tx.length);
      -      add('Approx. serialized size', `${byteSize(source.raws).toLocaleString()} bytes`);
      -      pane.append(el('p', 'Synthetic local block; not a Bitcoin block header and no proof-of-work is performed.', 'muted'));
      -      pane.append(el('h3', 'Transactions'));
      -      for (const txid of block.tx) {
      -        const record = chain.transactions.get(txid), p = el('p');
      -        p.append(link(`${txKind(record, chain)} · ${short(txid)}`, `#tx/${txid}`)); pane.append(p);
      -      }
      -    } else if (kind === 'tx' || kind === 'inscription') {
      -      const txid = kind === 'tx' ? id : id?.replace(/i0$/, '');
      -      const record = chain.transactions.get(txid);
      -      if (!record || (kind === 'inscription' && record.inscription?.id !== id)) throw new Error('Transaction or inscription not found on this local chain.');
      -      pane.append(el('h2', kind === 'inscription' ? 'Inscription detail' : record.coinbase ? 'Coinbase transaction' : 'Transaction detail'));
      -      add('Type', txKind(record, chain));
      -      add('Transaction ID', txid);
      -      add('Status', record.height ? `${chain.blocks.length - record.height + 1} confirmation(s)` : 'Pending in mempool — mine a block');
      -      add('Serialized size', `${Math.round(record.raw.length / 2).toLocaleString()} bytes`);
      -      if (record.height) { const p = el('p'); p.append(link(`Inspect block ${record.height} ↗`, `#block/${chain.blocks[record.height - 1].hash}`)); pane.append(p); }
      -      if (record.inscription) {
      -        add('Inscription ID', record.inscription.id); add('Content type', record.inscription.contentType);
      -        add('Body size', `${record.inscription.bytes.length} bytes`);
      -        pane.append(preview(record.inscription, new LocalOrdAPI(chain)), metadataView(record.inscription), lineage(record.inscription, chain));
      -        pane.append(link('Inspect commit transaction ↗', `#tx/${record.inscription.commitTxid}`));
      -      }
      -      pane.append(el('h3', 'Inputs → outputs'));
      -      const tx = record.tx;
      -      tx.vin.forEach(input => {
      -        const p = el('p');
      -        if (record.coinbase) p.textContent = 'Local coinbase · 50 fake BTC created';
      -        else p.append(link(`Input ${input.txid}:${input.vout}`, `#tx/${input.txid}`));
      -        pane.append(p);
      -      });
      -      tx.vout.forEach((out, i) => add(`Output ${i}`, `${Number(out.value).toLocaleString()} sats`));
      -      add('Fee', record.coinbase ? '0 (local reward)' : '500 fake sats');
      -      pane.append(el('h3', 'Raw Bitcoin transaction'), el('pre', rpc.call('getrawtransaction', [txid])));
      -      if (!record.coinbase) { pane.append(el('h3', 'Witness'), el('pre', JSON.stringify(tx.vin.map(input => input.witness), null, 2))); }
      -    } else throw new Error('Unknown explorer view.');
      -  } catch (error) { pane.append(el('p', error.message)); }
      -  if (scroll) { pane.scrollIntoView({ behavior: 'smooth', block: 'start' }); pane.focus({ preventScroll: true }); }
      -}
      -window.addEventListener('hashchange', () => inspect());
      -window.addEventListener('storage', event => {
      -  if (event.key !== STORAGE_KEY) return;
      -  closeReview(); load(); if (!failed) message('Local lab changed in another tab; refreshed.');
      -});
       function load() {
      -  try { lab = readLab(); failed = false; if (lab.wallet?.kind === 'extension-wallet' && inscriboorProvider) lab.wallet.attach(inscriboorProvider); if (lab.wallet) message(lab.wallet.kind === 'extension-wallet' ? 'Inscriboor wallet descriptor loaded. Reconnect the extension to sign.' : 'Test wallet loaded from this browser. Your local chain is ready.'); }
      -  catch (error) { failed = true; message(`Cannot load saved lab: ${error.message} Export it for recovery, then reset this local lab.`, true); }
      +  try { lab = readLab(); }
      +  catch (error) { lab = emptyLab(); say(`This browser's saved lab could not be read (${error.message}) Load a backup, or reset to start over.`, true); }
      +  derive();
      +}
      +function derive() {
      +  index = new MetadataIndex(lab.chain);
      +  events = lineageEvents(index);
      +  live = reduce(events, { pending: true });
      +  if (ui.beat !== null && ui.beat >= tip()) ui.beat = null;
      +  if (!node(ui.holding)) ui.holding = live.roots.at(-1)?.id ?? null;
      +  if (!node(ui.node) || node(ui.node).root !== ui.holding) ui.node = ui.holding;
      +  if (ui.event && !live.log.some(e => e.id === ui.event)) ui.event = null;
      +  view = ui.beat === null ? live : reduce(events, { upTo: ui.beat });
         render();
       }
      -window.addEventListener('inscriboor#initialized', () => { inscriboorProvider = window.inscriboor; if (lab?.wallet?.kind === 'extension-wallet') lab.wallet.attach(inscriboorProvider); render(); });
      -inscriboorProvider = window.inscriboor?.isInscriboor ? window.inscriboor : null;
      -initTheme();
      -load();
      \ No newline at end of file
      +function setBeat(value) {
      +  ui.beat = value >= tip() ? null : Math.max(0, value);
      +  view = ui.beat === null ? live : reduce(events, { upTo: ui.beat });
      +  render();
      +}
      +
      +// One path for every change: the lab's lock re-reads, acts and saves. A failure changes nothing and says why.
      +async function run(action, options) {
      +  if (ui.busy) return false;
      +  ui.busy = true;
      +  document.body.classList.add('busy');
      +  try { lab = (await mutate(action, options)).lab; return true; }
      +  catch (error) { say(error?.message || String(error), true); return false; }
      +  finally { ui.busy = false; document.body.classList.remove('busy'); derive(); }
      +}
      +// Act through a node, then say where it landed: at a beat, or cued for the next one.
      +async function perform(op, by, fields, done) {
      +  let id = null;
      +  if (!(await run(l => { id = act(l, op, by, fields); }))) return false;
      +  const e = index.events.find(x => x.id === id);
      +  say(done(id, e?.status === 'confirmed' ? `at beat ${e.height}` : 'cued for the next beat'));
      +  render();
      +  return true;
      +}
      +async function landCue(action) {
      +  const before = cued();
      +  if ((await run(action)) && cued() < before) say(`Beat ${tip()}: ${plural(before - cued(), 'cued act')} landed.`);
      +}
      +
      +function render() { renderClock(); renderHoldings(); renderTree(); renderAct(); renderInspect(); renderTimeline(); }
      +
      +function renderClock() {
      +  const n = cued(), { tempo } = lab.clock;
      +  for (const b of document.querySelectorAll('[data-tempo]')) b.setAttribute('aria-pressed', String(String(tempo) === b.dataset.tempo));
      +  $('clock-beat').textContent = String(tip());
      +  $('clock-cued').textContent = n ? `${n} cued · ${cueNote()}` : 'nothing cued';
      +  $('beat-now').disabled = !n;
      +}
      +function cueNote() {
      +  const { tempo, nextAt } = lab.clock;
      +  if (tempo === 'manual') return 'waiting for Beat now';
      +  if (typeof tempo === 'number' && nextAt) return `next beat in ${Math.max(0, Math.ceil((nextAt - Date.now()) / 1000))}s`;
      +  return 'landing on the next beat';
      +}
      +
      +function renderHoldings() {
      +  $('holdings-list').replaceChildren(...live.roots.map(r => {
      +    const c = census(live, r.id), on = r.id === ui.holding;
      +    const facts = [at(r.born), plural(c.orgs, 'org'), plural(c.agents, 'agent'), plural(c.belts, 'belt'), r.status === 'closed' && 'closed'].filter(Boolean);
      +    return h('li', {}, h('button', { type: 'button', class: `hold${on ? ' on' : ''}${r.status === 'closed' ? ' gone' : ''}`, 'aria-pressed': String(on),
      +      onclick: () => pick({ holding: r.id, node: r.id }) }, h('span', { class: 'hn', text: r.name }), ' ', h('span', { class: 'hm', text: facts.join(' · ') })));
      +  }));
      +  $('holdings-empty').hidden = live.roots.length > 0;
      +  $('holdings-sub').textContent = live.roots.length ? String(live.roots.length) : '';
      +  $('sample').disabled = live.roots.some(r => r.name === SAMPLE && r.status === 'active');
      +}
      +
      +function renderTree() {
      +  const box = $('tree-rows'), root = node(ui.holding);
      +  $('tree-sub').textContent = root ? `${root.name} · ${ui.beat === null ? 'now' : `beat ${ui.beat} of ${tip()}`}` : '';
      +  if (!root) return box.replaceChildren(h('p', { class: 'wait', text: 'No holding yet. Found one, or load the yolopreneur sample.' }));
      +  if (!view.nodes.has(root.id)) return box.replaceChildren(h('p', { class: 'wait', text: `${root.name} is founded at ${at(root.born)}. Move the slider forward.` }));
      +  const focused = box.contains(document.activeElement) ? document.activeElement.dataset.id : null;
      +  box.replaceChildren(...rows(view, root.id).map(r => (r.type === 'group'
      +    ? h('div', { class: 'row grp' }, h('span', { class: 'g', 'aria-hidden': 'true', text: r.guide }), h('span', { class: 'gl', text: r.label }))
      +    : treeRow(r))));
      +  if (focused) box.querySelector(`[data-id="${focused}"]`)?.focus();
      +}
      +function treeRow({ node: n, guide, path }) {
      +  const on = n.id === ui.node;
      +  const cls = ['row', on && 'on', n.status === 'closed' && 'gone', n.born === 'cued' && 'cue'].filter(Boolean).join(' ');
      +  return h('div', { class: cls }, h('button', { type: 'button', class: 'sel', 'data-id': n.id, 'aria-pressed': String(on), onclick: () => pick({ node: n.id }) },
      +    h('span', { class: 'g', 'aria-hidden': 'true', text: guide }), h('span', { class: 'p', text: `${path} ` }), h('span', { class: 'l', text: n.name }), ' ',
      +    h('span', { class: 'v' }, marks(n))));
      +}
      +function marks(n) {
      +  const list = [
      +    n.kind === 'agent' && h('span', { class: 'mk', text: 'agent' }),
      +    n.edge === 'adopted' && h('span', { class: 'mk ok', text: 'graduated' }),
      +    n.status === 'closed' && h('span', { class: 'mk bad', text: 'closed' }),
      +    n.born === 'cued' && h('span', { class: 'mk', text: 'cued' }),
      +    ...n.badges.map(belt),
      +  ].filter(Boolean);
      +  return list.flatMap((mark, i) => (i ? [' ', mark] : [mark])); // spaces keep accessible names readable
      +}
      +const belt = b => h('span', { class: `belt ${b.belt}`, title: `${b.skill}: ${b.belt} belt` }, b.skill, h('span', { class: 'sr', text: ` (${b.belt} belt)` }));
      +
      +const below = (x, n) => { for (let p = node(x.parent); p; p = node(p.parent)) if (p.id === n.id) return true; return false; };
      +// Who n can award: anyone else active in the holding, its own seats first (a belt is one skill for one seat).
      +const recipients = n => [...live.nodes.values()].filter(x => x.root === n.root && x.id !== n.id && x.status === 'active')
      +  .map(x => [x, (below(x, n) ? 0 : 2) + (x.kind === 'agent' ? 0 : 1)]).sort((a, b) => a[1] - b[1]).map(([x]) => x);
      +const derivedBelow = n => n.children.reduce((sum, id) => {
      +  const c = node(id);
      +  return c && c.edge === 'derived' && c.status === 'active' ? sum + 1 + derivedBelow(c) : sum;
      +}, 0);
      +// Why a verb is unavailable for this node ('' when it is available). The reducer has the final word.
      +function blocked(n) {
      +  const parent = node(n.parent), closed = n.status === 'closed' ? `${n.name} is closed` : '';
      +  return {
      +    birth: closed || (n.kind === 'agent' ? 'Agents do not give birth' : ''),
      +    award: closed || (n.kind === 'agent' ? 'Agents do not award belts' : recipients(n).length ? '' : 'There is nobody else in this holding to award yet'),
      +    decide: closed,
      +    graduate: closed || (!parent ? 'A holding has no parent to graduate from' : n.kind !== 'org' ? 'Only orgs graduate'
      +      : n.edge === 'adopted' ? `${n.name} has already graduated` : parent.status !== 'active' ? `${parent.name} is closed` : ''),
      +    close: closed,
      +  };
      +}
      +function renderAct() {
      +  const n = node(ui.node), past = ui.beat !== null;
      +  $('act-none').hidden = !!n;
      +  $('act-past').hidden = !n || !past;
      +  $('act-on').hidden = !n || past;
      +  $('act-sub').textContent = n?.name ?? '';
      +  $('past-beat').textContent = String(ui.beat ?? '');
      +  if (!n || past) return;
      +  const why = blocked(n), parent = node(n.parent);
      +  for (const b of document.querySelectorAll('[data-verb]')) {
      +    b.setAttribute('aria-pressed', String(b.dataset.verb === ui.verb));
      +    b.classList.toggle('off', !!why[b.dataset.verb]);
      +  }
      +  for (const form of document.querySelectorAll('form[data-form]')) form.hidden = form.dataset.form !== ui.verb || !!why[ui.verb];
      +  $('act-why').textContent = why[ui.verb] ? `${why[ui.verb]}.` : '';
      +  for (const el of document.querySelectorAll('#act-on [data-name]')) el.textContent = n.name;
      +  for (const el of document.querySelectorAll('#act-on [data-parent]')) el.textContent = parent?.name ?? '';
      +  const to = $('award-to'), keep = to.value;
      +  const options = recipients(n).map(x => h('option', { value: x.id, text: `${trail(live, x.id)}${x.kind === 'agent' ? ' (agent)' : ''}` }));
      +  const sig = list => list.map(o => `${o.value}=${o.textContent}`).join('|');
      +  if (sig(options) !== sig([...to.options])) { to.replaceChildren(...options); if (options.some(o => o.value === keep)) to.value = keep; }
      +  $('graduate-go').textContent = `Graduate ${n.name}`;
      +  const below = derivedBelow(n), arming = ui.closing === n.id;
      +  $('close-note').textContent = below
      +    ? `Closing ${n.name} spends it for the last time, and its ${plural(below, 'derived descendant')} close with it. Graduated children hold their own roots and stay open.`
      +    : `Closing ${n.name} spends its inscription for the last time. Nothing can act through it afterwards.`;
      +  $('close-go').textContent = arming ? `Yes, close ${n.name}` : `Close ${n.name}`;
      +  $('close-keep').hidden = !arming;
      +}
      +
      +function renderInspect() {
      +  const box = $('inspect-body'), e = ui.event ? live.log.find(x => x.id === ui.event) : null;
      +  if (e) { $('inspect-sub').textContent = 'event'; return box.replaceChildren(...eventView(e)); }
      +  const known = node(ui.node);
      +  $('inspect-sub').textContent = known?.kind ?? '';
      +  if (!known) return box.replaceChildren(h('p', { class: 'note pad', text: 'Select a node in the tree, or an event in the timeline.' }));
      +  const n = node(ui.node, view);
      +  if (!n) return box.replaceChildren(h('p', { class: 'note pad', text: `${known.name} is not born yet at beat ${ui.beat}.` }));
      +  box.replaceChildren(...nodeView(n));
      +}
      +const fact = ([k, v]) => h('div', { class: 'f' }, h('dt', { text: k }), h('dd', { text: v }));
      +function nodeView(n) {
      +  const parent = node(n.parent, view);
      +  const facts = [
      +    ['Born', n.born === 'cued' ? 'cued for the next beat' : `beat ${n.born}`],
      +    parent && ['Parent', `${parent.name} · ${n.kind} ${n.index}'`],
      +    parent && ['Edge', n.edge === 'adopted' ? `adopted at ${at(n.graduated)}: it holds its own root` : `derived: ${parent.name} holds its seed`],
      +    n.closed !== null && ['Closed', at(n.closed)],
      +    ['Children', n.children.length ? n.children.map(id => node(id, view)?.name).join(', ') : 'none yet'],
      +    ['Inscription', n.id],
      +  ].filter(Boolean);
      +  return [
      +    h('div', { class: 'ih' }, h('p', { class: 'eyebrow', text: trail(view, n.id) }), h('h3', { class: 'it', text: n.name }),
      +      h('div', { class: 'chips' }, h('span', { class: 'chip', text: n.kind }), h('span', { class: `chip ${n.status === 'active' ? 'ok' : 'bad'}`, text: n.status }),
      +        n.edge === 'adopted' && h('span', { class: 'chip ok', text: 'graduated' }), n.born === 'cued' && h('span', { class: 'chip', text: 'cued' }))),
      +    h('div', { class: 'ib' }, h('dl', { class: 'fl' }, facts.map(fact)),
      +      n.badges.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Belts' }), h('ul', { class: 'list' }, n.badges.map(b =>
      +        h('li', {}, belt(b), h('span', { class: 'when', text: ` from ${node(b.by)?.name ?? '?'}, ${at(b.beat)}` }))))),
      +      n.decisions.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Decisions' }), h('ul', { class: 'list' }, n.decisions.map(d =>
      +        h('li', {}, h('b', { text: d.title }), d.why && ` · ${d.why}`, h('span', { class: 'when', text: ` (${at(d.beat)})` })))))),
      +  ];
      +}
      +function eventView(e) {
      +  const raw = index.events.find(x => x.id === e.id), body = lab.chain.transactions.get(e.id.slice(0, -2))?.inscription?.text;
      +  const facts = [
      +    ['When', e.pending ? 'cued for the next beat' : `beat ${e.beat}`],
      +    ['Authorized by', e.parent ? `${node(e.parent)?.name ?? 'an unknown inscription'}, whose inscription this event spent` : 'nothing: a founding has no parent'],
      +    ['Inscription', e.id], body && ['Body', body], raw?.block && ['Block', raw.block],
      +  ].filter(Boolean);
      +  const entry = raw && { id: raw.id, status: raw.status, height: raw.height, block: raw.block, parent: raw.parent ?? null,
      +    metaprotocol: raw.metaprotocol ?? null, contentType: raw.contentType, sat: raw.sat, location: raw.location };
      +  return [
      +    h('div', { class: 'ih' }, h('p', { class: 'eyebrow', text: `${e.op} · ${e.pending ? 'cued' : `beat ${e.beat}`}` }), h('h3', { class: 'it', text: summary(e, live) }),
      +      h('div', { class: 'chips' }, h('span', { class: 'chip', text: raw?.protocol ?? '?' }), h('span', { class: `chip ${e.ok ? 'ok' : 'bad'}`, text: e.ok ? 'applied' : 'rejected' })),
      +      !e.ok && h('p', { class: 'idesc', text: 'The reducer rejected this event. It stays on the chain, but it does not change the tree.' })),
      +    h('div', { class: 'ib' }, h('dl', { class: 'fl' }, facts.map(fact)),
      +      h('div', { class: 'code' }, h('p', { class: 'code-h', text: 'Metadata · CBOR, tag 5' }), h('pre', { text: json(e.meta) })),
      +      entry && h('div', { class: 'code' }, h('p', { class: 'code-h', text: 'Raw index entry' }), h('pre', { text: json(entry) }))),
      +  ];
      +}
      +
      +function renderTimeline() {
      +  const t = tip(), slider = $('beat'), root = node(ui.holding);
      +  slider.max = String(t);
      +  slider.value = String(ui.beat ?? t);
      +  slider.disabled = t === 0;
      +  $('beat-label').textContent = ui.beat === null ? `Now · beat ${t}` : `Beat ${ui.beat} of ${t}`;
      +  $('replay').textContent = ui.replay ? 'Stop' : 'Replay';
      +  $('replay').disabled = !root || t === 0;
      +  $('to-now').disabled = ui.beat === null;
      +  $('timeline-sub').textContent = root?.name ?? '';
      +  const groups = new Map();
      +  for (const e of live.log) if (e.root && e.root === ui.holding) { const k = e.pending ? 'cued' : e.beat; groups.set(k, [...(groups.get(k) ?? []), e]); }
      +  const keys = [...groups.keys()].sort((a, b) => (a === 'cued' ? -1 : b === 'cued' ? 1 : b - a));
      +  if (!keys.length) return $('timeline-list').replaceChildren(h('li', { class: 'wait', text: root ? 'Nothing yet.' : 'Found a holding to start its timeline.' }));
      +  $('timeline-list').replaceChildren(...keys.map(k => {
      +    const block = k === 'cued' ? null : lab.chain.blocks[k - 1];
      +    const later = ui.beat !== null && (k === 'cued' || k > ui.beat), current = ui.beat !== null && k === ui.beat;
      +    return h('li', { class: `beat${later ? ' later' : ''}${current ? ' now' : ''}` },
      +      h('p', { class: 'bh' }, h('span', { class: 'bn', text: k === 'cued' ? 'Cued' : `Beat ${k}` }),
      +        h('span', { class: 'bt', text: block ? clockTime(block.time) : lab.clock.tempo === 'manual' ? 'waiting for Beat now' : 'landing on the next beat' })),
      +      h('ol', { class: 'evs' }, groups.get(k).map(e => {
      +        const [verb, ...rest] = summary(e, live).split(' ');
      +        return h('li', {}, h('button', { type: 'button', class: `ev${e.ok ? '' : ' bad'}${e.id === ui.event ? ' on' : ''}`, 'aria-pressed': String(e.id === ui.event),
      +          onclick: () => pick({ event: e.id, node: e.node ?? ui.node }) },
      +          h('span', { class: 'op', text: e.ok ? verb : `✕ ${verb.replace(/:$/, '')}` }), ' ', h('span', { class: 'es', text: rest.join(' ') })));
      +      })));
      +  }));
      +}
      +
      +// Verbs
      +$('f-found').addEventListener('submit', async event => {
      +  event.preventDefault();
      +  const form = event.currentTarget, name = slug(form.elements.name.value);
      +  if (!name) return say('Name the holding: lowercase letters, digits and hyphens.', true);
      +  if (await perform('found', null, { name }, (id, where) => { select({ holding: id, node: id }); return `${name} is founded, ${where}.`; })) form.reset();
      +});
      +$('sample').addEventListener('click', async () => {
      +  let id = null;
      +  say('Rehearsing the yolopreneur tree over six beats…');
      +  if (!(await run(l => { id = seedSample(l); }))) return;
      +  pick({ holding: id, node: id });
      +  say('Loaded the yolopreneur sample: six beats of history. Press Replay to watch it grow.');
      +});
      +$('f-birth').addEventListener('submit', async event => {
      +  event.preventDefault();
      +  const form = event.currentTarget, n = node(ui.node), name = slug(form.elements.name.value), kind = form.elements.kind.value;
      +  if (!name) return say('Name the child: lowercase letters, digits and hyphens.', true);
      +  if (await perform('birth', n.id, { name, kind }, (id, where) => `${name} is born to ${n.name}, ${where}.`)) form.elements.name.value = '';
      +});
      +$('f-award').addEventListener('submit', async event => {
      +  event.preventDefault();
      +  const form = event.currentTarget, n = node(ui.node), to = form.elements.to.value, skill = slug(form.elements.skill.value), level = form.elements.belt.value;
      +  if (!skill) return say('Name the skill, for example launch.', true);
      +  if (await perform('award', n.id, { to, skill, belt: level }, (id, where) => `${node(to)?.name} holds a ${level} belt in ${skill}, ${where}.`)) form.elements.skill.value = '';
      +});
      +$('f-decide').addEventListener('submit', async event => {
      +  event.preventDefault();
      +  const form = event.currentTarget, n = node(ui.node), title = form.elements.title.value.trim(), why = form.elements.why.value.trim();
      +  if (!title) return say('A decision needs a title.', true);
      +  if (await perform('decide', n.id, { title, why }, (id, where) => `${n.name} decided “${title}”, ${where}.`)) form.reset();
      +});
      +$('f-graduate').addEventListener('submit', async event => {
      +  event.preventDefault();
      +  const n = node(ui.node), parent = node(n.parent);
      +  await perform('graduate', parent.id, { of: n.id }, (id, where) => `${n.name} graduates from ${parent.name}, ${where}. Its edge is adopted now.`);
      +});
      +$('f-close').addEventListener('submit', async event => {
      +  event.preventDefault();
      +  const n = node(ui.node);
      +  if (ui.closing !== n.id) { ui.closing = n.id; renderAct(); return $('close-go').focus(); }
      +  ui.closing = null;
      +  await perform('close', n.id, {}, (id, where) => `${n.name} is closed, ${where}.`);
      +});
      +$('close-keep').addEventListener('click', () => { ui.closing = null; renderAct(); $('close-go').focus(); });
      +for (const b of document.querySelectorAll('[data-verb]')) b.addEventListener('click', () => { ui.verb = b.dataset.verb; ui.closing = null; renderAct(); });
      +
      +// Time: the tempo, Beat now, and the clock that lands cued acts when their beat is due
      +for (const b of document.querySelectorAll('[data-tempo]')) b.addEventListener('click', async () => {
      +  const value = b.dataset.tempo, tempo = /^\d+$/.test(value) ? Number(value) : value;
      +  if (!(await run(l => setTempo(l, tempo)))) return;
      +  say(value === 'each' ? 'Each act is its own beat.' : value === 'manual' ? 'Acts are cued until you press Beat now.'
      +    : `Acts are cued and land together on the next beat, at most ${value} seconds after the first.`);
      +});
      +$('beat-now').addEventListener('click', () => landCue(l => (l.chain.mempool.length ? beat(l) : false)));
      +setInterval(() => {
      +  renderClock();
      +  const { tempo, nextAt } = lab.clock;
      +  if (ui.busy || document.hidden || typeof tempo !== 'number' || !lab.chain.mempool.length || (nextAt && Date.now() < nextAt)) return;
      +  landCue(l => tick(l));
      +}, 1000);
      +addEventListener('storage', event => { if (event.key === LAB_KEY || event.key === null) load(); });
      +
      +// The timeline: any beat, a replay, and back to now
      +function stopReplay() { if (ui.replay) { clearInterval(ui.replay); ui.replay = null; renderTimeline(); } }
      +function startReplay() {
      +  const root = node(ui.holding);
      +  if (!root || !tip()) return;
      +  setBeat(typeof root.born === 'number' ? root.born : tip());
      +  if (ui.beat === null) return say('Nothing to replay yet: this holding has one beat of history.');
      +  ui.replay = setInterval(() => { if (ui.beat === null || ui.beat + 1 >= tip()) stopReplay(); setBeat(ui.beat === null ? tip() : ui.beat + 1); }, 800);
      +  renderTimeline();
      +}
      +$('beat').addEventListener('input', event => { stopReplay(); setBeat(Number(event.target.value)); });
      +$('replay').addEventListener('click', () => (ui.replay ? stopReplay() : startReplay()));
      +for (const id of ['to-now', 'past-now']) $(id).addEventListener('click', () => { stopReplay(); setBeat(tip()); });
      +
      +// Theme: system, light, dark (public/theme.js applies the saved choice before first paint)
      +const THEMES = ['system', 'light', 'dark'];
      +const showTheme = () => { $('theme').textContent = `Theme: ${document.documentElement.dataset.theme ?? 'system'}`; };
      +$('theme').addEventListener('click', () => {
      +  const next = THEMES[(THEMES.indexOf(document.documentElement.dataset.theme ?? 'system') + 1) % THEMES.length];
      +  if (next === 'system') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = next;
      +  try { if (next === 'system') localStorage.removeItem('orgchain-theme'); else localStorage.setItem('orgchain-theme', next); } catch { /* not remembered */ }
      +  showTheme();
      +});
      +
      +// The lab: save, load and reset
      +function confirmBox(text, label, onYes) {
      +  const box = $('confirm');
      +  box.replaceChildren(h('span', { text }), h('button', { type: 'button', class: 'btn sm danger', text: label, onclick: () => { box.hidden = true; onYes(); } }),
      +    h('button', { type: 'button', class: 'btn sm', text: 'Cancel', onclick: () => { box.hidden = true; } }));
      +  box.hidden = false;
      +  box.querySelector('button').focus();
      +}
      +const forgiving = { read: () => { try { return readLab(); } catch { return emptyLab(); } } };
      +async function replaceLab(next, message) {
      +  stopReplay();
      +  if (!(await run(l => { Object.assign(l, next()); }, forgiving))) return;
      +  Object.assign(ui, { holding: null, node: null, event: null, beat: null, closing: null });
      +  derive();
      +  say(message);
      +}
      +$('save').addEventListener('click', () => {
      +  const link = h('a', { href: URL.createObjectURL(new Blob([encodeLab(lab)], { type: 'application/json' })), download: `orgchain-lab-${new Date().toISOString().slice(0, 10)}.json` });
      +  document.body.append(link);
      +  link.click();
      +  link.remove();
      +  setTimeout(() => URL.revokeObjectURL(link.href), 5000);
      +  say('Saved a backup of this lab, test wallet included. Load it in any browser to carry on.');
      +});
      +$('load').addEventListener('click', () => $('load-file').click());
      +$('load-file').addEventListener('change', async event => {
      +  const file = event.target.files?.[0];
      +  event.target.value = '';
      +  if (!file) return;
      +  let next;
      +  try { next = decodeLab(await file.text()); } catch (error) { return say(`${file.name} is not an Orgchain lab: ${error.message}`, true); }
      +  confirmBox(`Replace this browser's lab with ${file.name}?`, 'Replace', () => replaceLab(() => next, `Loaded ${file.name}.`));
      +});
      +$('reset').addEventListener('click', () => confirmBox("Erase this browser's lab: every holding, the chain and the test wallet?", 'Erase',
      +  () => replaceLab(emptyLab, 'Erased. This is a fresh lab.')));
      +
      +showTheme();
      +load();
      diff --git a/src/orgchain.css b/src/orgchain.css
      new file mode 100644
      index 0000000..1081461
      --- /dev/null
      +++ b/src/orgchain.css
      @@ -0,0 +1,263 @@
      +/* Orgchain: lineage's key-ceremony look, set up for operating. Ink band, a sticky Time bar, holdings · tree · act and
      +   inspect, the timeline underneath. Tokens are lineage's (ventures/lineage/assets/lineage.css); hazard yellow marks the
      +   selection and the present beat. Everything is set in the system monospace. */
      +:root {
      +  --concrete: #e4e3dd; --slab: #f6f5f1; --ink: #141414; --ink-2: #4a4944; --ink-3: #66655f;
      +  --signal: #ffc21a; --on-signal: #141414; --ok: #1d7a3a; --danger: #b3261e; --focus: #2346ff;
      +  --band: #141414; --band-fg: #f6f5f1; --band-muted: #b0afa8; --shadow: #141414;
      +  --mono: ui-monospace, "SF Mono", "Cascadia Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", "DejaVu Sans Mono", monospace;
      +  --display: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
      +  --t-xs: 0.75rem; --t-s: 0.8125rem; --t-m: 0.9375rem; --t-l: 1.25rem;
      +  --gutter: max(16px, env(safe-area-inset-left, 0px));
      +  color-scheme: light;
      +}
      +@media (prefers-color-scheme: dark) {
      +  :root:not([data-theme="light"]) {
      +    --concrete: #151514; --slab: #1e1e1c; --ink: #ecebe4; --ink-2: #b9b8b0; --ink-3: #94938b;
      +    --ok: #62c784; --danger: #ff8a7f; --focus: #93a8ff; --band: #0b0b0a; --band-fg: #ecebe4;
      +    --band-muted: #9d9c95; --shadow: #4d4c47; color-scheme: dark;
      +  }
      +}
      +:root[data-theme="dark"] {
      +  --concrete: #151514; --slab: #1e1e1c; --ink: #ecebe4; --ink-2: #b9b8b0; --ink-3: #94938b;
      +  --ok: #62c784; --danger: #ff8a7f; --focus: #93a8ff; --band: #0b0b0a; --band-fg: #ecebe4;
      +  --band-muted: #9d9c95; --shadow: #4d4c47; color-scheme: dark;
      +}
      +
      +* { box-sizing: border-box; }
      +[hidden] { display: none !important; }
      +html { -webkit-text-size-adjust: 100%; }
      +body { margin: 0; background: var(--concrete); color: var(--ink); font: 400 var(--t-m)/1.5 var(--mono); }
      +body.busy { cursor: progress; }
      +::selection { background: var(--signal); color: var(--on-signal); }
      +button, select, input, textarea { font: inherit; color: inherit; }
      +:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
      +a { color: inherit; text-decoration-thickness: 2px; text-underline-offset: 3px; }
      +b { font-weight: 700; }
      +
      +/* header slab */
      +.band { background: var(--band); color: var(--band-fg); border-bottom: 3px solid var(--ink); }
      +.band-in {
      +  max-width: 1320px; margin: 0 auto; padding: 16px var(--gutter) 14px;
      +  display: flex; flex-wrap: wrap; gap: 14px 32px; align-items: flex-end; justify-content: space-between;
      +}
      +.kicker { margin: 0 0 8px; font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--band-muted); }
      +.kicker a { color: var(--band-fg); }
      +.wordmark { margin: 0; font: 900 clamp(2.25rem, 6vw, 3.5rem)/0.84 var(--display); letter-spacing: -0.05em; text-transform: uppercase; }
      +.lede { margin: 10px 0 0; color: var(--band-muted); font-size: var(--t-s); max-width: 88ch; }
      +.lede b { color: var(--band-fg); }
      +.flags { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
      +.flag {
      +  font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.1em; text-transform: uppercase;
      +  padding: 7px 9px 6px; border: 2px solid var(--band-fg); background: transparent; color: var(--band-fg); border-radius: 0;
      +}
      +.flag.hz { background: var(--signal); border-color: var(--signal); color: var(--on-signal); }
      +button.flag { cursor: pointer; }
      +button.flag:hover { background: var(--band-fg); color: var(--band); }
      +
      +/* Time: the clock bar */
      +.clock { background: var(--slab); border-bottom: 3px solid var(--ink); position: sticky; top: 0; z-index: 5; }
      +.clock-in { max-width: 1320px; margin: 0 auto; padding: 10px var(--gutter); display: flex; flex-wrap: wrap; gap: 10px 18px; align-items: center; }
      +.tempo { display: flex; flex-wrap: wrap; align-items: center; }
      +.tempo .lab { margin: 0 10px 0 0; }
      +.seg {
      +  font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.06em; text-transform: uppercase; padding: 9px 10px 8px;
      +  border: 2px solid var(--ink); background: var(--slab); color: var(--ink); cursor: pointer; border-radius: 0; margin-left: -2px;
      +}
      +.seg:first-of-type { margin-left: 0; }
      +.seg:hover:not([aria-pressed="true"]) { background: var(--concrete); }
      +.seg[aria-pressed="true"] { background: var(--ink); color: var(--slab); }
      +.seg:focus-visible { position: relative; z-index: 1; }
      +.beatno { margin: 0; flex: 1 1 22ch; font-size: var(--t-s); color: var(--ink-2); }
      +.beatno b { color: var(--ink); font-size: var(--t-m); }
      +.say { max-width: 1320px; margin: 0 auto; padding: 0 var(--gutter) 10px; font-size: var(--t-s); color: var(--ink-2); }
      +.say:empty { display: none; }
      +.say.bad { color: var(--danger); font-weight: 700; }
      +
      +/* page */
      +.wrap { max-width: 1320px; margin: 0 auto; padding: 32px var(--gutter) 56px; display: grid; gap: 40px; }
      +.work {
      +  display: grid; gap: 36px 28px; align-items: start;
      +  grid-template-columns: minmax(0, 250px) minmax(0, 1fr) minmax(0, 390px); grid-template-areas: "hold tree side" "time time side";
      +}
      +.hold-blk { grid-area: hold; }
      +.tree-blk { grid-area: tree; }
      +.time-blk { grid-area: time; }
      +.side { grid-area: side; display: grid; gap: 36px; min-width: 0; }
      +.blk { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; }
      +.blk > .panel { align-self: stretch; }
      +.tab {
      +  margin: 0; background: var(--ink); color: var(--slab); font: 700 var(--t-xs)/1 var(--mono);
      +  letter-spacing: 0.14em; text-transform: uppercase; padding: 8px 11px 7px; display: flex; gap: 12px; max-width: 100%;
      +}
      +.tab .sub { font-weight: 400; letter-spacing: 0.06em; text-transform: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      +.tab .sub:empty { display: none; }
      +.panel { background: var(--slab); border: 3px solid var(--ink); box-shadow: 8px 8px 0 var(--shadow); min-width: 0; }
      +.pad { padding: 16px 18px; }
      +.lab { display: block; font: 700 var(--t-xs)/1.2 var(--mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-2); }
      +.lab span { font-weight: 400; letter-spacing: 0.04em; text-transform: none; }
      +.note { margin: 0; font-size: var(--t-s); color: var(--ink-2); }
      +.note p { margin: 0 0 12px; }
      +.wait { margin: 0; padding: 4px 16px; color: var(--ink-3); font-size: var(--t-s); }
      +.hint { margin: 8px 0 0; font-size: var(--t-xs); color: var(--ink-2); }
      +input:not([type]), input[type="text"], select, textarea {
      +  width: 100%; padding: 8px 10px; border: 2px solid var(--ink); border-radius: 0; background: var(--concrete); min-width: 0;
      +}
      +textarea { resize: vertical; line-height: 1.5; }
      +.line { display: flex; flex-wrap: wrap; gap: 10px; align-items: stretch; }
      +.line input { flex: 1 1 12ch; width: auto; }
      +
      +/* buttons */
      +.btn {
      +  font: 700 var(--t-s)/1 var(--mono); letter-spacing: 0.06em; text-transform: uppercase;
      +  padding: 10px 12px 9px; background: var(--slab); color: var(--ink); border: 2px solid var(--ink);
      +  box-shadow: 3px 3px 0 var(--shadow); cursor: pointer; border-radius: 0;
      +}
      +.btn:hover { background: var(--concrete); }
      +.btn:active { transform: translate(3px, 3px); box-shadow: none; }
      +.btn.primary { background: var(--signal); color: var(--on-signal); border-color: var(--ink); }
      +.btn.danger { background: var(--danger); color: var(--slab); }
      +.btn.sm { font-size: var(--t-xs); padding: 7px 9px 6px; box-shadow: 2px 2px 0 var(--shadow); }
      +.btn.sm:active { transform: translate(2px, 2px); }
      +.btn:disabled, .seg:disabled { cursor: not-allowed; background: var(--concrete); color: var(--ink-3); border-color: var(--ink-3); box-shadow: none; transform: none; }
      +
      +/* holdings */
      +.holds { list-style: none; margin: 0; padding: 0; }
      +.holds li + li { border-top: 2px solid var(--ink); }
      +.hold { width: 100%; display: grid; gap: 3px; text-align: left; background: transparent; border: 0; padding: 12px 18px; cursor: pointer; border-radius: 0; }
      +.hold:hover .hn { text-decoration: underline; text-underline-offset: 3px; }
      +.hn { font-weight: 700; overflow-wrap: anywhere; }
      +.hm { font-size: var(--t-xs); color: var(--ink-2); }
      +.hold.on { background: var(--signal); }
      +.hold.on .hn, .hold.on .hm { color: var(--on-signal); }
      +.hold.gone .hn { text-decoration: line-through; text-decoration-thickness: 2px; }
      +.holds:not(:empty) ~ .found { border-top: 3px solid var(--ink); }
      +.empty p { margin: 0; font-size: var(--t-s); color: var(--ink-2); }
      +.found .lab { margin-bottom: 8px; }
      +.sample { border-top: 2px dashed var(--ink-3); }
      +
      +/* tree */
      +.tree { padding: 12px 0; font-size: 0.875rem; overflow-x: auto; }
      +.row { display: flex; align-items: stretch; min-width: max-content; }
      +.sel {
      +  flex: 1; display: flex; align-items: baseline; text-align: left; background: transparent;
      +  border: 0; padding: 4px 14px 4px 16px; cursor: pointer; line-height: 1.55; border-radius: 0;
      +}
      +.sel:hover .l { text-decoration: underline; text-underline-offset: 3px; }
      +.g { white-space: pre; color: var(--ink-3); flex: none; }
      +.p { white-space: pre; font-weight: 700; flex: none; }
      +.l { flex: none; color: var(--ink); font-weight: 700; white-space: nowrap; }
      +.v { flex: 1 0 auto; display: flex; gap: 5px; justify-content: flex-end; align-items: center; margin-left: 2ch; }
      +.grp { padding: 0 14px 0 16px; line-height: 1.55; color: var(--ink-3); }
      +.gl { color: var(--ink-2); }
      +.row.on .sel { background: var(--signal); }
      +.row.on .g, .row.on .p, .row.on .l, .row.on .mk, .row.on .belt { color: var(--on-signal); border-color: var(--on-signal); }
      +.row.gone .l, .row.gone .p { text-decoration: line-through; text-decoration-thickness: 2px; }
      +.row.gone .l { color: var(--ink-2); }
      +.row.cue .l { font-weight: 400; font-style: italic; }
      +.mk { font: 700 0.6875rem/1 var(--mono); letter-spacing: 0.06em; text-transform: uppercase; padding: 3px 5px 2px; border: 2px solid currentColor; color: var(--ink-2); white-space: nowrap; }
      +.mk.ok { color: var(--ok); }
      +.mk.bad { color: var(--danger); }
      +.belt { display: inline-flex; align-items: center; gap: 5px; font: 700 0.6875rem/1 var(--mono); padding: 2px 5px 2px 3px; border: 2px solid var(--ink); color: var(--ink); white-space: nowrap; }
      +.belt::before { content: ""; width: 10px; height: 10px; flex: none; border: 1.5px solid currentColor; background: #f6f5f1; }
      +.belt.green::before { background: #1d7a3a; }
      +.belt.black::before { background: #141414; }
      +
      +/* act */
      +.verbs { display: flex; flex-wrap: wrap; border-bottom: 3px solid var(--ink); }
      +.verbs button {
      +  flex: 1 1 auto; font: 700 var(--t-s)/1 var(--mono); padding: 12px 10px; background: transparent; border: 0;
      +  border-right: 2px solid var(--ink); color: var(--ink-2); cursor: pointer; border-radius: 0;
      +}
      +.verbs button:last-child { border-right: 0; }
      +.verbs button.off { color: var(--ink-3); }
      +.verbs button:hover:not([aria-pressed="true"]) { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
      +.verbs button[aria-pressed="true"] { background: var(--ink); color: var(--slab); }
      +.why { margin: 0; font-size: var(--t-s); color: var(--ink-2); }
      +.why:empty { display: none; }
      +.vf { display: grid; gap: 6px; }
      +.vf .lab { margin-top: 8px; }
      +.vh { margin: 0; font-size: var(--t-s); color: var(--ink-2); }
      +.vh b { color: var(--ink); }
      +.vf .go, .vf .line { margin-top: 12px; }
      +.vf > .go { justify-self: start; }
      +.vf .line .go { margin-top: 0; }
      +.kinds { border: 0; margin: 0; padding: 0; display: grid; gap: 6px; min-width: 0; }
      +.kinds legend { padding: 0; margin: 8px 0 6px; }
      +.kinds label { display: flex; gap: 8px; align-items: baseline; cursor: pointer; font-weight: 700; }
      +.kinds label span { font-weight: 400; font-size: var(--t-s); color: var(--ink-2); }
      +.kinds input { accent-color: var(--ink); margin: 0; }
      +
      +/* inspect */
      +.ih { padding: 16px 18px 14px; border-bottom: 3px solid var(--ink); }
      +.eyebrow { margin: 0 0 8px; font: 700 var(--t-xs)/1.3 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2); overflow-wrap: anywhere; }
      +.it { margin: 0; font: 700 var(--t-l)/1.25 var(--mono); overflow-wrap: anywhere; }
      +.idesc { margin: 10px 0 0; color: var(--ink-2); font-size: var(--t-s); }
      +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
      +.chip { font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; padding: 5px 7px 4px; border: 2px solid currentColor; }
      +.chip.ok { color: var(--ok); }
      +.chip.bad { color: var(--danger); }
      +.ib { padding: 4px 18px 18px; display: grid; gap: 16px; }
      +.fl { margin: 0; display: grid; }
      +.f { display: grid; gap: 4px; padding-top: 12px; margin-top: 12px; border-top: 1px dashed var(--ink-3); }
      +.f:first-child { border-top: 0; }
      +.f dt { font: 700 var(--t-xs)/1.2 var(--mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-2); }
      +.f dd { margin: 0; font-size: var(--t-s); overflow-wrap: anywhere; min-width: 0; }
      +.list { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 8px; font-size: var(--t-s); }
      +.list .when { color: var(--ink-2); }
      +.code { border: 2px solid var(--ink); min-width: 0; }
      +.code-h { padding: 6px 10px; border-bottom: 2px solid var(--ink); background: var(--concrete); font: 700 var(--t-xs)/1.3 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-2); }
      +.code pre { margin: 0; padding: 10px; font-size: var(--t-xs); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
      +
      +/* timeline */
      +.scrub { flex: 1 1 400px; display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
      +.scrub .lab { margin: 0; }
      +.scrub input[type="range"] { flex: 1 1 120px; min-width: 80px; min-width: 0; margin: 0; height: 28px; accent-color: var(--ink); cursor: pointer; }
      +.scrub output { font: 700 var(--t-s)/1 var(--mono); min-width: 12ch; white-space: nowrap; }
      +.beats { list-style: none; margin: 0; padding: 6px 0; max-height: 480px; overflow: auto; overscroll-behavior: contain; }
      +.beat { padding: 10px 18px; }
      +.beat + .beat { border-top: 1px dashed var(--ink-3); }
      +.bh { margin: 0 0 6px; font: 700 var(--t-xs)/1.3 var(--mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-2); display: flex; flex-wrap: wrap; gap: 4px 12px; }
      +.bt { font-weight: 400; letter-spacing: 0.04em; }
      +.beat.now .bh .bn { background: var(--signal); color: var(--on-signal); padding: 2px 5px; margin: -2px -5px; }
      +.evs { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
      +.ev { width: calc(100% + 6px); display: flex; gap: 1ch; text-align: left; background: transparent; border: 0; padding: 3px 6px; margin-left: -6px; cursor: pointer; border-radius: 0; font-size: var(--t-s); }
      +.ev .op { flex: none; min-width: 9ch; font-weight: 700; }
      +.ev .es { min-width: 0; color: var(--ink-2); overflow-wrap: anywhere; }
      +.ev:hover .es { text-decoration: underline; text-underline-offset: 3px; }
      +.ev.bad .op, .ev.bad .es { color: var(--danger); }
      +.beat.later .bh, .beat.later .op, .beat.later .es { color: var(--ink-3); }
      +.beat.later .ev .op { font-weight: 400; }
      +.ev.on { background: var(--signal); }
      +.ev.on .op, .ev.on .es { color: var(--on-signal); }
      +
      +/* footer */
      +.foot { display: grid; gap: 10px; color: var(--ink-2); font-size: var(--t-s); max-width: 100ch; }
      +.foot p { margin: 0; }
      +.labrow { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
      +.confirm { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 10px 12px; border: 2px dashed var(--ink); color: var(--ink); }
      +
      +@media (min-width: 761px) {
      +  .scrub { flex-wrap: nowrap; }
      +}
      +@media (max-width: 1120px) {
      +  .work { grid-template-columns: minmax(0, 1fr) minmax(0, 370px); grid-template-areas: "hold side" "tree side" "time time"; }
      +}
      +@media (max-width: 760px) {
      +  .clock { position: static; }
      +  .work { grid-template-columns: minmax(0, 1fr); grid-template-areas: "hold" "tree" "side" "time"; }
      +}
      +@media (max-width: 640px) {
      +  .panel { box-shadow: 5px 5px 0 var(--shadow); }
      +  .flag.proto { display: none; }
      +  .tree { font-size: var(--t-s); }
      +  .sel, .grp { padding-left: 10px; }
      +  .v .belt { display: none; }
      +  .beat { padding: 10px 14px; }
      +  .ev { flex-direction: column; gap: 0; }
      +}
      +@media (prefers-reduced-motion: reduce) {
      +  .btn:active, .btn.sm:active { transform: none; }
      +}
      +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
      +.code-h { margin: 0; }
      diff --git a/src/orgchain/actions.js b/src/orgchain/actions.js
      new file mode 100644
      index 0000000..238f5b9
      --- /dev/null
      +++ b/src/orgchain/actions.js
      @@ -0,0 +1,76 @@
      +// Orgchain's verbs as lineage/v0 inscriptions: found, birth, award, decide, graduate, close.
      +import { InscriptionAdapter, POSTAGE, FEE } from '../adapters/inscription.js';
      +import { MetadataIndex } from '../adapters/metadata-index.js';
      +import { TestWallet } from '../adapters/wallet.js';
      +import { PROTOCOL, lineageEvents, reduce } from './lineage.js';
      +import { armClock, beat } from './store.js';
      +
      +const FIELDS = { found: ['name'], birth: ['name', 'kind'], award: ['to', 'skill', 'belt'], decide: ['title', 'why'], graduate: ['of'], close: [] };
      +const BODY = { found: m => `found ${m.name}`, birth: m => `birth ${m.name}`, award: m => `award ${m.skill} ${m.belt}`,
      +  decide: m => `decide ${m.title}`, graduate: () => 'graduate', close: () => 'close' };
      +
      +export function metadataFor(op, fields = {}) {
      +  if (!Object.hasOwn(FIELDS, op)) throw new Error('Unknown verb.');
      +  const metadata = { op };
      +  for (const key of FIELDS[op]) { const v = fields[key]; if (typeof v === 'string' && v.trim()) metadata[key] = v.trim(); }
      +  return metadata;
      +}
      +
      +// The reducer is the validator: the would-be event must fold cleanly onto everything so far, cued acts included.
      +export function check(lab, op, by, fields) {
      +  const metadata = metadataFor(op, fields);
      +  const events = lineageEvents(new MetadataIndex(lab.chain));
      +  const next = { id: 'check', status: 'pending', height: null, metadata, parent: by ?? null, protocol: PROTOCOL };
      +  const { ok, why } = reduce([...events, next], { pending: true }).log.at(-1);
      +  if (!ok) throw new Error(`${why[0].toUpperCase()}${why.slice(1)}.`);
      +  return metadata;
      +}
      +
      +const spendable = lab => lab.chain.coins(lab.wallet.script).some(c => !c.inscription && c.value >= POSTAGE + 2 * FEE + 330);
      +// The lab's first beat pays its test wallet 50 test BTC, enough for millions of acts.
      +export function fund(lab) {
      +  if (!lab.wallet) lab.wallet = TestWallet.create();
      +  if (!spendable(lab)) beat(lab);
      +}
      +
      +function inscribe(lab, op, by, metadata) {
      +  return new InscriptionAdapter(lab.chain, lab.wallet).inscribe(BODY[op](metadata), by ?? null, metadata, { metaprotocol: PROTOCOL });
      +}
      +
      +// One act is one inscription whose parent is the authorizing node. At the 'each' tempo it lands at once; otherwise it is cued.
      +export function act(lab, op, by, fields, now = Date.now()) {
      +  const metadata = check(lab, op, by, fields);
      +  fund(lab);
      +  const id = inscribe(lab, op, by, metadata);
      +  if (lab.clock.tempo === 'each') beat(lab); else armClock(lab, now);
      +  return id;
      +}
      +
      +// The drawing in docs/lineage.md, rehearsed over six beats. Names stand in for inscription ids until those exist.
      +export const SAMPLE = 'yolopreneur';
      +const PLAN = [
      +  [['found', null, { name: 'yolopreneur' }]],
      +  [['birth', 'yolopreneur', { name: 'well-known', kind: 'org' }], ['birth', 'yolopreneur', { name: 'time', kind: 'org' }]],
      +  [['birth', 'well-known', { name: 'mailr', kind: 'org' }], ['birth', 'well-known', { name: 'lineage', kind: 'org' }],
      +    ['birth', 'time', { name: 'luna', kind: 'agent' }], ['birth', 'time', { name: 'sol', kind: 'agent' }], ['birth', 'time', { name: 'astra', kind: 'agent' }]],
      +  [['birth', 'time', { name: 'docscope', kind: 'org' }], ['birth', 'time', { name: 'dollhouse', kind: 'org' }],
      +    ['birth', 'time', { name: 'doodleloop', kind: 'org' }], ['birth', 'time', { name: 'hearthframe', kind: 'org' }]],
      +  [['award', 'time', { to: 'sol', skill: 'launch', belt: 'green' }], ['award', 'time', { to: 'luna', skill: 'post', belt: 'white' }],
      +    ['decide', 'yolopreneur', { title: 'Every venture gets a page', why: '.yolopreneur.lol, served from one pages branch.' }]],
      +  [['graduate', 'well-known', { of: 'lineage' }],
      +    ['decide', 'lineage', { title: 'Lineage gets its own org', why: 'Its own repo on Teahub now, lineage.llc later: adopted, not derived.' }]],
      +];
      +export function seedSample(lab) {
      +  fund(lab);
      +  if (lab.chain.mempool.length) beat(lab); // land anything already cued, so the sample's beats are its own
      +  const ids = {};
      +  for (const group of PLAN) {
      +    for (const [op, by, fields] of group) {
      +      const resolved = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, k === 'to' || k === 'of' ? ids[v] : v]));
      +      const parent = by ? ids[by] : null, metadata = check(lab, op, parent, resolved), id = inscribe(lab, op, parent, metadata);
      +      if (op === 'found' || op === 'birth') ids[fields.name] = id;
      +    }
      +    beat(lab);
      +  }
      +  return ids[SAMPLE];
      +}
      diff --git a/src/orgchain/lineage.js b/src/orgchain/lineage.js
      new file mode 100644
      index 0000000..35665d5
      --- /dev/null
      +++ b/src/orgchain/lineage.js
      @@ -0,0 +1,154 @@
      +// Orgchain's one interpretation: lineage/v0 events, folded into trees.
      +// MetadataIndex keeps inscriptions raw; this is a trusted, bundled reducer (no eval, no plugins).
      +// Rule: an event's parent inscription is the node that authorizes it. The chain enforces that by spending it.
      +export const PROTOCOL = 'lineage/v0';
      +export const BELTS = ['white', 'green', 'black'];
      +export const NAME_RULE = 'names are 1-40 lowercase letters, digits or hyphens (they become domains)';
      +const NAME = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
      +const isName = v => typeof v === 'string' && NAME.test(v);
      +const text = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
      +
      +// "Acme Holdings" -> "acme-holdings". Only a typing aid: the reducer still checks every name.
      +export function slug(value) {
      +  return String(value ?? '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
      +    .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
      +}
      +
      +export const lineageEvents = index => index.events.filter(e => e.protocol === PROTOCOL);
      +
      +// Fold events in chain order (blocks, then mempool). upTo: the last beat to include. pending: include cued events.
      +export function reduce(events, { upTo = Infinity, pending = false } = {}) {
      +  const nodes = new Map(), roots = [], log = [];
      +  const active = n => n.status === 'active';
      +  for (const e of events) {
      +    const confirmed = e.status === 'confirmed';
      +    if (confirmed ? e.height > upTo : !pending) continue;
      +    const m = e.metadata && typeof e.metadata === 'object' && !Array.isArray(e.metadata) ? e.metadata : {};
      +    const entry = { id: e.id, op: typeof m.op === 'string' ? m.op.slice(0, 20) : '?', beat: confirmed ? e.height : null,
      +      pending: !confirmed, meta: m, parent: e.parent ?? null, ok: true, why: '', node: null, root: null, also: [] };
      +    const by = entry.parent ? nodes.get(entry.parent) : null, when = entry.beat ?? 'cued';
      +    const fail = why => { entry.ok = false; entry.why = why; };
      +    const authority = role => (!by ? `the ${role} is not in this lineage` : active(by) ? '' : `the ${role} is ${by.status}`);
      +    let why = '';
      +    switch (entry.op) {
      +      case 'found':
      +        if (entry.parent) fail('a holding has no parent');
      +        else if (!isName(m.name)) fail(NAME_RULE);
      +        else if (roots.some(r => active(r) && r.name === m.name)) fail(`an active holding is already named ${m.name}`);
      +        else { const n = make(e, m.name, null, 'holding'); nodes.set(n.id, n); roots.push(n); entry.node = n.id; }
      +        break;
      +      case 'birth': {
      +        const kind = m.kind === 'agent' ? 'agent' : 'org';
      +        if ((why = authority('parent'))) fail(why);
      +        else if (by.kind === 'agent') fail('agents do not give birth');
      +        else if (!isName(m.name)) fail(NAME_RULE);
      +        else if (by.children.some(id => active(nodes.get(id)) && nodes.get(id).name === m.name)) fail(`${by.name} already has an active ${m.name}`);
      +        else {
      +          const n = make(e, m.name, by, kind);
      +          n.index = by.children.filter(id => nodes.get(id).kind === kind).length; // orgs and agents count separately; never reused
      +          by.children.push(n.id); nodes.set(n.id, n); entry.node = n.id;
      +        }
      +        break;
      +      }
      +      case 'award': {
      +        const to = nodes.get(m.to);
      +        if ((why = authority('issuer'))) fail(why);
      +        else if (by.kind === 'agent') fail('agents do not award belts');
      +        else if (!to || to.root !== by.root || to.id === by.id) fail('the recipient must be another node in this holding');
      +        else if (!active(to)) fail(`the recipient is ${to.status}`);
      +        else if (!isName(m.skill) || !BELTS.includes(m.belt)) fail('an award needs a skill name and a belt (white, green or black)');
      +        else {
      +          to.badges = to.badges.filter(b => b.skill !== m.skill);
      +          to.badges.push({ skill: m.skill, belt: m.belt, by: by.id, beat: when, id: e.id });
      +          entry.node = to.id;
      +        }
      +        break;
      +      }
      +      case 'decide':
      +        if ((why = authority('decider'))) fail(why);
      +        else if (!text(m.title, 140)) fail('a decision needs a title');
      +        else { by.decisions.push({ title: text(m.title, 140), why: text(m.why, 400), beat: when, id: e.id }); entry.node = by.id; }
      +        break;
      +      case 'graduate': {
      +        const of = nodes.get(m.of);
      +        if ((why = authority('parent'))) fail(why);
      +        else if (!of || of.parent !== by.id) fail('only its parent can graduate a child');
      +        else if (of.kind !== 'org') fail('only orgs graduate');
      +        else if (!active(of)) fail(`the child is ${of.status}`);
      +        else if (of.edge === 'adopted') fail('already graduated');
      +        else { of.edge = 'adopted'; of.graduated = when; entry.node = of.id; }
      +        break;
      +      }
      +      case 'close':
      +        if (!by) fail(entry.parent ? 'that node is not in this lineage' : 'a close spends the node it closes');
      +        else if (!active(by)) fail(`already ${by.status}`);
      +        else {
      +          // Derived children close with their parent; adopted (graduated) children hold their own roots and stay open.
      +          const shut = n => {
      +            n.status = 'closed'; n.closed = when;
      +            for (const id of n.children) { const c = nodes.get(id); if (c.edge === 'derived' && active(c)) { entry.also.push(c.id); shut(c); } }
      +          };
      +          shut(by); entry.node = by.id;
      +        }
      +        break;
      +      default: fail('not a lineage/v0 op');
      +    }
      +    entry.root = entry.node ? nodes.get(entry.node).root : by?.root ?? null;
      +    log.push(entry);
      +  }
      +  return { nodes, roots, log };
      +}
      +
      +function make(e, name, parent, kind) {
      +  return { id: e.id, name, kind, parent: parent?.id ?? null, root: parent ? parent.root : e.id, edge: parent ? 'derived' : null,
      +    status: 'active', index: null, born: e.status === 'confirmed' ? e.height : 'cued',
      +    children: [], badges: [], decisions: [], graduated: null, closed: null };
      +}
      +
      +// Depth-first rows for one holding, drawn like docs/lineage.md: agents, then orgs, under every node.
      +export function rows(state, rootId) {
      +  const out = [], root = state.nodes.get(rootId);
      +  const walk = (n, guide, inner) => {
      +    out.push({ type: 'node', node: n, guide, path: n.parent ? `${n.index}'` : '●' });
      +    const kids = n.children.map(id => state.nodes.get(id));
      +    const groups = [['agents', kids.filter(k => k.kind === 'agent')], ['orgs', kids.filter(k => k.kind !== 'agent')]].filter(([, list]) => list.length);
      +    groups.forEach(([label, list], g) => {
      +      const lastGroup = g === groups.length - 1, lead = inner + (lastGroup ? '   ' : '│  ');
      +      out.push({ type: 'group', label, guide: inner + (lastGroup ? '└─ ' : '├─ ') });
      +      list.forEach((k, i) => { const last = i === list.length - 1; walk(k, lead + (last ? '└─ ' : '├─ '), lead + (last ? '   ' : '│  ')); });
      +    });
      +  };
      +  if (root) walk(root, '', '');
      +  return out;
      +}
      +
      +export function census(state, rootId) {
      +  const c = { orgs: 0, agents: 0, belts: 0, decisions: 0, closed: 0 };
      +  for (const n of state.nodes.values()) {
      +    if (n.root !== rootId) continue;
      +    if (n.id !== rootId) { c[n.kind === 'agent' ? 'agents' : 'orgs']++; if (n.status === 'closed') c.closed++; }
      +    c.belts += n.badges.length; c.decisions += n.decisions.length;
      +  }
      +  return c;
      +}
      +
      +// "yolopreneur / time / sol"
      +export function trail(state, id) {
      +  const names = [];
      +  for (let n = state.nodes.get(id); n; n = state.nodes.get(n.parent)) names.unshift(n.name);
      +  return names.join(' / ');
      +}
      +
      +export function summary(entry, state) {
      +  const m = entry.meta, name = id => state.nodes.get(id)?.name ?? (typeof id === 'string' ? `${id.slice(0, 8)}…` : '?');
      +  if (!entry.ok) return `${entry.op}: ${entry.why}`;
      +  switch (entry.op) {
      +    case 'found': return `found ${m.name}`;
      +    case 'birth': return `birth ${m.name}${m.kind === 'agent' ? ' (agent)' : ''} ← ${name(entry.parent)}`;
      +    case 'award': return `award ${m.skill} · ${m.belt} → ${name(m.to)}, from ${name(entry.parent)}`;
      +    case 'decide': return `decide “${text(m.title, 140)}” · ${name(entry.parent)}`;
      +    case 'graduate': return `graduate ${name(m.of)} from ${name(entry.parent)}`;
      +    case 'close': return `close ${name(entry.parent)}${entry.also.length ? ` and ${entry.also.length} derived` : ''}`;
      +    default: return entry.op;
      +  }
      +}
      diff --git a/src/orgchain/store.js b/src/orgchain/store.js
      new file mode 100644
      index 0000000..ac376f7
      --- /dev/null
      +++ b/src/orgchain/store.js
      @@ -0,0 +1,68 @@
      +// Orgchain's lab: one test wallet, one local chain and the clock, kept in this browser under its own key.
      +import { LocalChain } from '../adapters/chain.js';
      +import { NETWORK, TestWallet } from '../adapters/wallet.js';
      +
      +export const LAB_KEY = 'orgchain:lab:v1';
      +// 'each': every act is its own beat. 10/30/60: acts are cued and land together on the next beat. 'manual': they wait for Beat now.
      +export const TEMPOS = ['each', 10, 30, 60, 'manual'];
      +
      +export function clockPreference(value = { tempo: 'each', nextAt: null }) {
      +  const { tempo, nextAt } = value ?? {};
      +  const timed = typeof tempo === 'number';
      +  if (!TEMPOS.includes(tempo) || !(nextAt === null || (timed && Number.isSafeInteger(nextAt) && nextAt > 0))) throw new Error('Invalid clock.');
      +  return { tempo, nextAt };
      +}
      +export const emptyLab = () => ({ wallet: null, chain: new LocalChain(), clock: clockPreference() });
      +
      +export function decodeLab(raw) {
      +  if (!raw) return emptyLab();
      +  const data = JSON.parse(raw);
      +  if (data?.kind !== 'orgchain-lab' || data.network !== NETWORK || data.version !== 1) throw new Error('That is not an Orgchain v1 lab.');
      +  return { wallet: data.wallet ? TestWallet.load(data.wallet) : null, chain: new LocalChain(data.chain), clock: clockPreference(data.clock) };
      +}
      +export const encodeLab = lab => JSON.stringify({ kind: 'orgchain-lab', network: NETWORK, version: 1,
      +  wallet: lab.wallet?.backup() ?? null, chain: lab.chain.snapshot(), clock: clockPreference(lab.clock) });
      +
      +export const readLab = () => decodeLab(localStorage.getItem(LAB_KEY));
      +export function saveLab(lab) {
      +  try { localStorage.setItem(LAB_KEY, encodeLab(lab)); }
      +  catch { throw new Error('Browser storage is full or unavailable, so nothing was saved. Save a backup, then reset.'); }
      +}
      +
      +// Every change re-reads the lab under one lock, changes it and saves it, so tabs never clobber each other.
      +// An action that returns false changed nothing and is not saved.
      +export async function mutate(action, { locks = globalThis.navigator?.locks, read = readLab, save = saveLab } = {}) {
      +  if (!locks) throw new Error('Orgchain needs Web Locks: open it over HTTPS or on localhost.');
      +  return locks.request(LAB_KEY, async () => {
      +    const lab = read();
      +    const result = await action(lab);
      +    if (result !== false) save(lab);
      +    return { lab, result };
      +  });
      +}
      +
      +// The clock. A beat is a block; cued acts wait in the mempool for it.
      +export function beat(lab) {
      +  if (!lab.wallet) lab.wallet = TestWallet.create();
      +  const block = lab.chain.mine(lab.wallet.script);
      +  lab.clock = clockPreference({ tempo: lab.clock.tempo, nextAt: null });
      +  return block;
      +}
      +// A timed tempo starts counting down when the first act is cued.
      +export function armClock(lab, now = Date.now()) {
      +  const { tempo, nextAt } = lab.clock;
      +  if (typeof tempo === 'number' && lab.chain.mempool.length && nextAt === null) lab.clock = { tempo, nextAt: now + tempo * 1000 };
      +}
      +export function setTempo(lab, tempo, now = Date.now()) {
      +  lab.clock = clockPreference({ tempo, nextAt: null });
      +  if (tempo === 'each' && lab.chain.mempool.length) beat(lab); else armClock(lab, now);
      +}
      +// Run on a timer, inside the lock: at most one beat, only when one is due, and never an empty one.
      +export function tick(lab, now = Date.now()) {
      +  const { tempo, nextAt } = lab.clock;
      +  if (typeof tempo !== 'number' || !lab.chain.mempool.length) return false;
      +  if (nextAt === null) { armClock(lab, now); return true; }
      +  if (now < nextAt) return false;
      +  beat(lab);
      +  return true;
      +}
      diff --git a/src/preview.js b/src/preview.js
      deleted file mode 100644
      index 89bf9db..0000000
      --- a/src/preview.js
      +++ /dev/null
      @@ -1,51 +0,0 @@
      -import { recursivePayload, connectRecursion } from './recursion.js';
      -// Inert parsing only; inscription markup is never attached to the parent DOM.
      -// The deliberately small allowlist also removes navigation (including meta refresh).
      -const tags = new Set('p div span h1 h2 h3 h4 h5 h6 br hr pre code b strong i em u s ul ol li table thead tbody tr td th blockquote svg g path rect circle ellipse line polyline polygon text tspan defs lineargradient radialgradient stop'.split(' '));
      -const attrs = new Set('style title width height viewbox fill stroke stroke-width d x y x1 y1 x2 y2 cx cy r rx ry points transform opacity offset stop-color font-size text-anchor'.split(' '));
      -export function preview(item, api = null) {
      -  if (api && item.id) {
      -    const response = api.request(`/content/${item.id}`);
      -    if (response.status !== 200) { const error = document.createElement('pre'); error.textContent = `${response.status}: ${response.body.error}`; return error; }
      -    item = { ...item, bytes: response.body, contentType: response.contentType };
      -  }
      -  const bytes = new Uint8Array(item.bytes);
      -  const type = item.contentType.split(';')[0];
      -  const text = new TextDecoder().decode(bytes);
      -  if (type === 'text/plain' || type === 'application/json') {
      -    const node = document.createElement('pre'); node.textContent = text; return node;
      -  }
      -  let node, blob;
      -  if (type === 'text/html' || type === 'image/svg+xml') {
      -    const inert = document.implementation.createHTMLDocument('');
      -    const template = inert.createElement('template'); template.innerHTML = text;
      -    const scripts = [...template.content.querySelectorAll('script')].filter(s => !s.src).map(s => s.textContent);
      -    const clean = parent => {
      -      for (const child of [...parent.children]) {
      -        if (!tags.has(child.localName.toLowerCase())) { child.remove(); continue; }
      -        for (const attr of [...child.attributes]) if (!attrs.has(attr.name.toLowerCase())) child.removeAttribute(attr.name);
      -        clean(child);
      -      }
      -    };
      -    clean(template.content);
      -    if (api && item.id && scripts.length) {
      -      node = document.createElement('iframe'); node.setAttribute('sandbox', 'allow-scripts');
      -      node.title = `${type} recursive preview`; node.referrerPolicy = 'no-referrer';
      -      const payload = recursivePayload(template.innerHTML, scripts, item.id);
      -      node.onload = () => { node.cleanup?.(); node.cleanup = connectRecursion(node, api, payload); };
      -      node.src = 'recursive-preview.html'; node.dataset.previewUrl = ''; node.className = 'inscription-preview';
      -      return node;
      -    }
      -    const csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; form-action 'none'; base-uri 'none'";
      -    blob = new Blob([``, template.innerHTML], { type: 'text/html' });
      -    node = document.createElement('iframe'); node.setAttribute('sandbox', ''); node.title = `${type} safe preview`;
      -    node.referrerPolicy = 'no-referrer';
      -  } else {
      -    blob = new Blob([bytes], { type }); node = document.createElement('img'); node.alt = `${type} inscription`;
      -  }
      -  const url = URL.createObjectURL(blob); node.src = url; node.dataset.previewUrl = url;
      -  node.className = 'inscription-preview'; return node;
      -}
      -export function clearPreviews(root) {
      -  root.querySelectorAll('[data-preview-url]').forEach(node => { node.cleanup?.(); URL.revokeObjectURL(node.dataset.previewUrl); });
      -}
      diff --git a/src/recursion.js b/src/recursion.js
      deleted file mode 100644
      index 6a0e148..0000000
      --- a/src/recursion.js
      +++ /dev/null
      @@ -1,44 +0,0 @@
      -// Deliberately exact paths: no URLs, query strings, escapes, traversal or local
      -// mutation endpoints. Both sides validate; the parent is the authority.
      -export function recursivePath(path) {
      -  return typeof path === 'string' && /^(?:\/content\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/(?:inscription|metadata|parents|children)\/[0-9a-f]{64}i(?:0|[1-9][0-9]*)|\/r\/tx\/[0-9a-f]{64}|\/r\/blockheight|\/r\/blockhash\/(?:0|[1-9][0-9]*)|\/r\/blockinfo\/(?:0|[1-9][0-9]*|[0-9a-f]{64}))$/.test(path);
      -}
      -export const RECURSIVE_DEMO = `

      Recursive local inscription

      Live data from this browser's chain:

      -`; - -// This function is serialized into a worker, never executed in the parent DOM. -function workerBootstrap(id, allowed) { - let next = 0; const pending = new Map(); - self.onmessage = ({ data }) => { - const resolve = pending.get(data.token); if (!resolve) return; - pending.delete(data.token); resolve(data); - }; - self.fetch = async (path, options = {}) => { - if (!allowed(path) || (options.method && options.method !== 'GET')) throw new TypeError('Only whitelisted local GET paths are allowed.'); - const token = ++next; - const response = await new Promise(resolve => { pending.set(token, resolve); postMessage({ type: 'fetch', token, path }); }); - if (response.error) throw new TypeError(response.error); - const body = response.body instanceof Uint8Array ? response.body : JSON.stringify(response.body); - return new Response(body, { status: response.status, headers: { 'Content-Type': response.contentType } }); - }; - self.ord = Object.freeze({ id, contentPath: '/content/' + id, render: text => postMessage({ type: 'render', text: String(text).slice(0, 65536) }) }); -} -export function recursivePayload(markup, scripts, id) { - const worker = `const recursivePath = ${recursivePath.toString()}; (${workerBootstrap.toString()})(${JSON.stringify(id)}, recursivePath); (async () => {${scripts.join('\n;\n')}\n})().catch(e => ord.render(e.message));`; - return { markup, worker }; -} -export function connectRecursion(frame, api, payload) { - const channel = new MessageChannel(); - channel.port1.onmessage = ({data}) => { - if (!data || data.type !== 'fetch') return; - const result = recursivePath(data.path) ? api.request(data.path) : { error: 'Blocked local path.' }; - channel.port1.postMessage({ ...result, token: data.token }); - }; - frame.contentWindow.postMessage({ type: 'local-ord', ...payload }, '*', [channel.port2]); - return () => channel.port1.close(); -} diff --git a/src/style.css b/src/style.css deleted file mode 100644 index c3b32e7..0000000 --- a/src/style.css +++ /dev/null @@ -1,8 +0,0 @@ -:root{font-family:Arial,Helvetica,sans-serif;font-synthesis:none;--orange:#f7931a;--orange-2:#ffad42;--bg:#0f1113;--panel:#171a1e;--panel-2:#20242a;--text:#f3f5f7;--muted:#9ca4ad;--line:#30363d;--soft:#23272e;--danger:#ff9b8d;color:var(--text);background:var(--bg)}:root[data-theme="light"]{--bg:#f5f3ef;--panel:#fffdf9;--panel-2:#f0ece5;--text:#1a1a1a;--muted:#6f6a63;--line:#d8d1c8;--soft:#ece7df;--danger:#a54536}*{box-sizing:border-box}html{background:var(--bg);color-scheme:dark}html[data-theme="light"]{color-scheme:light}body{margin:0;background:var(--bg);color:var(--text);transition:background .18s,color .18s}a{color:var(--orange);text-underline-offset:4px}button,input,textarea{font:inherit}.file-button,button{cursor:pointer}button{border:1px solid var(--line);border-radius:8px;background:var(--panel-2);color:var(--text);padding:12px 17px;font-weight:600;transition:background .15s,border-color .15s,transform .15s}button:hover:not(:disabled){border-color:var(--orange);transform:translateY(-1px)}button:disabled{opacity:.4;cursor:not-allowed;transform:none}button.primary{background:var(--orange);color:#1c1207;border-color:var(--orange)}button.primary:hover:not(:disabled){background:var(--orange-2)}.primary span{float:right;margin-left:20px}.quiet{background:transparent;border-color:var(--line);font-size:13px}button:focus-visible,a:focus-visible,summary:focus-visible,.file-button:focus-visible{outline:3px solid var(--orange-2);outline-offset:4px}header{max-width:1240px;margin:auto;padding:24px 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{text-decoration:none;color:var(--text);display:flex;align-items:center;gap:10px;font-size:15px;font-weight:800;letter-spacing:1px}.mark{display:inline-block;flex-shrink:0;background:var(--orange);width:34px;height:34px;border-radius:8px}.version{font:11px monospace;background:var(--soft);color:var(--orange);border-radius:4px;padding:3px 5px}.header-actions{display:flex;align-items:center;gap:14px}.network{font:11px monospace;color:var(--muted);letter-spacing:1px}.network i{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--orange);margin-right:6px}.theme-toggle{padding:8px 10px;font:11px monospace}main{max-width:1176px;padding:0 0 10px;margin:auto}.hero{display:flex;justify-content:space-between;align-items:center;padding:53px 0 36px}.eyebrow{font:11px monospace;letter-spacing:1.8px;color:var(--orange);margin:0 0 19px}h1{font-size:clamp(42px,5.5vw,70px);font-weight:500;line-height:1.02;letter-spacing:-3px;margin:0 0 24px}.intro{color:var(--muted);font-size:16px;line-height:1.65}.orbit{width:255px;height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:36px;background:radial-gradient(ellipse,color-mix(in srgb,var(--orange) 16%,transparent),transparent 70%);margin-right:65px}.cube{display:grid;place-items:center;width:110px;height:110px;border:1px solid var(--orange);box-shadow:13px 13px 0 color-mix(in srgb,var(--orange) 18%,var(--panel)),14px 14px 0 color-mix(in srgb,var(--orange) 48%,var(--panel));transform:rotate(-12deg);background:var(--panel-2);color:var(--orange)}.orbit span{font:10px monospace;letter-spacing:2px;color:var(--muted)}.notice{background:color-mix(in srgb,var(--orange) 7%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 24%,var(--line));border-radius:8px;padding:14px 18px;font-size:12px;color:var(--muted);line-height:1.6}.notice strong{color:var(--text)}#status{min-height:52px;padding:16px 2px;font-size:13px;color:var(--orange)}#status.error{color:var(--danger)}.workspace{display:grid;grid-template-columns:1fr 1fr;gap:18px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px;min-width:0}.panel-title{display:flex;align-items:center;gap:10px;margin-bottom:22px}.panel-title h2{font-size:17px;margin:0;font-weight:500;letter-spacing:-.4px}.step{font:11px monospace;color:var(--orange);background:color-mix(in srgb,var(--orange) 10%,var(--panel));border:1px solid color-mix(in srgb,var(--orange) 28%,var(--line));border-radius:6px;padding:7px}.tag{margin-left:auto;font:9px monospace;letter-spacing:1px;color:var(--muted);border:1px solid var(--line);padding:5px;border-radius:4px}.panel p{line-height:1.6;font-size:14px;color:var(--muted)}.label{display:block;font:10px monospace!important;letter-spacing:1.5px;color:var(--muted);margin:0 0 10px}.balance{display:flex;align-items:baseline;gap:12px;font-size:37px;letter-spacing:-1px;font-variant-numeric:tabular-nums}.balance small{color:var(--orange);font-size:13px;letter-spacing:1px}.muted{color:var(--muted)!important;font-size:12px!important;line-height:1.65}label{display:block;font-size:12px;color:var(--muted);margin:14px 0 9px}input,textarea{width:100%;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:12px;outline:none}input{font:11px monospace}textarea{resize:vertical;min-height:100px;line-height:1.5}input:focus,textarea:focus{border-color:var(--orange)}#export-wallet{margin-top:12px}.wallet-footer{border-top:1px solid var(--line);margin-top:23px;padding-top:5px}.file-button{display:inline-block;color:var(--orange);text-decoration:underline;text-underline-offset:4px}input[type=file]{font-size:11px;margin-top:4px}.wallet-footer p{margin-bottom:0}.chain-stats{display:flex;gap:64px;margin:34px 0}.chain-stats strong{font:40px monospace}#mine{width:100%}.inscribe{grid-column:1/-1}.compose-footer{display:flex;align-items:center;justify-content:space-between;margin-top:14px}#review-panel{border-top:1px solid var(--line);margin-top:22px;padding-top:14px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--bg);border:1px solid var(--line);padding:16px;border-radius:8px;font-size:13px;line-height:1.6}h3{font-size:17px;font-weight:500}.section-title{display:flex;align-items:center;justify-content:space-between;margin:40px 0 20px;gap:14px}.section-title .eyebrow{margin-bottom:8px;font-size:10px}.section-title h2{font-size:24px;font-weight:500;letter-spacing:-.7px;margin:0}#collection-count{font:12px monospace;padding:4px 8px;margin-left:7px;background:color-mix(in srgb,var(--orange) 11%,var(--panel));color:var(--orange);border-radius:5px}.collectibles{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.empty{grid-column:1/-1;border:1px dashed var(--line);border-radius:10px;padding:35px;text-align:center;color:var(--muted);font-size:14px;line-height:1.9;margin:0}.empty span{font-size:12px;color:var(--muted)}.collectible{border:1px solid var(--line);border-radius:10px;background:var(--panel);padding:20px;min-width:0}.collectible pre{background:transparent;border:0;padding:15px 0;min-height:68px;color:var(--text);margin:0;font:17px/1.5 Georgia,serif}.collectible .tag{display:inline-block}.collectible a{font:11px monospace;overflow-wrap:anywhere}.collectible p{margin:12px 0 0}.chain-board{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px;overflow:hidden}.mempool-head,.blocks-head{display:flex;align-items:end;justify-content:space-between;gap:16px}.mempool-head strong{display:block;margin-top:4px;font-size:14px}.chain-kicker{font:10px monospace;letter-spacing:1.6px;color:var(--orange)}.mempool-list{display:flex;gap:10px;overflow-x:auto;padding:16px 0 6px}.mempool-empty{border:1px dashed var(--line);border-radius:9px;padding:14px 16px;color:var(--muted);font-size:12px;min-width:260px}.mempool-card{min-width:210px;max-width:240px;text-align:left;background:color-mix(in srgb,var(--orange) 7%,var(--panel));border-color:color-mix(in srgb,var(--orange) 32%,var(--line));padding:14px}.mempool-card strong,.mempool-card span{display:block}.mempool-card strong{font:12px monospace;color:var(--orange);margin-bottom:8px}.mempool-card span{font:11px monospace;color:var(--muted);line-height:1.55}.chain-divider{height:34px;border-left:1px dashed var(--line);margin:2px 0 10px 22px;position:relative}.chain-divider span{position:absolute;left:10px;top:8px;font:9px monospace;color:var(--muted);letter-spacing:1px}.blocks-head{margin-bottom:12px}.blocks{display:flex;gap:12px;overflow-x:auto;padding-bottom:10px}.block-card{min-width:190px;text-align:left;padding:15px;background:var(--panel-2);position:relative}.block-card:before{content:"";position:absolute;left:-13px;top:50%;width:12px;border-top:2px solid var(--orange);opacity:.55}.block-card:last-child:before{display:none}.block-card strong{display:block;font:22px monospace;margin-bottom:9px}.block-card span{display:block;font:10px monospace;color:var(--muted);line-height:1.55}.block-card .block-accent{color:var(--orange);font-weight:700;margin-bottom:6px}.inspector{margin-top:16px;scroll-margin-top:20px}.inspector p{overflow-wrap:anywhere}.inspector code{font-size:12px;overflow-wrap:anywhere}.inspector h2{font-weight:500;font-size:22px}.inspector a{font-size:12px;overflow-wrap:anywhere}.tx-links{display:flex;gap:16px;flex-wrap:wrap}.about{margin-top:40px;border-top:1px solid var(--line);padding:20px 0;color:var(--muted);font-size:12px;line-height:1.8}.about summary{cursor:pointer;color:var(--text)}.about p{max-width:900px}footer{border-top:1px solid var(--line);padding:22px 0;display:flex;align-items:center;justify-content:space-between;font:10px monospace;color:var(--muted);letter-spacing:1px}.danger{color:var(--danger)}[hidden]{display:none!important}@media(max-width:1240px){main{margin:0 32px}}@media(max-width:700px){header{padding:18px;gap:10px}.header-actions{gap:8px}.network{display:none}.brand{font-size:12px;gap:7px}main{margin:0 18px}.hero{padding:36px 0 20px}.orbit{display:none}h1{letter-spacing:-2px}.intro{font-size:14px}.workspace{grid-template-columns:1fr}.inscribe{grid-column:auto}.panel{padding:20px}.tag{font-size:8px}.collectibles{grid-template-columns:1fr}.section-title{align-items:start}.section-title h2{font-size:21px}.section-title>.muted{font-size:10px!important}.compose-footer{gap:10px}.compose-footer button{font-size:12px}.notice{font-size:11px}.mempool-head,.blocks-head{align-items:start;flex-direction:column;gap:4px}.mempool-card{min-width:180px}.block-card{min-width:175px}footer{gap:10px}}select{width:100%;font:inherit;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:12px;min-width:0}select:focus{outline-color:var(--orange)}.inscription-preview{display:block;width:100%;max-height:300px;min-height:120px;object-fit:contain;border:1px solid var(--line);border-radius:8px;margin:12px 0;background:white} - -#indexes, #api-explorer { margin-top: 2rem; } -#protocol-events { max-height: 30rem; overflow: auto; } -#protocol-events article { padding: 1rem 0; border-bottom: 1px solid var(--line); } -#api-path { width: 100%; margin-bottom: 1rem; } -#api-response, #derived-state, .metadata-document pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 24rem; overflow: auto; } -#metadata { width: 100%; } diff --git a/tests/adapters.test.js b/tests/adapters.test.js index 686caa4..4aec797 100644 --- a/tests/adapters.test.js +++ b/tests/adapters.test.js @@ -335,7 +335,6 @@ test('two auto miners re-read deadline under shared lock; manual mining uses ide import { encodePointer, decodePointer } from '../src/adapters/inscription.js'; import { resolveContent } from '../src/adapters/local-ord-api.js'; -import { recursivePath } from '../src/recursion.js'; test('delegate tag 11 uses parent serialization and accepts future ids before signing', () => { const {ins,chain,wallet} = setup(); @@ -411,12 +410,6 @@ for (const [pointer, encoded] of [[0,''],[255,'ff'],[256,'0001'],[99999,'9f8601' }); } -test('recursive path whitelist rejects all URL, traversal and mutation forms', () => { - const id='a'.repeat(64)+'i0'; - for(const path of [`/content/${id}`,`/r/inscription/${id}`,`/r/metadata/${id}`,`/r/parents/${id}`,`/r/children/${id}`,`/r/tx/${'a'.repeat(64)}`,'/r/blockheight','/r/blockhash/2','/r/blockinfo/2',`/r/blockinfo/${'a'.repeat(64)}`]) assert.equal(recursivePath(path),true,path); - for(const path of ['https://example.com','//example.com','/\\example.com','/r/../local/protocols','/r/blockheight?x=1','/r/blockheight#x','/local/metadata-index','/content/%2f','/r/blockheight/','/r/blockheight\n']) assert.equal(recursivePath(path),false,path); -}); - test('a real future delegate resolves after its independently funded reveal arrives', () => { const {chain,ins} = setup(), futureWallet=TestWallet.create(); chain.mine(futureWallet.script); diff --git a/tests/flow.spec.js b/tests/flow.spec.js deleted file mode 100644 index 2c5f55b..0000000 --- a/tests/flow.spec.js +++ /dev/null @@ -1,316 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('create → mine → mempool → inscription → block/transaction → reload/load', async ({page}) => { - const errors=[], remote=[]; - page.on('pageerror',e=>errors.push(e.message)); - page.on('request',r=>{ if(!r.url().startsWith('http://127.0.0.1:4178')) remote.push(r.url()); }); - await page.goto('/'); - await expect(page.getByRole('button',{name:'Mine now'})).toBeDisabled(); - await expect(page.locator('#mempool-list')).toContainText('Mempool empty'); - await page.getByRole('button',{name:'Create test wallet'}).click(); - await expect(page.locator('#address')).toHaveValue(/^bcrt1p/); - await page.getByRole('button',{name:'Mine now'}).click(); - await expect(page.locator('#balance')).toHaveText('50.00000000'); - await expect(page.locator('#blocks .block-card')).toHaveCount(1); - await expect(page.locator('#blocks .block-card').first()).toContainText('HEIGHT 1'); - const payload=' Hello 🌱'; - await page.getByLabel('What will you put on your chain?').fill(payload); - await page.getByRole('button',{name:'Review inscription'}).click(); - await expect(page.locator('#review-text')).toHaveText(payload); - await expect(page.locator('#pending')).toHaveText('0'); - await page.getByRole('button',{name:'Sign & inscribe'}).click(); - await expect(page.locator('#pending')).toHaveText('2'); - await expect(page.locator('#mempool-list .mempool-card')).toHaveCount(2); - await expect(page.locator('#mempool-list')).toContainText('INSCRIPTION COMMIT'); - await expect(page.locator('#mempool-list')).toContainText('INSCRIPTION REVEAL'); - await expect(page.locator('.collectible pre')).toHaveText(payload); - await expect(page.locator('.collectible .tag')).toContainText('PENDING'); - await expect(page.locator('#balance')).toHaveText('49.99999000'); - await page.locator('#mempool-list .mempool-card').last().click(); - await expect(page.locator('#inspector')).toContainText('Pending in mempool'); - await page.reload(); - await expect(page.locator('.collectible pre')).toHaveText(payload); - await expect(page.locator('#pending')).toHaveText('2'); - await expect(page.locator('#mempool-list .mempool-card')).toHaveCount(2); - await page.getByRole('button',{name:'Mine now'}).click(); - await expect(page.locator('#mempool-list')).toContainText('Mempool empty'); - await expect(page.locator('#blocks .block-card')).toHaveCount(2); - await expect(page.locator('#blocks .block-card').first()).toContainText('HEIGHT 2'); - await expect(page.locator('.collectible .tag')).toHaveText('CONFIRMED · BLOCK 2'); - await page.getByRole('link',{name:'View reveal transaction'}).click(); - await expect(page.locator('#inspector')).toContainText('1 confirmation(s)'); - await expect(page.locator('#inspector')).toContainText('Raw Bitcoin transaction'); - await expect(page.locator('#inspector')).toContainText('INSCRIPTION REVEAL'); - await page.getByRole('link',{name:'Inspect commit transaction'}).click(); - await expect(page.locator('#inspector')).toContainText('4,999,998,000 sats'); - await expect(page.locator('#inspector')).toContainText('INSCRIPTION COMMIT'); - await page.getByRole('link',{name:'View block 2'}).click(); - await expect(page.locator('#inspector h2')).toHaveText('Block 2'); - await expect(page.locator('#inspector')).toContainText('Transactions'); - await expect(page.locator('#inspector a')).toHaveCount(4); - await page.getByRole('link',{name:/^Inscription /}).click(); - await expect(page.locator('#inspector')).toContainText('Inscription detail'); - const [download] = await Promise.all([page.waitForEvent('download'),page.getByRole('button',{name:'Save complete lab'}).click()]); - const backupPath=await download.path(); - page.on('dialog',dialog=>dialog.accept()); - await page.getByRole('button',{name:'Reset local lab'}).click(); - await expect(page.getByRole('button',{name:'Create test wallet'})).toBeVisible(); - await page.locator('#load-wallet').setInputFiles(backupPath); - await expect(page.locator('.collectible pre')).toHaveText(payload); - await expect(page.locator('#height')).toHaveText('2'); - await page.reload(); - await expect(page.locator('#balance')).toHaveText('99.99999000'); - expect(await page.evaluate(()=>window.unwanted)).toBeUndefined(); - expect(errors).toEqual([]); expect(remote).toEqual([]); - expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBeTruthy(); - await page.screenshot({path:`test-results/${test.info().project.name}-complete.png`,fullPage:true}); -}); - -for (const colorScheme of ['light', 'dark']) { -test(`dark default and explicit theme persistence with ${colorScheme} system preference`, async ({page}) => { - await page.emulateMedia({colorScheme}); - await page.goto('/'); - await expect(page.locator('html')).toHaveAttribute('data-theme','dark'); - await expect(page.getByRole('button',{name:'Switch to light mode'})).toBeVisible(); - await page.getByRole('button',{name:'Switch to light mode'}).click(); - await expect(page.locator('html')).toHaveAttribute('data-theme','light'); - await page.reload(); - await expect(page.locator('html')).toHaveAttribute('data-theme','light'); - await expect(page.getByRole('button',{name:'Switch to dark mode'})).toBeVisible(); - expect(await page.evaluate(() => localStorage.getItem('super-testnet-theme'))).toBe('light'); - await page.getByRole('button',{name:'Switch to dark mode'}).click(); - await page.reload(); - await expect(page.locator('html')).toHaveAttribute('data-theme','dark'); - expect(await page.evaluate(() => localStorage.getItem('super-testnet-theme'))).toBe('dark'); -}); -} - -test('wallet backup reload and rejected oversized or invalid input',async({page})=>{ - await page.goto('/'); await page.getByRole('button',{name:'Create test wallet'}).click(); - await expect(page.locator('#address')).toHaveValue(/^bcrt1p/); - const address=await page.locator('#address').inputValue(); - const [download]=await Promise.all([page.waitForEvent('download'),page.getByRole('button',{name:'Save test wallet'}).click()]); - await page.getByRole('button',{name:'Mine now'}).click(); - await page.locator('#payload').fill('🌱'.repeat(4097)); - await page.getByRole('button',{name:'Review inscription'}).click(); - await expect(page.getByRole('status')).toContainText('at most 16384'); - await expect(page.locator('#pending')).toHaveText('0'); - await page.locator('#load-wallet').setInputFiles({name:'bad.json',mimeType:'application/json',buffer:Buffer.from('{"network":"mainnet"}')}); - await expect(page.getByRole('status')).toContainText('Only Super Testnet'); - await expect(page.locator('#address')).toHaveValue(address); - page.on('dialog',d=>d.accept()); - await page.locator('#load-wallet').setInputFiles(await download.path()); - await expect(page.getByRole('status')).toContainText('Test wallet loaded.'); - await expect(page.locator('#address')).toHaveValue(address); - await expect(page.locator('#balance')).toHaveText('50.00000000'); -}); - -test('two tabs serialize mining and refresh shared storage',async({page,context})=>{ - await page.goto('/'); await page.getByRole('button',{name:'Create test wallet'}).click(); - await expect(page.locator('#address')).toHaveValue(/^bcrt1p/); - const other=await context.newPage(); await other.goto('/'); - await Promise.all([page.getByRole('button',{name:'Mine now'}).click(),other.getByRole('button',{name:'Mine now'}).click()]); - await expect(page.locator('#height')).toHaveText('2'); - await expect(other.locator('#height')).toHaveText('2'); - await expect(page.locator('#balance')).toHaveText('100.00000000'); -}); - -test('storage failure leaves the displayed and persisted chain unchanged', async ({page}) => { - await page.goto('/'); - await page.getByRole('button',{name:'Create test wallet'}).click(); - await expect(page.locator('#address')).toHaveValue(/^bcrt1p/); - await page.evaluate(()=>{ Storage.prototype.setItem = () => { throw new DOMException('Quota exceeded','QuotaExceededError'); }; }); - await page.getByRole('button',{name:'Mine now'}).click(); - await expect(page.getByRole('status')).toContainText('No changes were saved'); - await expect(page.locator('#height')).toHaveText('0'); - await expect(page.locator('#balance')).toHaveText('0.00000000'); - await page.reload(); - await expect(page.locator('#height')).toHaveText('0'); - await page.getByRole('button',{name:'Mine now'}).click(); - await expect(page.locator('#balance')).toHaveText('50.00000000'); -}); -test('MIME upload, reset only after success, and parent/child navigation', async ({ page }) => { - await page.goto('/'); - await page.locator('#create-wallet').click(); await page.locator('#mine').click(); - await page.locator('#payload').fill('parent'); await page.locator('#review').click(); await page.locator('#sign').click(); - await expect(page.locator('#payload')).toHaveValue(''); - await expect(page.locator('#byte-count')).toHaveText('0 / 16384 bytes'); - const parent = await page.locator('#parent option').nth(1).getAttribute('value'); - await page.locator('#parent').selectOption(parent); - await page.locator('#body-file').setInputFiles({ name: 'art.svg', mimeType: 'image/svg+xml', buffer: Buffer.from('') }); - await page.locator('#review').click(); - await expect(page.locator('#review-preview iframe')).toHaveAttribute('sandbox', ''); - await page.evaluate(() => { window.originalSetItem = Storage.prototype.setItem; Storage.prototype.setItem = () => { throw new Error('quota'); }; }); - await page.locator('#sign').click(); - await expect(page.getByRole('status')).toContainText('No changes were saved'); - await expect(page.locator('#review-panel')).toBeVisible(); - await expect(page.locator('#parent')).toHaveValue(parent); - expect(await page.locator('#body-file').evaluate(e => e.files.length)).toBe(1); - await page.evaluate(() => { Storage.prototype.setItem = window.originalSetItem; }); - await page.locator('#sign').click(); - await expect(page.locator('.collectible')).toHaveCount(2); - await expect(page.locator('#review-panel')).toBeHidden(); - await expect(page.locator('#parent')).toHaveValue(''); - await expect(page.locator('#byte-count')).toHaveText('0 / 16384 bytes'); - expect(await page.locator('#body-file').evaluate(e => e.files.length)).toBe(0); - await page.locator('.collectible').getByRole('link', { name: /^Parent / }).click(); - await expect(page.locator('#inspector pre').first()).toHaveText('parent'); - await page.locator('#inspector').getByRole('link', { name: /^Child / }).click(); - await expect(page.locator('#inspector')).toContainText('image/svg+xml'); - await page.reload(); - await expect(page.locator('#inspector iframe')).toBeVisible(); -}); - -test('HTML preview cannot execute, navigate or fetch remote resources', async ({ page }) => { - const remote = []; - page.on('request', r => { if (/^https?:/.test(r.url()) && !r.url().startsWith('http://127.0.0.1:4178')) remote.push(r); }); - await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click(); - await page.locator('#mime-type').selectOption('text/html'); - await page.locator('#payload').fill('

      safe words

      link'); - await page.locator('#review').click(); - await expect(page.frameLocator('#review-preview iframe').locator('p')).toHaveText('safe words'); - await page.locator('#sign').click(); - await expect(page.frameLocator('.collectible iframe').locator('p')).toHaveText('safe words'); - expect(await page.evaluate(() => window.unwanted)).toBeUndefined(); - for (const request of remote) await expect.poll(() => request.failure()?.errorText).toMatch(/csp|ERR_BLOCKED_BY_CSP/i); - await expect(page).toHaveURL('http://127.0.0.1:4178/'); -}); - -test('metadata review validation, witness persistence, indexes/reducer and local API explorer', async ({page,context}) => { - await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click(); - await page.locator('#payload').fill('independent body'); - await page.locator('#metadata').fill('{bad'); await page.locator('#review').click(); - await expect(page.getByRole('status')).toContainText('Invalid metadata JSON'); - await expect(page.locator('#review-panel')).toBeHidden(); - await page.locator('#metadata').fill(JSON.stringify({large:'x'.repeat(2048)})); await page.locator('#review').click(); - await expect(page.getByRole('status')).toContainText('2048 CBOR'); - await expect(page.locator('#pending')).toHaveText('0'); - const metadata={protocol:'example',note:'chunked '+ 'x'.repeat(600),nested:{data:true},code:'window.unwanted=true'}; - await page.locator('#metadata').fill(JSON.stringify(metadata)); - await page.locator('#review').click(); - await expect(page.locator('#review-metadata')).toContainText('CBOR, tag 5'); - await page.locator('#sign').click(); - await expect(page.locator('#metadata')).toHaveValue(''); - await expect(page.locator('#metadata-count')).toContainText('0 / 2048'); - await expect(page.locator('.metadata-document')).toContainText('example'); - await page.reload(); - await expect(page.locator('.metadata-document')).toContainText('chunked'); - await page.getByRole('link',{name:/^Inscription /}).click(); - await expect(page.locator('#inspector .metadata-document')).toContainText('example'); - await expect(page.locator('#index-stats')).toContainText('1 events · 1 with metadata · 0 confirmed · 1 pending · 1 protocols'); - await page.locator('#protocol').selectOption('"example"'); - await expect(page.locator('#protocol-events')).not.toContainText('independent'); // Body is not part of metadata events. - await page.locator('#reducer').selectOption('event-counter'); - await expect(page.locator('#derived-state')).toContainText('"count": 1'); - await expect(page.locator('#protocol-events')).toContainText('"status": "pending"'); - await page.locator('#mine').click(); - await expect(page.locator('#protocol-events')).toContainText('"status": "confirmed"'); - await expect(page.locator('#derived-state')).toContainText('"confirmed": 1'); - await expect(page.locator('#api-explorer')).toContainText('LOCAL / IN-BROWSER'); - await page.locator('#api-preset').selectOption('/r/inscription/:id'); await page.locator('#api-try').click(); - await expect(page.locator('#api-response')).toContainText('"protocol": "example"'); - await page.locator('#api-preset').selectOption('/content/:id'); await page.locator('#api-try').click(); - await expect(page.locator('#api-response')).toContainText('independent body'); - await page.locator('#api-path').fill('/r/blockheight'); await page.locator('#api-try').click(); - await expect(page.locator('#api-response')).toContainText('"body": 2'); - await context.grantPermissions(['clipboard-read','clipboard-write']); - await page.locator('#api-copy').click(); - expect(await page.evaluate(()=>navigator.clipboard.readText())).toContain('"body": 2'); - await page.locator('#api-path').fill('/missing'); await page.locator('#api-try').click(); - await expect(page.locator('#api-response')).toContainText('404'); - expect(await page.evaluate(()=>window.unwanted)).toBeUndefined(); - expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBeTruthy(); -}); - -test('auto miner default, preference reload, countdown, pending confirmation, manual reset and OFF', async ({page}) => { - await page.clock.install(); - await page.goto('/'); await page.locator('#create-wallet').click(); - await expect(page.locator('#miner-interval')).toHaveValue('0'); - await expect(page.locator('#miner-status')).toHaveText('Auto mining OFF'); - await page.locator('#miner-interval').selectOption('10'); - await expect(page.locator('#miner-status')).toContainText('10s interval'); - await page.clock.runFor(10000); - await expect(page.locator('#height')).toHaveText('1'); - await page.locator('#payload').fill('auto confirmed'); await page.locator('#review').click(); await page.locator('#sign').click(); - await expect(page.locator('#pending')).toHaveText('2'); - await page.clock.runFor(10000); - await expect(page.locator('#height')).toHaveText('2'); await expect(page.locator('#pending')).toHaveText('0'); - await page.locator('#miner-interval').selectOption('30'); - await page.reload(); await expect(page.locator('#miner-interval')).toHaveValue('30'); - await page.locator('#miner-interval').selectOption('60'); - await page.clock.runFor(5000); await expect(page.locator('#miner-status')).toContainText('55s'); - await page.locator('#mine').click(); await expect(page.locator('#height')).toHaveText('3'); - await expect(page.locator('#miner-status')).toContainText('60s'); - await page.locator('#miner-interval').selectOption('0'); - await page.clock.runFor(65000); await expect(page.locator('#height')).toHaveText('3'); -}); - -test('two tabs race one auto deadline under Web Locks', async ({page,context}) => { - const now = new Date('2026-10-05T12:00:00Z'); - await page.clock.install({time:now}); await page.goto('/'); await page.locator('#create-wallet').click(); - const other=await context.newPage(); await other.clock.install({time:now}); await other.goto('/'); - await page.locator('#miner-interval').selectOption('10'); - await expect(other.locator('#miner-interval')).toHaveValue('10'); - await Promise.all([page.clock.runFor(10000),other.clock.runFor(10000)]); - await expect(page.locator('#height')).toHaveText('1'); await expect(other.locator('#height')).toHaveText('1'); - await page.locator('#miner-interval').selectOption('0'); - await expect(other.locator('#miner-interval')).toHaveValue('0'); -}); - -test('recursive local fetch, delegate identity, external blocking and worker DOM isolation', async ({page}) => { - const remote=[]; - page.on('request',r=>{if(/^https?:/.test(r.url()) && !r.url().startsWith('http://127.0.0.1:4178')) remote.push(r);}); - await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click(); - await page.locator('#payload').fill('local recursive body'); await page.locator('#review').click(); await page.locator('#sign').click(); - const content=await page.locator('#parent option').nth(1).getAttribute('value'); - await page.locator('#mime-type').selectOption('text/html'); - await page.locator('#metadata').fill('{"protocol":"recursive","name":"source"}'); - await page.locator('#payload').fill(`

      Isolated recursion

      `); - await page.locator('#review').click(); await page.locator('#sign').click(); - const source=await page.locator('#parent option').nth(2).getAttribute('value'); - const output=page.frameLocator('.collectible iframe').locator('#recursive-output'); - await expect(output).toContainText('local recursive body'); - await expect(output).toContainText('"height":1'); - await expect(output).toContainText('"external":"blocked"'); - await expect(output).toContainText('"dom":"blocked"'); - await expect(output).toContainText('"xhr":"blocked"'); - await expect(output).toContainText('"import":"blocked"'); - await expect(output).toContainText(source); - await page.locator('#mime-type').selectOption('text/plain'); await page.locator('#payload').fill('delegating'); - await page.locator('#delegate').fill(source); await page.locator('#metadata').fill('{"name":"requested"}'); - await page.locator('#review').click(); await page.locator('#sign').click(); - const delegated=await page.locator('#parent option').nth(3).getAttribute('value'); - const delegatedOutput=page.frameLocator('.collectible:first-child iframe').locator('#recursive-output'); - await expect(delegatedOutput).toContainText(delegated); - await expect(delegatedOutput).toContainText('a1646e616d6569726571756573746564'); - await expect(page.locator('.collectible:first-child')).toContainText('Delegate'); - await expect(page.locator('.collectible').nth(1)).toContainText('Delegated by'); - // Chromium emits request events for CSP-denied XHR attempts; none reached the network. - for (const request of remote) expect(request.failure()?.errorText).toMatch(/csp|ERR_BLOCKED_BY_CSP/i); - await expect(page.locator('h1')).toContainText('Bitcoin universe'); -}); - -test('recursive template and reinscription stack persist across mining and reload', async ({page}) => { - await page.goto('/'); await page.locator('#create-wallet').click(); await page.locator('#mine').click(); - await page.locator('#recursive-demo').click(); await page.locator('#review').click(); await page.locator('#sign').click(); - const id=await page.locator('#parent option').nth(1).getAttribute('value'); - await expect(page.frameLocator('.collectible iframe').locator('#recursive-output')).toContainText(id); - await page.locator('#reinscribe').selectOption(id); await page.locator('#mime-type').selectOption('text/plain'); - await page.locator('#payload').fill('second on same sat'); await page.locator('#review').click(); - await expect(page.locator('#review-parent')).toContainText(`Reinscribe: ${id}`); - await page.locator('#sign').click(); - await expect(page.locator('.collectible')).toHaveCount(2); - await expect(page.locator('.collectible').first()).toContainText('stack position 1'); - await expect(page.locator('.collectible').last()).toContainText('stack position 0'); - await expect(page.locator('.collectible').first().getByRole('link',{name:/Sat history/})).toHaveCount(2); - await page.locator('#mine').click(); await page.reload(); - await expect(page.locator('.collectible')).toHaveCount(2); - await expect(page.frameLocator('.collectible iframe').locator('#recursive-output')).toContainText('"height": 2'); -}); diff --git a/tests/lab.test.js b/tests/lab.test.js new file mode 100644 index 0000000..12176cf --- /dev/null +++ b/tests/lab.test.js @@ -0,0 +1,115 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { MetadataIndex } from '../src/adapters/metadata-index.js'; +import { lineageEvents, reduce } from '../src/orgchain/lineage.js'; +import { LAB_KEY, decodeLab, emptyLab, encodeLab, mutate, setTempo, tick } from '../src/orgchain/store.js'; +import { SAMPLE, act, seedSample } from '../src/orgchain/actions.js'; + +const state = (lab, options = { pending: true }) => reduce(lineageEvents(new MetadataIndex(lab.chain)), options); + +test('at the default tempo each act is its own beat, and the first act funds the lab', () => { + const lab = emptyLab(); + const root = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', root, { name: 'ops' }); + assert.equal(lab.chain.blocks.length, 3); // funding, founding, birth + const s = state(lab, {}); + assert.deepEqual([root, ops].map(id => s.nodes.get(id).born), [2, 3]); + assert.equal(lab.chain.mempool.length, 0); + const record = lab.chain.transactions.get(ops.slice(0, -2)).inscription; + assert.equal(record.text, 'birth ops'); + assert.equal(record.metaprotocol, 'lineage/v0'); + assert.equal(record.parent, root); +}); + +test('a timed tempo cues acts, chains cued spends of one parent, and beats once when due', () => { + const lab = emptyLab(); + const root = act(lab, 'found', null, { name: 'acme' }); + setTempo(lab, 10, 1000); + const a = act(lab, 'birth', root, { name: 'a' }, 2000), b = act(lab, 'birth', root, { name: 'b' }, 3000); + act(lab, 'birth', a, { name: 'c', kind: 'agent' }, 4000); + assert.deepEqual(lab.clock, { tempo: 10, nextAt: 12000 }); + assert.equal(state(lab, {}).nodes.has(a), false); + assert.equal(state(lab).nodes.get(b).born, 'cued'); + assert.equal(tick(lab, 11999), false); + assert.equal(tick(lab, 12000), true); + assert.deepEqual(lab.clock, { tempo: 10, nextAt: null }); + assert.deepEqual([a, b].map(id => state(lab, {}).nodes.get(id).born), [3, 3]); + assert.equal(tick(lab, 99999), false); // nothing cued: no empty beats +}); + +test('manual waits for a beat; switching back to each lands the cue', () => { + const lab = emptyLab(); + const root = act(lab, 'found', null, { name: 'acme' }); + setTempo(lab, 'manual'); + act(lab, 'decide', root, { title: 'Ship weekly', why: '' }); + assert.equal(tick(lab, Date.now() + 1e9), false); + assert.equal(lab.chain.mempool.length, 2); + setTempo(lab, 'each'); + assert.equal(lab.chain.mempool.length, 0); + assert.deepEqual(state(lab, {}).nodes.get(root).decisions.map(d => [d.title, d.why]), [['Ship weekly', '']]); +}); + +test('the reducer vets an act before anything is signed', () => { + const lab = emptyLab(); + const root = act(lab, 'found', null, { name: 'acme' }), bot = act(lab, 'birth', root, { name: 'bot', kind: 'agent' }); + const before = encodeLab(lab); + assert.throws(() => act(lab, 'birth', bot, { name: 'x' }), /^Error: Agents do not give birth\.$/); + assert.throws(() => act(lab, 'found', null, { name: 'acme' }), /already named acme/); + assert.throws(() => act(lab, 'award', root, { to: bot, skill: 'launch', belt: 'gold' }), /belt/); + assert.throws(() => act(lab, 'graduate', root, { of: bot }), /Only orgs graduate/); + assert.throws(() => act(lab, 'sing', root, {}), /Unknown verb/); + assert.equal(encodeLab(lab), before); +}); + +test('closing spends the node; derived children close with it and graduated ones stay open', () => { + const lab = emptyLab(); + const root = act(lab, 'found', null, { name: 'acme' }), studio = act(lab, 'birth', root, { name: 'studio' }); + const kid = act(lab, 'birth', studio, { name: 'kid' }), grad = act(lab, 'birth', studio, { name: 'grad' }); + act(lab, 'graduate', studio, { of: grad }); + const close = act(lab, 'close', studio, {}); + const s = state(lab, {}); + assert.deepEqual([studio, kid, grad].map(id => s.nodes.get(id).status), ['closed', 'closed', 'active']); + assert.equal(s.nodes.get(grad).edge, 'adopted'); + assert.equal(lab.chain.transactions.get(close.slice(0, -2)).inscription.parent, studio); + assert.throws(() => act(lab, 'birth', studio, { name: 'again' }), /parent is closed/); + act(lab, 'decide', grad, { title: 'Carry on' }); +}); + +test('the yolopreneur sample is six beats of the drawing in docs/lineage.md', () => { + const lab = emptyLab(); + const root = seedSample(lab); + assert.equal(lab.chain.blocks.length, 7); // funding + six + const s = state(lab, {}), named = name => [...s.nodes.values()].find(x => x.name === name); + assert.ok(s.log.every(e => e.ok)); + assert.equal(s.nodes.get(root).name, SAMPLE); + assert.deepEqual([...s.nodes.values()].filter(x => x.root === root).length, 12); + assert.equal(named('lineage').edge, 'adopted'); + assert.deepEqual(named('sol').badges.map(b => `${b.skill}:${b.belt}`), ['launch:green']); + assert.deepEqual(named('time').children.map(id => s.nodes.get(id).name), ['luna', 'sol', 'astra', 'docscope', 'dollhouse', 'doodleloop', 'hearthframe']); + assert.deepEqual([2, 3, 4, 5].map(upTo => state(lab, { upTo }).nodes.size), [1, 3, 8, 12]); + assert.throws(() => seedSample(lab), /already named yolopreneur/); +}); + +test('labs round-trip, refuse foreign backups, and share one lock across tabs', async () => { + const lab = emptyLab(); + act(lab, 'found', null, { name: 'acme' }); + setTempo(lab, 30, 1000); + const copy = decodeLab(encodeLab(lab)); + assert.deepEqual(copy.clock, { tempo: 30, nextAt: null }); + assert.equal(copy.chain.blocks.length, 2); + assert.equal(copy.wallet.secret, lab.wallet.secret); + assert.throws(() => decodeLab(JSON.stringify({ kind: 'test-lab', network: 'super-testnet-local-v0', version: 1 })), /not an Orgchain v1 lab/); + assert.throws(() => decodeLab('{bad')); + assert.throws(() => decodeLab(encodeLab(lab).replace('"tempo":30', '"tempo":15')), /Invalid clock/); + let stored = encodeLab(emptyLab()), tail = Promise.resolve(), writes = 0; + const names = new Set(); + const io = { read: () => decodeLab(stored), save: l => { stored = encodeLab(l); writes++; }, + locks: { request(name, fn) { names.add(name); const next = tail.then(fn); tail = next.catch(() => {}); return next; } } }; + await Promise.all([mutate(l => act(l, 'found', null, { name: 'one' }), io), mutate(l => act(l, 'found', null, { name: 'two' }), io)]); + assert.deepEqual([...names], [LAB_KEY]); + assert.deepEqual(state(decodeLab(stored)).roots.map(r => r.name), ['one', 'two']); + const { result } = await mutate(l => tick(l), io); + assert.equal(result, false); + assert.equal(writes, 2); + await assert.rejects(mutate(l => act(l, 'found', null, { name: 'one' }), io), /already named one/); + assert.equal(writes, 2); +}); diff --git a/tests/lineage.test.js b/tests/lineage.test.js new file mode 100644 index 0000000..f8a5289 --- /dev/null +++ b/tests/lineage.test.js @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PROTOCOL, census, reduce, rows, slug, summary, trail } from '../src/orgchain/lineage.js'; + +let n = 0; +const ev = (op, parent = null, meta = {}, height = 1) => ({ id: `${String(++n).padStart(64, '0')}i0`, status: height === null ? 'pending' : 'confirmed', + height, metadata: { op, ...meta }, parent, protocol: PROTOCOL }); +const draw = (s, root) => rows(s, root).map(r => r.guide + (r.type === 'group' ? r.label : `${r.path} ${r.node.name}`)); + +test('found and birth build the tree; orgs and agents are indexed separately', () => { + const root = ev('found', null, { name: 'yolopreneur' }), wk = ev('birth', root.id, { name: 'well-known' }, 2), + time = ev('birth', root.id, { name: 'time', kind: 'org' }, 2), sol = ev('birth', time.id, { name: 'sol', kind: 'agent' }, 3), + doc = ev('birth', time.id, { name: 'docscope' }, 3); + const s = reduce([root, wk, time, sol, doc]); + assert.ok(s.log.every(e => e.ok)); + assert.deepEqual([time, sol, doc].map(e => s.nodes.get(e.id).index), [1, 0, 0]); + assert.equal(s.nodes.get(sol.id).edge, 'derived'); + assert.equal(s.nodes.get(root.id).kind, 'holding'); + assert.deepEqual(draw(s, root.id), ['● yolopreneur', '└─ orgs', " ├─ 0' well-known", " └─ 1' time", + ' ├─ agents', " │ └─ 0' sol", ' └─ orgs', " └─ 0' docscope"]); + assert.equal(trail(s, sol.id), 'yolopreneur / time / sol'); + assert.deepEqual(census(s, root.id), { orgs: 3, agents: 1, belts: 0, decisions: 0, closed: 0 }); + assert.equal(summary(s.log[3], s), 'birth sol (agent) ← time'); +}); + +test('every rule rejects with a reason, and rejected events change nothing', () => { + const root = ev('found', null, { name: 'acme' }), bot = ev('birth', root.id, { name: 'bot', kind: 'agent' }), + ops = ev('birth', root.id, { name: 'ops' }), other = ev('found', null, { name: 'other' }); + const bad = [ + [ev('found', root.id, { name: 'x' }), /no parent/], [ev('found', null, { name: 'Acme Co' }), /lowercase/], + [ev('found', null, { name: 'acme' }), /already named acme/], [ev('birth', 'f'.repeat(64) + 'i0', { name: 'x' }), /not in this lineage/], + [ev('birth', bot.id, { name: 'x' }), /agents do not give birth/], [ev('birth', root.id, { name: 'ops' }), /already has an active ops/], + [ev('award', bot.id, { to: ops.id, skill: 'launch', belt: 'green' }), /agents do not award/], + [ev('award', root.id, { to: other.id, skill: 'launch', belt: 'green' }), /another node in this holding/], + [ev('award', root.id, { to: root.id, skill: 'launch', belt: 'green' }), /another node/], + [ev('award', root.id, { to: bot.id, skill: 'launch', belt: 'gold' }), /belt/], [ev('decide', root.id, { title: ' ' }), /title/], + [ev('graduate', ops.id, { of: bot.id }), /only its parent/], [ev('graduate', root.id, { of: bot.id }), /only orgs graduate/], + [ev('close'), /spends the node/], [ev('nope', root.id), /not a lineage\/v0 op/], + ]; + const s = reduce([root, bot, ops, other, ...bad.map(([e]) => e)]); + bad.forEach(([e, why]) => { const entry = s.log.find(x => x.id === e.id); assert.equal(entry.ok, false, e.metadata.op); assert.match(entry.why, why); }); + assert.equal(s.nodes.size, 4); + assert.match(summary(s.log.find(x => x.id === bad[4][0].id), s), /^birth: agents do not give birth$/); +}); + +test('graduation flips the edge; closing cascades through derived children only', () => { + const root = ev('found', null, { name: 'acme' }), lab = ev('birth', root.id, { name: 'lab' }), kid = ev('birth', lab.id, { name: 'kid' }), + seat = ev('birth', kid.id, { name: 'seat', kind: 'agent' }), grad = ev('birth', lab.id, { name: 'grad' }), + up = ev('graduate', lab.id, { of: grad.id }, 2), again = ev('graduate', lab.id, { of: grad.id }, 2), + award = ev('award', lab.id, { to: seat.id, skill: 'launch', belt: 'green' }, 2), close = ev('close', lab.id, {}, 3), + after = ev('birth', lab.id, { name: 'late' }, 4), still = ev('decide', grad.id, { title: 'Carry on', why: 'adopted' }, 4); + const s = reduce([root, lab, kid, seat, grad, up, again, award, close, after, still]); + assert.equal(s.nodes.get(grad.id).edge, 'adopted'); + assert.equal(s.nodes.get(grad.id).graduated, 2); + assert.match(s.log.find(e => e.id === again.id).why, /already graduated/); + assert.deepEqual(s.nodes.get(seat.id).badges.map(b => `${b.skill}:${b.belt}`), ['launch:green']); + assert.deepEqual([lab, kid, seat, grad].map(e => s.nodes.get(e.id).status), ['closed', 'closed', 'closed', 'active']); + assert.deepEqual(s.log.find(e => e.id === close.id).also, [kid.id, seat.id]); + assert.equal(summary(s.log.find(e => e.id === close.id), s), 'close lab and 2 derived'); + assert.match(s.log.find(e => e.id === after.id).why, /parent is closed/); + assert.equal(s.nodes.get(grad.id).decisions[0].title, 'Carry on'); + assert.deepEqual(census(s, root.id), { orgs: 3, agents: 1, belts: 1, decisions: 1, closed: 3 }); +}); + +test('upTo shows a past beat; pending adds cued events', () => { + const root = ev('found', null, { name: 'acme' }, 1), a = ev('birth', root.id, { name: 'a' }, 2), b = ev('birth', root.id, { name: 'b' }, null), + award = ev('award', root.id, { to: a.id, skill: 'post', belt: 'white' }, null); + assert.equal(reduce([root, a, b, award], { upTo: 1 }).nodes.size, 1); + assert.equal(reduce([root, a, b, award]).nodes.size, 2); + const live = reduce([root, a, b, award], { pending: true }); + assert.equal(live.nodes.get(b.id).born, 'cued'); + assert.equal(live.nodes.get(a.id).badges[0].beat, 'cued'); + assert.equal(live.log.filter(e => e.pending).length, 2); +}); + +test('summaries and slugs', () => { + const root = ev('found', null, { name: 'acme' }), ops = ev('birth', root.id, { name: 'ops' }), seat = ev('birth', root.id, { name: 'sol', kind: 'agent' }); + const more = [ev('award', root.id, { to: seat.id, skill: 'launch', belt: 'green' }), ev('decide', ops.id, { title: 'Ship weekly' }), ev('graduate', root.id, { of: ops.id })]; + const s = reduce([root, ops, seat, ...more]); + assert.deepEqual(s.log.map(e => summary(e, s)), ['found acme', 'birth ops ← acme', 'birth sol (agent) ← acme', + 'award launch · green → sol, from acme', 'decide “Ship weekly” · ops', 'graduate ops from acme']); + assert.deepEqual(['Acme Holdings LLC', ' --Zoë_lab-- ', 'a'.repeat(50), '!!!'].map(slug), ['acme-holdings-llc', 'zoe-lab', 'a'.repeat(40), '']); +}); diff --git a/tests/orgchain.spec.js b/tests/orgchain.spec.js new file mode 100644 index 0000000..94bd562 --- /dev/null +++ b/tests/orgchain.spec.js @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; + +async function open(page) { + const problems = []; + page.on('console', m => { if (m.type() === 'error') problems.push(m.text()); }); + page.on('pageerror', e => problems.push(String(e))); + await page.goto('./'); + await expect(page.getByRole('heading', { name: 'Orgchain' })).toBeVisible(); + return problems; +} +const say = page => page.locator('#say'); +const row = (page, name) => page.locator('#tree-rows .sel').filter({ has: page.locator('.l', { hasText: new RegExp(`^${name}$`) }) }); +const verb = (page, name) => page.locator(`[data-verb="${name}"]`).click(); +const submit = (page, form) => page.locator(`#f-${form} [type="submit"]`).click(); +async function found(page, name) { await page.locator('#found-name').fill(name); await submit(page, 'found'); } +async function birth(page, name, kind = 'org') { + await verb(page, 'birth'); + await page.locator('#birth-name').fill(name); + await page.locator(`#f-birth input[value="${kind}"]`).check(); + await submit(page, 'birth'); +} +const slide = (page, beat) => page.locator('#beat').evaluate((el, v) => { el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); }, String(beat)); + +test('found a holding, then birth, award, decide, graduate and close at the default tempo', async ({ page }) => { + const problems = await open(page); + await found(page, 'Acme Holdings'); + await expect(say(page)).toHaveText('acme-holdings is founded, at beat 2.'); + await birth(page, 'studio'); + await expect(say(page)).toHaveText('studio is born to acme-holdings, at beat 3.'); + await row(page, 'studio').click(); + await birth(page, 'bot', 'agent'); + await expect(say(page)).toHaveText('bot is born to studio, at beat 4.'); + await verb(page, 'award'); + await page.locator('#award-to').selectOption({ label: 'acme-holdings / studio / bot (agent)' }); + await page.locator('#award-skill').fill('launch'); + await page.locator('#award-belt').selectOption('green'); + await submit(page, 'award'); + await expect(say(page)).toHaveText('bot holds a green belt in launch, at beat 5.'); + await verb(page, 'decide'); + await page.locator('#decide-title').fill('Ship weekly'); + await submit(page, 'decide'); + await expect(say(page)).toHaveText('studio decided “Ship weekly”, at beat 6.'); + await verb(page, 'graduate'); + await page.locator('#graduate-go').click(); + await expect(say(page)).toHaveText('studio graduates from acme-holdings, at beat 7. Its edge is adopted now.'); + await expect(row(page, 'studio')).toContainText('graduated'); + await row(page, 'acme-holdings').click(); + await verb(page, 'close'); + await page.locator('#close-go').click(); + await expect(page.locator('#close-go')).toHaveText('Yes, close acme-holdings'); + await page.locator('#close-go').click(); + await expect(say(page)).toHaveText('acme-holdings is closed, at beat 8.'); + await expect(row(page, 'acme-holdings')).toContainText('closed'); + await expect(row(page, 'studio')).not.toContainText('closed'); // graduated: it holds its own root + await row(page, 'bot').click(); + await verb(page, 'birth'); + await expect(page.locator('#act-why')).toHaveText('Agents do not give birth.'); + await page.locator('#timeline-list .ev', { hasText: 'Ship weekly' }).click(); + await expect(page.locator('#inspect-body')).toContainText('"op": "decide"'); + await expect(page.locator('#inspect-body')).toContainText('applied'); + expect(problems).toEqual([]); +}); + +test('a timed tempo cues acts; Beat now and the clock land them', async ({ page }) => { + const problems = await open(page); + await found(page, 'acme'); + await page.locator('[data-tempo="10"]').click(); + await birth(page, 'a'); + await expect(say(page)).toHaveText('a is born to acme, cued for the next beat.'); + await birth(page, 'b'); + await expect(page.locator('#clock-cued')).toContainText('2 cued · next beat in'); + await expect(row(page, 'b')).toContainText('cued'); + await page.locator('#beat-now').click(); + await expect(say(page)).toHaveText('Beat 3: 2 cued acts landed.'); + await birth(page, 'c'); + await expect(say(page)).toHaveText('Beat 4: 1 cued act landed.', { timeout: 15_000 }); + await page.locator('[data-tempo="manual"]').click(); + await birth(page, 'd'); + await expect(page.locator('#clock-cued')).toHaveText('1 cued · waiting for Beat now'); + await page.locator('[data-tempo="each"]').click(); + await expect(page.locator('#clock-cued')).toHaveText('nothing cued'); + await expect(page.locator('#clock-beat')).toHaveText('5'); + expect(problems).toEqual([]); +}); + +test('the sample replays, any beat can be inspected, and the lab survives a reload', async ({ page }) => { + const problems = await open(page); + await page.locator('#sample').click(); + await expect(say(page)).toContainText('Loaded the yolopreneur sample', { timeout: 20_000 }); + await expect(page.locator('#tree-rows .sel')).toHaveCount(12); + await expect(page.locator('#beat-label')).toHaveText('Now · beat 7'); + await expect(row(page, 'lineage')).toContainText('graduated'); + await expect(page.locator('#sample')).toBeDisabled(); + await slide(page, 3); + await expect(page.locator('#beat-label')).toHaveText('Beat 3 of 7'); + await expect(page.locator('#tree-rows .sel')).toHaveCount(3); + await expect(page.locator('#act-past')).toBeVisible(); + await page.locator('#replay').click(); + await expect(page.locator('#beat-label')).toHaveText('Now · beat 7', { timeout: 15_000 }); + await page.locator('#timeline-list .ev', { hasText: 'graduate lineage from well-known' }).click(); + await expect(page.locator('#inspect-body')).toContainText('"op": "graduate"'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.reload(); + await expect(page.locator('#tree-rows .sel')).toHaveCount(12); + await page.locator('#theme').click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + await page.locator('#theme').click(); + await page.reload(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expect(page.locator('#theme')).toHaveText('Theme: dark'); + await page.locator('#reset').click(); + await page.locator('#confirm button', { hasText: 'Erase' }).click(); + await expect(say(page)).toHaveText('Erased. This is a fresh lab.'); + await expect(page.locator('#holdings-list li')).toHaveCount(0); + expect(problems).toEqual([]); +});