<!-- MIT License. Copyright (c) 2026 Decentralized Science Labs LLC. -->
<!-- summary: The rules and modules of COLLAB's static web app: layout, URLs, the portal shell, design system, core modules, views and builders. -->
# The web app

COLLAB's interface is a static site: HTML, CSS and ES modules served as files. There is no
bundler, no framework, no compile step and no Node.js anywhere in the toolchain; the release build
only versions asset URLs and precompresses files. The browser talks only to the GenesisL1 RPC
endpoint the visitor uses, to the visitor's wallet, to the site's own files (`/config.js`, `/data/`,
`/og/`) and, when the visitor chooses a display currency, to the price API. This page is for
developers who change the app: it is the contract every page and view follows.

## Layout

```
web/
  index.html daos.html dao.html votes.html tokens.html create.html descifi.html docs.html admin.html
  settings.html privacy.html 404.html   entries, one page module each (see "URLs"); 404.html runs
                            js/fallback.js. Every entry carries the portal shell's static markup
                            between <!-- shell:top --> and <!-- shell:foot --> markers, written by
                            tools/shell_html.py (see "The portal shell")
  pools/index.html locks/index.html   copies of descifi.html with their own head
                            (python3 tools/prerender.py --static-pages)
  docs.html docs/           the documentation, generated from docs/*.md by tools/build_docs.py: the
                            home, docs/<slug>/index.html, docs/<slug>.md, index.json, search.json;
                            llms.txt beside them (see "The docs pages")
  config.js                 generated per deployment (tools/write_web_config.py); config.local.js for dev
  BUILD.json                in a release tree only: the release and its ?v= (tools/version_assets.py)
  _headers _redirects       headers and rewrites for Netlify and Cloudflare Pages (_redirects also for IPFS
                            gateways; deploy/ has nginx and Caddy)
  robots.txt sitemap.xml manifest.webmanifest favicon.svg
  assets/css/app.css        the design system, shared components and every area's styles (see "Design system")
  assets/css/deploy.css     the deploy flow (the admin page's Deploy and Check tabs)
  assets/img/               icons.svg (the sprite, see "Icons"), logo.svg, og.png and the app icons
  assets/fonts/             Inter, JetBrains Mono, Source Serif 4 (WOFF2) with their OFL texts
  assets/deploy/collab-protocol.json   the deploy bundle (tools/export_deploy_artifacts.py)
  assets/docs/              the documentation's figures, WebP (tools/docs_figures.py)
  js/theme.js               classic script in <head>: theme before first paint, settings of 1.0 migrated
  js/consent.js             cookie notice, and Google Analytics only after consent where configured
  js/fallback.js            on 404.html: sends a clean path to its entry on hosts without rewrites
  js/vendor/                ethers 6.17.0 (ESM), libsodium.js 0.8.2 (sodium-0.8.2.js), ML-KEM-768 (mlkem-0.0.7.js),
                            markdown-it 14; each file carries its version in its name, so a new version gets a new name
  js/core/                  shared modules (below). Owned by the core team; do not fork them.
  js/pages/<page>.js        page entry points
  js/views/dao-<view>.js    DAO sections, lazily loaded by js/pages/dao.js
  js/views/new/<kind>.js    proposal builders, lazily loaded by js/views/dao-new.js
  js/views/profile-editors.js  the logo and description editors (wizard, profile builders, representatives)
  js/create/                the creation wizard: its steps, draft, presets and review
  js/descifi/               DeSciFi and the tokens pages: chain data, the market, swap, pools, locks, token list and page
  js/sealed/                sealed data: browser cryptography compatible with CIPNFT, keys, the vault's operations
  js/admin/                 /admin: the Admin API client and session (api.js, session.js), sign-in (signin.js), the
                            frame and tabs (app.js, parts.js, status.js, publish.js, site.js with sitecheck.js and lookup.js)
  js/deploy/                the deploy flow (flow.js) and Check (check.js), loaded by the admin page's Deploy and Check tabs
```

The admin page's API is documented in [Admin API reference](../docs/admin-api.md), and the formats
of config.js, site.json and the /data/ files in [Data formats](../docs/data-formats.md).

## Non-negotiable rules

Security
- Never use `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write` or string HTML. Build DOM
  with `h()` / `s()` from `core/dom.js`. Every string from the chain (titles, descriptions, token
  names and symbols, constitutions, links, model metadata) is attacker-controlled. Clean names and
  symbols with `cleanText()` from `core/tokens.js` (it counts characters as code points, like
  `clean_text()` in tools/seo/text.py, and removes the same invisible, blank and bidi characters,
  astral ones included: change both together). Show a DAO by `dao.name`, never `dao.title` (see
  daodata.js). An element that shows a name or title from the chain gets `dir: "auto"`; inside a
  sentence, wrap the name in `h("bdi", {}, name)`, so a right-to-left name never reorders the text
  around it.
- Chain strings need not be valid UTF-8 (older contracts, other tokens and NFTs). Every interface
  from `core/contracts.js` (`iface()`, `contract()`, `read.*`) decodes them with U+FFFD for the bad
  bytes instead of failing the whole call; an ABI you write yourself goes through
  `lenientInterface([...])`, not `new ethers.Interface`.
- Markdown goes through `renderMarkdown()` (`core/md.js`: markdown-it tokens rendered to DOM nodes,
  links filtered), inside a `.doc` container.
- URLs go through `safeUrl()`; external links get `target="_blank" rel="noopener noreferrer"`.
  User-supplied links additionally get `nofollow`.
- Images from the chain are shown only as `data:` URLs produced by `readImageUrl()` (hash-verified,
  MIME allowlist) inside `<img>`; SVG never goes into the DOM as markup.
- The Content-Security-Policy forbids inline scripts and inline style attributes. Set styles with the
  `style: {…}` attribute of `h()` (CSSOM, allowed) or with classes. No `eval`, no `new Function`.
- No new third-party code. If something is missing, write it. No CDN, no remote fonts. Analytics
  exist only as `js/consent.js` loads them: in deployments that configure an ID, after Accept.
- Amounts are `BigInt` in base units end to end. Parse input with `parseUnits()` / the form fields
  in `core/forms.js`; format with `units()` / `l1()` / `money()`. Never use floating point for
  amounts sent on-chain (fiat values from `core/price.js` are for display only). Read `decimals`
  from token metadata; never assume 18 for ERC-20 tokens.
- Addresses are validated and checksummed with `ethers.getAddress()` (`addressField` does it).
- Every transaction goes through `send()` / `sendValue()` from `core/wallet.js`: they preflight with
  `estimateGas` (reverts are explained before the wallet opens), show progress toasts and
  invalidate caches. Never call `signer.sendTransaction` directly.
- Irreversible or dangerous user actions (giving up token powers, deleting a model, revealing data,
  declassifying, publishing keys) require `confirmDialog()` with plain consequences.
- Browser storage only for conveniences, under `collab-*` keys, always wrapped in try/catch (use
  `readRaw` / `writeRaw` / `readJSON` / `writeJSON` from `core/settings.js`), never trusted without
  validation. Add every new key to `STORAGE_KEYS` there: /privacy lists them. Secrets (derived
  encryption keys) live in memory only and are dropped on account change or page unload.

Links and paths
- Build every link with `url.*` from `core/router.js` (and `ctx.href` inside a DAO). `tools/lint_web.py`
  refuses hand-written `*.html` page links and `#/` hash routes in modules.
- Every path in HTML is root-absolute (`/assets/…`, `/js/…`, `/config.js`) or a clean URL: pages are
  served at any depth (`/dao/0x…/proposals/3`). The site must be hosted at a domain root.

Licence
- Every source file we own starts with the licence line: JS and CSS
  `/* MIT License. Copyright (c) 2026 Decentralized Science Labs LLC. */` as the first line, then the
  description comment; Python the same text as a `#` comment after the shebang; HTML
  `<!-- MIT License. Copyright (c) 2026 Decentralized Science Labs LLC. -->` right after
  `<!doctype html>`; Solidity `// SPDX-License-Identifier: MIT` then
  `// Copyright (c) 2026 Decentralized Science Labs LLC`. Vendored files keep their own headers.
  `tools/lint_web.py` checks web/ and tools/.

Design and copy
- Use the design system (see "Design system") and its classes. The look follows
  genesisl1.com/gov.html: white windows, hairline borders, GenesisL1 blue, status pills.
- Feature styles live in their area's section of `app.css`, each marked
  `/* ---- <area> (merged from parts/<area>.css) ---- */` (portal, dex, dao, governance, models,
  sealed, create, treasury, tokens, admin, core, landing, settings), before the design system's
  shared rules; the docs pages have a section of their own. Prefix every new class with your area
  (`tr-`, `tk-`, `cr-`, `sl-`, `md-`, `dx-`, `gv-`, `dc-`, `dao-`, `ld-`, `st-`, `ad-`) and never
  restyle shared classes there; add a modifier class instead. Theme colours come from the tokens; check dark mode.
- To style areas in parallel, split one out again as a part, `assets/css/parts/<area>.css`, imported
  at the top of `app.css` with `@import url("/assets/css/parts/<area>.css");`, and edit only your
  own part. Before a release `python3 tools/merge_css.py` folds every part into `app.css` at its
  import line (the order stays exactly as tested; a part with `@import` or a relative `url()` must
  be merged by hand) and deletes the parts; `tools/build_release.py` refuses to pack while imports
  remain.
- Cascade order: the area sections (and imported parts) come first, so a shared rule in `app.css`
  wins over an area rule of equal specificity. To adjust a shared class inside your area, combine
  it with your own class (`.kv.tr-kv`, `.item.tr-tok`) rather than repeating the shared selector.
- Copy: plain verbs, sentence case, specific. Buttons say what happens ("Submit proposal",
  "Reclaim deposit"), and the toast and the history use the same words. Errors say what happened and
  how to fix it. Empty states invite the next action. Do not use: ALL-CAPS labels, eyebrow labels
  above headings, meta strings joined with middle dots ("a · b"), spaced em-dash fragments
  ("Label — fragment"), arrows appended to link text.
- Responsive down to 360 px wide with no sideways scroll; visible keyboard focus; `prefers-reduced-motion`
  respected; tables wrapped in `.table-wrap`. Numbers use `.num` (tabular figures).

## URLs

Clean paths, each served by one HTML entry (production servers rewrite them; see docs/DEPLOY.md):

| path | entry | notes |
|---|---|---|
| `/` | index.html | the portal's home |
| `/daos` | daos.html | directory; `?q=&sort=members\|treasury\|newest&mine=1` |
| `/dao/<slug>[/<section>/…]` | dao.html | sections below; `/dao/<address>` also works and leads to the slug URL; prerendered copies serve `/dao/<slug>` |
| `/votes` | votes.html | open proposals across DAOs; `?state=voting\|deposit\|waiting&dao=<slug>` |
| `/tokens[/<address>]` | tokens.html | every token on DeSciFi, one token (W-DEX) |
| `/admin[/<tab>]` | admin.html | the operator's admin: deploy, publish, check, site settings, status (W-ADMIN) |
| `/create` | create.html | wizard; the step as `?step=identity\|constitution\|members\|governance\|people\|review` |
| `/swap[/<in>/<out>]` | descifi.html | the zero address is native L1 |
| `/pools`, `/pools/<pair>`, `/pools/add[/<a>/<b>/<feeBps>]` | descifi.html | pool list, one pool, add liquidity |
| `/locks[/<owner>]` | descifi.html | lock boxes |
| `/docs`, `/docs/<slug>`, `/docs/<slug>.md` | docs.html, `docs/<slug>/index.html` | the docs home, one page, its Markdown; generated by tools/build_docs.py |
| `/deploy` | (301) | moved to `/admin/deploy` in 2.2 |
| `/settings` | settings.html | `#network #explorer #currency #appearance #privacy` |
| `/privacy` | privacy.html | cookie and privacy policy, `#storage` lists every key |

DAO sections: (overview) `proposals`, `proposals/<id>`, `propose`, `propose/<kind>`,
`propose/<kind>/<builder>` with `?preset=<base64url JSON>`, `treasury`, `members`, `tokens`,
`liquidity`, `sealed`, `sealed/<tokenId>`, `models`, `models/<id>`, `constitution`,
`constitution/v<n>`, `constitution/compare/<a>/<b>`, `settings`. Addresses in paths are lowercase; the
router accepts any case and fixes it with `history.replaceState`.

`core/router.js`
- `url` builders, the only way to write a link: `url.home()`, `url.daos({q, sort, mine})`,
  `url.dao(dao, ...parts)` where `dao` is a DAO object (its slug wins), a slug or a treasury
  address (`url.dao(d, "proposals", 12)` or `url.dao(d, "proposals/12")`; pass the object so the
  link is the canonical slug URL), `daoKey(dao)` (the path segment: slug, else lowercase address),
  `url.votes({state, dao})`, `url.tokens(address?)`, `url.admin(tab?)`,
  `url.propose(treasury, kind, builder, preset)`, `url.create(step)`, `url.swap(a, b)`,
  `url.pool(pair)` (no pair: the list), `url.pools()`, `url.addLiquidity(a, b, feeBps)`,
  `url.locks(owner)`, `url.docs(ref)` (`""` the docs home, `"proposals"` a page, `"proposals#delegation"`
  a section; an anchor of 2.2.0's single docs page, such as `"holding-period"`, leads to where that
  section is now through `DOC_MOVED`, which `tools/build_docs.py --check` verifies), `url.settings(anchor)`,
  `url.deploy()` (= `/admin/deploy`), `url.privacy(anchor)`.
- `route()` → `{entry, parts, query (URLSearchParams), hash, path}` for the current location; `parts`
  are the path segments inside the entry (`/dao/0x…/proposals/12` → `["0x…", "proposals", "12"]`,
  `/pools/add/a/b/30` → `["pools", "add", "a", "b", "30"]`). `ENTRY` is this page's entry.
- `onRoute(fn)` registers the page's router; `fn(route, {action: "push"|"pop"|"replace"})`. Once one
  is registered, same-origin links to this entry are handled without a reload (modifier keys, other
  buttons, `target` and `download` links behave as usual; in-page `#anchors` scroll natively).
- `navigate(path, {replace})`: within this entry `pushState` (or `replaceState`) and the page's router
  renders; any other path is a full load. `replaceQuietly(path)` / `pushQuietly(path)` change the
  address bar without rendering (a filter, a wizard step the page already shows).
- `encodePreset(obj)` / `decodePreset(str)` (base64url UTF-8 JSON, plain objects only, 64 KB at most),
  `daoHref(treasury, path)`, `siteUrl(path)` (CFG.siteUrl + path, for canonical links).
- Version 1 URLs redirect on load: `dao.html?dao=X#/proposal/3` and `dao.html#/X/new/<kind>/<builder>/<json>`
  to `/dao/x/proposals/3` and `/dao/x/propose/<kind>/<builder>?preset=…`, `descifi.html#/pool/P` to
  `/pools/p`, `docs.html#x` to `/docs#x`, `index.html?mine=1` to `/daos?mine=1`.
- Hosts without rewrites answer clean paths with 404.html, whose `/js/fallback.js` sends them to
  `/<entry>.html?route=<path>`; the router then puts the clean path back with `replaceState`.

## Settings, prices and consent

`core/settings.js`  typed settings over localStorage (memory when storage is blocked):
`get(name)`, `set(name, value)` (invalid values throw; the default removes the key), `onChange(name, fn)`
(this tab and others) for `theme` ("system" | "light" | "dark"), `rpc` ("" = automatic, or an
endpoint URL), `explorer` ("" = the default, or an https URL) and `currency` (`CURRENCIES`: l1 usd
eur gbp chf jpy cny krw inr rub uah try pln aed cad aud brl sgd hkd btc eth; `CURRENCY_NAMES`).
`setTheme(pref)`, `applyTheme()`, `effectiveTheme()`, `isEndpointUrl(url)`; raw access `readRaw`,
`writeRaw`, `readJSON`, `writeJSON`; `STORAGE_KEYS` ([{key, what}], shown on /privacy).

| key | holds |
|---|---|
| `collab-theme` | System, Light or Dark |
| `collab-rpc` | the chosen endpoint (absent: automatic) |
| `collab-explorer` | the chosen explorer (absent: the default) |
| `collab-currency` | the currency the visitor chose (absent: US dollars from the site's price) |
| `collab-price` | the last L1 price the site published, or fetched for the chosen currency, and its time (reused for 5 minutes) |
| `collab-cookie-info` | when the cookie notice was closed |
| `collab-consent` | `{v: 1, analytics, at}` where analytics are configured |
| `collab-wallet` | the last wallet's EIP-6963 rdns |
| `collab-tokens` | tokens the visitor added |
| `collab-descifi` | slippage and deadline |
| `collab-create-draft` | the creation wizard's draft |
| `collab-sealed-key-version:<chain>:<keyring>:<account>` | the registered sealed-data key version |
| `collab-deploy:<chainId>:<account>` | progress of a deployment from /admin/deploy: external addresses, tx hash, nonce and address of each contract |

`js/theme.js` moves the `collab.*` keys of version 1.0 to these names on the first visit.

`core/price.js`  the L1 price. By default amounts show in US dollars from the site's own
`/data/price.json` (`{usd, usd24hChange, at, source}`, at most 10 KB, written by the server's
prerender; used within 24 hours, and only on the configured site, `onSite()`), so no visitor's
browser contacts a price service by itself. Only when the visitor chooses a currency (settings,
the top bar's chip) does the browser fetch it live from `CFG.priceApi` (CoinGecko simple price),
cached 5 minutes; L1 needs no price, and a failure shows no fiat value.
`money(l1Wei, {maxFrac})` → `<span class="money">1,234 L1 <span class="fiat">≈ $217</span></span>` that
updates itself when the price arrives or the currency changes; `fiatOf(l1Wei)` → "≈ $217" or null;
`formatFiat(value, vs)`, `unitPrice()` ("1 L1 = $0.0042"), `price()` (`{vs, price, change, at}`),
`sitePrice()`, `priceSource(q)` ("CoinGecko, 14:05"), `loadPrice()`, `onPrice(fn)`, `currency()`.
`chosen("currency")` in settings.js tells a chosen US dollar from the default.

`core/capped.js`  bodies of bounded size: `readCapped(res, max)` (bytes), `textCapped`, `jsonCapped`
(null when larger) and `discard(res)`. The body is streamed and the read stops at the cap, because
Content-Length is the compressed size under gzip_static and absent on a chunked answer. Read every
/data/ file and any other bounded answer through it.

`js/consent.js` (an ES module the shell imports)  A compact card at the bottom left of the content
column, over nothing that matters and with no overlay or focus trap. Without analytics: a one-time
note ("COLLAB keeps your settings in this browser. No tracking or ad cookies.", Cookie policy, OK).
With `CFG.analytics.googleMeasurementId`: the same card asks ("May COLLAB count visits with Google
Analytics? …"), "Accept" and "Decline" the same size, nothing requested from Google before Accept,
asked again after 6 months (Decline) or 12 (Accept); a browser that sends Global Privacy Control
gets a note instead and counts as a Decline.
`openConsent()` (the footer's Cookie settings), `consentState()` → `{enabled, decided, analytics, loaded}`.

Network: the settings page offers the `CFG.rpcs` presets, Automatic (every preset, with failover)
and, with `CFG.allowCustomRpc`, a custom https URL (http only on localhost) that must answer
`eth_chainId` with `CFG.chainId` and a recent block before it is saved (latency and head block are
shown). `tools/write_web_config.py --patch-csp` writes the CSP that matches: `connect-src` lists the
presets and the price API, plus `https:` when custom RPCs are allowed; Google's origins appear only
with `--ga-id`. Every entry carries the same CSP.

## The portal shell

Every page sits in one frame, after gl1x.com: a slim alpha strip across the top (text from the site
settings), a sidebar (brand with the Alpha badge, the live network card with chain, latest block,
L1 price and this site's deployment, then the navigation in three groups: Discover, DeSciFi,
Build), a top bar over the content (global search, currency, theme, wallet), an announcement slot
under it, and the ecosystem footer. Below 1024px the sidebar is a drawer opened from the top bar's
menu button (it also holds currency and theme there); below 768px the search folds into a button.

The markup is static in every entry (no layout shift when the module runs; links crawlable),
written between the `<!-- shell:top … -->` and `<!-- shell:foot … -->` markers by
`python3 tools/shell_html.py --write`; `tools/lint_web.py` fails on an entry whose shell is missing
or stale. Never edit those blocks by hand: change tools/shell_html.py and run it. A new entry gets
the shell by running the tool once. The prerender writes copies with the site settings baked in.
`shell()` in ui.js only fills in what is live.

The body is a grid: `alpha-strip` spans the top row; `.side` and `.topbar` span every row below it
(that is what lets both stick without a wrapper); `<main id="main">` and `.site-foot` sit in the
content column. Pages keep `<main id="main" class="…">` as before and lay out inside it: `.bands`,
`.band`, `.frame` work as they did. A page never adds a second rail: section navigation inside a
page is a bar (`.tabs`, or `.ws-nav`, which is a sticky bar under the top bar at every width), and
contents of a long document go on the right.

Global search (core/search.js): DAOs by title, slug, share symbol or address, tokens by symbol,
name or address (`/data/tokens.json` on the site, plus wL1), proposals as a DAO and a number ("opc
3"), and the site's pages; "/" or Ctrl/Cmd K focuses it. Hidden DAOs and tokens are left out.

Undeployed mode: `CFG.deployed` is false until the operator deploys from /admin. The shell shows
"Not deployed" in the network card; every page calls `deployedOrNotice()` and shows its notice
instead of reading the chain.

## Design system

Tokens are CSS variables on `:root`, redefined under `:root[data-theme="dark"]` (the GenesisL1 zinc
theme). Use them; never hard-code colours.

- Surfaces: `--bg` (page), `--bg-2` (bands, table heads, sunken areas), `--bg-3` (hover, bar tracks),
  `--surface` (windows), `--surface-2` (rows nested in windows).
- Text: `--ink`, `--ink-2` (secondary), `--muted` (labels, captions), `--faint` (placeholders, ticks).
- Lines: `--ring` (every 1px hairline), `--ring-2` (stronger: inputs, hover borders).
- Blue: `--primary`, `--primary-hover`, `--primary-soft` (selected rows, info notices), `--primary-line`,
  `--on-primary` (text on filled blue). Wallet: `--wallet`, `--wallet-ring`, `--wallet-ink`.
- Portal: `--chrome` (sidebar and top bar), `--band` and `--band-line` (genesisl1.com's soft blue
  band: page mastheads, `.band-head`), `--strip-bg` `--strip-ink` `--strip-em` `--strip-link` (the
  alpha strip), `--live` (a live status dot).
- Market (DeSciFi): `--buy`, `--buy-soft`, `--buy-line` and `--sell`, `--sell-soft`, `--sell-line`:
  buying and selling, gains and losses. W-DEX owns their use.
- DeSci accent: `--science`, `--science-soft`, `--science-line` (data, models, sealed assets, figures).
- Feedback: `--ok`, `--warn`, `--danger` with `-soft` and `-line`. States: `--st-<voting|deposit|passed|rejected|failed>`
  with `-ink` and `-line` (the gov.html pairs). Votes: `--yes`, `--no`, `--veto`, `--abstain`.
  Data palette, in order: `--d1` … `--d6`.
- Shapes: `--r-window` 14px (windows, popovers, modals), `--r-row` 12px (rows in windows, notices),
  `--r-ctl` 10px (buttons, inputs, selects), `--r-chip` 6px (code), `--r-pill` (pills, badges).
  Windows have no shadow; popovers, menus and modals use `--shadow-pop`.
- Type: `--sans` Inter (interface), `--serif` Source Serif 4 (documents), `--mono` JetBrains Mono
  (addresses, hashes, block numbers, numeric table cells). Sizes `--fs-12` `--fs-13` `--fs-14`
  `--fs-15` (base) `--fs-17` `--fs-20` `--fs-24` `--fs-30` `--fs-38` `--fs-48`, and `--fs-display`
  (`clamp(40px, 4.2vw, 64px)`, page heroes; class `.display`). Headings 600–650, tight tracking.
- Layout: `--max` 1600px (the content frame, inside the content column), `--gutter`
  `clamp(16px, 2.6vw, 40px)`, `--topbar` 64px, `--side` 256px (the sidebar; 0 below 1024px, where it
  is a drawer), `--aside` 360px. Sticky things inside a page stick at `top: var(--topbar)`.

Which class for which purpose:

| purpose | classes |
|---|---|
| page frame | `.frame` (alias `.shell`): `--max` wide with gutters; `.page` adds vertical padding |
| full-width sections | `<main class="bands">` with `<section class="band">` (`.alt` on `--bg-2`, `.band-head` for a page masthead) each holding a `.frame` |
| page header | `.crumbs` (breadcrumb `<nav>` with an `<ol>`), `.page-head` with `.page-head-text` (h1 + `.lede`) and `.page-head-actions` |
| stacks and rows | `.stack` `.stack-sm` `.stack-lg`, `.row` `.row-tight` (+ `.between` `.end` `.grow`) |
| grids | `.grid` + `.g2` `.g3` `.g4` (step down at 960/640px), `.grid-cards` (cards, at least 300px), `.grid-auto` (at least 240px) |
| main + side | `.split` (360px side), `.split-wide` (420px), `.sticky` side; inside the DAO workspace they stack when the main column is under 900px (container query) |
| DAO workspace | `.ws` with `.ws-rail` (`display: contents`: `#dao-tabs.ws-nav`, the section bar, and `#dao-me.ws-me`), `.ws-main`, `#dao-aside.ws-aside` (dao.js builds it); the section bar runs across the top at every width, the aside sits beside the main column from 1440px |
| undeployed | `undeployedNotice(what)` (`.undeployed`), via `deployedOrNotice()` |
| window | `.card` with `.card-head` (h2/h3 15px + actions), `.card-body`, `.card-foot`; `.pad` `.pad-lg` `.pad-sm` |
| rows | `.list` (hairlines between children) of `.item` (links get a hover); `.row-card` (a bordered row); `.sunken` (a recessed area) |
| buttons | `.btn` (secondary, white with a ring), `.btn.primary` (one per view), `.btn.wallet`, `.btn.danger`, `.btn.ghost`; sizes `.sm` `.lg`, `.block`; `.icon-btn` (36px square), `.link-btn` (a button that reads as a link) |
| form controls | `field()` (`.field` with label, hint, `.err`), `.input` (`.mono` for hex), `.select`, `.textarea` (`.prose`, `.code`), `.input-group` + `.addon`, `.check`, `toggle()` (`.switch`), `segmented()` (`.seg`) |
| labels | `.pill` (neutral facts: kind, symbol, link), `.badge` + state (`voting deposit passed executed rejected vetoed failed expired queued executable critical science frozen`), `stateBadge()`, `kindBadge()`, `.chip` (a small rounded button, e.g. currency), `.code-chip` (inline code) |
| navigation | `.tabs` (a section bar: links with `aria-current="page"`, `.count`), `.ws-nav` (the DAO rail), `.crumbs` |
| data | `table.t` in `.table-wrap` (`th` muted 12px; `td.num` mono; `.r` right-aligned; `.t-compact` for side panels), `.kv` (definition grid), `.stat`/`.stats`, `.money` + `.fiat`, `.addr` (`addr()`), `.avatar.s24/s40/s48/s64/s88` |
| figures | `figure.fig` + `figcaption.fig-cap` (numbered "Figure N." by CSS), `.chart` SVG classes `axis tick tick-label grid ref ref-label guide series marker floor-*`, `.legend`, `.progress` (9px bar on `--bg-3`, fill in a `<span>`), `.mini-ballot`, `.ballot` |
| documents | `.doc` (18px serif, 70ch; markdown output), `.doc.compact` (16px), `.doc-note` (margin notes such as "Version 3, adopted by proposal #12 on 22 Oct 2026"), `.diff` |
| feedback | `notice(kind, title, body)` (`.notice.info/.warn/.danger/.ok`), `toast()`, `modal()`, `confirmDialog()`, `popover()` (`.pop`), `skeleton()` (`.skel`), `empty()`, `errorBox()`, `.spinner` |
| wizards | `.steps` (numbered step buttons), `.drop` (file drop zone), `.kinds` / `.kind` (choice cards) |

Motion: none except short (150–200 ms) responses to actions (opening, expanding, toggling) and the
landing hero's single assembly; everything honours `prefers-reduced-motion`.

## Head template for a new page

Copy this into `web/<page>.html`, then add the page to `core/router.js` (entry list and a `url`
builder), to `tools/serve_local.py` (rewrites), `tools/lint_web.py` (ENTRIES), the server configs
in `deploy/` and the sitemap. `tools/write_web_config.py --patch-csp` keeps the CSP in step.

```html
<!doctype html>
<!-- MIT License. Copyright (c) 2026 Decentralized Science Labs LLC. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="(copy it from index.html)">
<meta name="referrer" content="strict-origin-when-cross-origin">
<title>Page name | COLLAB on GenesisL1</title>
<meta name="description" content="What the page is and what a visitor can do there, in one or two sentences.">
<link rel="canonical" href="https://collab.genesisl1.com/<clean-path>">
<meta property="og:type" content="website">
<meta property="og:site_name" content="COLLAB">
<meta property="og:title" content="Page name | COLLAB on GenesisL1">
<meta property="og:description" content="…">
<meta property="og:url" content="https://collab.genesisl1.com/<clean-path>">
<meta property="og:image" content="https://collab.genesisl1.com/assets/img/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="COLLAB: scientific DAOs on GenesisL1, with molecule glyphs of DAOs">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Page name | COLLAB on GenesisL1">
<meta name="twitter:description" content="…">
<meta name="twitter:image" content="https://collab.genesisl1.com/assets/img/og.png">
<meta name="twitter:image:alt" content="COLLAB: scientific DAOs on GenesisL1, with molecule glyphs of DAOs">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/assets/img/icon-32.png" type="image/png" sizes="32x32">
<link rel="icon" href="/assets/img/icon-any-192.png" type="image/png" sizes="192x192">
<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="preload" href="/assets/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/css/app.css">
<script src="/js/theme.js"></script>
<script src="/config.js" defer></script>
<!-- preload (tools/shell_html.py writes this block: the page module's static imports) -->
<!-- /preload -->
<script type="module" src="/js/pages/<page>.js"></script>
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebPage","name":"Page name","url":"https://collab.genesisl1.com/<clean-path>","isPartOf":{"@type":"WebSite","name":"COLLAB","url":"https://collab.genesisl1.com/"}}</script>
</head>
<body>
<main id="main" class="frame page">
  <nav class="crumbs" aria-label="Breadcrumb"><ol><li><a href="/">COLLAB</a></li><li><span aria-current="page">Page name</span></li></ol></nav>
  <header class="page-head"><div class="page-head-text"><h1>Page name</h1><p class="lede">…</p></div></header>
  …
</main>
</body>
</html>
```

Then run `python3 tools/shell_html.py --write` to give it the shell markup and its preload block. The page module starts
with `shell("<nav key or empty>")` and, when it reads the chain, `if (deployedOrNotice("…")) …`.
Preload Source Serif 4
(`/assets/fonts/source-serif-400.woff2`) on pages that show documents. One h1 per page.

## Core modules (js/core)

`env.js`  `CFG` (validated config: `deployed` (false before the operator deploys the contracts:
then `addresses` is `{}`, the integrations are empty and nothing may read a COLLAB contract), `site`
(the public site settings the admin edits, normalized: `alpha {show, text}`, `announcement {text,
href, tone} | null`, `featuredDaos` and `hiddenDaos` (slugs or lowercase addresses),
`verifiedTokens` and `hiddenTokens` (lowercase addresses), `seo {title, description}`, `links
{website, x, telegram, discord, github}`), `chainId`, `chainName`, `nativeSymbol`, `rpcs` (presets
`[{name, url}]`, failover order), `rpcUrls` (their URLs), `allowCustomRpc`, `explorers` (presets
`[{name, url}]`), `explorer` (the default), `priceApi` (URL with `{vs}`, "" = prices off),
`analytics` (`{googleMeasurementId}` or `{}`), `siteUrl`, `startBlock`, `version`,
`addresses.{factory, daoLens, marketLens, textStore, keyring, desciFactory, desciRouter, lockFactory,
distributor, launcher, wl1}` (nothing else), and the integrations this deployment lists, from
config.js `integrations`: `gl1f` (`[{id, name, site, registry, nft, market, runtime, store}]`) and
`cipnft` (`[{id, name, site, address}]`); ids are lowercase letters, digits and hyphens ("x-" is
reserved), names plain and at most 40 characters and passing `names.js`, sites https, every address
distinct and non-zero, at most 8 of each), `ZERO`, `BURN` (0x…dEaD), `MAX_UINT64`, `explorerBase()`
(the visitor's explorer, else the default), `explorerTx(hash)`, `explorerAddr(addr)` ("" without an
explorer); `daoHidden(dao)`, `tokenHidden(address)`, `tokenVerified(address)` (the site settings:
hidden means left out of listings, search and the sitemap on this site, the page stays reachable
by URL with a notice; nothing changes on chain), `isSlug(s)`, `onSite()` (this page is on
`CFG.siteUrl`, the server that publishes `/data/` and `/og/`: fetch those only when it is true).

`names.js`  The rule for those names, shared with the deploy page and mirrored in
`tools/write_web_config.py` (same tables and messages): `nameProblem(name)` ("" or the rest of a
sentence after the entry): NFKC form; letters all Latin (A to Z and the Latin-1 letters only) or
all CJK, with digits, spaces and common punctuation (no Greek, Cyrillic, Latin Extended or other
letters with Latin look-alikes); `nameKey(name)`, the look-alike skeleton names are compared by
(trimmed, NFKC, lowercase, then 1/i/l/| and 0/o folded, rn read as m and vv as w: "GLIF" and
"GL1F" are one); `skeleton(text)` the same for free text in any script (DAO titles): Cyrillic, Greek
and Armenian letters that pass for Latin ones become them first (not part of the mirrored rule);
`nameSpace()` → `{problem(kind, name), add(kind, name, what)}`, checked GL1F
first: no two collections share a name, and no CIPNFT contract takes another one's name or a
collection's address-book label ("GL1F market"). The deploy page's chain 29 comparison with
Deploy.s.sol's defaults matches names by `nameKey` too.

`dom.js`  `h(tag, attrs, ...children)` (attrs: `class`, `style` object, `dataset`, `on<event>`
functions, `text`, booleans; children: nodes, strings, arrays, null), `s()` for SVG, `frag`, `clear`,
`mount(el, ...children)`, `$`, `$$`, `icon(name)`, `safeUrl`, `debounce`, `onVisible(el, fn)`.

`fmt.js`  `units(v, decimals=18, maxFrac=4)`, `compact(v, decimals)`, `l1(v, maxFrac)`,
`parseUnits(text, decimals)` (throws readable errors; accepts digits with an optional dot, and
commas, spaces or underscores only between groups of three digits: "12,500.5" is 12500.5, while
"1,5" is refused with "Use a dot for decimals"), `tryParseUnits` (`null` instead of throwing),
`bps(n)` → "33.4%", `pct(part, whole)`, `ratio`, `shortAddr`, `shortHash`, `duration(sec)`,
`relTime(unix, now)`, `date(unix)`, `day(unix)`, `int(n)`, `bytesLabel(n)`, `excerpt(md, max)`.

`bus.js`  `state` (`account`, `walletChainId`, `block`, `blockTime`), `on(evt, fn)`, `emit`.
Events: `account` (connect, disconnect, switch, chain change), `block`, `rpc`, `tx`.

`rpc.js`  Reads go to the visitor's endpoint (settings `rpc`) alone, or to every preset in order
with failover; a change in settings rebuilds the providers at once. `endpoints()`, `activeRpc()`,
`allowedEndpoint(url)`, `checkEndpoint(url)` → `{chainId, block, timestamp, ms}` (rejects with a
readable message unless the endpoint serves this chain with a recent block), `provider()`,
`withProvider(fn)` (failover), `cached(key, ttlMs, fn)`,
`invalidate(prefix)`, `refreshBlock()`, `blockReady()` (await it before using chain time for a
decision), `waitForBlock(n, timeoutMs)` (polls until the read RPC has block `n`), `chainNow()`
(chain time in seconds; use it for every "ends in" computation),
`getLogs(filter, {fromBlock, toBlock, step, limit, newestFirst=true})`: ranges of at most
`LOG_RANGE` (10,000) blocks, 4 requests in parallel, the newest range ending at `"latest"` unless
you pass `toBlock`; the order is deterministic (newest first by default, block and log index), and
with `limit` the scan stops once the ranges nearest the start of that order hold enough logs. Pass
`fromBlock: dao.createdBlock` for DAO contracts. `getBlockLogs(filter, block)`, `getCode`, `getBalance`.

`contracts.js`  `iface(name)` (the shared `LenientInterface` of a shipped ABI),
`contract(name, address, runner?)`, `lenientInterface(abi)` (for ABIs a module writes itself), `read.<singleton>()` handles
(`factory, daoLens, marketLens, textStore, keyring, desciFactory, router, lockFactory, distributor,
launcher, wl1`), `daoContracts(dao)` → `{treasury, governor, shares, profile, policy, vault}`,
`gl1fContracts(collection, runner?)` → `{registry, nft, market, runtime}` and
`cipnftContract(entryOrAddress, runner?)` for integrations (pass the entry from `integrationsOf(dao)`:
a handle built from an unlisted entry of a DAO carries `collabIntegration`, and `send()` then checks
through the wallet's own network that the DAO's policy gives that address that role, as it checks
`daoContracts` tags). The `ModelRegistry` and `ModelMarketplace` interfaces include GL1F Crypto's
functions (`changeModelLicense`, `setInternalsVisibility`, `internalsVisibility`, `licenseOf`,
`getLicenseInfo`, `burnFees`, `pendingOwner`…), so one ABI serves both GL1F families; calling one
on an original GL1F contract reverts. `explainError(e)` → `{title, detail}` (plain
title; for custom errors `detail` is the error tag such as `NotAToken(0x…)`, for string reverts the
reason code). `signatureOf(selector)` → `"transfer(address,uint256)"` or null and
`functionLabel(selector)` → `"transfer"` (the full signature when the name is overloaded), over every
shipped ABI; `selectorIndex()` → Map(selector → [{name, fragment}]).
ABI names: `DAOFactory DAOGovernor DAOTreasury ShareToken DAOPolicy DAOProfile CipherVault TextStore
CollabKeyring DAOToken TokenLauncher MemberDistributor DeSciFiFactory DeSciFiPair DeSciFiRouter
LockFactory LockBox DAOLens MarketLens PolicyBuilder CIPNFT ModelRegistry ModelNFT ModelMarketplace
ForestRuntime WL1 ERC20 ERC721 ERC1155`. Full signatures: `docs/CONTRACT_API.md`.

`wallet.js`  `connect()`, `restore()`, `disconnect()`, `ensureChain()`, `signer()`,
`send({contract, method, args, value, label, gasBuffer=1.3, gasLimit, quiet, verifyDAO})` → receipt or `null`
(the user already saw why). `gasLimit` (BigInt) is for transactions heavier than nodes estimate
(eth_estimateGas stops at the json-rpc gas cap, 25,000,000 by default), such as a large logo: the
preflight still runs and a revert still stops the transaction; only an estimate that failed at the
cap, without a revert, sends with this limit. Before the wallet opens, a DAO contract from `daoContracts()` is read
again from the DAO factory through the wallet's own network, and a handle of an unlisted
integration (`gl1fContracts()` / `cipnftContract()` of an entry a DAO added itself) is checked
against that DAO's policy (`integrationRole`) the same way; a mismatch sends nothing. It resolves once the read RPC has the receipt's block and the caches are cleared,
so a view rendered right after it sees the transaction's state and events. Starting a transaction
closes the confirmation and error toasts of earlier ones. `sendValue({to, value, label})` (the
same), `signMessage(text)`, `signTypedData(domain, types, value)`.

`notify.js`  `toast({title, body, kind: "ok"|"err"|"pending", link, timeout})` → `{update, close}`,
`modal({title, body, actions: [{label, kind, onClick}], wide, onClose})` (return `true` from
onClick to keep it open), `confirmDialog({title, body, confirm, danger})` → `Promise<boolean>`.

`ui.js`  `shell(activeNavKey)` (call once per page: wires the portal shell, see below, then block
polling and wallet restore; keys `home daos votes tokens swap pools locks create docs`, "" for
none, or 2.1's `descifi` (the path picks swap, pools or locks); returns `{deployed}`),
`deployedOrNotice(what, target)` (true when COLLAB is deployed on this site; otherwise it puts
`undeployedNotice(what)` in `target`, by default replacing the page's main, and returns false: call
it right after `shell()` and skip every chain read when it is false), `undeployedNotice(what)`,
`popover(anchor, build,
{label, align, cls})` (a small window under a button: closes on Escape and outside clicks, returns
focus), `copyText`, `addr(a, {full, label, link})`,
`stateBadge(state)`, `kindBadge(kind, critical)`, `stat(label, value, unit)`,
`notice(kind: "info"|"warn"|"danger"|"ok", title, body)`, `empty(title, body, action)`,
`skeleton(lines)`, `errorBox(e, retry)`, `field(label, control, hint, errEl)` (returns a `.field`
element: its `<label>` holds only the label text, and the hint and `errEl` sit under the control,
inside the returned element, linked with `aria-describedby`, so they are not part of the control's
accessible name; a control that is not an input, select or textarea is named as a group; an
`.sr-only` label node stays visually hidden), `input(attrs)` (`mono: true` for hex),
`select(options, value)`, `textarea(attrs)` (`variant: "code"|"prose"`),
`toggle(label, checked, onChange)` (`el.set(v)` sets it quietly),
`segmented(options, value, onChange)` (options `{value, label, disabled, reason}`: a disabled
option shows `reason` as its title; choosing another option calls `onChange` and then dispatches a
bubbling `change` event; `el.set(v)` changes the choice without either),
`section(title, body, actions)` (a card), `pager(page, pages, onPage)`.

`forms.js`  Validating fields. Each returns `{el, read(), peek(), fail(msg)}`: `read()` marks errors
in place and returns the parsed value or `null` (a valid read clears the mark); `peek()` returns what
`read()` would, without marking anything (for live summaries); `fail(msg)` shows `msg` in the
field's error slot, for checks that span fields (duplicates, totals), and returns `null`;
`fail("")` clears it. Most fields also expose `input` (their control) and `set(value)`.
- `textField({label, value, hint, maxBytes, required, pattern: [RegExp, message], mono})`
- `addressField({suggestions, allowZero, required})` (+ `suggest(list)`) → checksummed address.
- `amountField({decimals, symbol, max, min, allowZero, required})` (+ `setToken`, `setMax`,
  `setLabel(text)`, `isEmpty()`: with `required: false`, `read()` is `null` both for an empty and an
  invalid amount).
- `tokenField({dao, owner, allowNative, value, extra, hint, onChange, held})`: L1 is `ZERO` with
  `native: true`. Offers the DAO's tokens, wL1, tracked tokens and `extra`, and with `dao` (unless
  `held: false`) the tokens its treasury holds, added in the background from `holdings()`. `get()`
  / `peek()` give the loaded token (while a newer choice loads, still the previous one); async
  `read()` waits for the choice the select shows, so it always returns that token; async
  `set(address)`; `ready` (initial load); `select` and `other` (the address input). A custom `hint`
  shows under the balance line.
- `durationField({seconds, min, max, allowZero})` → seconds; zero is allowed when `min` is 0 or with
  `allowZero` (a zero timelock is valid). `set(seconds)`, `input`, `unit` (the unit select).
- `bpsField({bps, min, max})`: percent in ("33.4" or "33.4%"), basis points out. `set(bps)`, `input`.
- `dateField({unix, allowPast, required})` → unix seconds. "In the future" is checked against
  `chainNow()`; `ready` resolves once the chain head is known, so await it before `read()` where
  that decision matters. A value given as `unix` or through `set(unix)` reads back to the exact
  second while untouched (the control itself has minute precision). `input`.
- `markdownField` (Write / Preview, byte counter; `textarea`, also as `input`).
- `fileField({accept, maxBytes, hint, onChange})` → `{bytes, mime, name}`. `onChange(file | null)`
  runs after a pick or a drop (a drop also dispatches a bubbling `change`, as picking does);
  `set({bytes, mime, name})` shows an existing file as the current one; `clear()`; `input`.
- `listField({label, make, min, max, addLabel, initial, extra})`: repeating rows (`extra`: more
  buttons shown beside Add, such as "Add members"), `make(preset)` →
  `{el, read(), peek()?}`. `add(preset)` → the row, `rows()` (current rows in order, for checks
  across rows), `count()`, `clear()` (re-enables Add). Adding or removing a row dispatches a
  bubbling `change` from `el`. Rows are `.row.list-row`: they never wrap, so the remove button stays
  beside a tall row.
- `maskField({options, mask})` (bit masks; `set(mask)`).

`daodata.js`  `getDAOs(offset, limit)`, `isDAO(treasury)`, `getDAO(key, ttlMs=10000)` (`key`: the
treasury address or the slug; an unknown slug rejects with "not a DAO"; cached;
normalized summary + details: `treasury governor shares profile policy vault creator createdAt
createdBlock title name slug logoHash logoLength shareName shareSymbol totalSupply totalUnits memberCount treasuryL1
treasuryShares proposalCount constitutionVersion transfersEnabled descriptionHash descriptionLength links
constitutionPtr/Hash/Timestamp/Proposal deposit{minDeposit, proposalThreshold, maxDepositPeriod,
executionWindow, minInitialDepositBps, flags} standardRules criticalRules{votingPeriod,
executionDelay, quorumBps, thresholdBps, vetoThresholdBps, minYesBps} reps repInfo[{perms,
termEnd, since}] stewards activeEpoch epochCount announcementCount criticalKinds integrations
holdingBlocks everTransferable`; `slug` is the DAO's permanent URL name, `logoHash`/`descriptionHash`
(lowercase, the zero hash when empty) and the lengths describe the bytes its profile stores (read
them with `core/daoprofile.js`; summaries from `getDAOs` carry the logo but not the description);
`daoIdBySlug(slug)` (0 = none), `slugStatus(slug)` → `{available, taken}`, `treasuryOfSlug(slug)` (no
chain read; from DAOs already loaded); `sameNameAs` (on objects from `getDAO` and `allDAOs`) lists the
slugs of the other DAOs whose title looks the same, oldest first: [] when none, null when the directory
could not be read in time. Titles are not unique, slugs are: wherever a DAO vouches for something, show
its slug and flag a shared name. `nameKey(title)` is the comparison key (cleanText, then `skeleton()` of
names.js: look-alike letters of other scripts, compatibility forms, case, 1/l/I and 0/O),
`nameIndex()` all DAOs by key (DAOLens.directory, 200 a call; read once per session, in memory and
sessionStorage by chain and factory; after 5 minutes the DAO count is read again and only newer DAOs
are fetched), `sameNameAs(dao)` waits for it, `markSameNames(list, complete)` sets the field on a list; `minYesBps` (also on every proposal's `rules`) is the least Yes, as
a share of all voting power at the snapshot, that passes (0 = off, also when the chain has none);
`integrations` is `{gl1f: [{registry, nft, market, runtime}], cipnft: [{address, daoKey}], source:
"lens" | "defaults"}` from DAOLens (`daoKey`: the DAO's X25519 key registered on that contract, zero
when none), or the deployment's defaults when the lens has none: read it through
`integrationsOf(dao)` (integrations.js), never directly; `holdingBlocks` is the governor's holding period in blocks, null when
unreadable; `everTransferable` is true once the shares have ever been transferable (ShareToken v2
never clears it; a v1 token reports its current setting), null when unreadable. Equal per-member
airdrops are allowed only when it is exactly `false`; check again at submit time with
`readEverTransferable(dao.shares)`, which is never cached; `title` is the stored title
for forms, `name` = `daoName(title)` the one to show: `cleanText(title, 80)`, "Untitled DAO" when
nothing visible is left; `shareName` and `shareSymbol` come cleaned), `getProposals(dao, fromId,
count)` (rows carry `holdSnapshot` only when the deployed DAOLens returns it), `getProposal(dao, id)`
(`tally{yes, abstain, no, veto}`, `totalPower`, `snapshot`, `holdSnapshot`, `flags` (the DAO's
deposit flags when it was submitted, as the governor applies them), `my*` fields for the connected
account), `proposalSnapshot(dao, id)` → `{snapshot, holdSnapshot}` (the two blocks votes
are read at; each vote counts the lower of the two; equal without a holding period),
`getMembers(dao, accounts)` (`balance units votes delegate frozen lockUntil isRep repPerms repTermEnd
isSteward keyringX25519 keyringPqcHash l1Balance`, plus `excluded` when the deployed DAOLens returns
it), `memberAccounts(dao)` (every account that held voting units or was frozen, oldest first,
without the treasury and without accounts the DAO excluded as infrastructure), `plain(result)`.
The fields DAOLens v2 appends (`holdingBlocks`, `holdSnapshot`, `excluded`) are used only after a
one-time probe shows the deployed lens has them; otherwise the governor and shares are read.

`tokens.js`  `NATIVE`, `tokenInfo(tokens, owner, spender)` → Map(lowercase address → `{token, ok,
name, symbol, decimals, totalSupply, balance, allowance, launchedBy}`), `tokenMeta(tokens)` (cached),
`trackedTokens()`, `trackToken(addr)`, `baseTokens()`.

`holdings.js`  `holdings(dao)` → Promise of the ERC-20 tokens the treasury holds now, sorted by
symbol: `{token, symbol, name, decimals, balance, totalSupply, launchedBy, daoToken, shares, pool,
wl1}` (`shares`: the DAO's own share token; `pool`: a DeSciFi LP token). Leads are Transfer logs to
the treasury since `dao.createdBlock` (through `getLogs`: ranges of at most 10k blocks, a few at a
time) plus wL1, DAO-launched and tracked tokens; every balance, name and symbol is then read from
the chain (`cleanText` applied). Cached 30 s per treasury and never rejects: an RPC that refuses
the log query only loses the leads. `receivedTokens(treasury, fromBlock)` → addresses that sent the
treasury ERC-20 transfers (`[]` when refused).

`daoprofile.js`  A DAO's logo and description (2.2): stored in its DAOProfile and overwritten in
place on every edit (the title never changes). Reads go to the site's static cache first
(`/data/logos/<hash>.png`, `/data/texts/<hash>.txt`, 64 lowercase hex without 0x, written by the
prerender; fetched only when `onSite()`), then to the profile in 16 KB chunks (`logoChunk`,
`descriptionChunk`); the bytes must hash to what the chain reports, and results are cached by hash.
`daoLogoUrl(dao)` → a verified `data:image/png` URL or null, `logoUrl({hash, profile, length})`,
`logoBytes(…)`, `daoDescription(dao)` → the verified text ("" when none; throws when unreadable),
`descriptionText({hash, profile, length})`, `hasLogo(dao)`, `hasDescription(dao)`, `rememberBytes(hash,
profile, length)` (daodata.js calls it, so a logo can be drawn from its hash alone). Preparing writes:
`decodeImage(file)` (any image the browser opens, as a canvas), `drawSquare(canvas, {x, y, size})`
(halving first, so detail survives), `encodeLogo(canvas)` (the smallest of a lossless palette PNG, a
filtered truecolour PNG and the browser's own), `pngInfo(bytes)` / `logoProblem(bytes)` (the contract's
Png.header checks, in its order), `descriptionBytes(text)` → `{text, bytes, removed, over}` (LF line
breaks, other control characters removed). Gas, from the measured formula in spec 2.2 As built
(constants in `GAS` and `MARGIN`): `storeGas`, `procGas`, `createGasLimit(emptyEstimate, payloads)`,
`createGasUsed`, `executeGasLimit(actions, writes)`, `submitGasLimit(actions, noteBytes)`,
`directGasLimit(bytes, old, {text})`, `profileWrites(dao, actions)` (the logo and description writes
in a batch), `beyondEstimate(gas)`, `failureFee(limit, price)` and `failureLine(limit, cause)` (on
GenesisL1 a failed transaction still pays half its gas limit: every transaction sent with COLLAB's own
limit says so),
`gasPrice()`. Limits: `MAX_LOGO_BYTES` 524,288, `MAX_DESCRIPTION_BYTES` 262,144, `MAX_CHUNK_BYTES`,
`LOGO_PIXELS` 128.

`blob.js`  `readBlob(ptr, hash)`, `readText(ptr, hash)` (refuses on hash mismatch),
`readImageUrl(ptr, hash, mime)`, `writeBlob(bytesOrText, {label, onProgress})` → `{ptr, hash}`
(deduplicated; large blobs upload in chunks then commit).

`actions.js`  `proposalActions(governor, id, createdBlock)`, `actionsHash(actions)`,
`simulate(dao, actions, proposal?)`: with `proposal` (getProposal()'s object) in state Executable,
an eth_call of `governor.execute(id, actions)`, exactly the transaction anyone may send, → `{ok,
error?}`; otherwise `treasury.executeBatch(actions)` from the governor, without the governor's
`executingProposal` context, → `{ok, approx: true, note, error?}` (`note` is a plain sentence to
show as it is: airdrops count the previous block's members, constitution versions and key vault
records keep no proposal); `error` is explainError()'s `{title, detail}`. `addressBook(dao)`,
`actionMeta(actions, {dao})` → the `meta` Map for describe() (never throws): token metadata for
every address in the targets and the decoded arguments, nested arrays and tuples included; pools
(router paths, pairs, LP tokens) with their tokens from `MarketLens.poolsInfo`; LP lock boxes; the
address a launch will create; blob sizes; Model NFT numbers per registry; GL1F Crypto licences;
the integrations the batch adds. `describe(action, {dao, meta, book})` → `{title, lines, warn,
hint}` (irreversible effects are in `warn`; without `meta` addresses are shown instead of symbols).
A party (recipient, spender, owner, fee recipient, module) is named only from `addressBook(dao)`
(protocol contracts, the integrations this deployment lists by their config.js names such as
"GL1F Crypto market", this DAO's contracts, the burn and zero addresses), otherwise by its
checksummed short address: a contract's own symbol never names it, and an integration a DAO added
itself is shown by its address with a warning. Model NFTs are named with their collection ("GL1F
Crypto Model NFT #3"), CIPNFT assets with their contract ("CIPNFT #2"). Where treasury spends are
critical, a GL1F pricing, access-plan or owner-key call on a registry the DAO lists carries a line
saying why it is critical (RV-1); whether a proposal is critical still comes from `policy.validate()`.
Symbols name only the token an action moves. Transfers always show the recipient's address in the title, and `hint` holds the full
addresses: render it as the line's `title` attribute (`h("span", {title: d.hint || null}, d.title)`).
`decodeAction(action, {dao, meta})` → `{contract, fn, signature, args}` or null, and
`signatureOf` / `functionLabel` (from contracts.js). When you add a builder for a function
`describe()` does not know, the review shows a generic decoding; tell the core team which
plain-language line it deserves.

`charts.js`  Charts are figures: hairline axes, mono ticks, dashed reference lines with labels and a
caption numbered by CSS ("Figure 2. …"). `ballot(tally, totalPower, rules, {decimals, symbol,
caption})` (a `figure.fig`: votes against all voting power with the quorum line, the four options,
and turnout, Yes share and veto share each against its threshold, plus "Yes of all voting power"
against `rules.minYesBps` when it is above 0), `miniBallot`, `donut(segments,
{size, thickness, center, caption})`, `poolCurve({eBase, eQuote, floorQuote, label, width, height})`
(an `<svg>` with ticks, guides to the current point and the launch floor as a dashed line),
`allocationBar(parts, total)` (its bar is `role="img"`, named after the parts and their shares),
`figure(node, caption)` (wraps any chart in a numbered figure).
`glyph.js`  `glyph(seed, size)` (atoms in the data palette, hairline bonds; tools/seo/glyph.py ports
it for Open Graph images: change both together), `avatar(seed, {logoPtr, logoHash, logoMime, logoLength,
profile, size, label})` (a token's TextStore image by `logoPtr`; a DAO's logo by `logoHash`),
`daoAvatar(dao, {size, label})` (the one to use for a DAO object: its verified logo, else its glyph). The draws of the generator, in order: motif, rotation, the motif's own (ring size and one
per bond, or chain length and one per bond after the first; none for the two rings), substituent
count and one atom index each, hue, five for the palette (Fisher-Yates: for i = 5 down to 1, swap
i with floor(R() * (i + 1))), then one per atom for its colour.
`directory.js`  `allDAOs(max)` → `{list, total}`, `memberOf(list, account)` → Set of lowercased
treasuries or null, `openProposals(dao)` (open ones among the newest 20, counted again after 60 s,
`OPEN_TTL_MS`, or when the proposal count changes), `daoCard(dao, {member})` (the directory card;
the whole card is the link; a shared name shows its slug and a "Name shared" badge),
`sameNameBadge(dao)` (that badge, or null).
`kinds.js`  `KIND`, `KINDS` (`id key name icon critical desc`), `STATE`, `STATES`, `OPTIONS`,
`FLAGS` (`BURN_VOTE_QUORUM 1, BURN_DEPOSIT_PREVOTE 2, BURN_VOTE_VETO 4, PENALTY_TO_TREASURY 8,
FREEZE_LOCK 16, FREEZE_BOND 32`: a freeze proposal that ends Rejected or No quorum forfeits its
deposit), `REP_PERMS`, `TOKEN_FLAGS`, `INTEGRATION_ROLE`, `BLOCK_SECONDS` (14, the average block
time; use it for every "blocks, about N hours" estimate), `kindOf`, `stateOf`, `isLive`.

`integrations.js`  The GL1F collections and CIPNFT contracts a DAO works with. Each DAO keeps its
own lists on chain (DAOPolicy, changed only by its critical votes); the deployment's defaults in
config.js are "listed". A contract a DAO added itself is unverified input, like a pasted token
address: never named after what it says about itself.
- `integrationsOf(dao)` → `{gl1f: Collection[], cipnft: Cipnft[]}` in the DAO's order (without a
  DAO: the listed ones). Collection = `{registry, nft, market, runtime, store, id, name, site,
  listed, key, used}`: `listed` when the four addresses equal one of `CFG.gl1f` exactly (id, name,
  site and store then come from it); otherwise id `x-<registry 6 hex>`, name "Unlisted GL1F
  collection", site "" and store null; `key` is the lowercase registry; `used` is true for the DAO's
  own entries. Cipnft = `{address, id, name, site, listed, daoKey, key, used}` ("Unlisted CIPNFT
  contract"; `daoKey` the DAO's registered key there, null when unknown). Entries are frozen.
- `collectionOf(dao, address)` (by any of its five addresses) and `cipnftOf(dao, address)`: the
  DAO's own entry first, else a listed one (`used: false`), else null. `integrationName(dao,
  address)` → "GL1F Crypto market", "CIPNFT" or null.
- `knownCollections()`, `knownCipnfts()`, `defaultIntegrations()`.
- `checkCollection({registry, nft, market, runtime}, dao?)` and `checkCipnft(address, dao?)` →
  `{ok, problems: [plain sentences]}`: what DAOPolicy checks when the DAO adds one (valid, distinct,
  non-zero addresses with code, not COLLAB's or the DAO's own, not already integrated there,
  registry.modelNFT() = nft, market.nft() = nft, runtime.registry() = registry, nft.registry() =
  registry; a CIPNFT contract must answer like one).
- `collectionFeatures(c)` → `{family: "gl1f" | "gl1f-crypto" | null, changeModelLicense,
  setInternalsVisibility, adminBurnAndDelete, twoStepOwner}` from the registry's code (for offering
  builders and for display only).
- `externalPowers(entry)` → Promise of `[{role, contract, holder, pending, can: [], cannot: [],
  reviewed}]`: one item per outside admin key (GL1F: "Registry owner", "Market owner"; CIPNFT:
  "CIPNFT admin") with the live holder from owner()/admin() (pending: GL1F Crypto's two-step owner)
  and what the key can and cannot do to the DAO's assets, in plain sentences. An unlisted contract
  gets `reviewed: false` items for every owner()/admin() it answers: its powers are unknown.
- `ROLE` (`CIPNFT 1, GL1F_REGISTRY 2, GL1F_NFT 3, GL1F_MARKET 4, GL1F_RUNTIME 5`), `ROLE_NAMES`,
  `GL1F_ROLES`, `roleTarget(role)` (DAOPolicy's rule key for every contract of a role, 0x…0100 +
  role), `MAX_GL1F`, `MAX_CIPNFT` (8 each).

## DAO views

`/dao/<slug>/<section>/<args…>` is routed by `js/pages/dao.js`, which renders the workspace
(section bar, breadcrumbs, aside) and calls the view module's `export async function render(ctx)` with
the context below. It also writes the head as tools/prerender.py does for a DAO (title, canonical,
robots, Open Graph and Twitter tags, and one JSON-LD block `script[data-dao]` with the Organization
and its BreadcrumbList); on a prerendered page it keeps that page's description and image URLs.

```
ctx.dao          normalized getDAO() result (refetched on navigation once older than 4 s)
ctx.member       getMembers() row for the connected account, or null
ctx.root         element to render into (already cleared; this render's own container)
ctx.args         path segments after the section (URI-decoded): proposals/12 → ["12"],
                 propose/treasury/send → ["treasury", "send"], sealed/5 → ["5"],
                 models/1 → ["1"], constitution/v3 → ["v3"], constitution/compare/1/2
ctx.preset       the composer's prefill (decoded ?preset=), or null
ctx.aside        this render's aside container: what a view puts there shows as the right
                 column at 1440 px and wider and under the main column below that. A view with
                 its own .split / .split-wide side column and an empty aside gets no aside; any
                 other view gets the DAO's decision rules there.
ctx.href(path)   link inside this DAO, e.g. ctx.href("proposals/4") → /dao/<slug>/proposals/4
                 (version 1 names such as "proposal/4" and "new/<kind>" still work)
ctx.go(path)     navigate inside this DAO (no page load)
ctx.proposeHref(kindKey, builderKey, preset)   link to the composer, prefilled (see below)
ctx.refresh()    reload DAO data, header and the current view (call after a transaction); does
                 nothing once ctx.stale() is true
ctx.stale()      true once a newer render (a navigation or a refresh) has started
ctx.delegateDialog(dao, presetAddress)
ctx.setCrumb(label)   ends the breadcrumb with the item shown (DAOs / <DAO> / <Section> / <label>);
                 cleaned with cleanText(…, 120); reset on every navigation
ctx.setHead({title, description})   for the item shown: `title` replaces the section name in
                 the document title ("<title> | <DAO> | COLLAB on GenesisL1") and in og:title and
                 twitter:title; `description` (plain text or Markdown, cut to 155 characters and
                 cleaned) replaces the DAO's description, og:description and twitter:description.
                 Both last until the reader leaves the page; the canonical URL, og:url, images and
                 JSON-LD stay the DAO's
ctx.title(text)  shorthand for ctx.setHead({title: text})
```

`setCrumb`, `setHead` and `title` do nothing once the render is stale. A refresh renders the view
into a hidden container and swaps it in when it is done, so the page never shows half a render.
A view marks its own side column with `[data-side]`; the frame then leaves the aside for it.

The frame's markup (dao.html and pages/dao.js): the masthead `#dao-mast` holds `#dao-crumbs` and
`#dao-header.dao-id` (the DAO's mark, name, summary, pills, key facts and actions); the workspace
`#dao-ws` holds the rail (`#dao-tabs`, `#dao-me`), `.ws-main` and `#dao-aside`. On `#dao-ws`,
`data-view` names the view, `data-aside` is on or off, `data-me` says whether the membership box
sits in the rail or the aside, and `data-aside-fit` is `fits` or `tall` (a tall aside does not stick).

A view renders immediately with skeletons, loads with `Promise.all` where possible, shows
`errorBox(e, retry)` on failure, and works without a wallet (read-only), with a wallet that is not a
member, and for frozen members. It re-renders through `ctx.refresh()`; it does not keep timers.
After a long await, check `ctx.stale()` before doing more work for a page the user has left.

## DeSciFi and the tokens pages

descifi.html (`/swap`, `/pools`, `/locks`; js/pages/descifi.js) and tokens.html (`/tokens`,
`/tokens/<address>`; js/pages/tokens.js) own their masthead and route by path. Each view in
js/descifi/ exports `render(ctx)`: `ctx.root` (render into it), `ctx.args` (path segments),
`ctx.head({h1, icon, lede, crumbs, actions, title, description})` (the masthead and the document
head), `ctx.scope` (`alive`, and `on(evt, fn)` subscriptions that end when the reader navigates) and
`ctx.refresh()`. Both pages call `deployedOrNotice()` before reading a contract.

- Data: js/descifi/data.js reads pools and tokens through MarketLens (getPools and tokenInfo in pages
  of 40; `aggregate` with one token per batch, since its targets get all the gas) and holds the
  integer price math. js/descifi/market.js builds the market that /tokens, the token page and the
  swap's market head share (once per block). On the site it also reads `/data/tokens.json`, which
  tools/seo/tokens.py writes (schema in its docstring). Live values always win; the file only adds what
  needs history: holders, the 24 h change and the 7 day line.
- Site settings: `site.verifiedTokens` adds the verified check and drops the unverified warning;
  `site.hiddenTokens` leaves a token out of lists, the picker and the sitemap. Its page still works,
  with a notice and noindex.
- Styles: parts/dex.css only, with `dx-` class names (tabular figures apply under `.dx-page`). `--buy`/`--sell` (and `--dx-on-buy`/`--dx-on-sell`
  for text on them) mean buy, sell, up and down; everything else stays blue or neutral. Layouts follow
  the frame through container queries (`dx` on `.dx-band > .frame`, `dxmain` on the token page's main
  column), not the window. The swap ticket's `data-side` (`buy`, `sell`, `neutral`) colours its edge
  and main button.
- Tests: `tools/e2e/run.py descifi` (swaps, liquidity, locks, fees, safety checks) and
  `tools/e2e/run.py market` (/tokens, token pages, tokens.json, hostile names). For a dev copy with
  data, run `python3 tools/seo/tokens.py --rpc … --deployment deployments/local.json --data DIR`, then
  serve it with `serve_local.py --data DIR`.

## Proposal builders

The composer (`js/views/dao-new.js`) handles kind choice, the write-up, deposit, policy check
(`DAOPolicy.validate`), decoding, simulation and submission. A proposal type's module
`js/views/new/<kindKey>.js` only turns a form into actions:

```js
export const builders = [{
  key: "send",                    // path segment: propose/<kindKey>/<key>
  label: "Send L1 or tokens",     // shown on the block and on "Add another action"
  icon: "send",                   // sprite id
  desc: "One sentence on what it does.",
  single: false,                  // true: the only block allowed in the proposal
  form({ dao, member, preset, changed, kind }) {
    // preset: plain object from ?preset= (strings and numbers) or null; the composer itself reads
    // preset.title and preset.description for the write-up
    // changed(): call after programmatic changes (typing is detected automatically)
    return {
      el,                         // the form
      async build() {             // → { actions: [{target, value: bigint, data: "0x…"}], title? }
        // Validate with the forms.js fields; throw new Error("what to fix") when invalid.
        // May send preparatory transactions (e.g. writeBlob for a constitution text).
      },
      preset() {                  // optional: the form's current values as a preset, so
        // "Copy a link to this draft" reopens the form filled in
      },
    };
  },
}];
```

Rules for builders: produce exactly the calls the DAO's default policy allows for that kind
(`contracts/core/PolicyBuilder.sol` lists them; approvals only to the protocol spender of that
kind). Reset approvals you raise (`approve(spender, 0)` after the call that spends). Use
`ZERO`-checks, bounds and decimals from chain data. Prefill from `preset` so other views can link
straight to a ready form: `ctx.proposeHref("membership", "freeze", {account: "0x…"})` makes
`/dao/<t>/propose/membership/freeze?preset=<base64url JSON>`. A preset only fills the form; the
review still decodes, policy-checks and simulates exactly what will be submitted.
Kind keys: `signal treasury params constitution profile membership issuance tokenadmin descifi
cipnft gl1f signature custom`. A kind's module may also export `note({dao, kind})`, shown under its
builders (the profile's says the DAO's name is permanent).

## Logo and description editors

`js/views/profile-editors.js` serves the wizard, the profile builders and the representatives'
tools, so the three behave the same:
- `logoEditor({seed, current, initial, allowRemove, name, onChange})`: pick any image (or drop it);
  drag it and zoom to choose the square to keep (keyboard: arrows move, + and − zoom); the result is
  a 128 × 128 PNG drawn in the browser, shown at its actual size and as cards and lists show it,
  with its byte count against 512 KB. `value()` → `{change: false}` or `{change: true, bytes}` (empty
  bytes remove the logo); `await settled()` before reading it.
- `descriptionEditor({value, label, rows, hint})`: Markdown up to 256 KB with a live counter, a bar
  and a preview; `read()` → `{text, bytes, removed}` as the contract stores it, or null when too long.
- `inPlaceNote(what)` (an edit overwrites the stored bytes; the state does not grow),
  `historyNote(what)` (in a proposal: the bytes enter the chain's history three times, a
  representative's direct edit once) and `titleNote(name)` (the name is permanent), where people look
  for them.
A large logo or description costs more gas than nodes estimate: builders show the execution's gas
(`executeGasLimit`), the composer and the proposal page say when they cannot simulate it, and
Submit, Execute, the wizard and the representatives' tools pass the formula's limit to `send()`.
Where that limit applies they show `failureLine()`; Execute reads the proposal's state again just
before sending, and the wizard checks the web address again just before the wallet opens.

## The docs pages

The documentation is Markdown: the files of docs/ and this page (slug `web-app`), read on GitHub as
they are. `tools/build_docs.py` (standard library only) turns them into the site's /docs section,
after GL1X's docs. Its registry, `PAGES`, holds every page in reading order with its slug, file,
title, group and writer; `GROUPS` orders the groups. A page starts with the licence line, then
`<!-- summary: … -->` (one sentence, at most 160 characters: the index, search results and the meta
description use it), then `# Title`, which must be the registry's title, then a one-paragraph lead.

```
python3 tools/build_docs.py --check [FILE ...]   # sources, the app's links into the docs, generated files
python3 tools/build_docs.py --check --strict     # a placeholder or missing page fails too (the release)
python3 tools/build_docs.py --write              # write every generated file
python3 tools/build_docs.py --list               # the registry with each page's state and words
python3 tools/build_docs.py --index-md           # the page list docs/README.md carries
python3 tools/docs_figures.py --src DIR [KEY ...]  # screenshots to web/assets/docs/<key>.webp
```

- **What --write writes.** web/docs.html (the docs home from docs/README.md: audiences, a search
  box, and the index of every page by group in place of the README's page list),
  `web/docs/<slug>/index.html` (each page with the portal shell, `tools/shell_html.py`'s markup and
  modulepreload block, its head, "On this page", the group's pages and previous/next),
  `web/docs/<slug>.md` (the Markdown as it is), web/docs/index.json (the registry with headings,
  words and dates), web/docs/search.json (each section's heading and text, for the search box) and
  web/llms.txt. A page's "updated" date is the day `--write` first saw its current text
  (index.json keeps a digest of each text), so writing again changes no date. In a release tree
  every asset URL carries the release's `?v=`.
- **What --check refuses.** A missing licence or summary line, a summary over 160 characters, a
  title that is not the registry's, a duplicate slug or heading anchor, a link to a page, an anchor
  or a repository file that does not exist, a link that is neither https:, a site path nor a docs
  link, an unknown figure or live figure key, a live figure twice on a page, more than four figures
  (screenshots and live figures together), a figure without its caption, a README page list that
  misses a page or leaves reading order, a `url.docs()` in web/js, an entry of `DOC_MOVED` or a
  `/docs/…` link in web/*.html that leads nowhere, and a generated file that is out of date. It
  warns on raw HTML (shown as text) and on a README summary that differs from the page's.
  `tools/lint_web.py` runs it.
- **The Markdown subset.** `#` to `####` headings (`##` and `###` make the contents; ids as GitHub
  makes them), paragraphs, `**bold**`, `*italic*`, `` `code` ``, nested `-` and `1.` lists, GFM
  tables, ```` ```lang ```` fences, `---`, callouts (`> **Note.**`, `> **Warning.**`, `> **Tip.**`),
  figures (`![Caption](figure:KEY)` or `figure:KEY:phone`) and live figures
  (`![Caption](live:KEY)`), each alone on a line. Everything is escaped; HTML in the Markdown is
  shown as text. Links resolve relative to the file, as GitHub resolves them: another page
  (`proposals.md#delegation`, `../web/DEVELOPING.md#urls`) becomes `/docs/<slug>#id`, a repository
  file that is not a page shows as its text (no repository URL is set: `REPO_URL`), and only
  https:// links leave the site. In docs/faq.md every `###` question with the text under it becomes
  a Question of the page's FAQPage JSON-LD.
- **Figures.** Screenshots of the site, committed as WebP in web/assets/docs/: the top 1360 × 850 of
  a gallery capture scaled to 1088 × 680, or 390 × 844 for `:phone`, at most about 120 KB each.
  `tools/docs_figures.py` holds the keys with their sources and crops; the docs build only reads
  their sizes and never converts.
- **Live figures.** Drawings that js/pages/docs.js makes in the page, from a fixed list of keys
  (`LIVE_FIGURES` in the build): `anatomy` (a DAO's six contracts as a molecule), `holding` (which
  shares count with a holding period), `lifecycle` (the life of a proposal), `ballots` (the worked
  example under the standard and critical rules, as a proposal page draws its tally) and
  `deployment` (this site's network and contract addresses, from config.js and the DAO factory;
  "This site has no deployment yet" before deployment). The build writes the caption and an empty
  slot; the figure stays hidden until the module has drawn it, so without JavaScript there is no
  empty frame. A drawing with a narrow layout is made in both and the slot's width picks one
  (container queries), and colours are the theme's tokens. The `.md` copies keep the line as it is,
  and the search text has the caption.
- **The pages.** One reading column of Source Serif at about 70 characters; "On this page" and the
  group's pages on the right from 1280px, and below that a bar under the top bar with both as menus
  (the site's sidebar stays the only rail). Styles are the docs section of app.css (`dc-` classes).
  js/pages/docs.js draws the live figures and adds the scroll-spy, copy buttons on code, keyboard
  access to tables and code that scroll sideways, the home's search over /docs/search.json, and it
  forwards the anchors of the single docs page of 2.2.0 (`/docs#holding-period`) to their new place.
  The top bar's search lists docs pages and sections from /docs/index.json as its "Docs" group.
- **Links from the app.** `url.docs(ref)` takes "", a slug, `slug#id`, or an old anchor that
  `DOC_MOVED` in js/core/router.js maps to its page and section; `--check` fails when a target is
  gone, so renaming a heading the app links to means updating `DOC_MOVED`.
- **Serving.** `/docs` is docs.html, `/docs/<slug>` is `docs/<slug>/index.html`, `/docs/<slug>.md`
  is served as `text/markdown; charset=utf-8`, `/llms.txt` as text. On the server they come from
  the release only, never from the prerender's folder. The sitemaps list the written pages after
  the static ones (`python3 tools/prerender.py --static-sitemap` refreshes web/sitemap.xml).
- **Tests.** `python3 tools/tests/test_build_docs.py` (the renderer: escaping, links, tables,
  callouts, code, figures, live figures, FAQ) and `tools/e2e/run.py docs` (`docs_live_figures`
  draws every live figure at 390 and 1360 px in both themes and compares the addresses with
  config.js and the factory).


## Running locally

Prerequisites: Python 3.10 or later, Foundry (`anvil`, `cast`, `forge`, on PATH or in
`$FOUNDRY_BIN`), Playwright for Python with Chromium. The sealed-data scenarios also need a checkout
of the CIPNFT site, whose own code plays the other side of every trade: point `$CIPNFT_REF` at it.
[Local development](../docs/local-development.md) has the whole setup, the sandboxes, the test
wallets and the common pitfalls; [Testing](../docs/testing.md) has the suites.

```
python3 tools/local_chain.py up --seed          # chain on :8545 with demo data, writes web/config.local.js
python3 tools/serve_local.py --port 8080        # serves web/ with the clean paths; reload to see edits
python3 tools/serve_local.py --no-rewrites      # a host without rewrites: clean paths go through 404.html
python3 tools/dev_sandbox.py snapshot           # snapshot the seeded chain (once)
python3 tools/dev_sandbox.py up <name> --rpc-port 86xx --web-port 87xx   # your private copy
python3 tools/dev_sandbox.py reset <name>       # back to the snapshot
python3 tools/dev_sandbox.py warp <name> 3600   # move your chain clock forward
python3 tools/dev_sandbox.py down <name>
python3 tools/lint_web.py                       # the rules a script can check: 0 errors before every commit
```

`serve_local.py` serves the same rewrites as production (see "URLs"), prerendered pages
(`web/dao/<slug or address>/index.html`, `web/pools/`, `web/locks/`) when they exist, and generates `/config.js` from
`web/config.local.js` with the sandbox RPC, no explorer and a local price endpoint (`/dev/price`,
a fixed demo price in CoinGecko's format), so nothing leaves the machine. Its CSP allows any
`http://127.0.0.1:*` endpoint, to try a custom RPC in settings.

Use your own sandbox when you send transactions or move time, so you never disturb anyone else.
The test wallet in `tools/e2e/harness.py` (`Browser(base, rpc, account)`) injects an EIP-6963
wallet backed by anvil's unlocked development accounts, and marks the cookie notice as seen
(`cookie_notice=True` shows it). `b.go(path)` loads a page; `b.nav(path)` follows an in-app link
instead (no page load, so what the tab holds in memory, such as unlocked keys, stays). Look at what
you build: take screenshots (`b.shot(name)`), read
them, and fix what looks wrong; check light and dark (`dark=True`) and a 390 px wide viewport.
`b.errors` must stay empty of errors.

End-to-end tests: `tools/e2e/scenarios/<area>.py` exports `SCENARIOS = [fn, …]`; each `fn(env)`
gets a fresh sandbox (`env.browser(i)`, `env.dao(n)`, `env.dao_url(n, *parts, preset=)` (the clean
path), `env.send(i, to, sig, *args, value=)`, `env.cast(...)`, `env.warp(s)`, `env.rpc(m, p)`,
`env.accounts`, `env.deploy`, `env.demo`). Version 1 URLs (`dao.html#/<t>/proposal/3`) still
redirect, so older scenarios keep working. Run
`python3 tools/e2e/run.py <filter> --name <you> --rpc-port <p> --web-port <q>`, and
`python3 tools/e2e/sweep.py --base <web> --rpc <rpc>` for the main pages and one DAO's tabs in light,
dark and 390 px (not /votes, /tokens or the pages under /docs/).

`serve_local.py`'s other modes: `--undeployed` (config.js before the operator deploys), `--site
site.json` (site settings), `--data DIR` (DIR at /data/, and this server becomes the configured
site), `--prerender DIR` (the prerender's output before web/, as on the server). A missing /data/
file answers 204.

### Release build: versions, preloads and compression

There is no bundler. `python3 tools/build_release.py` stages a copy of the tree and, in the copy:
`tools/version_assets.py` appends one release-wide `?v=<12 hex>` to every module import (static and
dynamic), every `/js/…` string, the stylesheets, fonts, the icon sprite and CSS `url()`s, and fails
when a reference is left unversioned or one module is reached under two URLs (two instances),
the modulepreload links in each entry included (the page module's static imports, written by
`tools/shell_html.py`, which reads the version from the copy's `BUILD.json`); the static pages
(/pools, /locks) and the docs pages are written again from the versioned tree; then every text
file of 1 KB or more gets a reproducible `.gz` sibling for nginx's `gzip_static`. The extracted
archive is checked again (`version_assets.py --check`, `shell_html.py --check`, `prerender.py
--static-pages --check`, `build_docs.py --check --strict`). The server caches URLs with the current
release's `?v=` for a year as immutable (the installer reads it from `web/BUILD.json`) and
revalidates any other `?v=` and the rest. [Building a release](../docs/release-build.md) lists
every step.
What this means for code: import modules by relative path (`./x.js`, never with a query of your
own), name scripts root-absolute (`/js/…`), and keep the modulepreload blocks fresh with
`tools/shell_html.py --write` after changing a page's imports (lint warns). The e2e tests of a
release run against the built tree. web/pools/ and web/locks/ are copies of descifi.html with
their own head: after changing descifi.html, run `python3 tools/prerender.py --static-pages`
(lint fails on stale copies).

## Demo data in the snapshot

Accounts are anvil's development accounts #0–#11 (1,000,000 L1 each). `deployments/demo.json`
holds the demo USDC (6 decimals; #0–#9 hold 250,000).

- Every demo DAO has a slug from its title (`open-proteomics-collective`, `longevity-data-commons`,
  `university-microscopy-core`, `crypto-signals-research-guild`, `open-model-exchange`), a 128 × 128
  PNG logo (`script/assets/<slug>.png`, 1.9–4.3 KB, drawn by `python3 tools/make_demo_logos.py`), a
  short Markdown description and one representative, its second founder, with permissions 30
  (description, logo, links and announcements).
- DAO 1, Open Proteomics Collective (OPC): members #0–#7 with 400/250/150/80/50/40/20/10 OPC; the
  treasury holds 200 OPC, about 2,250 L1, 45,000 USDC and PROT. Representative #1. Stewards #0 and #1. Minimum deposit 2 L1. Standard
  rules 1 h voting, 10 min timelock, quorum 33.4%, pass 50%, veto 33.4%; critical 2 h, 20 min,
  40%, 66.67%, 33.4%. Proposals: #1 PROT issuance (executed), #2 250 L1 grant to #9 (executed),
  #3 FAIR signal (passed), #4 lower deposit (executed), #5 freeze #7 (rejected), #6 GL1F pricing,
  #7 raw payment, #8 constitution v2 (all voting), #9 hackathon signal (deposit period).
  PROT: launched by the DAO; member airdrop claimed by #0–#5; a one-sided PROT/USDC launch pool
  (1% fee, LP held by the treasury) and a two-sided PROT/USDC pool (0.3%, LP locked forever, fees
  to the treasury). GL1F Model NFT #1 is owned by the treasury. One announcement by #1.
- DAO 2, Longevity Data Commons (LDC): members #2, #3, #8, #9; 800 L1 in the treasury.
- DAO 3, University Microscopy Core (UMC): members #4–#8 with 100 shares each.
- DAOs 1–3 use the GL1F collection and the first CIPNFT contract. DAO 4, Crypto Signals Research
  Guild (CSRG): members #6, #7, #8 with 150/100/50, 300 L1, the GL1F Crypto collection and the second
  CIPNFT contract only, and a GL1F Crypto Model NFT #7 donated. DAO 5, Open Model Exchange (OMX):
  members #1, #5, #9 with 120/90/60, 300 L1, both collections and both CIPNFT contracts, and two
  models #5 donated (one GL1F Crypto, one GL1F).
- A public wL1/USDC pool (0.3%). CIPNFT #1 "Phospho cohort sealed" listed by #9 for 25 L1 (dummy
  ciphertext), and on the second CIPNFT contract "Natural history registry sealed", listed by #9 for
  40 L1. Keyring entries for #0–#3 are dummy keys.

## Icons

`icon(name)` uses `/assets/img/icons.svg`: menu sun moon wallet copy external check x plus minus
search chevron-right chevron-down arrow-left arrow-down signal vault sliders scroll users shield
coins token swap lock unlock model pen terminal clock info warn flask molecule download upload eye
key chart refresh trash edit link doc gift fire layers home vote droplet send gear sparkline.
Need another? Draw it inline with `s()` in your module and mention it in your report.
