# gobj-ui — Yuneta UI Library

Reusable GUI components for Yuneta GClass front-ends: a declarative shell
(`C_YUI_SHELL`/`NAV`/`PAGER`/`WIZARD`), floating windows
(`C_YUI_WINDOW`/`WINDOW_MANAGER`), TreeDB editors, charts and maps. The
legacy GClass GUI stack (`C_YUI_MAIN`/`TABS`/`ROUTING`) was removed from
this line in `3.0.0` — the frozen v1 npm line still ships it.

Published as `@yuneta/gobj-ui`. Built on top of [`@yuneta/gobj-js`](https://github.com/artgins/gobj-js).

> **Routing & navigation:** every navigable state is a URL. Before adding any
> view or navigable element, read **[`ROUTING.md`](ROUTING.md)** — the shell's
> routing contract (URL = source of truth, push/replace history, the
> position/preference/transient litmus).
>
> **BREAKING (7.0.0):** a **dependency-only major** — no component API moved.
> The `maplibre-gl` peer floor rises to `^6.4.1`, which is where `DOM.sanitize`
> stops leaving dangerous attributes behind when several sit next to each other.
> Raise the range in every consumer that declares maplibre.
>
> **BREAKING (5.0.0):** a **dependency-only major** — no component API moved.
> The peer floors moved to `maplibre-gl` `^6.0.0` (see 7.0.0 above), `@yuneta/gobj-js` `^7.8.7`,
> `i18next` `^26.3.6`, `tom-select` `^2.6.2`, `vanilla-jsoneditor` `^3.13.0`.
> maplibre v6 is ESM-only with no default export, so a consumer imports it as
> `import * as maplibregl`; bundling the map with Vite 8 also means emitting
> maplibre's worker + shared chunk yourself and pointing `setWorkerUrl()` at
> them (Vite cannot statically follow v6's dynamic worker URL, and a `.mjs`
> worker is refused when the host serves it as `application/octet-stream`).
> The test-app's `vite.config.js` (`maplibre_worker_assets`) + `src/main.js`
> are the reference wiring.
>
> **BREAKING (4.0.0):** `yui_shell_navigate(shell, route)` now **pushes** a
> history entry by default; pass `{replace:true}` for a redirect / normalization
> / F5-restore — anything *code* decided rather than the user. It used to replace
> unless given `{push:true}` (still accepted, now redundant). A call left
> unmigrated only leaves a spurious Back entry; the default is the
> failure-tolerant direction, since a forgotten `{push}` silently broke Back.
> See ROUTING.md §7/§9.1.
>
> **BREAKING (4.0.0):** the legacy `__yui_main__` theme/resize service is
> **gone from v2**. Components are self-contained: the theme lives in
> `<html data-theme>` and gclasses follow it through **`src/yui_theme.js`**
> (`yui_theme_now()` / `yui_is_dark()` / `yui_watch_theme(gobj)`, all
> barrel-exported — the watcher translates the DOM mutation *and* the OS
> `prefers-color-scheme` flip into `EV_THEME`); reflow uses each component's
> own `ResizeObserver`. An app that registered a `__yui_main__` service for
> gobj-ui's benefit can delete it; do not re-add one.
>
> **BREAKING (4.0.0):** a window/modal **`title` is now an i18n KEY**,
> rendered with `data-i18n` so it re-translates on language change. Pass the
> key, never `t(key)`, and never compose data into it — the DATA half (a
> topic/service/marker name) travels in the new **`title_prefix`** attr/opt,
> shown before the title and never translated (`C_YUI_WINDOW`,
> `yui_shell_show_modal`, the dock chip). The old `title_fn`/`retitle_modal`
> hooks are removed.
>
> **BREAKING (4.0.0):** **minimize requires a window manager.**
> `C_YUI_WINDOW` paints its minimize button only when the window has a
> `manager` (`C_YUI_WINDOW_MANAGER`): minimize means "send to the dock", and
> without a manager there is nowhere to send it — so `showMin` is now **ignored**
> when there is no manager, and a manager-less window shows only
> maximize/restore + close. The self-contained **"shade"** fallback (roll up to
> the title bar in place) and its `is-shaded` CSS are **removed**; an app that
> relied on shading needs to register a manager.

## Two maintained lines

This repository carries **two parallel lines** with different layouts and
consumers. They are independent snapshots (no shared git ancestry):

| Line | Branch | Tag | Layout | Consumed by | How | Status |
|------|--------|-----|--------|-------------|-----|--------|
| **v2** | `main` | `2.0.0`+ | `src/` subdir | **gui_agent**, **gui_treedb** | local `file:` dep on the yunetas submodule | active development |
| **v2** | `main` | `2.0.0`+ | `src/` subdir | **wattyzer** | published npm `@yuneta/gobj-ui@^5.0.0` (dist-tag `latest`) | active development |
| **v1** | `v1` | `1.0.1` | `src/` subdir | **estadodelaire**, **hidraulia** | published npm `@yuneta/gobj-ui@^1.0.1` (dist-tag `legacy`) | frozen, maintenance-only |

- **v2 / `main`** is the active development line: the declarative shell
  (legacy-stack-free since `3.0.0`). It is embedded as a git submodule in **yunetas** at
  `kernel/js/gobj-ui`, and the in-repo JS yunos
  (**`yunos/js/gui_agent`**, **`yunos/js/gui_treedb`**) consume that checkout as a
  `file:` dependency (`@yuneta/gobj-ui` → `../../../kernel/js/gobj-ui`), importing
  by package specifier (`@yuneta/gobj-ui/src/*.js`, exports map `"./src/*"`; the
  `index.js` barrel and the vite plugin stay at the package root).
  **wattyzer takes the same line from the registry** (since 2026-07-25): the
  published tarball ships `src/`, `index.js` and the vite plugin, so the import
  specifiers are identical — but library work only reaches it after a
  `npm publish` and a range bump on its side. Two consequences worth knowing:
  a fix cannot be validated in wattyzer before it is released, and wattyzer is
  the consumer that proves the **tarball** is complete, not just the checkout.
- **v1 / `v1`** is the frozen legacy-only stack (the declarative shell is not on
  this line). It is **published to npm**; estadodelaire and hidraulia depend on
  `@yuneta/gobj-ui@^1.0.0` from the registry. Land only maintenance fixes here,
  then `npm publish` a new `1.x`.

All new feature work lands on `main`/v2.

## Usage

```bash
# v2 (active): clone yunetas with submodules; the in-repo yunos pick it up via file:
git clone --recurse-submodules <yunetas>
git submodule update --init kernel/js/gobj-ui      # yunetas tracks main/v2

# v2 from the registry (wattyzer, and any out-of-tree consumer)
npm install @yuneta/gobj-ui@^5.0.0

# v1 (frozen): consumers just install the published package
npm install @yuneta/gobj-ui@^1.0.0
```

Edit v2 from the yunetas `kernel/js/gobj-ui` checkout, commit on `main` in this
repo, then bump that submodule pointer in yunetas. For v1, work from a `v1`
checkout and publish.

## Build & test

```bash
npm install
npm run build      # vite -> dist/ (ES/CJS/UMD/IIFE, min + non-min)
npm test           # vitest (v2/main only; v1 has no test target)
```

`dist/` is gitignored. v1 consumers get `dist/` from the **published** npm
tarball; v2 consumers import source files by specifier, whether they resolve
them from the checkout or from the tarball's `src/`. Rebuild `dist/` to
validate and before publishing a release.

## Components

### Site map — `yui_shell_show_route_map`

Every declarative-shell app can render its WHOLE navigation surface —
toolbar + account menu + every declared menu + live dynamic tabs + each
view's contributed sub-routes + the routes declared only in the route table —
as a printable, filterable, clickable tree (a floating `C_YUI_WINDOW`, modal
fallback) that doubles as the app's basic documentation. The current route is
marked "you are here". Wire it from an account-menu entry
(`type:"event"` → `EV_OPEN_SITEMAP`, or a deep-linkable `/sitemap` action
route with `redirect:"back"`) and call
`yui_shell_show_route_map(shell, {t})` from the handler; a second call
toggles it closed. The tree model is `yui_shell_nav_map()` /
`route_map_model.js` (pure, unit-tested). Semantics and the contributor
protocols (`yui_shell_set_sub_routes`, `yui_shell_register_event_handler`)
live in [`ROUTING.md`](ROUTING.md).

### C_YUI_NODE — navigation as a tree of gobjs (prototype)

`C_YUI_SHELL`'s menu tree is **two levels** (a primary item and its
`submenu.items`); a submenu item cannot declare a submenu of its own, so a
section with sub-sections has to flatten everything into one tab strip.

`C_YUI_NODE` is the prototype of the other model: **the gobj tree IS the
navigation tree.** A node is a gobj, the URL is the path of node ids under a
single declared `base_route`, and a parent holds *how it wants its children
seen*:

```json
{
    "gclass": "C_YUI_NODE",
    "kw": {
        "node_id": "cards", "base_route": "/cards",
        "projection": {
            "index":  {"layout": "cards"},
            "chrome": [{"layout": "tabs", "show_on": ">=tablet"},
                       {"layout": "backbar", "show_on": "<tablet"}]
        },
        "content":  {"gclass": "C_MY_LANDING", "kw": {}},
        "children": [
            {"id": "energy", "label": "Energy", "icon": "yi-bolt",
             "projection": {
                 "index":  {"layout": "cards"},
                 "chrome": [{"layout": "tabs", "show_on": ">=tablet"},
                            {"layout": "backbar", "show_on": "<tablet"}]
             },
             "children": [ /* … any depth … */ ]}
        ]
    }
}
```

- **`projection`** is a `C_YUI_NAV` render config (so cards/tabs/vertical/
  icon-bar/backbar and `show_on` all work unchanged) in two modes: `index`
  when the node is the tip of the path — the projection IS the page — and
  `chrome` when a child is showing — the projection is the strip around it.
- **Chrome belongs to the node that declares it — so every branch declares its
  own.** A node's `chrome` strip lists *that* node's children, and its backbar
  goes back to *that* node's route (`back_route = my_route`). A branch that
  declares no `chrome` therefore contributes no strip, and the only ← the user
  can reach is the nearest ancestor that did declare one. Declaring the pair
  **only at the root** is the mistake this rule exists to name: the whole
  subtree then shows one "← root" that says the same thing at every depth,
  instead of one ← per level going up exactly one level. Repeat the same
  `chrome` on every branch that can have a tip below it — that is what
  `test-app`'s `/cards` does, and what makes its ← hierarchical.
- **`content` and `children` are not exclusive**: a section with its own page
  and sub-pages is one node.
- **The route table does not grow.** The host declares ONE route; everything
  below arrives as the shell's `subpath` (ROUTING.md §4), and the tree
  contributes its full shape to the site map via `yui_shell_set_sub_routes`.
- **Two ways to show depth.** Stacked chrome — one strip per ancestor —
  reads well at three levels and eats the screen at five. The other way is
  **`projection.path`**: the trail down to the user as ONE line
  (`{"layout": "breadcrumb"}`), drawn from the tree root whichever node
  declares it, each crumb a link to that level. Declared per branch, so a
  deep corner can trade its strips for a breadcrumb (`chrome_depth: 0` +
  `projection.path`) while the rest of the tree keeps its tabs. Note the
  asymmetry that makes it a third mode and not a layout: `index` and `chrome`
  project a node's CHILDREN; `path` projects the way in.
- **`remember_position` — an item points at where you LEFT that child.**
  Without it a nav item points at the canonical route of its child, so a strip
  of children behaves like a row of tabs that forgets: open a topic inside one,
  move to a sibling, come back, and the tab is at its landing — browser Back
  the only way to what was open. With it, the item carries the tail that was
  last active under that child.

  It stays a **real position**, which is the reason it is done here and not by
  the viewer restoring itself: clicking is a navigation like any other, nothing
  redirects and nothing argues with the url. A bare navigation to a child
  records "its home", so choosing the landing sticks too.

  Off by default: a tree whose children are pages wants the item to BE the
  destination. On for a tree whose children are workspaces with a position
  inside them — the agent console's strip of treedbs, each with its open topic.
  Since 6.2.0. **Declarable in a child spec since 7.23.140** (`"remember_position":
  true` next to its `id`): the attr existed, but the spec never passed it on,
  so a node declared in a config could not turn it on. yunovatios turns it on
  for its treedb nodes, so the graph tab returns to `<graph>/<topic>` with its
  focus and the data tab to the topic that was open.

- **`nav_mode` — the three shapes as one runtime knob.** The two bullets above
  describe what a tree *declares*; `nav_mode` is how a user *chooses* between
  the shapes without the app rewriting anything:

  | mode | what it shows | equivalent declaration |
  |---|---|---|
  | `"stack"` (default) | one strip per ancestor | whatever each branch declares |
  | `"back"` | only the tip's parent, as a `← parent` | `chrome: {"layout":"backbar"}` everywhere + `chrome_depth: 1` |
  | `"path"` | the trail as ONE line | `path: {"layout":"breadcrumb"}` on the root + `chrome_depth: 0` |

  It belongs to the **root** (`yui_node_set_nav_mode(root, "path")`, or
  `"nav_mode"` in its declaration) and the whole tree reads it from there —
  ancestors stacking strips while a descendant drew a breadcrumb would be
  saying the same thing twice in two languages. Set on a middle node it is
  refused, loudly, rather than accepted and ignored.

  A mode **filters the renders as they are asked for; it never rewrites what
  the app declared.** That is what makes `"stack"` an exact restore: a branch
  that declared `vertical` chrome comes back as `vertical`, not as the tabs a
  canonical "stacked" shape would have imposed. The `index` projection is
  never touched by a mode — how a node shows its own children when it IS the
  page is not a statement about depth. Modes are per tree, so an app can run
  `/admin` as a breadcrumb and `/alarms` as a backbar; `test-app`'s node lab
  cycles all three on the live tree.
- **`chrome_depth`** caps the stacked chrome: with every ancestor painting its
  own strip, depth N shows N-1 of them. A node declares how many its corner of
  the tree deserves (`0` = none, omit = all), the **deepest declaration on the
  path wins, and an intermediate node whose only job is to hold that number is
  a legitimate node.
- **Declarative and dynamic are the same code.** The declared `children` attr
  is fed to the same `EV_ADD_NODE` the runtime API uses:

  ```js
  yui_node_add(node, spec, index)      yui_node_remove(node, node_id)
  yui_node_set_projection(node, proj)  yui_node_set_content(node, content)
  yui_node_set_chrome_depth(node, n)   yui_node_tree_version(node)
  yui_node_set_nav_mode(root, mode)    yui_node_nav_mode(node)
  yui_node_find(node, "energy/north")  yui_node_route(node)
  ```

  A node added at runtime is deep-linkable like one declared at boot. Removing
  the branch the user is standing on moves them to the nearest living ancestor
  (`replace`, logged) — with a live tree the ground can disappear under a
  bookmark.

Every move goes through the URL: a projection click publishes
`EV_NAV_CLICKED`, the node turns it into a push navigation, and the shell's
`EV_ROUTE_CHANGED` walks back down the tree as `EV_ACTIVATE`. Back, Forward,
F5 and deep links are therefore correct by construction.

**The root can be a node too** — `config.shell.tree`. Declared there, the
shell stops owning the menu and keeps only the **space** (zones, layers,
stages, toolbar, overlays, theme, breakpoints): the root node's children are
the app's primary options, and it projects them into zones instead of into its
own body.

```json
"shell": {
    "zones": {"top": {"host": "toolbar"}, "left": {"show_on": ">=desktop"},
              "bottom": {"show_on": "<desktop"}, "center": {"host": "stage.main"}},
    "stages": {"main": {"zone": "center", "default_route": "/"}},
    "tree": {
        "base_route": "/", "stage": "main",
        "projection": {
            "index":  [{"zone": "left", "layout": "vertical"},
                       {"zone": "bottom", "layout": "icon-bar"}],
            "chrome": [{"zone": "left", "layout": "vertical"},
                       {"zone": "bottom", "layout": "icon-bar"}]
        },
        "children": [ /* the primary options, and everything under them */ ]
    }
}
```

Note what is NOT there: no zone declares `host: "menu.<id>"`, and there is no
`menu` block at all. A render config with a `zone` mounts through
`yui_shell_zone()` and **persists** — the rail is standing chrome, so it is
built once and told where the user is, not rebuilt per navigation.
`menu.primary.render` always was a per-zone projection; this just gives it an
owner that can hold it.

`shell.tree` synthesizes exactly ONE route entry, flagged `owns_subtree`, which
is the only case where root `/` may match as an ancestor (`route_resolver.js`).
The unknown-route diagnostic is not lost by that: it moves to the node that
actually knows the names of its children. Runnable reference:
`test-app/tree.html` (`_qa_root.mjs`), served beside `index.html` so the two
navigation models can be compared in one browser.

**Where the tree ends.** One gobj per structural node is right; one gobj per
meter reading is not. A node marks the boundary with `link` — a pointer into a
data space (a timeranger: millions of raw records, series/time, key/value) plus
the viewer suited to that shape:

```json
{"id": "m1", "label": "Meter 1",
 "link": {"kind": "tranger", "gclass": "C_MY_TRANGER_VIEW",
          "kw": {"topic": "meters^north^m1"}}}
```

A link node is always the **tip of the structure**: the url keeps going, but
its tail is handed to the viewer as `EV_ROUTE_CHANGED {base, subpath}` — the
same contract the shell gives a view (ROUTING.md §5), so a viewer cannot tell
whether the shell mounted it at a declared route or a node did, deep in a tree.
`base` is the node's canonical route, which is what the viewer builds its own
deep links from. An empty subpath means the viewer's home, which is what makes
Back out of a deep data position land on it. Below a link there are no nodes:
`link` + `children` (or `link` + `content`) is a config error, because a silent
winner in "who owns the subpath" would be the worst outcome.

**The tree is a contract, not runtime state.** Once published, a node's path
is a url a client may have bookmarked, scripted, or been sold as another door
into the system. So there is deliberately **no reparent/move API**: the shape
is versioned (`tree_version` on the root, `yui_node_tree_version()`), and a
rename migrates through `aliases` — the former id keeps resolving and the URL
is rewritten (replace) to the canonical spelling, the same shape as an HTTP
301. Anything a version bump cannot cover is a new tree, declared as such.

Runnable reference: the **Cards** chapter of `test-app` (four levels plus a
panel that mutates the live tree), driven by `test-app/_qa_nodetree.mjs` and
`test-app/_qa_extra.mjs`.

### C_YUI_SERVICE_VIEW — mounting a view that talks to a backend

A view asks the backend for data with `gobj_command(remote, …, src = itself)`,
and `C_IEVENT_CLI` routes the answer back with
`gobj_find_service(gobj_name(src))` — **which only finds registered services**.
Neither host creates one, so a backend-talking view mounted directly at a route
never receives a single answer: it sits empty while the ievent logs *"service
not found"* once per answer. And a route's `target.kw` is static JSON, so it
cannot carry the live transport pointer either.

Two shapes, because the callers are not alike:

```js
/*  A route with NO extras: declare the host, name the view it hosts.  */
{ gclass: "C_YUI_SERVICE_VIEW", kw: {
    view_gclass:  "C_MY_VIEW",
    service_name: "#my-view",          // UNIQUE per mount — see below
    view_kw:      { title: "…" }
}}

/*  A wrapper that keeps its own extras (url segments into the hosted view,
 *  rebinding it when a connection drops): drop only the boilerplate.       */
let view = yui_mount_service_view(gobj, {
    gclass:    "C_YUI_TREEDB_TOPICS",
    name:      service_name(gobj),
    kw:        {...},
    transport: remote            // already resolved (e.g. per connection);
});                              // omit it for "__remote_service__"
```

The hosted gclass must declare the attr the transport is injected under
(`gobj_remote_yuno` by default), build its `$container` in `mt_create`, and flag
`EVF_PUBLIC_EVENT` on whatever arrives from the backend — the ievent drops
events that are not public.

**The service name must be unique per mount**, and a duplicate is dangerous
precisely because it is not fatal: gobj-js logs *"service ALREADY REGISTERED.
Will be UPDATED"* and **rebinds the name**, so two mounts of one route would
cross their answers. Derive it from the route (or from whatever else makes the
mount unique — a connection id, a workspace), never from the gclass alone.

> **Why the hosts do not just create services.** It would make every routed view
> an inter-yuno endpoint by default, against the framework's rule that only
> named services are; most views never talk to a backend; and the collision
> above would become the default failure mode. Opt-in per route instead.
>
> **Known asymmetry, deliberately left alone:** `C_YUI_SHELL` mounts a view with
> `gobj_create()` (a plain child) and `C_YUI_NODE` with
> `gobj_create_pure_child()`. The flag decides whether a gclass that consults
> `gobj_is_pure_child()` sends its output event straight to the parent or
> publishes it. Today nothing consults it *on a view* (only `c_ievent_cli` and
> `c_timer` do, and always about themselves), so the difference has no observed
> consequence — but the same view gclass does get a different flag depending on
> who mounted it. Align it the day it bites, with the case that bit.

### C_YUI_JSON — lazy JSON tree viewer

Indentation follows the house rule: four characters per level, plus a **guide
line per ancestor**. The rows are siblings with growing padding rather than
nested boxes, so the guides are painted as a repeating gradient bounded to
each row's own indentation (`background-size` set per row) — which is why the
hover state must set `background-color`, never the `background` shorthand, or
the guides vanish under the cursor.

A container-agnostic viewer (like `C_YUI_PAGER`): it owns only a toolbar +
scrollable tree body and exposes a `$container` the parent mounts wherever it
wants (a `C_YUI_WINDOW` body, a `yui_shell_show_modal` card, or inline). It is
built to show **arbitrarily large** JSON, so it never assumes the whole
document fits in memory or the DOM.

**Server-driven lazy expansion.** The C kernel's `kw_collapse()` (`kwid.c`,
used by the `print-tranger` command) truncates over-limit dicts/arrays into a
sentinel — `{ "__collapsed__": { "path": …, "size": N } }` (dict) or
`[ { "__collapsed__": … } ]` (array). `C_YUI_JSON` renders each sentinel as an
expandable stub and, when the user opens it, **does not fetch anything itself**:
it publishes `EV_EXPAND_PATH {path, size}` to its subscriber. The subscriber is
the only party that knows the backend (it re-issues `print-tranger path=<path>`
with limits, or any equivalent), and hands the subtree back via
`EV_SUBTREE_LOADED {path, json}`. Only expanded containers are materialised in
the DOM, so the tree stays bounded regardless of document size. With no
sentinels present it degrades to a plain client-side collapsible tree.

**Three views, one document.** The toolbar switch (and the `view_mode` attr,
`"tree"` | `"text"` | `"graph"`) picks which. They answer three different
questions:

The switch reads `text · tree · graph`, flattest reading first — which is not
the order of arrival.

**Which view it opens on** is decided in three steps: what the HOST asked for
(the `view_mode` attr), then what the READER chose last time, then the tree.
That is why the attr's default is the **empty string** and not `"tree"`: as a
default and as a host's explicit choice `"tree"` was the same string, so
nothing could tell *"show me the tree"* from *"I have no opinion"*, and a
memory that cannot see the difference has to lose to both. Pass `view_mode`
only to PIN a view.

The reader's choice is kept in `localStorage` under one key for the whole
library — which of the three views somebody reads JSON in is a habit of the
person, not a property of the document — and only a view they picked is
remembered: a mode the host pinned, or the tree we fell back to, would
otherwise be written back as if somebody had chosen it.

| view | question | notes |
|---|---|---|
| `text` | *what does this document say, verbatim* | `JSON.stringify(…, 4)`, four characters per level |
| `tree` | *where is this value, and what is around it* | the lazy view; the only one that can drill; the default |
| `graph` | *what shape is this* | a hosted `C_YUI_JSON_GRAPH` child (AntV/G6) |

**What the graph draws is the document, not a rearrangement of it.** Every key
is a ROW of its card, containers included — `cols` is one key of a topic dict
exactly like `pkey` is, and a drawing that leaves it out does not say what the
document says. What a container row does NOT get is its contents repeated
underneath it: the row says `[14]` and the fourteen cards say the rest, which
is the part that has to scale.

A container with **no scalars of its own** gets no card at all: it is not a
thing, it is a LIST of things, described by the row that names it plus the
edges to what it holds, and its children hang from its parent. The root always
keeps a card.

Each container key opens a **G6 port** on the line of its own row, and its edge
leaves from there; the edge carries an arrowhead and arrives at an `in` port
centred above the target card's title. Ports on an html node need care: the
HTML is a DOM layer over the canvas, so a port fully inside the box is painted
under the card — they sit ON the border.

Cards are `JSON_CARD` and carry `data-json-path` (`GOBJ_CARD` /
`data-gobj-name` in the gobj tree, `TREEDB_CARD` in the treedb graph): a gclass
that builds DOM owes the Inspector a readable tree, and the anchor's mark needs
something to find its card by after a rebuild — the node id does not survive
one, the path does.

Since `7.23.2` the schema and cell-JSON popups of `C_YUI_TREEDB_TOPIC_WITH_FORM`
present that viewer as a floating `C_YUI_WINDOW` on a laptop — movable,
resizable, maximisable, position remembered — and keep the modal sheet on a
phone. A JSON document is read *while* looking at the table it came from, which
a modal cannot allow; a phone has no room to arrange anything, so the sheet
stays. Note the close contract: `C_YUI_WINDOW.close_window()` calls `on_close`
and THEN destroys itself, so a host must drop its reference rather than destroy
the window too.

Its camera comes from **`yui_graph_camera.js`** (`7.23.4`), the one place every
DOM-toolbar graph asks for one — this gclass and the gobj tree both, so a
cloned toolbar cannot drift apart again. The vocabulary is the treedb graph's
(`c_g6_nodes_tree.js`, which draws its toolbar as a G6 plugin over an SVG
sprite and so cannot share the code, only the decisions): zoom in / zoom out, a
live **zoom readout**, `yi-fit` for fit — the same corner brackets the sprite
draws for `g6-icon-fit` — and the WRITTEN `1:1` for actual size, which is
written in every editor that offers it and never drawn. Fold is the
double-chevron pair (`yi-angles-down` / `yi-angles-up`, `7.23.147`), the same
as the lazy tree viewer's toolbar: not the single chevron of a node's handle,
which opens ONE node. The readout hangs off G6's
`aftertransform`, the one hook that also covers the wheel.

Both graph toolbars are arranged the same way (`7.23.7`): the **global**
expand/collapse leftmost, ahead of the find box; camera and refresh in the
centre; the layout picker on the right. The **per-node** fold is a different
control and lives elsewhere — on the right of each card's own header, last,
where the gobj tree puts its `+N` / `−`.

The graph carries **its own** toolbar, a **layout picker**
(`EV_CHANGE_LAYOUT {layout}`: `vertical tree`, `dagre top-down`,
`dagre left-right` — the layout and the edge type move together), and since
`7.22.0` the same two facilities the tree has: a **find box** (rate-limited, `EV_FIND_NODES`)
that highlights matching rows and outlines the cards they are in and says how
many matched, and **expand-all / collapse-all** (`EV_EXPAND_ALL` /
`EV_COLLAPSE_ALL`) that fold every card but the root, marking each cut with
`▸ N` so the shape stays legible and you can see where the rest went. Since
`7.23.0` each card that HAS a branch also carries its own handle in its header
(`EV_TOGGLE_FOLD {path}`); a leaf gets a spacer of the same width instead,
because a handle that does nothing is worse than none and the labels should
stay on one axis.

That handle is the **same filled chip the gobj tree draws** (`+N` folded, `−`
open — see `render_toggle_html` in `c_yui_gobj_tree_js.js`), and it is a chip
for a reason: `7.23.0` shipped it as a bare `▾` and on a phone it was
invisible. At the zoom that fits a document on screen a glyph is a couple of
pixels of ink; a filled chip is still a visible blob and reads as something you
press. `+N` also carries the count of what is hidden.

The handle is delegated from the canvas mount in the **capture** phase, over
`pointerdown`/`pointerup`/`mousedown`/`click`: the card is an `innerHTML`
string so nothing can be bound to it directly, G6 binds on the node element
below, and G6 builds its click from the POINTER sequence — swallowing `click`
alone leaves the fold working and the card also reporting an item click. A find
does not move the camera — a viewport that jumps on every keystroke is
unusable — which is why the count is there.

Note **where the highlight lives**: baked into the card's own markup, never set
as a G6 node state. The key shape of an `html` node is a DOM element and G6
paints no state style on it, so setting `active` selects correctly and shows
nothing.

- **Neither text nor graph is lazy.** Both show what the client currently
  holds, `__collapsed__` sentinels included, because that is honestly what it
  has. Drill in the tree and they grow with it.
- **The tree-only controls** (search, expand-loaded, collapse-all) hide with
  the tree; copy stays. A control that can answer nothing is worse than an
  absent one. The graph brings its own zoom/centre toolbar.
- **The graph child is built on first entry** into graph mode, never in
  `build_ui`: G6 sizes itself from its container, so a graph created behind
  `is-hidden` comes up 0×0. `register_c_yui_json()` auto-registers
  `C_YUI_JSON_GRAPH` (and that register is idempotent, so an app may also
  register it itself, in either order).

Two layout facts the browser taught this component, both worth keeping:

- In the text view long lines scroll sideways **inside** the viewer
  (`white-space: pre` on a `max-content`-wide `<pre>`), never on the page body:
  in a raw dump the indentation *is* the structure, and a wrapped line restarts
  at column 0 and lies about the depth of everything under it. The `<pre>` must
  be `max-content` wide or the container reports no overflow and the tail of
  every long line is unreachable.
- The graph body carries a **definite `height`** (`24rem`, with
  `flex: 1 1 auto` so a constrained host still wins). Not `min-height`: a
  percentage height does not resolve against a box sized by a minimum, and the
  tree and the text push their own height while a canvas pushes none — so in an
  unconstrained host the graph came up as a 2px hairline.

**Contract:**

- Attributes: `subscriber`, `title` (i18n key, optional — hidden on mobile,
  where the toolbar cannot hold it as well as the buttons), `json_data`
  (initial JSON, optional — given, the viewer starts in `ST_READY`), `view_mode` (`"tree"` default | `"text"` |
  `"graph"`), `$container` (mounted by the parent).
- Input events: `EV_SET_JSON {json}` (replace the whole document; `ST_EMPTY` →
  `ST_READY`), `EV_SUBTREE_LOADED {path, json}` (splice a fetched subtree),
  `EV_SUBTREE_ERROR {path, error}`, `EV_SET_VIEW_MODE {mode}` (`"tree"` /
  `"text"` / `"graph"`; **no mode advances** to the next view, which is what
  the two-view toggle did when the list was two long), plus `EV_REFRESH` /
  `EV_SHOW` / `EV_HIDE` / `EV_LANGUAGE_CHANGED`.
- Output event: `EV_EXPAND_PATH {path, size}` (`EVF_OUTPUT_EVENT`) — the parent
  must declare it in its own FSM (CHILD subscription model). That is the ONLY
  one, on purpose: this viewer is a child of its host and subscribes it to
  everything it publishes, so every output event is a mandatory declaration in
  every host's FSM. The graph child's `EV_JSON_ITEM_CLICKED` stops here
  (`7.21.0` forwarded it and broke exactly that way); a host that wants node
  clicks mounts `C_YUI_JSON_GRAPH` itself.
- Internal (DOM → FSM): `EV_TOGGLE_NODE`, `EV_EXPAND_COLLAPSED`, `EV_SEARCH`,
  `EV_EXPAND_ALL`, `EV_COLLAPSE_ALL`, `EV_COPY_ALL`. Every kw carries only a
  `path` string — never a DOM node or gobj.
- i18n keys the switch needs: `tree view`, `text view`, `graph view`,
  `text truncated; collapse some branches`. All spelled out inside `t()` in
  `view_label()` — never `t(VIEWS[i].key)`, which no `validate-locales` can
  see (that shipped once, in `7.20.0`).
- Paths use the kernel delimiter (backtick) and index arrays numerically, so a
  path emitted by the viewer round-trips through `kw_find_path` on the backend.

**Backend note.** The Raw JSON feed is `print-tranger`, which serves the tranger
with both dict- and array-drill (via `kw_collapse()`): `c_tranger.c` for a
`C_TRANGER` service, and `C_NODE` (it holds `priv->tranger`) for a **treedb**.
A document that arrives with no `__collapsed__` sentinels is simply rendered
client-side (no lazy drill).

Logical DOM classes: `JSON_VIEWER`, `JSON_TOOLBAR`, `JSON_SEARCH`, `JSON_TREE`,
`JSON_ROW`, `JSON_KEY`, `JSON_VALUE`, `JSON_SUMMARY`, `JSON_COLLAPSED`,
`JSON_TIME`. The gclass imports its own `c_yui_json.css`.

### What a node in the graph is CALLED

`C_G6_NODES_TREE` (the record graph inside `C_YUI_TREEDB_GRAPH`) labels a card
by what NAMES the record, which is not always what KEYS it. A topic whose id
column is flagged `rowid`, `uuid` or `qualified` keys its records by something
that is not the plain name — a counter, a random string, or the name with
every ancestor in front of it — and the name lives in the secondary key the
topic declares (`pkey2s`). `treedb_system_schema` is the case that forced it:
its `topics` and `cols` records are named in `value`, so the graph drew cards
reading `181`, `225`, `193` while they were keyed by rowid, and would read the
whole path now that they are qualified.

The rule is in **`treedb_node_label.js`** (pure, unit-tested): read the pkey
column's flags from the desc; if the key is not the plain name, take the first
`pkey2s` field the record actually carries; otherwise keep the id. **The pkey is never
lost** — it is the card's tooltip, on the chip and on the entity card alike.

It needs the descriptor to carry `pkey2s`, which `tranger2_topic_desc()` only
clones from **SDK > 7.13.0**. Against an older node the desc has no `pkey2s`, the
label falls back to the id, and nothing else changes.

### Which backend a view browses: `source_url`

`C_YUI_TREEDB_TOPICS` takes an optional **`source_url`** string and prints it in
its toolbar, between the left buttons and the *raw json* one. A host passes the
url of the connection the view reads through (`wss://host:port`), and an empty
value renders nothing.

The tab that hosts the view is labelled with the **treedb** name, and a treedb
name is not unique across backends: two tabs reading `treedb_yuneta_agent` are
two different machines, and a wrong assumption there is a write on the wrong
node. The url is what tells them apart, and it does not fit in a tab label —
a tab wide enough for `wss://artgins.yunetacontrol.com:1996` is a tab bar with
room for one tab. So the view carries it, where there is a whole row for it.

The buttons of that toolbar never shrink. When the row runs out of room, the
url is what gives way, cut with an ellipsis, and the whole value stays in the
`title` and the `aria-label`.

### Reading a topic a page at a time

> ⚠️ **`with_remote_paging` is OFF in every consumer, and must stay off until
> linking works with a partial topic.** A record's link picker is built from
> the rows the PARENT topic's table holds — `build_fkey_options()` asks it
> with `get_topic_data`, which answers `tabulator.getData()` — and with paging
> that is one page. So the picker offers 200 of the 5891 possible parents (the
> right one is usually not among them), and a record whose parent is not on
> the loaded page opens with an fkey the form cannot match, which saving then
> drops. A treedb is a **memory database** — the backend holds the topic in
> RAM either way — so paging buys little and costs the links. The machinery
> below stays, working and tested, for the day linking learns to ask the
> BACKEND for a parent instead of the sibling table.
>
> **Local pagination is not affected and stays on.** `getData()` answers the
> WHOLE dataset whatever page is on screen, so paginating the DISPLAY is safe;
> only paginating the FETCH is not. `page_size` sets the display page for both
> paths.

**A topic table opens sorted by `id`** (since `7.23.143`): the pkey, ascending
and natural (`alphanum`, so `d2` comes before `d10`) — normalized records are
read by their key. Not when the app passes its own `initialSort`, and not with
`with_remote_paging`, where a sort in the browser would reorder one page.

`C_YUI_TREEDB_TOPICS` takes **`with_remote_paging`** (off by default) and
forwards it to every topic table: the table pulls the page it is showing
instead of the host pushing the whole topic down. It needs the SDK's `nodes`
with `from` / `limit` (see `YUNO_TREEDB.md` §5.3).

**The page size is generous on purpose** (`page_size`, 200 when paging). A
treedb that fits in one page behaves exactly as it did — paginator hidden,
every filter seeing every row. Only a topic that does NOT fit pays for paging,
and for that one loading it whole was never an option.

**Safe against a backend that cannot page:** it answers the whole list, which
`nodes_answer()` reads as one page. That is the truth, and it is why the table
can ask without knowing what it is talking to.

`filterMode: "local"` says the plain truth: the header filters and the search
box work on the page that is loaded. Same as the tranger browser's Rows card,
and for the same reason — the alternative is pushing every filter to the
backend and changing what "search" means.

Who does what: the transport belongs to the HOST, so the table asks with
`EV_REQUEST_PAGE` and the answer comes back as `EV_PAGE_LOADED`, correlated by
an id echoed in `__md_command__`. **Read that id flat off the command stack**
(`kw_command.req_id`): `C_IEVENT_CLI` EXTRACTS `__md_command__` and pushes it
AS the stack's `kw`, so one level deeper is a level too far — and the symptom
is every request timing out with its answer sitting right there.

The promise Tabulator wants is parked in the table (`ajaxRequestFunc` must
RETURN a promise — it is a data source, not an event), with a watchdog,
because the link can stay up and an answer still never land. A refresh
re-pulls the page the reader is on rather than throwing them back to the
first.

### What a delete takes with it

A treedb delete is not one thing, and these views delete with **`force`**.
`force` on a node does not only remove it: its children are **UNLINKED** —
they survive, loose — and it is cleaned off its parents. So "delete this row"
can mean "detach eleven records from their only parent", and the question that
used to be asked, `are you sure`, said none of it.

The confirmation names what is going (the record's key, or how many) and adds
a line per thing at stake, **each only when there is something at stake** — a
loose record must not be dressed up as a dangerous one:

- *N children will be UNLINKED, not deleted*
- *It will be detached from M parents*

Counted off the record the table already has (`list_dict` fills the hook and
fkey columns), so asking costs no round trip. The counting is
`delete_impact.js`, pure and tested, because the shapes are the fiddly part: a
hook or fkey value arrives as a list of refs, a dict keyed by id, or a single
ref string. A column that is BOTH hook and fkey still counts on both sides,
for stores written before the SDK began refusing that pair at schema parse.

In the graph the node-delete popover carries the same two lines, and the
**unlink** popover carries the reassurance that is its whole point: *neither
record is deleted*. Next to a delete button painted the same red, that is not
obvious.

Three things this cost, worth knowing before composing any message from keys:

- `yui_shell_confirm_*` renders its message **as an i18n key**, so a composed
  sentence can never be one. Pass DOM instead — the helper takes it.
- `createElement2` **trims text nodes**, so a `["span", {}, " "]` separator
  vanishes and the question reads "BorrarDeveloper". Space with CSS.
- a **counted** word carries no `i18n` attribute: `yui_shell_show_modal` calls
  `refresh_language()` on the dialog's content, which re-translates from the
  key alone — without the count — and puts the plural back over the singular.
  Nothing is lost, because a dialog with a backdrop never sees a language
  change.

### Editing a topic table in place

A writable scalar is editable in the table, in edition mode
(`with_inline_edit`, default on). Changing one field used to mean opening the
record form, changing it, saving and closing.

**Which cells, and why not the rest.** The schema decides first: only a column
flagged `writable`, and never the pkey — renaming what a record is KEYED by is
not a field edit. Then the type: a hook holds children and an fkey IS a link,
so both are edited by linking; a dict or a list is a document the form has an
editor for; a date cell shows a formatted string over an epoch, so typing into
it would write the string. Those stay with the form, one click away on the
same row. `boolean` gets a tick, `enum` the list of its own values, numbers a
number editor.

**The write is a partial update with no `autolink`, and that is the whole
safety of it.** `treedb_update_node()` merges (`json_object_update`), so the
fields it does not carry are left alone; `autolink` is the option that wipes a
node's links and rebuilds them from the fkeys the record carries, and on a
partial record it reads that as "no parents", detaches the node and answers
**success**. So a cell edit travels as its own event, `EV_UPDATE_FIELD`, and
not as `EV_UPDATE_RECORD` — that one does send autolink, and may, because the
form hands it the whole record with its fkeys in it. See
`schema_write_options.js` for the rule and why each word of it is there.

`editable` is a **function** on the column, not a flag: edition mode is
toggled on a table that is already built, so the answer has to be asked for at
the moment of the click.

A refused write puts the topic back to what the treedb has. Leaving the typed
value on screen is tolerable for a form, which stays open on the values it
failed with; a cell edited in place would just look saved.

**The form stays open until the backend answers** (7.23.193). With
`form_waits_for_answer` set — `C_YUI_TREEDB_TOPICS` sets it — a Save leaves the
form open and busy, and `EV_CREATE_RECORD` / `EV_UPDATE_RECORD` carry a
`form_write` serial. The host echoes it back in `EV_WRITE_DONE` (the form
closes) or `EV_WRITE_REFUSED` (it stays open on what was typed), and it must
answer EVERY way the write can end: refused before it left (read-only, no
session) as well as answered by the backend, or the form waits forever. A host
that does not answer leaves the attribute off, and the form closes when the
write is published, as before 7.23.193 — whatever the backend says.

```js
// A host of C_YUI_TREEDB_TOPIC_WITH_FORM that answers the form's writes
gobj_create_pure_child(name, "C_YUI_TREEDB_TOPIC_WITH_FORM",
    {topic_name: "users", form_waits_for_answer: true /* ... */}, host);
// on the backend's answer to the write that carried kw.form_write:
gobj_send_event(topic_form, ok ? "EV_WRITE_DONE" : "EV_WRITE_REFUSED",
    {form_write: kw.form_write}, host);
```

**A delete is resolved by IDENTITY, never by position** (7.23.193). The row's
`id` crosses the confirmation dialog; a Tabulator position is regenerated by
every row the node events add or remove while the dialog is open, and it used
to delete the row that sat there when the person answered.

### Reading a topic table: filters, columns, CSV

`C_YUI_TREEDB_TOPIC_WITH_FORM` had one global search box over the loaded rows.
Three tools join it, each behind its own flag, all **on** by default:

| attr | what it adds |
|---|---|
| `with_header_filters` | a filter box in the header of each column a match means something on |
| `with_columns_button` | a dialog that ticks which columns the table shows |
| `with_export_button` | downloads what the table holds as CSV |

**Not every column gets a filter box, on purpose.** A hook holds children, a
dict holds a subtree, and a date cell shows a formatted string over an epoch
number — a text match against the raw value there answers a question nobody
asked, so those columns get no box rather than a box that lies. A `boolean`
gets a tristate tick, an `enum` gets a list of its own values, and an `fkey`
gets a box whose match stringifies the value first, because *which rows point
at X* is the question fkey columns exist to answer and a fkey arrives as a ref
string, a list of them or a dict.

The search box and the header filters are **separate layers**: clearing the
search does not silently drop the column filters. The CSV carries what the
table HOLDS — the loaded rows, the visible columns, both filters applied, which
is what the reader is looking at. It is not the topic: a server-side dump of
every node is not something this view can stream, and the button's title says
so.

Searching is a user action, so it crosses the FSM (`EV_SEARCH`) like the rest;
it used to call `tabulator.setFilter` straight from the DOM handler, where the
`machine` trace could not see it.

### A hook opens the rows it links

A hook cell shows how many children the row has (`[5675]`). A click on it
opens the **child topic's table, filtered** to the rows whose fkey names this
row (since `7.23.162`). Above the table, a chip says which filter is on,
"filtered by device_types electric_bt", and its ✕ brings the whole topic back.

The child table already pages, searches, opens a record and exports. Until
`7.23.162` the click opened a popup that listed every child id instead:
5,675 rows in a box with no height, no scroll and no way to close it but a
click inside, and the ids did nothing.

- The child topic and its fkey come from the hook's own mapping in the
  schema, `{child_topic: fkey_col}`:

  ```c
  'devices': {
      'header': 'Equipos',
      'type': 'object',
      'flag': ['hook'],
      'hook': {
          'devices': 'place'
      }
  }
  ```

  A click on this hook opens `devices`, filtered to the rows whose `place`
  names the clicked row.
- A hook whose children live in more than one topic asks which one, in the
  standardized dialog (Escape, Back and a click outside close it).
- The switch goes through the same entry point as a tab click, so the host
  puts it in the URL and Back returns to the topic the hook was on. The
  filter itself is not in the URL.
- The parent filter and the search box are **one** filter. Tabulator's
  `setFilter()` replaces every programmatic filter, so as two filters each
  would wipe the other.
- A child row matches when its fkey names the parent id, and also its topic
  and hook when the ref carries them: one row can hang from the same parent
  through two hooks.
- The filter works on the rows the table holds. With `with_remote_paging`
  that is one page, which is one more reason that flag stays off.

The table asks its host to switch, because the tabs belong to the host:

```
C_YUI_TREEDB_TOPIC_WITH_FORM                      C_YUI_TREEDB_TOPICS
  EV_OPEN_LINKED {topic_name: "devices", fkey: "place",
                  parent_topic: "places", parent_id: "es", hook: "devices"}  ──►
                                          select the tab of `devices`
  EV_FILTER_BY_PARENT {fkey, parent_topic, parent_id, hook}  ◄──  to that topic's table
```

`EV_CLEAR_PARENT_FILTER` is the ✕, and `EV_CHOOSE_LINKED` is a pick in the
"which topic" dialog. All three are input events; `EV_OPEN_LINKED` is the one
output event, and `C_YUI_TREEDB_TOPICS` declares it.

**The table loads a hook as its count** (since `7.23.163`). Its three loads
ask with `hook_size`, so a hook arrives as `[{"size": N}]` and not as the ids
of its children, which nothing in the table reads any more:

```js
const TABLE_READ_OPTIONS = {list_dict: true, hook_size: true};
```

The node events still carry whole hooks, so after a live update a row can
hold the list again; the cell and a delete's question count either shape. The
search box no longer finds a parent row by the id of one of its children: to
find what hangs from a row, click its hook.

### Read-only treedbs: `readonly`

`C_YUI_TREEDB_TOPICS` and `C_YUI_TREEDB_GRAPH` take a **`readonly`** attr; the
topics view propagates it to every topic it builds. It is not one more button
flag: it is the STATE of the treedb and it beats each `with_*` flag at once,
because a treedb whose tranger the yuno does not master answers **every** write
with

```
ERROR -1: <yuno>: treedb '<name>' is READ-ONLY, this yuno is not the master of its tranger
```

(the yuno refuses since SDK 7.13.0), so offering the buttons anyway turns a
fact into an error message per click. Ask the yuno which it is with
`command-yuno id=<yuno> service=<treedb> command=treedb-info`, which answers
`{treedb_name, master, schema_version, topics}` — and remember the flag is per
TREEDB and is runtime state: a yuno is routinely the master of its
`treedb_system_schema` and a replica of a data treedb it shares.

What `readonly` takes away: the edition mode, the *new* / *delete* / *paste*
buttons, the in-row edit icons, and the write half of the record form's toolbar
(`copy` stays — reading a record includes taking it with you) with the cells not
editable. The record form still OPENS: looking is the point of a replica.

In the **graph** it takes away the `edition` operation mode, which is the only
one that draws the create / delete / link affordances — the mode select stops
offering it, and a graph left in edition on a master comes back in `reading` on
a replica (the mode is a persisted preference). The other modes are untouched:
panning, zooming and opening a node are reading.

Two implementation notes worth keeping:

- the decision lives in **`treedb_write_plan.js`** (pure, tested), not in five
  `!readonly && with_x` expressions — five places to forget the sixth;
- and the write **events** are refused as well, in every gclass, with a
  `log_error`. Hiding a button is not the same as refusing a write: an event can
  still arrive from a keyboard path or a form that outlived the flag, and an
  ignored write is exactly the behaviour this whole change exists to stop.

### The graph's viewport toolbar

**The wheel scrolls; Ctrl + wheel zooms** (since `7.23.75`, in every operation
mode of every graph built on `C_G6_NODES_TREE`). A graph of many nodes is
taller than the screen, and a wheel that zoomed instead made it a thing to be
looked at from afar or read through a keyhole — never scrolled, which is what
a wheel does on a map and on every page. Shift + wheel scrolls sideways, a
trackpad pinch arrives as Ctrl + wheel and keeps zooming, and the two-finger
pinch on a touch screen is [our own](#the-graphs-on-a-touch-screen). G6's
`scroll-canvas` takes the plain wheel (its `enable` stands aside for a Ctrl
wheel) and `zoom-canvas` takes `trigger: ['Control']`. **One key, named the
same on both sides**: the scroll used to stand aside for Meta as well, so
Cmd + wheel on a Mac neither scrolled nor zoomed (`7.23.84`). It cannot be
both keys, either — a G6 `trigger` is a CHORD, not a list of alternatives:
`Shortcut.match()` compares the keys held to the keys bound as a SET, so
`['Control', 'Meta']` would mean both at once.

**One glyph size for every toolbar of the library** (`7.23.86`, moved to its
proper home in `7.23.87`): `YUI_TOOLBAR_ICON_SIZE` in `yui_toolbar.js`, with a
`yui_toolbar_icon()` that builds the `<i>`. It is in **`rem` and not `em`**
for a reason worth keeping: in `em` the same `1.5em` drew 24px inside a plain
button, 18px inside an `is-small` chip of the legend, and a labelled button
left its icon at the inherited 16px — three sizes in two strips that are read
as one thing. It is the toolbar's size and not the graph's: the JSON viewer,
the treedb topics row and the treedb table row take it too. The app BAR keeps
its own, bigger glyph (`.yui-toolbar-item .icon`, 1.35rem on 44px items) —
that is a different band, not drift. The legend under the toolbar
follows it: topic name at `1rem`, count at `.875rem` (the one deliberate step
down), glyphs at the shared size, and `align-items: stretch` so a chip's star
and crosshair are as tall as its body.

`C_G6_NODES_TREE` floats a vertical toolbar over the canvas:

| control | what it does |
|---|---|
| zoom in / zoom out | one step of scale |
| **the zoom level** | a readout, not a button — `85%` |
| ── | |
| fit | `fitView()`: the whole graph in the viewport |
| **`1:1`** | `zoomTo(1)`: actual size |
| **anchor** (crosshairs) | pick one element; every zoom then leaves it in the middle |
| ── | |
| **fit to selection** | `fit`, for the part that is selected (edition only, disabled while nothing is) |
| ── | |
| full screen | the container, not the camera |

Two of those rows are the answer to a real complaint, and the reasoning
generalises:

- **`1:1` used to be a house**, and the house was the thing people reached for
  when they wanted the graph back. It never gave it to them: the action is
  `zoomTo(1)`, which sets the **scale** and leaves the camera where it was, so
  from a corner of a large graph it answered with the same corner at 100%. A
  house means *the initial extent* in a map and *the starting view* in an
  editor — never a scale — and this one sat directly under `fit`, so the pair
  read as two ways to do one thing. Actual size is **written** in every editor
  that offers it, because there is no glyph anybody recognises for it.
- **the zoom level is shown** because `1:1` is a jump to a number, and a jump
  to a number is only meaningful next to the number you are on.
- **fit to selection** wears the `fit` icon with a marked object inside it,
  drawn rather than borrowed: the two sit next to each other and are the same
  action at two scopes, so they have to read as a family. It appears only in
  edition — a button that can never be enabled is furniture, not a control —
  and is disabled while nothing is selected. `fitView()` has no subset form,
  so the bounds are measured off the elements and the zoom is clamped to the
  graph's own `zoomRange`, the only limit that is not invented here. One card
  filling the view is not a bug: that is what zooming to it means.

The **separators are gaps, not lines**: every item already carries a hairline
against its neighbour, so one more line would not group anything. Full screen
is behind the second one because it is a window control that happens to live in
a camera toolbar.

Both toolbars (this one and the edit one) **follow the theme**. They used to be
pinned to a light background in both themes, with the icon colour pinned dark
so it survived that — two light islands over a dark canvas.

**New keys for consumers: `actual size`, `zoom level`, `zoom to selection`**
(all tooltips, so a host that has not defined them shows the key on hover and
nothing else breaks).

### The wheel over HTML nodes: `yui_graph_forward_wheel`

G6's HTML nodes are drawn in a DOM layer **beside** the canvas element, and
they pass on six pointer events and not the wheel. `scroll-canvas` listens on
the canvas element and `zoom-canvas` reads G's wheel, which comes from that
same element. So a wheel over an HTML card reaches neither: the graph does not
scroll and does not zoom.

`yui_graph_forward_wheel(graph, $host)` catches the wheel on the element G6 was
mounted in and dispatches it again on the canvas element, with its deltas, its
position and its modifiers. Both behaviors then take it as a wheel over empty
canvas. It returns the function that removes the listener; call it before the
graph is destroyed.

```js
priv.unforward_wheel = yui_graph_forward_wheel(graph, priv.$canvas);
// ... in the teardown, before graph.destroy():
priv.unforward_wheel();
```

An element that must scroll by itself inside the graph (a popover) stops the
wheel with `stopPropagation()` before it reaches `$host`.

A graph that puts other DOM in its container, such as its own toolbars or a
legend, passes `opts.selector`. Then only a wheel whose target is inside a
matching element is forwarded, and a wheel over the rest keeps its own
behaviour:

```js
priv._unforward_wheel = yui_graph_forward_wheel(graph, priv.$container, {
    selector: ".TREEDB_CARD, .TREEDB_PILLS"
});
```

`C_YUI_TREEDB_SCHEMA` forwards every wheel (its container holds only the
cards). `C_G6_NODES_TREE` forwards only the wheels over its cards and pill
strips (since `7.23.164`), so Ctrl + wheel over its toolbar does not zoom the
graph.

### The anchor: one element the camera holds

The same crosshairs button is in all three graphs' toolbars, drawn once in
`yui_graph_camera.js`. Why it exists: a graph that FITS on screen is unreadable
at the zoom that makes it fit — one topic's schema fits at 37%, where every
card is grey texture — and the zoom that makes it readable does not fit. So the
useful view is always a fraction of the document, and WHICH fraction was
nobody's decision: `1:1` translated to the layout's origin, a corner with
nothing in it, and the reader then hunted for the node they had been looking at.

Three states, because two could not say what a press does: `off`, **arming**
(the attention colour, and the pointer over the canvas turns to a crosshair)
and **on** (pressed). The anchored card carries a **dashed amber outline** —
dashed because in the treedb graph a solid amber ring already means a find
match and blue means the selection, so a third solid ring would be a third
thing to learn in one channel.

Two things a host does not have to think about but a maintainer does:

- While it is arming, `drag-element` is swapped OUT with `setBehaviors()`. With
  it in, a click that drifts two pixels — which is every click a hand makes —
  becomes a drag, G6 fires no `node:click`, and the pick silently does not
  happen.
- The camera move is POSTED to the gclass, not made in the handler: a translate
  issued inside G6's click dispatch or its `aftertransform` is swallowed. And
  the translate itself is a closed form, `T = (canvasCentre - nodeWorld) *
  zoom`, read off G6's own `getTranslateOptions()` — `focusElement()` and
  `translateBy()` compute the right offset, resolve, and do not move the camera
  at all.

**New keys for consumers: `anchor view`, `click the element to centre on`,
`centred: click to release`.**

### The camera belongs to the reader

Once somebody has chosen a zoom, nothing changes it but a camera command. Not a
**refresh**, not a change of **node mode**, not a new **main topic**: those
rebuild the CONTENT, and content moving is no reason to move the reader. The
opening frame is the only one the graph picks — a fit, and only for a graph
nobody has arranged.

Two mechanisms, because the question is not the same in the two cases:

- **Across a rebuild in the same view**, a NODE is held at the pixel it was on:
  `yui_graph_viewport_of()` before, `yui_graph_place_at()` after — the pair the
  folds have always used. It keeps the zoom because it only translates, and it
  survives a relayout that moves everything, which restoring raw coordinates
  would not.
- **Across a RELOAD** the same thing is saved and replayed: the zoom, a NODE,
  and the viewport pixel that node was on. **Not G6's own position** — an
  absolute `translateTo()` leaves the camera at `canvasCentre - T/zoom`, so
  `getPosition()` and `translateTo()` are each other's inverse only at zoom 1,
  and restoring one with the other brings the zoom back and puts the graph
  somewhere else. That is the whole reason `yui_graph_place_at()` exists.

Wiring: `C_G6_NODES_TREE` takes a `camera` attr — the viewport to restore on its
FIRST draw, empty meaning *open fitted* — and publishes **`EV_CAMERA_CHANGED
{zoom, node, x, y}`** once a move has settled. A saved node this load does not
have leaves the graph to its opening fit, checked before the zoom is touched:
a zoom with no framing to go with it is worse than a fit. The settle is a real time (700 ms of
the browser's `setTimeout`, not a `C_TIMER`): a wheel notch, a pinch and a drag
each fire `aftertransform` many times, and what is worth saving is where the
gesture ENDED. It fires for a PAN as well as a zoom — a pan is the reader's just
as much. `C_YUI_TREEDB_GRAPH` holds the other half: `camera` is one more
`SDF_PERSIST` attr of the view, saved under its name like `main_topic`, so the
graph opens where that treedb was left.

A host that does not care persists nothing and loses nothing: the event carries
`EVF_NO_WARN_SUBS`, and the attr defaults to empty.

### Moving the cards, and folding without losing your place

The JSON graph and the gobj tree take `drag-element`: a card can be dragged.
The position is deliberately NOT kept — both are rebuilt from their source on
every refresh, fold and layout change, and neither is a document of its own to
save it to — and it is worth having anyway, because pulling two cards apart to
read the lines between them is most of what a reader wants from a graph.

**Folding no longer moves anything.** It used to re-pack the tree: five
surviving cards would all slide 240px sideways for a fold that removed nothing
they could see, and the card under the finger slid out from under it. Two
halves:

- the card you clicked is read before the rebuild and put back afterwards
  (`yui_graph_place_at()`, the anchor's arithmetic with the target point left
  free);
- and the others stay because a fold leaves a **phantom child** — an invisible
  node of exactly the width its children had, in the place they had in the
  order. Reserving the width as a NUMBER does not work: the layout centres a
  parent over its children, so a lump appended at the end moves every sibling
  by half of it.

`collapse all` reserves nothing on purpose: it is asking for a COMPACT drawing.

**The gobj tree remembers how you left it** — layout, zoom, camera, folds and
anchor — in `localStorage`, keyed by the gobj's name. Not in a persistent attr
because only a SERVICE can save those and this gclass is hosted as a child, and
because how somebody left THEIR tree is a fact about that browser. The layout
and the folds are restored BEFORE the first build, since they decide what is
built; the camera after, and once only, or every fold would drag the reader
back.

### The graphs on a touch screen

The three G6 graphs (`C_G6_NODES_TREE` under the treedb view,
`C_YUI_JSON_GRAPH`, `C_YUI_GOBJ_TREE_JS`) have always DRAWN correctly on a
phone. What they could not be was **operated** on one, for reasons that were
structural rather than cosmetic — and none of them is visible in the CSS:

- **There is no wheel on a phone**, and `zoom-canvas` binds the wheel and
  nothing else, so the only zoom was the toolbar's `+`/`−`. G6 ships a pinch
  recogniser, but `trigger: ['pinch']` REPLACES the wheel (its `bindEvents` is
  an `if/else`), and its `PinchHandler` keeps its instance and callbacks in
  **statics** — with two graphs on a page the second registers against the
  first one's emitter, so pinching one zooms both and pinching the other does
  nothing. `g6_touch_gestures.js` recognises the gesture per graph and
  registers a `zoom-canvas` that keeps the wheel, the same pattern
  `g6_drag_canvas_touch.js` already uses for `drag-canvas`. **Every graph gets
  it with no change to its `behaviors` list.**
- **G6 does not read the DOM's `contextmenu` event.** Its `BehaviorController`
  synthesises the event from `pointerdown` with `button === 2`, so the context
  menu was a right click and only a right click, whatever the browser does
  with a long press. The same module gives it a **long press** (500ms, 10px of
  slop) that re-emits G6's own forwarded event under the name the plugin
  listens for — so `getItems(e)` sees exactly what a right click gives it,
  including the port under the finger.
- **One finger has to serve three commands, so the press is arbitrated — at
  the RELEASE** (`press_arbiter.js`, pure and tested): moved → drag; still and
  let go quickly → the element's own action; still and held past 500ms → the
  context menu — with a 15ms haptic tick at the 500ms mark, which is a NOTICE
  and not the decision (a finger that buzzes and then carries the node away
  still gets its drag). Firing the menu on a TIMER instead cannot arbitrate
  anything,
  because at the moment it fires the gesture is not over: it opened while
  `drag-element` was already carrying the node, so one press meant both things
  and the menu sat over a card running away underneath it. Two more things the
  same press was doing, both fixed with it: `click` is not taken from the DOM
  either (`@antv/g`'s `onPointerUp` synthesises one), so the press that opened
  the menu went on to click the node it opened the menu on — the click
  handlers ask `consume_long_press_click()` first; and the BROWSER's own menu,
  which opens while the finger is still down, is refused for as long as there
  is a finger on the glass.
- **The browser was taking the gesture outright.** G6 puts `touch-action:
  none` on its canvas and nothing on its HTML nodes, which are ordinary DIVs
  over it — so a drag that started on a **card** was a page scroll: two
  `pointermove`s through, then `pointercancel`, and the node stopped dead
  about 20px in while the page slid. `.graph-container` refuses those gestures
  whole now; the panels and the context menu keep `touch-action: auto`, so a
  finger still scrolls what is meant to scroll.

Everything a finger has to LAND on is sized off `(pointer: coarse)`, which
means **a mouse sees no change at all**: node resize handles become a 14px
mark in a 44px box and drop to the four corners (eight fingertip-sized boxes
around a 90px node overlap into one blob, and a corner resizes both axes);
`node properties` and `delete node` go from two 28px circles 4px apart —
one fingertip covered both, with the destructive one underneath — to 44px
and 12px apart; a port's hit area stops being a flat `+4` in **world** units
(a different target at every zoom, and 5 screen px at the 50% a phone lands
on after fit) and becomes a screen measurement converted to world; popover
controls and context-menu rows get a 44px floor at 16px, under which iOS
Safari zooms the page on focus and never zooms back.

And the two **floating toolbars fold**. They are drawn inside the canvas, one
on each edge: on a 356px-wide phone canvas they took a third of the drawing
area and stood on top of the nodes. Under **480px of container** — measured on
the container, not the window, because the same graph is a full page in one
app and a card in a column in another — they collapse behind a single `⋮`,
and the edit strip is removed rather than emptied (an empty toolbar is still
a card with a border sitting on the graph). Above that width nothing changes.

**New keys for consumers: `show toolbar`, `hide toolbar`.**

**Multi-selection reaches a finger through a MODE, not through a gesture**
(`7.23.11`). Both halves of it — shift+click to add a card, shift+drag for the
rubber band — hang off a key a phone does not have, and there is no spare
gesture to give them: G6 binds panning and the band to the same plain drag, so
one of the two has to stand aside. The edit toolbar carries a **selection
mode** toggle (the dashed marquee, next to `+`): while it is on, a tap picks a
card and a drag on the background draws the band, and panning is what stands
aside. It looks **pressed** while it is on (`7.23.12`, `pressed_state`, an
inverted neutral chip) rather than taking one of the palette's colours: each of
those names a KIND of action, and `7.23.11` borrowed undo/redo's violet for it —
which put the same colour on two neighbouring buttons for two different
reasons, and carried the whole state change in the hairline of an outline
glyph. It is deliberately a button and not a heuristic — the toolbar says which
of the two the graph is listening for, and turning it off gives the camera
back. It is not device-specific: the same button spares a desktop reader the
key. Leaving edition turns it off, and it is not persisted.

**New key for consumers: `selection mode`.**

**The two selects of the toolbar speak the app's language** (`7.23.13`). They
rendered their raw names — `reading`, `edition`, `dagre`, `manual` — in every
language, because neither went through `t()` at all: not a missing key, a
missing call. The label is translated and carries its own `data-i18n`, so it
follows a language change; the option's `value` is now set EXPLICITLY, because
an `<option>` with no value answers with its own text and a translated label
would have sent `"Edición"` to the FSM as the mode to enter.

**New keys for consumers: `reading`, `operation`, `writing`, `edition`,
`manual`, `dagre`, `antv-dagre`, `d3-force`, `force-atlas2`.** A host that adds
a layout of its own gets its name untranslated, which is what all of them got
until now.

### Tabs opened at runtime, and the two decisions their url costs

`yui_tab_routes.js`. A workspace whose tabs are opened by the operator —
`/<ws>/<home>/<id>`, with whatever the tab is showing below it — pays for that
url twice, and both apps in this family learned the same two lessons, one of
them the hard way.

**`yui_tab_split_subpath(subpath)` → `{id, tail}`.** On a cold load the tab's
route does not exist yet: it is registered when the tab is opened, so a reload
on `/<ws>/<home>/<id>/<tail>` resolves only as far as the workspace home and the
shell hands the WHOLE rest over as the subpath — `<id>/<tail>`, not `<id>`.
Reading all of it as the id matches nothing, and an app that then falls back to
its first tab **answers a reload with somebody else's default**. It hides well:
a bare tab route survives, because there the subpath IS the id, so only the deep
case breaks and only for whoever reloads on one.

Only the id segment is decoded. These ids are composite (`<node>`+`0x1F`+
`<yuno>`, `<conn>`+`0x1F`+`<treedb>`) and reach the url percent-encoded, so
decoding the whole tail first would turn an encoded slash inside an id into a
separator and cut it in two.

**`yui_tab_position_plan(prev_base, base, subpath, remembered)` →
`{record, replay}`.** A tab's nav item is a FIXED route —
`yui_shell_set_submenu()` registers it, and that route is where the view is
mounted — so the position inside a tab cannot travel in the item and has to be
replayed when the tab is entered again. "Entered again" is the whole subtlety:
arriving at the root of the tab you were ALREADY in is the way OUT of whatever
was open, and replaying the position there would make that button do nothing.

**What is NOT here: the wiring.** One host restores on its transport's
`EV_ON_OPEN`, another normalizes the route as it arrives, and both are right for
what they know about when their tabs become real. These are the decisions, not
the plumbing — which is also why they are pure and tested rather than three
lines inside an action.

### Selecting several nodes, and moving them together

In **edition** mode the graph has a real selection, not just "the node you
clicked":

| gesture | what it does |
|---|---|
| click a node | selects it **and opens it**: resize handles, ports, popovers |
| **shift + click** | adds that node to the selection, or takes it out |
| **shift + drag on the canvas** | rubber band: the selection becomes what it enclosed |
| the **selection mode** button | makes those two the PLAIN gestures — a tap picks, a canvas drag is the band — for as long as it is on (panning stands aside meanwhile) |
| **ctrl/cmd + A** | every node |
| the **fit-to-selection** button | puts the viewport on what is selected |
| drag any selected node | **moves the whole selection**, as one undo |
| **Delete** / **Backspace** | deletes the selection, after a confirmation that counts what it takes |
| **Esc**, or a click on the canvas | clears it |

The keys reach the graph only while the **graph has focus** — G6 gives its
canvas a `tabIndex` of its own — which is what keeps `ctrl+A` inside the find
box a selection of the TEXT and not of every node: the focus is in the input,
and the input is not inside the canvas. They arrive as `EV_KEY_DOWN` and the
action decides, so a key is as visible in the `machine` trace as a click.

Three decisions are worth knowing, because each one is where this could have
gone wrong:

- **G6's `selected` element state IS the selection.** `drag-element` decides
  what a drag moves by asking the graph for it
  (`getElementDataByState('node', 'selected')`), so a set kept anywhere else
  would be a second truth the drag never consults — the ring would say five and
  one would move. It also batches the move, so a group drag is one history
  entry rather than one per node.

- **The ring is painted into the card's own html**, and it had to be. A state
  style paints on a node's KEY SHAPE, and every node here is an `html` node
  whose key shape is a DOM element — the same reason the amber highlight had
  never appeared before `7.3.0`. Selecting with `brush-select` and nothing else
  would have selected correctly and shown **nothing**. The ring is blue and
  drawn OUTSIDE the amber halo, so a node that is both a find match and
  selected wears both; one function composes them (`ring_shadow`), because
  before it each repaint wrote its own flag and erased the other's.

- **The gesture is G6's, the result is an event.** `brush-select` gets an
  `onSelect` that sends `EV_BRUSH_SELECT` with the ids, and the action does the
  work — so a marquee shows up in the `machine` trace like every other action.
  Shift+click is not G6's `click-select` at all: this gclass already owns
  `EV_NODE_CLICK`, and adding a second selection owner outside the FSM is how
  the two end up disagreeing. The band comes from `g6_brush_select_owned.js`,
  a `brush-select` under our own id with ONE half removed: the built-in also
  rewrites the `selected` state of every node and every edge on each canvas
  click, behind the gclass and outside the history pause with which it sets
  and clears the selection — a recorded command whose before and after are
  identical, which is a lit Save on a graph nobody has touched (`7.23.10`).

Panning gives way while **Shift** is held AND while the pointer is on a card
(`drag-canvas` takes an `enable` predicate) — the first because the canvas
would otherwise pan under the rubber band, the second because it would
otherwise pan under the node being dragged. That second half is G6's own
default, and an `enable` REPLACES the default rather than adding to it: while
it was missing, a drag moved the card at twice the pointer and slid the whole
graph underneath it (`7.23.10`).

**Undo, Redo and Save are the history plugin, and it follows the MODE.** It is
installed on entering edition and removed on leaving it — including when
edition is reached through the mode selector on a graph whose data is already
loaded, which is the ordinary way in. Before `7.23.10` it was installed at the
arrival of the last topic of the load and only if the graph was in edition
right then, so that ordinary way left the graph with dead Undo/Redo buttons
and a `history_pause()` nothing answered, while Save still lit on its own.

**A delete says what it takes, whether it is one or twenty.** The Delete key
asks the same question the per-node delete icon asks, built by the same
function: the record's key when there is one, the count when there are more,
and then the two lines that are actually at stake — *N children will be
UNLINKED, not deleted* and *it will be detached from M parents* — where over a
set the numbers are the sums. These views delete with `force`, so an operator
pressing Delete over twelve cards has to read eleven detached children, not
"are you sure". It needs no new keys: the sentence is the one `7.10.0` already
defined. The question has no icon to hang off, so it is asked in the middle of
the graph it is about.

**A marquee selects, it does not open.** Even when it encloses exactly one
node, the handles and ports stay away: `_selected_node_id` means *the node
opened for editing*, and only a click sets it. Everything that hangs off a
single node reads that field, so a multiple selection puts all of it away by
construction rather than by a check in twenty places.

### Finding a node in the graph

`C_YUI_TREEDB_GRAPH` carries a find box in the middle of its toolbar. It
matches the term against the node's **label**, its id and its topic name, and
puts every match **on screen** in the same amber `active` state the topic focus
uses. It changes nothing else: no group is opened, no layout runs, the camera
does not move. Emptying the box takes the amber off and leaves the graph
exactly as it was.

Details that are not decoration:

- it **only looks** (since `7.23.158`). It used to search the whole treedb and
  unfold a page of the matches it found, so every keystroke re-laid the graph
  out, and the groups it opened stayed open when the box was cleared. Opening
  up to a topic is the legend's focus button, which still searches the whole
  treedb and unfolds what it needs.
- the term **stays live**. It is repainted after every change of what is on
  screen (a group opened by hand, a fold level, a refresh, a hidden topic), so
  newly shown matches arrive lit and the count follows the screen.
- **Enter** centres the next lit card, **Shift+Enter** the previous one, in
  reading order (top to bottom, then left to right). That is the one camera
  move of the find, and the reader asks for it. The count then reads `k/N`.
- it matches the **label**, not only the id. On a topic keyed by `rowid`,
  `uuid` or `qualified` the id is a counter or a path and the name a human
  knows the record by lives in a secondary key — the same reason `node_label()`
  exists.
- it **says how many** it found, and how many more it did NOT light:
  `12 matches (+513 not shown, +40 in hidden topics)`. A graph with no amber
  looks identical whether nothing matched or the matches are folded away, so
  the count is shown next to the box (hidden while the box is empty; a typed
  term that matches nothing shows `0`, which is an answer).

The find and the topic focus **share the highlight**: starting one clears the
other (a topic focus also empties the box). Two amber sets at once would say
nothing about either.

The highlight is painted **into the card's own html**, not with G6's `active`
element state. That state is an amber `stroke` + `halo`, both properties of a
node's KEY SHAPE — and every node here is an `html` node, whose key shape is a
DOM element. There was nothing for either property to paint on, so the amber
had never appeared, for the topic focus either. Only the cards whose state
changes are repainted, and a theme switch carries the highlight across (it
rebuilds every card, and rebuilding them without it would clear what is on
screen).

Wiring: the box sends `EV_FIND_NODES {text}` to the view, and Enter sends
`EV_FIND_NEXT {back}`; the view forwards both to `C_G6_NODES_TREE`. The graph
answers `EV_FIND_RESULT {term, matches, folded_matches, hidden_matches,
current}`, which the view declares like every other event its child publishes.
Consumer i18n keys: `matches` (counted), `not shown`, `hidden topics`,
`find on screen` (the box's title and name).

### The graph opens FOLDED, like a JSON viewer

A treedb drawn whole is a pile. A nave with a hundred and forty devices was a
**row of a hundred and forty cards** under one parent, the edges to them a band
of parallel lines, and a 6400-record treedb took every one of those records
through a DOM card before the first pixel appeared. So the graph now reads the
treedb the way a JSON viewer reads a document: **a tree by its hooks**, opened
a level or two, with a count on every cut.

- **Every record is fetched, only the visible ones become G6 nodes.** The
  arithmetic lives in `treedb_fold_model.js` (pure, no G6, tested): the roots
  are the records with no drawable parent, a node's children are the records
  whose fkey names it through a hook, and a node is visible when a visible
  parent shows it or it is a shown root — the **union**, so a device that hangs
  from its place AND its controller is drawn once, under whichever opened
  first, and folding one of the two leaves it where the other shows it.
- **`expand_depth`** (default `1` since `7.23.142`, `2` before) is how much
  opens on load until the reader steps: the roots alone. **`fold_page_size`** (default `24`) is how many children of
  one hook are shown at a time; the rest is a **`+N` chip** at the end of the
  page that opens the next one. Both are attrs of `C_YUI_TREEDB_GRAPH`,
  forwarded to the engine — and since `7.23.93` so are the seven the engine
  had always had and no host could reach, because this view is what creates
  the engine and an option it does not forward is an option that does not
  exist: **`minimap_min_nodes`** (default `30`), **`with_gridline`**,
  **`with_fullscreen`**, **`with_toolbar`**, **`toolbar_position`**
  (`right-top`), **`confirm_delete_node`** and **`confirm_unlink_edge`**. The
  defaults repeat the engine's, so a host that says nothing gets what it got
  before.
- **A pill per hook on the card** — `▸ devices 142` folded, `▾ devices 24/142`
  open on a page — in the colour of the child topic, the same colour its hook
  port wears. Tapping it opens or folds that hook, and the card stays where it
  was on screen while the layout moves everything else (`yui_graph_place_at`).
  The pills are inside the card's `innerHTML`, so their click is delegated from
  the container in the capture phase over four event types, exactly as the
  JSON graph's fold handles are — G6 never reads the DOM `click`, it builds its
  own from the pointer pair.
- **The toolbar's fold STEPPER** (since `7.23.141`; it replaced the *expand
  all* / *collapse all* pair): `▸` · **`places level 2/4`** · `▾`. It walks the
  **main tree** one level at a time, the way the frontend view opens one level
  per press: level 1 is the main topic's roots (and the loose records the
  reader asked to see), each `▾` opens the next level through every topic that
  is not hidden, each `▸` folds the deepest one. The readout says what the two
  buttons act on — the main topic's swatch and name (data, never translated;
  gone on a phone, the numbers stay) and `level N/M` — the WORD as well as the
  numbers, because the legend chip under it says `places 2/4` too and there it
  means records on screen — and a button is disabled at either end. **A level is a floor, not a picture**: stepping down
  opens only what is folded above the new level and keeps the pages and pills
  already open; stepping up folds from the new level down and touches nothing
  above it. A topic the schema does not tie to the main one is a tree of its
  own, has no level, and is opened by its pills. The level is the reader's,
  per treedb: `fold_level` is `SDF_PERSIST` on `C_YUI_TREEDB_GRAPH`, handed to
  the engine on creation (`0` = never stepped = `expand_depth`), so a reload
  or a *refresh* opens where it was left. The old pair opened every hook of the
  treedb at once — the pile, on purpose, which on a treedb of thousands nobody
  reads — and closing threw away everything the reader had opened.
  **It does not move the zoom** (since `7.23.78`): a fold is not a reason to
  change the scale the reader chose. The camera holds the anchor still when
  there is one, else the first root.
- **`dagre` reads LEFT TO RIGHT** now, with explicit `nodesep`/`ranksep`: the
  children of a node are a column beside it and the graph reads like a file
  tree. The ports move to the sides with it (fkeys on the left edge, hooks
  spread along the right one) and the edges are horizontal beziers;
  `antv-dagre` keeps the top-down reading, so the layout picker is also the
  direction picker. A layout change turns the ports of every card in place.
- **The find and the topic focus search the RECORDS**, not the cards, and open
  the path down to what they find. The find reveals at most one **page** of
  hidden matches (a single letter matches half the treedb); the count reported
  is of all of them. The topic focus depends on who asks: a click on the
  **legend** reveals the **whole topic** (`reveal: "all"` on
  `EV_SET_FOCUS_TOPIC`) — *show me these* means all of them — while the graph's
  per-topic **route** (`.../graph/devices` is a focus on `devices`) gets one
  page, because it lands on every load, and revealing a whole topic there
  opened 563 groups of a 6400-record treedb before anybody had touched
  anything.
- **Edition and the saved geometry are untouched by folding.** Only the cards
  on screen are read when the graph is saved; a folded card keeps the entry it
  had in `__graphs__`. A card on its default size grows by the pill row when it
  has pills and shrinks back when it has none; a saved size is the owner's and
  is left alone. The `+N` chips are not records: they are skipped by save,
  resize-all, select-all and the minimap's blocks.

One consumer key came with it: `show more`, the chip's tooltip.

### Layouts: what G6 offers, and the two made for a treedb

What a treedb graph IS, once folded: a **forest read top to bottom**. A main
topic is a tree (places: country, region, site, hall), the other topics hang
from its nodes through hooks, a record may hang from two parents (a device
from its place AND its controller), and every visible node was reached from a
root. Tens to a few hundred cards on screen, never the store.

G6 5.1 registers these layouts, and this is how each meets that shape:

| layout | what it does | for a treedb |
|---|---|---|
| `dagre` / `antv-dagre` | layered DAG: longest-path ranks, crossing minimisation, one direction | the right reading (LR); but the crossing heuristic **reorders the siblings on every run** — open one hook and the column next to it shuffles — and it is the one thing that takes time on a big expansion |
| `compact-box`, `indented`, `dendrogram`, `mindmap` | @antv/hierarchy tree layouts | the shape wanted, but G6 runs them on a tree it **builds from the edges** (`createTreeStructure`): a node with two parents lands under whichever parent the builder met first, unstably |
| `radial` | MDS on graph distances from a focus node, a ring per depth, overlaps pushed apart afterwards | the right idea — a hall with a hundred devices is a **fan**, not a column — but the ring radius is one it is TOLD: twenty cards of 172px on a ring of radius 200 have 52px each and pile up. Tried, and replaced by our own (below) |
| `force`, `d3-force`, `force-atlas2`, `fruchterman` | physics | scatter a tree: the pile with springs. `d3-force` and `force-atlas2` stay in the picker for the rare flat treedb |
| `circular`, `concentric`, `grid`, `mds`, `random` | rings, rings by degree, a grid, projection, noise | none knows a parent from a child |
| `combo-combined`, `fishbone` | combos / cause-effect | not this data |

So two of our own, in `treedb_layout.js` (pure, tested), registered as G6
layouts by two thin adapters in `c_g6_nodes_tree.js`:

- **`treedb-tree`** — the classic tidy tree, **top to bottom**. A row per
  depth as tall as its tallest card, a node centred over the block of its
  children, siblings side by side with `nodesep`. **The default** for a
  treedb nobody has arranged (was `dagre`). It reads down because down is
  where a tree has room: a hall with a hundred devices is a wide row, not a
  column beside a card — and read right (as it did until `7.23.74`) it was
  `dagre` with the siblings held still, which nobody could tell apart. The
  algorithm is written once, left to right; the top-down tree is the same
  tree fed transposed cards and read back transposed (`direction: "LR"`
  keeps the other reading for a host that wants it). The outline that shipped
  beside it (`treedb-outline`, one row per node) was removed in `7.23.75`: a
  list that indents is a JSON viewer, and this library already has one.
- **`radial`** (`treedb-radial`) — the root in the middle, a ring per depth,
  every subtree an angular **sector** proportional to its leaves, and the
  radius of each ring the largest of three: one `ranksep` out from the ring
  before, the length its cards need side by side, and (since `7.23.84`) far
  enough for each card to clear the one it HANGS FROM — `ranksep` is a step
  between CENTRES, so 180 between two cards that reach ~95 each way is two
  cards touching, where the tidy tree's `ranksep` is a gap between COLUMNS
  and a column carries its own width. A fan that cannot overlap by
  construction, across the ring or along the radius; several roots share the
  circle around an empty centre.

**Elbow edges** (`7.23.149`) are an option of the edges, not a layout: the
toolbar's `GRAPH_EDGE_ELBOW` toggle sets `edge_shape` to `elbow`, and every
edge goes straight out of the parent's port to the channel half way to the
child, along it, and straight into the child's port — mxGraph's tree routing.
The children of one hook share a row and a port, so they share the channel
and read as one bus. The two edge types (`treedb-elbow-v` / `-h`) subclass
G6's `Polyline` and compute the elbow from the ports on every draw, so
dragging, folding and relayout need nothing else. It follows the reading
direction, and a layout with no rows (`rowless_layouts`: `radial`,
`d3-force`, `force-atlas2`) keeps the curve, with the toggle disabled.
`edge_shape` is `SDF_PERSIST` on `C_YUI_TREEDB_GRAPH`, a preference per
treedb like `node_labels`. Consumer key: `elbow edges`.

Two cases are not a channel (`7.23.150`, `treedb_elbow.js`, pure and
tested). An elbow that does not run **forward** — the target above its
source or in the same row, which one edge of every reciprocal pair is —
goes **round** the two cards instead of through them: a short run out of its
port, along the outside of both, and in from the other port's own side. And
the edges joining the **same two cards**, either way round, take **lanes**
10px apart in the order they were made (0, +1, −1…), so a new one never
moves the ones already drawn; a detour on a negative lane goes round the
left.

And no elbow crosses a card (`7.23.151`). Each is tried simple first — the
channel, or the detour round its two cards — and only when that line crosses
a card is it **routed**: out of its port, the shortest way along the gaps
between the cards, and in. It is our own orthogonal search on a sparse grid
(the lines of the cards' borders, grown by a 12px clearance that grows with
the lane, plus the two ends; a turn costs 40px of line), over the cards within
400px first and all of them if that finds no way; with no way at all the
simple elbow stays. A parent and the row of children under it keep their bus;
what gets routed is a detour with a third card in its way, an edge dropping
several rows, an edge between two cards of one row. Since `7.23.152` a node is
measured **whole** — card, ports and label — so a line keeps clear of the
ports as well; the edge's own ports stick out of its own cards, so its first
and last segments are not tested against their own box. G6's `shortest-path`
router is not used: it is not exported, it
falls back in silence to a route that crosses cards, and it rewrites its
module defaults with every config it is given.

Both take the **same spanning tree**, chosen deterministically: roots are the
nodes with no incoming edge, in node order; a node belongs to the **first
parent that reaches it** in a breadth-first walk (the place, not the
controller, because the place's column came first) and its other links are
drawn as edges across the tree; the children of a node are ordered by the
**hook they hang from** (the parent's port order — the schema's column order)
and then by node order (record order, the pages' order); a cycle nothing
reaches gets a root. Consequences that dagre cannot give: O(n), no crossing
heuristic, and **opening a hook moves nothing that is not under or beside
it** — the cards keep their order, the eye keeps its place, and the `+N` chip
sits at the end of its own hook's children. The hook rank comes from the
edge's `sourcePort` looked up in the parent's port keys — carried in the
node's `data.port_keys`, because a CLOSED node (below) has no ports on its
style and its children still sit in schema order — so the adapter needs no
model, only the G6 data.

The walks are **iterative**, not recursive: a self-referent hook (a place
inside a place inside a place) is as deep as the store says, and the stack is
not. A 20000-node chain is a test.

### The three views of a record: card, pill, figure

A card is 172×96 px with its ports, and a graph of a few hundred of them is
readable card by card and unreadable as a whole: at the zoom that fits it on
screen every card is a smudge. Since `7.23.82` a record has **three views**
(`node_mode`), the same three the gobj tree view offers as `Vertical` /
`Vertical compact`:

- **`expanded`** — the card, with its name, its pills and its **ports**. The
  view a hook is opened from, and the only one a size can be edited in.
- **`compact`** — a one-line **pill** with the name inside, the tint and the
  border of the topic, and small ports (radius 6) on its edges, so a link can
  still be drawn. The same one-line chip a leaf record already is, at the
  tier's width (200 / 180 / 160 px). No pills.
- **`shape`** — a **figure** of the topic's colour, no ports, no text: the
  topology and nothing else. A native G6 node — a **circle** by default
  (since `7.23.133`; a square until then), or a square (rounded `rect`),
  diamond, triangle, hexagon or star — in the outline the card and the pill
  wear (1, or the width chosen; it was a floor of 2) — so the focus, the
  selection and the anchor are its own stroke and halo. The three tiers keep their
  order of size (32 / 28 / 22), the structural tier its dashed border. The
  figure is chosen in the **node properties** popover (`shape`, with the
  popover's own scope: this node, the topic, every node), remembered as the
  topic's default and saved per node in `__graphs__` as `node_shape`.

**The colours are remembered the same way** (since `7.23.85`): `apply` writes
`fill`, `stroke` and `lineWidth` into the node's entry in `__graphs__` — and
into the topic's defaults for the wider scopes — and the three views read
them back through one `node_paint_of()`, while every repaint takes the colour
from the style the node is wearing rather than from `desc.color`. Before
that, the colour lived on the live style alone, which on an html card is
UNDER the html: the first repaint (a selection, the focus, a theme switch, a
pill appearing) rebuilt that html from the topic's colour and the choice was
gone, and nothing of it ever reached the store. Choosing the topic's own
colour back, with the stroke and line width that go with it, **forgets** the
entry instead of writing it: the palette is assigned by the topics'
ALPHABETICAL order (since `7.23.136`, in the graph and the schema diagram
alike; by the backend's order before, which varies from load to load, so two
topics could swap colours), and adding a topic still shifts it — a colour
written down would freeze today's palette on that card, the trap the sizes
fell into in `7.23.80`.

In the view's toolbar (`C_YUI_TREEDB_GRAPH`), next to the fold pair: three
push buttons in one group — a card, a line, a figure — the pressed one being
the view that is on (`full` / `compact` / `shape`, persisted as `node_mode`;
a change lays the graph out again, because a figure and a card do not take
the same room; buttons and not a select since `7.23.83`, because a view is
picked at a glance and a closed list says nothing until it is opened) and a
labels toggle (`node_labels`, the name under each figure,
enabled in the `shape` view only — nothing moves, the label hangs outside the
figure and the layout measures the figure).

And **one node against the rule**: a double click expands a pill or a figure
into its card, and collapses a card into a pill; the node's context menu
(`expand node` / `collapse node`) does the same, which is the door a finger
has to it. The node holds still on screen while the rest makes room, as a
fold does. A change of the view forgets these exceptions. The `+N` chip
follows the shape of the card it continues: full beside a card, small beside
a pill or a figure. Consumer i18n keys: `nodes`, `full`, `compact`, `shape`,
`node labels`, `expand node`, `collapse node`, and the figures `square`,
`circle`, `diamond`, `triangle`, `hexagon`, `star`.

The ports of an open card grew with this (radius 14 on a card, 8 on a chip
since `7.23.79`; 10 / 5 in `7.23.75`, 2 px stroke): they are what a link is
drawn from and what a resize takes hold of, and at radius 6 with a hairline
nobody could tell they were either.

**A size nobody chose is not saved** (since `7.23.80`). `Save` collects the
geometry of every card into `__graphs__`, and it used to write the SIZE and
the port radius of every card too — so every Save froze the library's size of
the day into the treedb, and a later default (the bigger ports above) reached
no saved treedb: the same trap as the invented cascade coordinates, for the
size. A card on its tier's default now saves its position and nothing else;
a closed node saves no size at all (a square is not the card's size), its
entry keeps what it had.

**Every saved look has a way back, in the context menu** (since `7.23.134`,
replacing `reset sizes` / `reset topic sizes`). In edition, the node, the port
and the edge menus each offer three resets — this one, its kind (`reset topic
nodes`, `reset topic ports`, `reset same type edges`, the scopes of their
properties popovers) and `reset all …` — and a reset FORGETS the saved value,
the element's own and the defaults of its scope, so the library's default
comes back on the spot and Save writes the cleared entries. The node reset is
offered in every view and forgets size, colours, line width and figure; the
ports have their own (shape and radius); no reset moves a node. Consumer i18n
keys: `reset node`, `reset topic nodes`, `reset all nodes`, `reset port`,
`reset topic ports`, `reset all ports`, `reset edge`, `reset same type edges`,
`reset all edges`.

**An edge is saved only where it differs from what it INHERITS** (since
`7.23.134`): its hook's default in the parent topic, else the topic's, else the
library's. It used to be "any line width but 2", and the library's width for an
edge between two topics is 1.6, so every Save froze every such edge with its
width and its theme's colour — a theme toggle no longer re-themed it after a
reload. `reset all edges` clears what the old Save froze.

**The browser's own menu never opens over the graph** (since `7.23.133`): G6's
context-menu plugin cancels the event `@antv/g` synthesises from `pointerdown`,
not the DOM's `contextmenu`, so the container cancels that one (a popover's form
field keeps it, for paste). An edge has a menu in edition (`edge properties`,
`unlink`).

**The rest of the 7.23.133–7.23.140 round, in one place:**

- **The legend keeps its order** (`7.23.135`): its chips are alphabetical,
  always, and the main topic takes no seat of its own (its gold star and bold
  name tell it apart) — starring a topic no longer moves the strip.
- **Never a blank viewport** (`7.23.137`). A camera is saved by the node
  nearest the middle of the viewport (or the anchor), a saved camera whose
  pixel is off screen is not restored, and after any placement — a restore, a
  refresh or a global fold holding a node still, a saved arrangement opened
  with no camera — a viewport with no node in it is fitted. It used to save
  the first root of the tree wherever it was, and a reload whose layout moved
  by a hair showed the tree in the minimap and nothing in the view.
- **The toolbar in three parts** (`7.23.137`): left, how the graph is built
  (layout, operation mode); center, how it is shown (fold pair, node views);
  right, what it is asked (find, refresh, raw json).
- **The topics view opens the graph as it was left** (`7.23.138`–`7.23.140`).
  `C_YUI_TREEDB_TOPICS` has a `graph` button left of `raw json`, in the
  landing and with a topic open; its route is `card_action_routes.graph`
  without its `/{topic}`, and its click navigates to
  `yui_shell_last_route_under()` of that route, so a focused topic
  (`<graph>/<topic>`) comes back with it. The cards dropped their graph icon
  — its topic segment was a focus, laid over whatever the reader had left —
  and show instead, from the desc with no request, the topic's version, its
  number of columns, the topics it hangs from (`↑ parents`) and the ones
  hanging from it (`↓ children`).

**A port has a shape, and its own properties popover** (since `7.23.81`).
G6 draws every port as a circle — `drawPortShapes` upserts a `Circle` and
nothing in the port style says otherwise — so a record's card is now a node
of its own, `treedb-card` (G6's html node with `drawPortShapes` overridden),
where a port's `shape` picks the G shape by the names G6 registers itself:
`circle`, `square`, `diamond`, `triangle`. `r` stays the one size (the
half-side of the square, the half-diagonal of the diamond), and the hit test,
the resize handles and the edge's landing point read `r` and none of them care
about the outline; the HIT TEST does, since `7.23.84` — a circle around a
diamond answers for the air off its corners while missing the middle of its
edges — so the distance ranks two overlapping ports and the shape says which
are in the running. A selected port shows a gear beside it, as the node does
(under the link icon on an fkey); it opens the **port properties** popover —
shape, radius, and the scope: *this port*, *the same port of every card of
the topic* (the hook or fkey column), or *every port there is* — with a live
preview on the port and a cancel that puts it back — and since `7.23.84` any
other way out puts it back too: the undo is kept beside the popover and run
by whoever hides it (a click on the canvas, the icon pressed again, the
element going away), because a preview is not the reader's answer until
`apply` is pressed. The same for the node and edge popovers. The choice is
remembered
as the topic's default (`port_shapes[key]` / `port_shape`, beside
`port_sizes` / `portR`), so a card that arrives later is born with it, and
saved per node in `__graphs__` as `port_shapes`. On a touch screen the port's
context menu has the same entry. The `+N` chip stays an `html` node.
Consumer i18n keys: `port properties`, `shape`, `circle`, `square`, `diamond`,
`triangle`, `radius`, `this port`, `same port in topic`, `all ports` — and
the node and edge popovers' own labels, which had no key at all (`apply to`,
`fill color`, `stroke color`, `line width`, `color`, `create`), so they
rendered in English in every language.

### The legend is the graph's layer control

The legend strip under the toolbar is **always there** now (the *Legend*
button is gone), and it is not a colour key: it is where the reader decides
**what the tree is made of**. One chip per topic — swatch, name, `visible/total`
— with a small target for each thing it can do, so a finger lands on one of
them:

| control | what it does |
|---|---|
| the chip body | **show / hide** the topic. A hidden topic leaves the MODEL, not just the drawing: no card, no pill counts it, no edge reaches it, and whatever hung from it alone has no parent any more. The name is struck through and the count is the total. |
| `★` / `☆` | the **main topic**: the trunk the tree hangs from, always a **hierarchical** topic (hooked to itself); the hollow star appears only on the other hierarchical topics, and only when there are two or more (`7.23.142`). Filled on the main one (a mark, not a button — the main topic cannot be hidden, because hiding the trunk turns everything into roots, which is the pile); hollow on the others, where it moves the star. |
| `+N` | the topic's **loose records**: the ones that should hang from the main tree and do not (a device with no place). Counted here, shown only on request. |
| `⌖` | **highlight** the topic — the focus, with `reveal: "all"`. |

**Only a HIERARCHICAL topic can be the main one** (since `7.23.142`): a topic
hooked to ITSELF — places inside places, groups inside groups, roles inside
roles (`fold_is_hierarchical()`). A topic that only reaches OTHER topics is a
list with children, and the old rule (*the topic whose hooks reach the most
others*) drew the agent's treedb from `yunos` instead of `realms`. In order:

1. the reader's pick (the star), if it is hierarchical — a saved pick the rule
   refuses is ignored;
2. the topic the schema **marks**, `main_topic: true` on the topic, sent in the
   desc by SDK 7.19+ (`tranger2_topic_desc()`); the schema allows one, and only
   on a topic hooked to itself;
3. the hierarchical topic that reaches the most other topics, schema order
   breaking a tie.

In every treedb of the SDK and the apps there is exactly one hierarchical
topic (`places`, `device_groups`, `systems`, `roles`, `realms`), so the mark
only has something to decide once a schema grows a second one. A treedb with
no topic hooked to itself has no trunk, and every topic is a tree of its own.

(Not the engine's card tier: a topic with hooks AND fkeys is an `entity` card
— it was called `hierarchical` until `7.23.142` — and that only sizes a card.)

What the main topic governs, in `treedb_fold_model.js`:

- its parentless records are the **roots**;
- a parentless record of a topic the schema hangs from it (`linked`: reached
  from the main topic by following hooks) is **loose**, not a root — it is
  counted per topic and drawn only when its `+N` is on;
- a topic the schema does not tie to the main one is its own tree, with its
  own roots, as before.

So *places with their users and nothing else* is hiding `devices`,
`controllers` and `device_types`; *places with their devices* is hiding
`users` and `controllers`. Each combination is a user preference **per
treedb**: `hidden_topics`, `main_topic` and `loose_topics` are `SDF_PERSIST`
attrs of `C_YUI_TREEDB_GRAPH`, saved under the view's name, and both hosts
name the view after the treedb.

Three edges of it: the URL wins over the strip (a route that lands on a hidden
topic shows it first, or it would highlight nothing and say nothing about
why); the find searches hidden topics too and counts them apart (`3 matches
(+2 in hidden topics)`), because `0` alone reads as *does not exist* when it
means *is hidden*; and moving the star reopens the tree at the reader's fold
level, counted from the new roots, since other roots are another tree.

Wiring: the engine publishes `EV_LEGEND_STATE` after every reconcile — one
entry per topic (hidden ones included) with colour, total, visible, loose and
flags, plus the main topic and `fold: {topic, level, levels}` for the toolbar's
stepper — and the view paints the strip and the stepper from that and nothing
else. A step is `EV_EXPAND_LEVEL` / `EV_COLLAPSE_LEVEL` (internal); the view
persists `fold_level` and forwards `EV_SET_FOLD_LEVEL {level}` (the engine's
`EV_EXPAND_ALL` / `EV_COLLAPSE_ALL` are gone). Stepper keys: `expand one
level`, `collapse one level`, `fold level`, `level`. The strip's clicks are `EV_LEGEND_TOPIC {topic, action}` with
`action` one of `toggle`, `main`, `loose`, `focus`; the view persists and
forwards them as `EV_SET_HIDDEN_TOPICS`, `EV_SET_MAIN_TOPIC`,
`EV_SET_LOOSE_TOPICS`. Consumer i18n keys: `show topic`, `hide topic`,
`main topic`, `highlight topic`, `loose records`, `hidden topics`; `legend`
is no longer used.

**The layout select has a stepper beside it** (`7.23.146`): ↑ and ↓
(`yi-arrow-up` / `yi-arrow-down` since `7.23.147`, a shafted arrow steps a
sequence; the fold stepper's level buttons are tree boxes, `yi-square-minus`
/ `yi-square-plus`) step to
the layout before or after the current one, in the order the engine offers
them (`layout_names`), without opening the list. Layouts are picked by trying
them one after another, and a list closes on every pick. The stepper reads the
layout the engine is USING (its `layout` attr, which `auto_layout` can set),
not the persisted choice, and disables the button at each end of the list. A
press is `EV_STEP_LAYOUT {step}` (internal), which sends `EV_SET_LAYOUT` with
the neighbour. Consumer i18n keys: `previous layout`, `next layout`.

**The main topic's chip is not a disabled button** (`7.23.88`): it cannot be
hidden, so its body is a `div` wearing the `.button` skin — bold, at full
strength, with a gold star — and not a toggle painted at Bulma's half
opacity, which is what made the trunk of the tree the weakest chip of the
strip. The star takes `--bulma-warning-on-scheme` and not `--bulma-warning`:
the raw brand gold is 1.75:1 on a light ground. The count dropped
`has-text-grey` (3.86:1 in dark) for the chip's own colour at 80%, and the
chips dropped `is-small`: the strip scrolls sideways, so it never had to fit
a chip per topic on one row.

**The strip is repainted on a language change** (`7.23.84`): its chips are
drawn on `EV_LEGEND_STATE`, which arrives after a reconcile and not when the
language changes, and a chip's title says an ACTION that depends on the
state — so the view subscribes to the shell's `EV_LANGUAGE_CHANGED` and
redraws the strip and the find box's counted line. The keys travel in the
DOM as well (`data-i18n-title`, `data-i18n-aria-label`), so
`refresh_language()` reaches the titles even before the repaint.

### C_YUI_TREEDB_SCHEMA — the treedb drawn the way its `.c` draws it

A landing view that draws a treedb the way its schema literal draws it in ASCII
(`treedb_schema_*.c`, `treedb_system_schema.c`): **one card per topic**,
listing its fields in schema order, and **one edge per hook**, between the row
that declares the hook and the fkey row of the child it names. The **arrowhead
is on the hook**, the way the `.c` draws it and the way the `↖` of the fkey
mark reads: the reference is held by the child and points at its parent. The
edge itself is declared parent → child, which is what ranks the parent to the
left, first, as the literal lists it. Built from the schema `descs` **alone**:
no data, no backend calls. It is the "every
treedb is a graph" rule applied to the schema itself, and an alternate landing
to the topic cards. A node click opens that topic's table through a real hash
navigation, so the graph is a navigation surface rather than a picture.

The marks are the notation of those `.c` literals, so the drawing and the
source read the same:

| Mark | Meaning |
|------|---------|
| `{}` | dict hook — N unique children |
| `[]` | list hook — n not-unique children |
| `()` | a single child |
| `(↖)` | 1 fkey — 1 parent |
| `[↖]` | n fkeys — n parents |
| `{↖}` | N fkeys — N parents |
| `(2)` | a secondary key (`pkey2s`) — the name is bold, like the pkey's |
| `(t)` | the time key (`tkey`) |
| `*` | required |
| `#` | the primary key |

The key marks come first in the mark column, because they change what a record
of the topic is. A field that is a pkey2 and an fkey reads `(2) (↖)`. The
`(t)` mark is new to the notation, and the legends of the `.c` literals carry
it too. For example, `binaries.version` in `treedb_yuneta_agent` is declared
like this:

```C
'topic_name': 'binaries',
'pkey': 'id',
'tkey': '',
'pkey2s': 'version',
```

and its card draws the row `version (2)`. An empty `tkey` marks no field.

The topic cards (`C_YUI_TREEDB_TOPICS`) and its topic-info panel say the same
keys. A card of a topic with `pkey2s` has a `pkey2s` line under its version and
column count. The info panel always has a `tkey` row, which says *append time*
when the topic has none, a `pkey2s` row when the topic has secondary keys, and
`system` by the flag names (`sf_string_key`), not by the number. In its column
table, the *key* cell of a key field says `pkey`, `pkey2` or `tkey`.

`dict` and `object` are one shape and `list` and `array` are another, exactly
as tr_treedb's hook/fkey switches treat them. A self-referent hook (a tree)
draws as a loop.

**Not to be confused with the node graph** (`C_G6_NODES_TREE`, hosted by
`C_YUI_TREEDB_GRAPH`), which draws the **records**. On a treedb whose records
are schemas — `treedb_system_schema` — that one draws a box per column,
hundreds of them, each labelled by a pkey that is a rowid: a correct picture of
the storage and an unreadable picture of the schema. This view answers the
schema question; that one answers the data question.

The demo `test-app/schema.html` mounts it alone against the real yuneta agent
schema, so the drawing can be held against the ASCII one in its `.c`.

**Its camera is the family's** (since `7.23.160`). It has a toolbar with the
`yui_graph_camera.js` cluster (zoom in, zoom out, the readout, fit, `1:1`), and
it takes the wheel the same way as the other graphs: the wheel **scrolls**,
Ctrl + wheel zooms, and a pinch zooms on a touch screen. This is true over the
cards too, through `yui_graph_forward_wheel()`: the cards cover most of this
drawing, and without it the wheel worked only in the gaps. It still never fits
itself when it appears; fit is a button. The toolbar buttons fire
`EV_ZOOM_IN`, `EV_ZOOM_OUT`, `EV_ZOOM_RESET` and `EV_CENTER` to the view
itself, so a host declares nothing. `wide` (default `40px`) sets the button
height.

**Contract:**

- Attributes: `subscriber`, `descs` (`{topic_name: desc}`, the schema),
  `node_route` (a hash-route template carrying a `{topic}` placeholder, e.g.
  `#/topics/db/<sel>/{topic}` — a node click resolves it and navigates),
  `system` (include the `__*__` system topics too, default `false`),
  `$container` (mounted by the parent).
- Events: `EV_SHOW`, `EV_REBUILD`, `EV_THEME` (restyle — it repaints the G6
  graph in place, preserving the user's zoom/pan), plus `EV_NODE_CLICK`, which
  a node click sends into the FSM. **With a `node_route` the click IS a
  navigation** and this view makes it. **Without one the click is dropped,
  unless the host asked for it with `with_node_click`** — then it is published
  as `{topic}`, for a host that draws the same picture inside its own screens
  and opens the topic in place, with no hash involved. That host must declare
  `EV_NODE_CLICK` in its own FSM, as with every event a child publishes, which
  is exactly why it is opt-in: the CHILD subscription model subscribes a host
  to ALL of this view's events, so publishing one unasked turns a click into
  "Event NOT DEFINED in state" underneath a host that never wanted it. Since
  6.1.2 (6.1.0 and 6.1.1 published it unconditionally).

Barrel-exported and public from 4.0.0. Renders with `@antv/g6`; the cards are
HTML nodes carrying their own inline colours, so a theme switch repaints them in
place (no CSS of its own).

### C_YUI_SCHEMA_EDITOR — a schema edited as a schema

Every schema a yuno holds lives in its `treedb_system_schema`, stored as data
in three flat topics linked by fkeys: `treedbs` → `topics` → `cols`. That is
the right **storage** and it is not a **screen**. Opened with the ordinary
topic editor, adding one column to one topic means finding it in a table
holding every column of every topic of every treedb the yuno has, composing the
parent fkey by hand, and remembering to raise a `topic_version` that nothing
asks about.

This view puts the schema back together and edits that: **treedb → topics →
columns**, each in its declared `order`, with the storage composed underneath —
the qualified id, the fkey to the parent, the place among the siblings, and the
versions that publish the change.

**An edit here is a draft** (7.23.196, with the SDK's M36 design). A write moves
no version and reaches no treedb. The HOST publishes it with `C_TREEDB`'s
`save-schema`, which raises the `topic_version` of each topic that changed
(the one that regenerates `topic_cols.json`) and the `schema_version` (the one
that makes the schema file win over the literal), once, and puts it in use with
`apply-schema` and a restart of the owning yuno. Until 7.23.195 every write
here raised both numbers, so an edit half made was already the schema of the
next start. The topic list marks what this session wrote and has not saved,
and the column screen says so in a banner; the host sends `EV_REFRESH` after a
save, and the reload forgets the drafts. The export (C literal and JSON) warns
while there are drafts: the literal carries the versions a save publishes.

What the screens offer:

| Screen | What it is for |
|--------|----------------|
| treedbs | one card per treedb, with its topic count and its `schema_version` beside the `c_schema_version` it was projected from |
| topics | the topics of one treedb in schema order: pkey, column count, version, system flag |
| columns | the heart. Rows in `order`, **draggable** — `order` is a field, so a drop writes the rows whose place actually changed, two or three and not forty |
| diagram | the treedb **drawn from the records being edited**, through `C_YUI_TREEDB_SCHEMA` |

And what the toolbar offers, each answering a question the storage could not:

- **check** — what the treedb would refuse, from the records alone: a pkey
  naming no column, a hook whose target is missing or is not a fkey, two hooks
  on one fkey, an `enum` flag with no `enum`. Applying a schema is restarting
  the yuno that owns it, so a schema it refuses costs an outage to discover and
  the message lands in that yuno's log, on the node, minutes later.
- **export** — the schema as its **C literal**, ready to paste into the source.
  An edit made here works and lives nowhere the next build knows about;
  `diff-schema` says the two halves drifted, and this is the other half of that
  answer. Escaping crosses two layers and the second is not JSON's:
  `helper_quote2doublequote()` rewrites *every* single quote before the parse,
  so a quote inside a value can only survive as `\u0027`.
- **import** — the writes that make the stored schema equal a pasted one, shown
  as a **plan** before it runs. Import is the one operation here that can delete
  a column, so what is confirmed is what runs.

The **flags** of a column are checkboxes that say what they do, grouped the way
they act and dimmed (never hidden) when they are meaningless on the chosen type:
a flag already set on a column of another type has to stay visible or the next
save drops it silently. `hook` and `fkey` turn each other off, because they are
the two ends of one link.

A **name is not editable**: the store keys a column by its qualified id —
treedb, topic and name — so renaming one is creating another and deleting this
one. The form says so rather than offering a field that quietly does something
else.

**Contract:**

- Attributes: `subscriber`, `gobj_remote_yuno` (the transport — the treedb's
  service, or an adapter that reaches it: this view cannot tell and must not),
  `treedb_name` (the system-schema treedb), `readonly` (this yuno does not
  master the tranger, so it refuses every write), `base_route`, `$container`.
- In: `EV_SHOW` (`{subpath}` — the tail it owns is `<treedb>[/<topic>]` or
  `<treedb>/diagram`), `EV_HIDE`, `EV_TRANSPORT_STATE`, `EV_REFRESH`,
  `EV_LANGUAGE_CHANGED`, `EV_MT_COMMAND_ANSWER`.
- Out: `EV_POSITION_CHANGED` (`{subpath}` — the host writes the url; this view
  navigates nothing itself), `EV_RECORD_WRITTEN` (whoever owns the Apply needs
  to know the yuno has not re-read its schema yet), `EV_SCHEMA_CHECKED`
  (`{errors, warnings, first}` — so the confirmation that restarts the yuno can
  say what it is about to restart onto).
- States, because each is a screen and a set of legal actions: `ST_IDLE`,
  `ST_LOADING`, `ST_EMPTY`, `ST_TREEDBS`, `ST_TOPICS`, `ST_DIAGRAM`,
  `ST_COLUMNS`, `ST_SAVING`.

**Three words decide whether a write does what it says** (`schema_write_options.js`,
and they are tested because getting one wrong costs a store repaired by hand):
a create goes through `update-node` and not `create-node`, because only that
path carries **`autolink`** — and without a link a new column belongs to no
topic. `autolink` rewrites a node's links from the fkey fields the record
carries, so it goes with a create, where the record has one, **and with nothing
else**: on a partial update it finds none, reads that as "no parents", and
detaches the node. Raising a topic's version with it on unlinked that topic
from its treedb, and the write answered success.

The logic is pure and tested apart from the view: `schema_model.js` (the three
lists regrouped — grouping follows the **fkey**, not a split of the qualified
id on `.`, which works right up to the first name that carries one),
`schema_validate.js`, `schema_descs.js`, `schema_to_c.js`, `schema_import.js`,
`schema_flags.js`, `schema_write_options.js`. Since 6.1.0.

**A drag can be undone.** Reordering a column is a WRITE — `order` is a field —
so the drop lands in the store the moment you let go. The toolbar grows an
`Undo the order` button that puts the columns back where they were **before the
dragging started** (remembered once per topic, before the FIRST drag), shown
only while there is somewhere to go back to, spent when used, and dropped by a
refresh. Undoing is another write, like the drag. The host must define the key
`"undo the order"`.

### The bytes a node owns but cannot hold — `yui_asset_*`

A treedb node often owns something that is not JSON: a photo, a plan, a
clip. Those bytes cannot live in the treedb — it is held in memory and
timeranger2 rewrites the whole record on every update — so the SDK's
`C_ASSETS` keeps them in a directory it owns and the node names one with an
**fkey**.

`get-asset` answers in one of two shapes, and the **backend** decides which:

```json
{"mode": "url",    "url": "/assets/ab/cd/<id>.jpg?e=<expires>&s=<token>"}
{"mode": "inline", "content_type": "image/jpeg", "content64": "..."}
```

It signs a URL when a web server sits in front of the store and hands over
the bytes when there is none, so a consumer has **one** code path and a node
with no web server still shows its images instead of showing nothing.

This module is the two ends of that, and **it does not talk to the
backend** — asking is an action and belongs in the view's own FSM:

| | |
|---|---|
| `yui_asset_id(ref)` / `yui_asset_ids(ref)` | the id(s) a column names. A link comes back in whichever shape the READER asked for — the stored `"assets^<id>^as_foto"`, the bare `"<id>"` that `fkey_only_id` collapses it to, or an expanded `{id}` — and either alone or in a list, because an unset single-valued fkey is still an empty list. All of them are read; an empty column answers nothing rather than throwing |
| `yui_asset_src(answer)` | the two shapes into one `src`. `null` when the answer carries neither — never an empty string, because `<img src="">` reloads the page in some browsers |
| `yui_asset_kind(content_type)` | `"image"`, `"video"`, `"audio"`, `"pdf"` or `"other"`, from the content type the backend stored (a `; charset=…` parameter is ignored) |
| `yui_asset_element(answer, opts)` | the element, picked by that kind: `<img>`, `<video>`, `<audio>`, a PDF in an `<iframe>` (the browser's own viewer), or, for any other kind, a card with the content type and an open link. Video and audio are assets too, and an `<img>` whose src is a film shows the broken box this exists to remove |
| `yui_asset_open_link(answer)` | an `<a target=_blank>` that opens the asset at full size, named through `open in a new tab`. `null` when there is nothing to open |
| `yui_asset_href(answer)` | `{href, blob}`: the url as it comes, or a `blob:` url over inline bytes. Firefox will not NAVIGATE to a `data:` url (a new tab stays blank) and its PDF viewer does not run on one in a frame, so frames and links need a blob |
| `yui_asset_file_answer(file)` | the answer a picked `File` would have had: a `url` answer over a `blob:` url of the File itself, so a file not saved yet goes through the same element as a stored one |
| `yui_asset_release($root)` | revoke every `blob:` url this module made under `$root`, `$root` included. A blob url keeps its bytes alive until revoked and removing the DOM revokes nothing, so call it on whatever element you drop |
| `yui_asset_missing(detail, opts)` | the marker, for when there is nothing to show |

**A missing asset is now said out loud.** It used to leave a broken box and
no word about it, which is indistinguishable from a slow one and from a bug
— 47 such holes in one day on `artgins.ytreedb.com` before anybody noticed.
`yui_asset_element()` wires `onerror` so the dead element is **replaced** by
the marker, whatever the reason: an expired signature, a blob gone from the
store, an unsupported codec.

The marker's label carries its i18n key (`asset not available`, overridable
with `opts.key`), so it follows a language change; `opts.detail` is DATA — a
name or a path, the thing a person can act on — and is never translated.

```js
import {yui_asset_id, yui_asset_element} from "@yuneta/gobj-ui/src/yui_asset.js";
import "@yuneta/gobj-ui/src/yui_asset.css";

// in the view's FSM, not in a DOM callback:
const id = yui_asset_id(device.foto);
if(id) {
    gobj_send_event(gobj, "EV_ASK_ASSET", {asset_id: id, slot: "foto"}, gobj);
}
// ...and when the answer arrives, in the action:
$box.appendChild(yui_asset_element(answer, {detail: device.foto_name}));
```

#### In the treedb views

The treedb form and table show the asset a `file` column names, and they ask
for it in the same way as a page of rows: the table publishes the request UP
and its host owns the transport.

```
C_YUI_TREEDB_TOPIC_WITH_FORM                 C_YUI_TREEDB_TOPICS
  EV_REQUEST_ASSET {topic_name, req_id, asset_id}  ──►  get-asset asset_id=<id>
                                                          (service = assets_service)
  EV_ASSET_LOADED {req_id, answer}  ◄──  the answer, by req_id
  EV_ASSET_FAILED {req_id, error}   ◄──  a refusal, no backend, or 30 s of silence
```

- **Form:** when the edit dialog opens, one request goes out per `file` column
  that names an asset. The answer reaches the form as
  `EV_SET_FILE_PREVIEW {name, id, answer, error}` and is drawn under the
  control only while the column still names that id. A picked file needs no
  request: it is previewed from the `File`.
- **Table:** a click on a file cell opens a popup with one slot per asset the
  cell names. Each slot starts as `loading` and is filled by its own answer.

Set the service on the host. `"assets"` is the default, and it is what a yuno
names its `C_ASSETS`; an empty string turns the requests off:

```js
gobj_create_service("#topics-mydb", "C_YUI_TREEDB_TOPICS", {
    gobj_remote_yuno: remote,
    treedb_name:      "treedb_mydb",
    assets_service:   "assets"
}, gobj);
```

The user needs the `read` permission of that service. The service is not the
one the connection was opened for, so a user who is not root must also hold a
role in it, or the ievent gate refuses to route the command there.

### JSON viewer — `setup_json_pad`

`setup_json_pad(self)` opens a **blank JSON pad** (`C_YUI_JSON_PAD`) in a
floating `C_YUI_WINDOW`: paste JSON from outside — a log line, a config file, an
answer copied from a terminal — and read it with the library's own viewer
(`C_YUI_JSON`: tree, text, graph). Wire it to an account-menu entry, next to the
frontend view (`EV_OPEN_JSON_VIEWER` in every SPA of the ecosystem). Like the
frontend view it returns `null` when the window is already open, so the host
toggles with it, and it registers `C_YUI_JSON_PAD` (and the `C_YUI_JSON` it
hosts) itself when the app did not.

- A **paste anywhere in a pane** replaces its document and shows it at once;
  **view** (or Ctrl+Enter) reads the text area as typed; **clear** empties it.
- Text that is not JSON leaves the last document on screen and says so under
  the text area, with the parser's reason.
- **Two panes.** *Second json* opens a second pane beside the first (stacked
  when the pad is narrower than 40rem), and *compare* puts the differences of
  the two documents in place of the two viewers: one row per id of the flat
  form (`json2flat`, `json_diff_rows()` in `json_view_helpers.js`), tagged
  added / removed / changed, sorted by id. Edit either text area and view it
  again: the differences follow.
- **What was pasted is kept**: both texts and the layout, in `localStorage`
  under the pad's `storage_key` attribute (default `yui_json_pad`; empty keeps
  nothing), so the next pad opens as the last one was left. A text too big for
  the browser's quota is not kept, and the pad says so in its toolbar.
- A `__collapsed__` sentinel in a pasted dump is answered with
  `EV_SUBTREE_ERROR`: the source truncated it, and a pad has no backend to ask.

Consumer i18n keys: `json viewer`, `paste json here`, `invalid json`,
`collapsed in the source`, `first json`, `second json`, `compare`, `added`,
`removed`, `changed`, `path`, `no differences`, `compare needs two json`,
`cannot compare`, `json not kept` (plus `view` and `clear`, which every app
has). `compare`, `view` and `clear` reach `t()` as variables, so a consumer's
`validate-locales` does not see them.

### Frontend view — `setup_frontend_view`

`setup_frontend_view(self)` opens the **gobj tree of the app's own yuno** in a
floating `C_YUI_WINDOW` — the browser-side peer of the Developer window
(`setup_dev` / `build_dev_panel` / `apply_dev_traces` / `dev_window_was_open`,
`yui_dev.js`), and the JS answer to `view-gobj-tree` on a C yuno. Wire it to an
account-menu entry. It returns `null` when the window is already open, so the
host can use it to toggle. The tree is a **pure child of the window**, so every
teardown path (the ✕, or the host destroying the window to toggle the entry
off) takes it down too.

**What a card says.** Every node carries what it IS and what it is DOING,
because opening a popover per node to learn it is not reading a tree:

| On the card | Says |
|---|---|
| Colour (border + left bar) | the ROLE: sky = the yuno, emerald = a service, amber = a pure child, pink = a volatil one, violet = a plain child |
| Status pill | a SYMBOL and, where there is room, the word: `▶` playing, `‖` running but not playing (PAUSED, which is what the popover calls it in full), `■` stopped, `⊘` disabled. The symbol and not only a colour, because a dot has one shape and stopped is the state that has to be seen |
| Badges | `service`, `pure child`, `volatil child`, `disabled`, `bottom` (it has a bottom gobj), `commands` (its gclass exposes commands) |
| Its own line | the FSM state |
| Dashed border, dimmed | disabled — that branch is out of the game |

The popover adds the rest (`full name`, `parent`, `bottom gobj`, `traces`,
`flags`, how many children a fold is hiding) and shows only the rows that have
an answer. Its two state rows are `status` and **`fsm state`** — different
keys on purpose: they used to be `status` and `state`, which every Spanish app
translated to the same word, so the popover showed *Estado: Parado* directly
above *Estado: ST_IDLE*.

**The find box** matches gclass, name, full name and FSM state; a match wears
an amber ring over its role colour (the card still has to say what it IS) and
the chip counts them, because a graph that did not move looks the same whether
nothing matched or the match was already on screen. A find is a REBUILD — an
html node paints no G6 state, so the highlight lives in the card's own markup —
which is why the box is rate-limited, and why the view is preserved: somebody
who typed a letter did not ask to be moved.

**A service the app creates is the app's to START.** `c_yuno`'s `mt_play` starts
only the DEFAULT service, so anything else stays stopped unless its creator
starts it — and a gclass with no `mt_start` (`C_YUI_WINDOW_MANAGER` builds its
dock in `mt_create` and runs off events) WORKS stopped, which is how one shipped
that way in two apps: nothing failed, it just read `!!C_YUI_WINDOW_MANAGER` in
every trace line and *stopped* in this view.

**It draws DESCRIPTORS, not gobjs** (`gobj_tree_model.js`), which is what lets
this same view show a **backend** yuno. A descriptor is plain data whose field
names are the ones the C kernel's `gobj2json()` writes, and there are two
producers of it:

```js
import {describe_js_gobj, describe_backend_tree} from "@yuneta/gobj-ui/src/gobj_tree_model.js";

let root = describe_js_gobj(gobj_yuno(), true);      /*  this browser yuno   */
let root = describe_backend_tree(answer);            /*  `view-gobj-tree`    */
```

`load_tree()` calls the first one, on one line. Swapping it for the second is
the whole of showing a remote yuno; nothing below that line changes. (The
remote fetch itself is not wired yet.)

### The gclass viewer — `yui_gclass_view.js`

```js
import {gclass_view_available, open_gclass_view, close_gclass_view}
    from "@yuneta/gobj-ui/src/yui_gclass_view.js";

let handle = open_gclass_view(host, "C_TIMER", {title_prefix: yuno_name, on_close});
close_gclass_view(handle);
```

What a GClass IS — its attrs, its commands, the methods it implements, its
trace levels, its FSM — in a `C_YUI_GCLASS` window, laid out by **zones**.
The framework had no such viewer: the C kernel answers `view-gclass` with
exactly this document and the only thing that ever read it was a terminal.

`gclass_describe.js` builds it for a gclass of the **browser** yuno from the
gobj-js registry, in the shape `gclass2json()` answers (`id`, `gcflag`,
`attrs`, `commands`, `gclass_methods`, `internal_methods`, `FSM`, plus
`instances` and `trace_levels`, which the browser can answer for free). For a
gclass of a **backend** yuno the host passes the answer of `view-gclass` as
`opts.description` and the same window draws it.

`opts.current_state` is the state the INSTANCE the reader came from is in. The
description describes the CLASS and cannot carry it, so the host supplies it —
the gobj tree passes `node_data.state` — and the machine lights that column.
Omitted, nothing is lit.

`close_gclass_view(handle)` is the host-driven teardown, and the ✕ runs it too:
the window destroys itself, but the viewer hangs from the HOST, so nothing else
would take it down — and a viewer left alive keeps its service name, which made
the next click answer *"service ALREADY registered"* (fixed in 7.23.38).

`gclass_view_available()` registers `C_YUI_GCLASS` on demand and reports
whether it is there, so no app has to mount a gclass for a control it never
asked for. The gobj tree still asks before drawing its `gclass` button: a
registration that failed must not leave a button that opens nothing.

### C_YUI_GCLASS — a gclass by zones

```js
import {register_c_yui_gclass} from "@yuneta/gobj-ui/src/c_yui_gclass.js";
```

Attrs: `description` (the document), `gclass_name` (heads the view when the
document carries no `id`), `current_state`, `view_mode` (`"zones"` | `"raw"`),
`$container`. Events in: `EV_SET_DESCRIPTION {description, current_state}`,
`EV_SET_VIEW_MODE {mode}`, `EV_TOGGLE_ZONE {zone}`, `EV_SEARCH {text}`,
`EV_COPY_ALL`, `EV_LANGUAGE_CHANGED`, `EV_REFRESH`, `EV_SHOW`, `EV_HIDE`. It
publishes nothing, so hosting it adds no declaration to a host's FSM.

`gclass_view_model.js` is where the document becomes the view, and it reads
**both dialects of it**. The C kernel and the browser registry answer the same
document in different words, and a viewer that knew only one drew half a page
for the other side:

| key | C kernel | browser |
|---|---|---|
| `flag` | `"SDF_RD\|SDF_PERSIST"` | `["SDF_RD", …]` |
| `type` | `"string"` | `"DTP_STRING"` |
| command name | `command` | `id` |
| parameter name | `parameter` | `id` |
| trace levels | `info_gclass_trace` (dict) | `trace_levels` (array) |
| action | the literal `"action"` | the function's name |

The last one is not a rename: `states2json()` cannot name an action, so a cell
from a **backend** gclass says THAT there is one and never which. The viewer
draws a mark rather than printing a name nobody wrote — and an em dash for the
third case, an event declared with NO action (`{EV_TX_READY, 0, 0}`, which
C_WEBSOCKET has in all four states): legal in that state, and deliberately
doing nothing.

**The machine is a matrix**, rows = events and columns = states — the shape the
FSM is declared in, and the one that survives 12 events against 3 states as
well as 86 commands against one. Two things it says that a JSON dump cannot:

- **An empty cell is information.** An event with no action in a state is not
  ignored, it is refused with *"Event NOT DEFINED in state"*, so the empty
  cells are the map of what breaks. They are hatched, never blank.
- **A state nothing declares a way INTO is marked.** An action may jump with
  `gobj_change_state()` — `C_IEVENT_CLI` reaches `ST_SESSION` that way — and no
  description can see inside an action. Marking it is the alternative to
  drawing the working half of a gclass as unreachable.

Events declared but handled in no state are OUTPUT events: they leave the
matrix (one empty row per column says nothing) and are listed under
*publishes*. The `raw` view keeps the description verbatim — it is the
authoritative answer of the backend and is never thrown away.

### C_YUI_FSM_GRAPH — the machine, drawn

The machine zone's second view, switched from a pair of buttons inside the
zone (the zone heading is itself a button, and a button inside a button is not
a control). The matrix stays the default; the graph earns its place where the
matrix is worst — many states, few events each — and in graph mode the machine
takes the whole width and the other zones stack under it, because 600px of a
1200px viewer is where six states stop fitting.

Attrs: `fsm` (the `fsm` block of the view model), `current_state`,
`$container`, `canvas_id`, `wide`, `layout` (`dagre-lr` | `dagre-tb`). Events
in: `EV_SET_FSM {fsm, current_state}`, `EV_CHANGE_LAYOUT {layout}`, the camera
set (`EV_ZOOM_IN` / `EV_ZOOM_OUT` / `EV_ZOOM_RESET` / `EV_CENTER`),
`EV_RESIZE`, `EV_THEME_CHANGED`, `EV_LANGUAGE_CHANGED`, `EV_SHOW`, `EV_HIDE`.
It publishes nothing.

What it refuses to draw is the interesting half:

- **One edge per PAIR of states**, not per transition, labelled with the events
  that make the jump (three, then a count). G6 draws overlapping edges on top
  of each other, so a second edge between the same two states is invisible and
  nothing tells the reader it is there. A RECIPROCAL pair (`A -> B` and
  `B -> A`) is bowed apart into two arcs — with the SAME `curveOffset` on both,
  because G6 measures it from each edge's own direction, and with the type
  varied through a FUNCTION on `edge.type`, because a `type` written on the
  datum is overridden by the graph-level default.
- **An event a state handles without leaving is not an edge.** Ten self-loops
  on one card say nothing a number cannot say better, so the card carries
  `n events · m leave` instead.

It fits with `fitCenter()` then `fitView()` — the house pair; `fitView` alone
leaves an html-node graph off centre — and never below `MIN_READABLE_ZOOM`
(0.5), the same floor `C_G6_NODES_TREE` uses: a six-state machine fits a panel
at 38%, which is the whole graph and worth nothing.

> **Mount before you start a hosted view.** `mt_create` builds the DOM;
> `mt_start` renders it, and a view that measures a canvas or observes its box
> needs to be IN the document by then. A host that creates a viewer, reads its
> `$container` and hands it to a window as `body` must therefore start the
> viewer **after** the window — the window is what puts it in the document.
> Started before, a graph viewer measures nothing, attaches no
> `ResizeObserver`, and its canvas keeps its birth size for the life of the
> window while everything resizes around it (fixed in 7.23.31).

### Toolbar badge — a count pinned to an item's icon

```js
/*  app_config.json — seeds the FIRST paint only  */
{ "id": "alarms", "icon": "yi-triangle-exclamation", "align": "end",
  "aria_label": "alarms", "badge": 0,
  "action": {"type": "navigate", "route": "/alarms"} }

/*  the interface that matters: a count is a RUNTIME fact  */
yui_shell_set_toolbar_item_badge(shell, "alarms", 3);
yui_shell_set_toolbar_item_badge(shell, "alarms", 0);   // clears it
```

An icon-only toolbar button is a link; the badge is what makes it a
**signal**. Without a number, an alarm bell cannot say whether anything needs
you — which is the reason to look at it at all.

Rules baked in, all for the same reason (a badge that lies costs more than no
badge):

- **`0`, `""`, `null` and `false` all clear it.** A badge reading "0" is worse
  than none: it draws the eye to say nothing.
- **Over 99 renders `99+`.** The toolbar is a fixed-width row and a four-digit
  pill pushes its neighbours off a phone screen.
- **A string passes through** for the states that are not counts (`"!"`, `"…"`).
- **Unknown item id is a silent no-op**, so an app whose toolbar has no such
  item does not log an error on every tick of whatever feeds the number.
- Writing the **same** value again touches no DOM: the badge is a
  `role="status"` live region, and rewriting it would have a screen reader
  announce the same number on every tick.

`role="status"` and not `aria-hidden`, deliberately: the button carries an
explicit `aria-label`, and an explicit label **replaces** an element's content
for a screen reader — a badge inside it would otherwise be silent. As its own
live region it is both read and announced when it changes.

> `C_YUI_NAV` items do **not** have this. Its item contract listed `badge` for a
> long time and nothing ever rendered it; the claim is gone. Implement it there
> the day a menu entry needs one.

### Modals — `yui_shell_show_modal` and the `before_close` veto

`yui_shell_show_modal(shell, $box, opts)` is the standard popup: pass
`{dialog:true}` for the adaptive dialog (centered card with the X top-right on
desktop, full-screen sheet with a back arrow on mobile), and the shell wires
Escape / backdrop / browser Back for you. It returns a `close()`.

**`opts.before_close`** guards the dismiss. It is consulted on every
*user-driven* close — Escape, backdrop, the X / back-arrow, browser Back — and
returning **`false` vetoes** it, so the caller can run its own flow instead (the
canonical case is an unsaved-changes prompt that closes the modal itself once
confirmed). On a vetoed browser-Back the history entry is re-armed, so Back
keeps working afterwards. With no guard a modal closes exactly as it always
did, and the returned `close()` always closes **unconditionally** — the veto is
for the user's dismiss, not for the code's.

### Confirmations — and the red one, `yui_shell_confirm_danger`

`yui_shell_confirm_yesno(shell, message, opts)` asks a question and resolves to
a boolean. Its yes is `is-link`, the right colour for *"do you want to
continue"*.

**`yui_shell_confirm_danger(shell, message, opts)`** is the same call with a
**red** confirm button and the error icon (`type: "danger"` by default). Use it
whenever the yes destroys something — deleting an account, dropping a record.
The two must not look alike: the destructive one is precisely the one that must
not be clicked by reflex.

In both, the **safe answer is the last button**, so Escape, the backdrop and
the X all resolve to it.

```js
if(await yui_shell_confirm_danger(shell, t("delete account detail"))) {
    /* only here has the red button been pressed */
}
```

### `C_YUI_FORM` — choosing the bottom toolbar

By default the form shows **save + undo + clear + copy + paste**. The
`toolbar` attr takes the button names you want, in the order you want them:

```js
gobj_create("form", "C_YUI_FORM", {toolbar: ["save"]}, parent);   // one action
gobj_create("form", "C_YUI_FORM", {toolbar: []}, parent);         // no toolbar
```

Save/undo/clear stay on the left of the bar and copy/paste on the right — the
split the layout has always drawn — so dropping a whole group leaves no hole in
the middle, and **a toolbar left with a single group is centred**. An unknown
name is reported, not silently dropped: a typo would otherwise remove the save
button with no trace of why.

### `C_YUI_FORM` — which field is editable, and the one `writable` does not govern

A form opened to LOOK at a record has no editable field: that is a property of
the opening (`readonly`), not of the schema, and it wins over everything. Under
it the schema decides — a column needs `writable` — **with one exception, and
it is the whole of linking: an fkey.**

`writable` governs the WRITE of a column's VALUE, and an fkey is not written,
it is **linked**. An fkey is normally declared with no `writable` flag at all
(`treedb_authzs`'s `users.roles` is `['fkey']`, and so is almost every fkey in
the tree), so reading "not writable" as "not editable" takes away the only way
to link a record to its parent — with `users.roles`, the only way to give a
person a role. The other half of the same rule is in the topic view, where the
record travels back: it sends "the writable cols, the fkeys (a link is edited
by linking) and the pkey". The two halves have to agree; when they did not, the
form disabled the control whose value the save was still waiting for.

The rule lives in `form_field_readonly.js`, pure and tested. A `file` column is
an fkey too (`['fkey','file']`) and is deliberately NOT covered: it answers
`type: "file"`, its bytes travel beside the record, and the SDK declares as
legal a `file` column that only a load fills.

**And `readonly` is an attribute of a TEXT control and of nothing else.** A
`<select>`, a checkbox and a radio accept it and let the reader change the
value anyway; only `disabled` stops them, and tom-select has to be told through
its API. So the form applies `disabled` to those three, and the record is still
read from a disabled control — it is collected by reading each marked control,
never by submitting the form.

### Selecting rows in any table — `yui_table_select.js`

Deleting twenty rows one confirmation at a time is not a workflow. Any view
whose table can remove (or export, or act on) a row eventually needs to do it
to several at once, so the checkbox column, the settings behind it and the bar
that appears while something is ticked live here once:

```js
import {
    yui_selection_column,
    yui_selection_settings,
    yui_selection_bar,
    yui_wire_selection,
    yui_selected_rows,
    yui_clear_selection,
} from "@yuneta/gobj-ui/src/yui_table_select.js";

/*  1. the column, FIRST in the list  */
let columns = [yui_selection_column(), ...my_columns];

/*  2. the settings it needs  */
let table = new Tabulator($div, {...yui_selection_settings(), columns: columns, ...});

/*  3. the bar. Every button's job is to SEND AN EVENT  */
priv.bar = yui_selection_bar(t, {
    name:    "CONNECTIONS",
    actions: [{
        label:    "remove selected",        /*  an i18n KEY  */
        icon:     "yi-trash",
        class:    "is-danger",
        on_click: () => gobj_send_event(gobj, "EV_REMOVE_SELECTED", {}, gobj)
    }],
    on_clear: () => gobj_send_event(gobj, "EV_CLEAR_SELECTION", {}, gobj)
});
$container.appendChild(priv.bar.$el);

/*  4. the table tells the bar how many are ticked  */
yui_wire_selection(table, (n) => gobj_send_event(gobj, "EV_SELECTION_CHANGED",
    {count: n}, gobj));
```

Two decisions are baked in, both learned in the treedb topic table:

- **Selection is driven only by the checkbox** (`selectableRows: "highlight"`),
  never by clicking the row. A row is full of things to click — an editor, an
  icon, a nested table — and click-to-select ticks a row every time you reach
  for one of them.
- **The header checkbox covers the ACTIVE rows**, the ones the filters leave on
  screen (`titleFormatterParams: {rowRange: "active"}`). "Select all" over rows
  nobody can see is how a filtered delete takes the whole topic with it.

The bar takes its words from the HOST's `t` (this library translates through
the app's i18next): the app must define **`"{{n}} selected"`** and
**`"clear selection"`**, and each action's own key. The count is composed at
render time, so `refresh_language()` cannot reach it — call `bar.refresh()`
from the view's `EV_LANGUAGE_CHANGED` action.

`yui_selected_rows(table)` and `yui_clear_selection(table)` answer safely on a
table that is not built yet or is already gone. Clear the selection after
acting on it: the rows it names are no longer there.

**In the treedb topic table** (`C_YUI_TREEDB_TOPIC_WITH_FORM`, and through it
`C_YUI_TREEDB_TOPICS`) the bar is **opt-in**: pass `with_selection_bar: true`.
It is off by default because the bar takes its words from the HOST's i18n, and
a host that has not defined `"{{n}} selected"` and `"clear selection"` renders
the keys. It shows only while the table is in **edition mode** — outside it the
checkbox column is hidden, and a count of rows nobody can see or untick is a
count you cannot act on. The bar carries no action there: the table's own
toolbar already has Delete and Copy, and they act on the selection.

`yui_selection_column({visible: false})` is for a table that reveals the column
only in an edit mode — `C_YUI_TREEDB_TOPIC_WITH_FORM` shows it with
`showColumn("_check_box_state_")`, and takes its `selectableRows` and every
read of its selection from here too. A **radio** column (pick ONE row) is not
this facility: it is `formatter: "rowSelection"` with no `titleFormatter` and
`selectableRows: 1`, and the header checkbox, the count and the bar all mean
nothing there.

### Clearing a header filter — `yui_table_filter_clear.js`

A column filter is set by typing and undone by deleting what you typed, letter
by letter, and nothing in the header says it can be dropped at all. With
several columns filtered, getting back to the whole table is an exercise in
remembering which ones you touched. One call, on a table that is already built:

```js
import {yui_table_filter_clear} from "@yuneta/gobj-ui/src/yui_table_filter_clear.js";

table.on("tableBuilt", () => {
    yui_table_filter_clear(table);
});
```

The app must define the i18n key **`"clear"`**. Three decisions are baked in:

- **The ✕ is visible whenever the filter has content, focus or not** — the
  opposite of the ✕ in a form (`yui_inputs.js`), which shows only on the field
  being edited so a form full of filled fields does not light up an ✕ on every
  one. Here the filter is already set, the focus is elsewhere, and the whole
  point is to see it and drop it; hidden behind focus it would take two clicks.
- **It clears through `setHeaderFilterValue()`**, not by synthesising a
  keystroke on the input — what a header filter listens to depends on that
  column's filter editor.
- **It decorates the header by walking it**, because a table's columns are
  often built from data (a treedb schema) and the header rebuilds itself when
  they change. The walk is idempotent and gated by a single `querySelector`, so
  repeating it on every render costs nothing.

### Searching a row that is not flat — `yui_row_search.js`

`row_matches(row, term)` is what the treedb topic table's search box uses, and
it exists because **a treedb row is not flat**: with `list_dict` an fkey
arrives as a list of objects — `[{id, topic_name, hook_name}]` — and
`String(val)` of that is `"[object Object]"`. A search written the obvious way
therefore never finds the place a node is linked to, while the cell plainly
renders that id: what you see and what is searched are not the same thing.

It walks into lists and objects, and reads only the **`id`** of an fkey:
`topic_name` and `hook_name` are the same two words on every row, so matching
them turns any such term into a wildcard over the whole topic. Keys starting
with `_` are never searched, at any level — scaffolding (`_operation`) and
metadata (`__md_treedb__`).

## Conventions

### Colour: a brand token as INK takes `-on-scheme`, as a FILL it does not

A Bulma brand colour (`--bulma-link`, `--bulma-danger`, …) is built to carry
**white text on it**, so it is the wrong colour to write WITH: measured
against the scheme background, the raw tokens as ink are

| token | raw, light | raw, dark | `-on-scheme`, light | `-on-scheme`, dark |
|---|---|---|---|---|
| `link`    | 5.16 | **3.51** | 6.33 | 5.65 |
| `danger`  | **2.80** | 6.46 | 5.83 | 6.46 |
| `success` | **2.14** | 8.48 | 8.14 | 8.48 |
| `warning` | **1.75** | 10.38 | 7.09 | 10.38 |
| `info`    | **1.73** | 10.46 | 7.66 | 10.46 |
| `primary` | **1.95** | 9.28 | 6.45 | 9.28 |

— every one of them fails the 4.5:1 of small text in one scheme or the other
(`link` in dark, the rest in light). So:

- **ink** (`color`, a glyph, a thin border that MARKS a state):
  `var(--bulma-<name>-on-scheme, var(--bulma-<name>, #fallback))`;
- **fill** (`background`, a chip, a pressed button): the raw token, paired
  with `--bulma-<name>-invert` for the text on it. A darkened background
  under inverted text is a button nobody asked for.

Bulma computes `-on-scheme` per scheme: the same hue, darkened on a light
page (`warning` goes to 23% lightness), unchanged on a dark one.

**Quiet text**: `.yui-text-quiet` (the text colour at 75%), never Bulma's
`has-text-grey` — one mid grey for both schemes, 3.86:1 on the dark one, and
what the library writes in grey is an empty state, a timestamp, a topic id, a
notice's detail: text somebody has to read. And a **placeholder** is text
too; the shell's stylesheet raises Bulma's 30% alpha to 70% (2.47:1 → 7.96 in
dark) where Bulma declares the variable, since a `:root` override does not
reach it.

### A control without a name is a bug: `title` + `aria-label`, always

**Every control this library draws — and every control an app draws with it —
carries a `title` AND an `aria-label`, and both are translatable.** A control
is any `input`, `select`, `textarea`, `button`, or anything that behaves as
one. All four attributes are written where the control is built:

```js
['button', {class: 'FOO_SAVE button',
            title: t('save'),        'data-i18n-title': 'save',
            'aria-label': t('save'), 'data-i18n-aria-label': 'save'}, …]
```

It is a floor, not a preference. The ecosystem was swept against it — the
deployed page of six SPAs, read control by control — and the target is **zero
controls without a name**. Three things it exists to stop, each of which
shipped:

| What looks like a name | Why it is not |
|---|---|
| A `<label>` **beside** the control (Bulma's `field`) | No `for`, no wrapping: it names the box for the eye and for nothing else |
| `<label for="x">` over a control carrying only `name="x"` | `for` matches an **`id`**, which the control does not have — correct-looking source, no association |
| A `placeholder` | Gone the moment something is typed; a reader need not announce it |
| The visible text | Disappears with `is-hidden-mobile`; and where the text is the STATE (`on`/`off`), it does not say what the control DOES |

A `<label>` that **wraps** its control is the one shape that needs no
`aria-label` — the label IS the accessible name. And a **literal**
`aria-label` next to a visible `i18n` label is worse than none: it OVERRIDES
the translated text for a reader.

Two things carry no attribute anyone can set, and are named after the render
instead — and again on every rebuild, because a name written onto a widget's
DOM is a race with the next one:

- **What a widget draws for itself**: Tabulator's header filters and its
  row-selection checkbox (`yui_tabulator_name_filters` /
  `yui_tabulator_name_row_selects`, hung off `columnsLoaded` and
  `renderComplete`), Tom Select's box in front of the `<select>` it hides
  (`name_form_control`).
- **An `<option>`**, which is text like any other: it carries `data-i18n`,
  with `value` kept explicit — a translated option with no value tells the FSM
  to enter a mode that does not exist.

**How to check it.** Not with a grep: dump `title`/`aria-label` from the
DEPLOYED DOM, resolve each control's name the way a reader does (`aria-label`
→ `label[for]` → a wrapping `<label>` → the text → `title` → `placeholder`),
then switch language and diff. A key that arrives as a VARIABLE — from a data
table, a helper's argument, a local alias of `t()` — is invisible to
`validate-locales` and shows up only there.

### i18n: a string must be able to CHANGE language, not just be translated once

Passing a string through `t()` is **not** enough. `refresh_language()` only
re-translates a node that **carries its key**, so anything a view composed with
`t()` at render time stays in the old language for the rest of its life. Three
shapes, and the fix for each:

| Shape | Symptom | Fix |
|---|---|---|
| Text built with `t()` | never changes language | `i18n` / `data-i18n` on the element (`["span", {i18n: "rows"}, t("rows")]`) |
| A composed string (`` `${key} · ${t(mode)}` ``) | carries no key at all | split it: the translatable halves get their own key. (Note `createElement2` **trims** text nodes — space a `·` separator with CSS, not with spaces.) |
| `title` / `aria-label` set with `t()` | tooltip stuck in the old language | `data-i18n-title` / `data-i18n-aria-label` |
| Anything a WIDGET renders (a Tabulator header, its paginator, a formatter; the innerHTML of a G6 node) | drawn once; no attribute reaches it | subscribe to the shell and re-render (below). For a graph whose cards are innerHTML — the gobj tree — re-rendering means REBUILDING the graph: nothing walks inside a G6 canvas |
| DOM built AFTER start up (a node's strips, a dropdown panel) | renders the raw key — indistinguishable from a MISSING key | `yui_shell_translate(shell, $el)` right after building it (below) |

**Carrying the key is not enough for the FIRST render.** A node is born holding
the raw English key, and the app's `refresh_language()` passes only walk what
already exists: the shell tree at start up, `document.body` on a language
switch. Anything built later — a `C_YUI_NODE` strip rendered when you walk into
it, a lazily-built toolbar panel — is reached by neither, so it renders the key:
lower-case English that never changes language, which is exactly what a missing
key looks like. Division of labour:

```js
yui_shell_translate(shell, $el);   // LIBRARY-built DOM, right after building it
```

and **app view gclasses translate their own DOM** — they own a `t`, so they call
`refresh_language($container, t)` at the end of their build (this is why the
shell does not translate a mounted view: see `mount_view` in `c_yui_shell.js`).
`yui_shell_translate` is a no-op when the app registered no translator, so
behaviour is unchanged for apps that never call `yui_shell_set_translator`.

**The contract.** The app owns the locales: it switches its i18next and calls

```js
yui_shell_language_changed(shell);   // c_yui_shell.js
```

which re-translates the document and publishes **`EV_LANGUAGE_CHANGED`**. Any
view that builds DOM imperatively subscribes to its shell (`yui_shell_of(gobj)`)
and re-renders in the ACTION — a language change is an OS notification like any
other, so it crosses the FSM, never a raw `i18next.on("languageChanged")`.

**Tabulator** renders its own chrome (the paginator, the placeholder, the
loading/error notices) and it never went through i18n. Use:

```js
new Tabulator($el, {...settings, ...yui_tabulator_lang(t)});   // at build
yui_tabulator_relocalize(table, t);                            // on the event
```

Every key falls back to the English string Tabulator used to render
(`defaultValue`), so an app that defines none of them sees no change. Two traps
the implementation already handles: `setLocale()` with the locale name already
in force is a **no-op** (hence a fresh name per switch), and re-applying a locale
makes Tabulator re-run a title formatter on the EXISTING header cell, which
**appends** to it — rebuild the columns from their definitions.

**A missing key is invisible:** i18next answers an unknown key **with the key
itself**, so it renders (lower-case English) and simply never changes language.
A **duplicate** key in a locale file is silent too — an object literal keeps the
last one. Both are caught by the apps' `scripts/validate-locales.mjs`, which
also scans the gobj-ui modules the app mounts: **the library translates through
the APP's i18next**, so every key it asks for must be defined by the app.

### Dates: never hand-roll them again

Every date UI in the projects had grown its own copy of the same two things —
"epoch → the local wall clock" and "what are the bounds of this week" — and the
copies disagreed (one rendered UTC, another local; one closed a range on the
next bucket's first instant, another on its last). Both now live here, and
nothing else should.

**`yui_time.js` — the pure half** (no DOM, no dependency):

- `epoch_to_local_input` / `local_input_to_epoch` / `fmt_epoch` / `epoch_to_ms`
  / `ms_to_epoch` — every conversion crosses the producer's unit flag
  (`ms`: seconds unless a topic's `system_flag` says milliseconds).
- `period_bounds` / `period_shift` / `period_start` / `period_label` /
  `infer_period` / `is_current_period` — the algebra of **periods**.

A period is **`(unit, count)`**, not a name from a fixed list:

```js
{id: "quarter",  unit: "month",  count: 3}    // and semester is count 6,
{id: "bimester", unit: "month",  count: 2}    // bimester 2, decade year×10,
{id: "15min",    unit: "minute", count: 15}   // …
```

so an app that reports by quarter DECLARES a quarter — it does not ask for a new
component. `YUI_PERIODS` is the catalog of the named ones; anything an app
invents labels itself by its own edges (`1 jul – 31 aug 2026`).

Three invariants worth knowing before touching it:

- **Buckets are aligned**, never counted back from now: months to the year (so
  2/3/4/6/12 fall on calendar boundaries), weeks to Monday (ISO), hours to local
  midnight. A window that ends at `now` is a **rolling** window (`YUI_ROLLING`),
  a different animal — it has no previous, and its upper end stays **open**.
- **The upper bound is inclusive** — the bucket's last millisecond, not the next
  one's first. Both ends of a match condition are inclusive, and an exclusive end
  handed to one silently swallows the record that landed on the boundary.
- **Stepping is calendar arithmetic**, never `+86400000`: a DST day is 23 or 25
  hours long, and `31 jan + 1 month` is february, not "3 march".

**`C_YUI_PERIOD` — the UI half**: a granularity strip + `‹ label › >|` + a
calendar on the label (day / month / year grid, chosen by the granularity's own
unit). It publishes `EV_PERIOD_CHANGED {mode, anchor, from, to}` and mirrors
`from`/`to` in read-only attrs, in the consumer's unit, `0` = unbounded. Modes
that cannot be walked (`span`, `custom`, a rolling window) live in `ST_FLAT`, so
an arrow arriving there fails loudly. `with_custom` reveals a `$custom` slot the
HOST fills (its own from/to inputs): the component shows and hides it with the
mode, the host owns what is in it. Reference consumer: the Rows options of
`gui_treedb`'s `C_TRANGER_VIEW`; live demo in `test-app` (chapter **Period**).

The library asks the APP's i18next for its keys, so a consumer must define them
(`day`, `week`, `quarter`, `today`, `week {{n}}`, `quarter {{n}} {{y}}`,
`previous period`, …) — copy the block from `test-app/src/locales.js`, which is
the complete one: it is the only consumer that declares every mode, `rolling`
included (`last 24h`, `last 7 days`), and a missing key is **invisible** —
i18next answers it with the key itself.
The picker subscribes ITSELF to the shell's `EV_LANGUAGE_CHANGED` (its labels
are composed at render time), so a host has nothing to forward — a host that
forwards the event anyway just repaints it twice, harmlessly. All Intl
formatting (month names, weekday initials, the parked-bucket label) follows
i18next's ACTIVE language, not `navigator.language` — the calendar never mixes
scripts with the UI around it.

### Inputs: a clear (✕) is the norm on free-text fields

Every editable free-text field carries a clear button — a big help on mobile,
and `C_YUI_FORM` wires it into its field factory automatically (text / password
/ url / tel and the text-backed numerics; excluded: color, datetime-local,
readonly). Build a bespoke one-off clear and it will look different from every
other one, so use the helper:

```js
import {attach_clear, refresh_clear} from "@yuneta/gobj-ui";

attach_clear($control, $input, on_clear);   // Bulma .delete inside the control
```

`attach_clear($control, $input, on_clear)` appends a Bulma `.delete` that is
visible only while the field has content, hides itself while the input is
`readonly`/`disabled`, dispatches a **synthetic `input` event** so existing
handlers re-run on their own (which is why a component rarely needs a dedicated
"cleared" event), then refocuses. Its tooltip carries `data-i18n-title` /
`data-i18n-aria-label`, so it re-translates on a language change.

`refresh_clear($input)` re-syncs the button's visibility after a change that
fires **no** `input` event — a value loaded into the form, or `readonly` toggled
by the form mode. No-op on an input that never got a clear.

### Indentation is always FOUR spaces

Anywhere structure is shown as indentation — the site map's tree, `C_YUI_JSON`
and the raw dump behind it, any `JSON.stringify` a view puts on screen — one
level is **four** characters. Not two here and four there: the reader is using
the indentation to see the shape, and a shape that changes width between two
panels of the same app is one more thing to decode.

- `JSON.stringify(value, null, 4)` — never `2`.
- Rendered trees indent in **`ch`** (`padding-left: 4ch`), not `rem`: it
  follows the row's own monospace font, so the guides stay lined up with the
  text they belong to instead of drifting at some zoom level.

### Logical class names on important DOM blocks

When a gclass builds DOM, tag its elements so the tree is self-describing in
the browser Inspector:

- **Root of the view:** the `GCLASS_NAME` class **plus** a logical card name,
  e.g. `class="C_AGENT_CONSOLE CONSOLE_CARD view-card"`.
- **Every meaningful child** (status line, response panel, input row, input,
  button, list…) gets a logical class **prefixed by the view/feature name**:
  `CONSOLE_STATUS`, `CONSOLE_COMMENT`, `CONSOLE_RESPONSE`, `CONSOLE_INPUT_ROW`,
  `CONSOLE_INPUT`, `CONSOLE_EXEC`, …

**Casing: `UPPER_SNAKE`, exactly like the gclass names** — `CONSOLE_COMMENT`,
never `console-comment`. CSS/styling classes stay lowercase (`view-card`,
`is-size-7`), so in a `class` attribute the case alone tells the two
namespaces apart: **uppercase = logical block name, lowercase = styling**.
Keep the existing Bulma/utility classes and **prepend** the logical name(s).

**Logical names are independent of whatever CSS class names each app uses.**
They form their own namespace: they identify blocks, they don't style them,
and they are tied to no CSS framework or app stylesheet. Each app keeps its
own styling classes alongside them — restyling or swapping the CSS layer never
renames a logical class, and adding a logical class never requires a CSS rule.

**Why:** a bare `<pre class="is-size-7 mb-2">` is unidentifiable in devtools —
you can't tell it's "the comment line". These are primarily debug aids, but
they **may** double as real CSS hooks; styling them is fine when useful.

#### Naming a window / modal from the app: `logical_class`

The library's own chrome carries its block names — a window is tagged
`WINDOW_HEADER` / `WINDOW_CONTROLS` / `WINDOW_MIN` / `WINDOW_MAX` /
`WINDOW_CLOSE` / `WINDOW_BODY` / `WINDOW_FOOTER` / `WINDOW_RESIZE` and its
default title bar `WINDOW_TITLE` / `WINDOW_TITLE_PREFIX` / `WINDOW_TITLE_KIND`,
a modal
`MODAL` / `MODAL_BACKDROP` / `MODAL_CONTENT` / `MODAL_HEADER` / `MODAL_BACK` /
`MODAL_TITLE` (+ `MODAL_TITLE_PREFIX` / `MODAL_TITLE_KIND`) / `MODAL_CLOSE` /
`MODAL_BODY`, a confirm `CONFIRM*` and a toast
`TOAST*`.

Those names identify the *kind* of block, not the *instance*: every window in
the app is a `C_YUI_WINDOW`, every popup is a `MODAL`. To target **one**
exactly, the caller passes its own name:

```js
gobj_create_service("keys", "C_YUI_WINDOW",
    {logical_class: "TRANGER_KEYS_WINDOW", ...}, gobj);

yui_shell_show_modal(shell, $box,
    {logical_class: "TRANGER_KEYS_SHEET", dialog: true, ...});

yui_shell_confirm_yesno(shell, msg, {logical_class: "...", ...});
```

It lands on the root element, alongside `C_YUI_WINDOW` / `MODAL` / `CONFIRM`.

Copyright (c) 2024-2026, ArtGins. All Rights Reserved.
