# Lyra — Repository Review & Improvement Ideas

> **Status:** review complete (2026-06-10). Source: `vega/lyra` cloned to
> `reference/lyra` (branch `lyra2019`, last commit `fb284bf`, 2021-05-14 — unmaintained).
> **Purpose:** mine Lyra, UW IDL's _direct-manipulation_ Vega design environment, for
> interaction patterns that could improve Astrolabe's chart-building experience. This is a
> companion to [`chart-builder-research.md`](./chart-builder-research.md) (which already
> seats Voyager, also from UW IDL); Lyra is the _no-code authoring_ sibling we hadn't read.
> **Bottom line:** take Lyra's **interaction primitives and data-context UI**, not its
> architecture. Astrolabe's chart-choice intelligence (Tier B) is already _ahead_ of Lyra;
> the gaps Lyra exposes are about **how the user touches fields and sees their data**, plus
> **making our guidance actionable**.

---

## 1. What Lyra is, and the one fact that decides what transfers

Lyra lets you build a custom visualization **without writing code** — drag data fields onto
graphical mark properties, position marks with connectors, resize with handles, and define
interactions by demonstration. It targets **raw Vega** (not Vega-Lite), is a React + \*\*Redux

- Immutable.js** app on a **d3 v3\*\* runtime, and is a 2019-era research prototype that was
  never finished ("does not contain all functionality" — README) and has been dormant since

2021.

The single most important architectural fact:

> **Lyra's source of truth is a _decomposed GUI model_; the Vega spec is a one-way _export_.**

Lyra keeps the design as a Redux store of typed primitives — `Mark`, `Scale`, `Guide`
(axis/legend), `Pipeline`/`Dataset`, `Signal`, `Interaction`, `Widget`
(`src/js/store/factory/*`) — and `ctrl/export.ts` (`exporter()`, 546 lines) serializes that
store **into** a Vega spec on demand. There is no inverse: Lyra **cannot import and edit an
arbitrary Vega spec**. The GUI _is_ the document.

**Astrolabe is the exact inverse.** Our source of truth is the **JSON spec** (a snippet);
the builder is a _view that emits_ JSON (`buildChartSpec`/`buildSnippetSpecText` in
`src/core/chart-builder.ts`), and the user can always drop to Monaco and hand-edit. For a
**snippet manager** this is the right call — Lyra's model would forbid the round-trip our
whole product depends on. So we take **none of Lyra's architecture** and **all of the
transferable lessons are at the interaction layer**, where the GUI-vs-JSON question doesn't
matter.

This also reframes the comparison: Lyra is not a "better chart builder" to catch up to. On
**chart-choice intelligence** Astrolabe is already further along — Lyra has no
recommendation logic at all (that was Voyager's job, which we already mined). Lyra's value to
us is the parts of the _authoring experience_ we haven't built: field-first interaction, live
data context, and one-click assistance.

---

## 2. Where Astrolabe already leads Lyra (so we don't chase the wrong things)

| Dimension                 | Lyra                                                                   | Astrolabe (today)                                                                                          |
| ------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Chart-choice guidance     | none — manual mark + manual encoding                                   | Tier B: smart default mark, valid-type locking, Size discipline, non-blocking warnings (`builderWarnings`) |
| Transforms in the builder | data-pipeline only (filter/formula/aggregate/lookup), not per-encoding | per-channel Aggregate / Bin / `timeUnit`, chart-level Sort / Stack                                         |
| Target language           | raw Vega (verbose, low-level)                                          | Vega-Lite (the right altitude for "pick mark + channels")                                                  |
| Import / round-trip       | impossible (GUI is the doc)                                            | native — JSON is the doc, builder emits it                                                                 |
| Stack                     | d3 v3 / Redux / Immutable / class components (stale)                   | React + Zustand + CSS Modules (current)                                                                    |

The takeaways below deliberately **avoid** re-building anything in the right column.

### 2.1 — Counterpoint: Lyra exposes a far larger _control surface_

On chart-choice _intelligence_ we lead, but on **raw number of direct controls** Lyra is well
ahead. Selecting a primitive (mark / scale / axis / legend) opens an inspector of literal
knobs — and the inventory dwarfs our builder's:

| Primitive (`components/inspectors/*`) | Lyra's direct controls                                                                                                                                                                                                                     |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Mark — **Point** (`Symbol.tsx`)       | x, y, **shape**, size, fill **color**, fill **opacity**, stroke color, stroke width                                                                                                                                                        |
| Mark — **Bar/Rect** (`Rect.tsx`)      | X extent (start/center/end _spatial preset_), Y extent, fill color/opacity, stroke color/width                                                                                                                                             |
| Mark — **Line** (`Line.tsx`)          | x, y, stroke color/width, **interpolate (curve)**, tension                                                                                                                                                                                 |
| Mark — **Area** (`Area.tsx`)          | baseline extent, fill color/opacity, stroke, interpolate, tension, orient                                                                                                                                                                  |
| Mark — **Text** (`Text.tsx`)          | text (expr/template), **font face/size/weight/style**, color, opacity, x/y, dx/dy offset, **align**, baseline, **rotation**                                                                                                                |
| **Scale** (`Scale.tsx`)               | **type** (linear/log/time/ordinal/band/point), **zero/nice/clamp/reverse/round**, **domain** (auto fields or manual min/max / value list), **range / colour scheme** (tableau, category10/20, sequential, diverging), padding(Inner/Outer) |
| **Axis** (`Axis.tsx`)                 | orient, axis-line colour/width, **title text + font/size/colour/offset**, **label size + angle + colour**, **grid on/off** + colour/opacity/width, **tick count/size/colour**                                                              |
| **Legend** (`Legend.tsx`)             | orient, border, title text/size/colour, label font, symbol shape/size/fill/opacity, gradient                                                                                                                                               |

And the structural multiplier: **every mark property is _droppable_** — each is _either_ a
literal value _or_ a bound field. Lyra therefore has **no fixed channel set**; you can bind
data to strokeWidth, opacity, shape, fontSize, dx… Our builder caps at **X / Y / Color /
Size** and exposes **zero** styling, scale, axis, or legend controls.

**Why this is not simply "Lyra is more capable":**

1. **It's a _builder-surface_ gap, not a capability gap.** Everything above, Vega-Lite
   expresses too (mark config, `scale`/`axis`/`legend`, channels like `opacity`/`shape`/
   `theta`/`row`/`column`). In Astrolabe you reach it by **hand-editing the JSON in Monaco**.
   Lyra had **no editor and no round-trip** — the GUI was the _only_ way to touch the spec, so
   it was forced to surface every knob. We split the work on purpose: a small **guarded**
   encoding builder + a first-class JSON editor for the long tail.
2. **Lyra's breadth carries zero intelligence and high cost** — no defaults, no valid-type
   locking, no guardrails, on raw low-level Vega, in an unfinished inspector.
3. So the question is not "match Lyra's control count" but **"which slices of that surface are
   common _and_ awkward enough in JSON to deserve promotion into the guarded builder?"**

**Slices worth promoting (ranked):**

- **Constant-value styling** via the value-or-field channel (§3.3): fixed fill colour /
  opacity / point shape / line curve without touching JSON — the most common "tweak the look."
- **Colour-scheme picker** on the Color channel (categorical / sequential / diverging) + a
  measure-axis **`zero` / `log`** toggle. Palette changes are common and fiddly in JSON.
- **A few more channels** — `opacity`, `shape`, `tooltip` (already auto-enabled), **`theta`**
  (unlocks pie/donut → a true part-to-whole), **`row`/`column`** facets (backlog **B8**). These
  _are_ the field-shelf model (§3.4): adding channels and adding the shelf are one project.
- **Light axis/legend text** — custom **axis title**, **legend title / hide**. Common, low-risk.

**Guardrail:** promote a control only when it is _both_ common _and_ awkward in JSON.
Otherwise the clean guarded builder drifts toward Lyra's fiddly everything-inspector — the
very complexity that helped leave Lyra unfinished. Styling/scale/axis breadth for its own sake
belongs in Monaco, not the builder.

---

## 3. High-value ideas to adopt (ranked, in-scope, mapped to our code)

### 3.1 — Make `builderWarnings` **actionable** (Lyra's `Hints`) — _highest value, smallest lift_

Lyra's hint system (`components/hints/Hints.tsx`) is a contextual nudge that carries an
**action**: `{title, text, action, action_text}` — a one-click button that _applies the fix_
and clears the hint. Our guidance is strictly advisory text today: `BuilderWarning`
(`chart-builder.ts:333`) renders as a passive hint with no affordance.

Several of our existing warnings have an obvious one-click remedy and should become
**suggested actions** (Carbon "inline notification with action" / NN-g "make the system do the
work"):

- _"…draws one mark per row; aggregate the measure"_ → **[Aggregate as Sum]** sets the
  measure channel's `aggregate`.
- _"…long labels; flip to a horizontal bar"_ → **[Swap X/Y]** (we already have the action —
  just wire it to the hint).
- _"Two measures usually read better as a scatter"_ → **[Switch to Point]**.
- _"Area split into many colour series…"_ → **[Stack]** or **[Remove colour]**.

This keeps the Tier-B "guarded, non-blocking" philosophy — the action is an _offer_, never a
forced change — while turning a wall of advice into a guided improvement loop. Concretely:
extend `BuilderWarning` with an optional `fix?: { label: string; apply: (c) => BuilderConfig }`
(pure, lives in `chart-builder.ts`, fully unit-testable), and render the button in
`ChartBuilderModal`. **Run the copy + affordance through `/council`** (it auto-fires on
guidance copy and new interactive affordances).

### 3.2 — A live **data-table preview** in the builder (Lyra's `DataTable`) — _closes a real gap_

Confirmed gap: neither `ChartBuilderModal` nor `DatasetsModal` ever shows the **actual data
rows**. The builder picks columns from a dropdown and renders a _chart_ preview, but the user
never sees the values they're encoding. Lyra always shows the data — `DataTable.tsx` is a
paged, scrollable grid with a **per-field type icon** in each column header and hover
inspection.

A compact, read-only **data preview** (first N rows, per-column type chip in the header)
would let the user sanity-check "is this column really a date / really numeric" _before_
building — exactly when type inference is most likely to surprise them. Good homes:

- a collapsible "Data" strip in the builder's left pane (under the dataset name), and/or
- the Datasets manager, where a dataset's shape is otherwise invisible.

We already compute per-column type + cardinality + extent in `profile.ts` (the A3/A4
profiling extension), so the header chips are free; we only need the row sample. Keep it
**read-only** — editing data is out of scope (and is where Lyra's pipeline complexity lives).

### 3.3 — The unified **Property** primitive: one control = value · field · scale (Lyra's signature UI)

Lyra's best idea is `components/inspectors/Property.tsx` — a **single droppable control** that
is, depending on binding state, _either_:

- a **literal value** editor (number / color / range / select / text — `FormInputProperty`), **or**
- a **field chip** (drag a column onto it; chip shows source-vs-derived, click to unbind), **or**
- a **scale chip** (the scale the field flows through; click to unbind).

Every mark inspector is then just a **declarative list** of these — e.g. `Symbol.tsx`
declares Position/Geometry/Fill/Stroke groups as ~10 `<Property>` lines. One primitive, one
drop-to-bind gesture, one unbind gesture, reused for _everything_ including the transform
expression fields (`Filter.tsx`, `Formula.tsx` reuse the same `Property` in autocomplete
mode).

For Astrolabe this is the conceptual model for a **richer channel row**: a channel is "a field
**or** a constant **or** (future) a datum," and Vega-Lite encodes exactly that distinction
(`field` vs `value` vs `datum`). Today our channel row is field-only (column dropdown +
type segmented control). Adopting the Property model would let a channel also hold a **constant
value** (e.g. a fixed color/size) with one consistent control and a chip showing the binding
kind. This is a medium lift and a genuine capability gain, and it generalizes cleanly if we
ever add channels beyond X/Y/Color/Size.

### 3.4 — Field-first interaction: a **field shelf** you drag onto channels (Lyra drop-zones + Voyager)

Both UW tools converge on **fields as the primary objects**: Lyra drags fields onto mark
properties; Voyager has a field list with type chips you add to channel shelves. Astrolabe is
**channel-first** (pick a channel, then choose its column). A **field shelf** — the dataset's
columns listed with type chips, dragged or clicked onto channels — inverts that to match how
people actually think ("I have these fields; where do they go?").

This is the largest interaction shift here and overlaps our **deferred Tier-C / faceting**
work (`chart-builder-research.md` §8 B8/C). Recommend treating it as the **interaction
substrate for Tier C**, not a standalone task: when we build the intent-first front door and
add Row/Column facet channels, a field shelf is the natural way to assign many fields across
many channels. Note now; sequence with Tier C.

### 3.5 — One-click **type cycling** on the field chip (Lyra's `FieldType`)

Lyra's `FieldType.tsx` is a tiny, nice touch: the field's **type icon is the control** —
click it and it cycles N→O→Q→T (within the field's valid set). We already show a type
indicator next to each column option and a full `N|O|Q|T` segmented control per channel. The
cheap win is making the **per-option type chip itself interactive** (and, in a future field
shelf, the chip on each field), so type is a property of the _field_ the user can toggle in
place — fewer controls, the same guard (only valid types, via our `validFieldTypes`).

### 3.6 — Inline **expression validation** via Vega's own parser (Lyra's transform technique)

Lyra validates a filter/calculate expression with `parseExpr` from `vega-parser` **before
committing** it to the model (`Filter.tsx`, `Formula.tsx`), catching malformed expressions at
the keystroke. We don't expose raw expressions in the builder, so this isn't an immediate
builder feature — but the **technique** (validate Vega/VL expression strings with the
library's parser and surface the error inline) is directly reusable for our Monaco editor's
diagnostics and for any future calculate/filter affordance. Worth recording in
`architecture/08` (vega-editor techniques) as a known approach; Lyra even left the
"_indicate error in parsing_" TODO unfinished, so we'd be completing the idea, not copying it.

### 3.7 — **Starter examples** gallery (Lyra's `ExampleGallery`) — _cheap, optional_

Lyra's toolbar has an `ExampleGallery` modal that `hydrate()`s a curated example into the
editor — a "start from something" affordance. Astrolabe's snippet library is the natural home
for a small set of **curated starter snippets** (one per FT intent category we cover —
Magnitude/Bar, Change-over-Time/Line, Correlation/Point…). Low effort, improves first-run and
doubles as living documentation of what the app does well. Lower priority than 3.1–3.2.

### 3.8 — **Per-chart export: image + standalone spec** (Lyra's `Export` menu) — _biggest miss; cheap_

The one squarely-useful idea I almost overlooked. Lyra's toolbar `Export` (`components/toolbar/Export.tsx`)
offers **PNG**, **SVG**, **JSON spec**, and a **standalone HTML** scaffold — using
`view.toImageURL(type)` on the live Vega view.

Astrolabe's export today is **workspace-JSON backup only** (`docs/spec/08`) — there is **no way
to get a single chart out** as an image, as its own spec file, or onto the clipboard. Yet our
renderer already holds exactly the Vega `view` Lyra exports from (`services/chart-renderer.ts`,
`renderer: 'svg'`), so this is a handful of lines:

- **Copy spec** (clipboard) and **Download `.vl.json`** for the active snippet — the most-asked
  "get my chart out" actions, both trivial given the snippet _is_ the spec.
- **Download PNG / SVG** of the rendered chart via `view.toImageURL('png'|'svg')` — drop a chart
  into a doc/slide without a screenshot.
- (Optional) **standalone HTML** — a self-contained vega-embed page; ties to the
  share/"private-move" direction in `monetization-and-sync-exploration.md`.

Home: a snippet-level "Export / Share" affordance (library row action or editor toolbar),
distinct from the workspace Export. High value-to-effort; should jump near the top of the queue.

### 3.9 — Scale / axis / legend as **auto-derived _override_ panels** (Lyra's `bindChannel` pipeline)

If we ever promote scale/axis/legend controls (§2.1), Lyra shows the right _editing model_.
Binding a field in Lyra runs a pipeline (`actions/bindChannel/`): `parseScales` + `parseGuides`
**auto-materialize** the scale and axis/legend, `aggregateDependencies` re-points them when the
data is aggregated, and **`cleanupUnused`** garbage-collects scales/datasets no longer
referenced. The user edits a scale/axis only to _override_ an auto-derived default.

Vega-Lite already auto-derives scales/axes/legends from encodings, so we get the inference for
free — the borrow is purely UX: surface scale/axis/legend as **optional override panels that
start empty (inheriting VL defaults) and only emit JSON when touched**, and **clearing a channel
drops its overrides** (the `cleanupUnused` lesson — no orphaned `scale`/`axis` config left behind).
This is the discipline that keeps §2.1's promoted controls from bloating the output spec.

### 3.10 — Expression **autocomplete with field/signal chips** (Lyra's `AutoComplete`)

`components/inspectors/AutoComplete.tsx` is a `contenteditable` expression field that
autocompletes **dataset field names** (and signals), rendering each `datum.field` reference as a
styled, non-editable **chip**. For any future filter/calculate affordance — and for Monaco
completions in expression positions — autocompleting the dataset's own column names (we already
have the schema from `profile.ts`) is the high-value half; the chip rendering is polish. Niche
until we expose expressions, but cheap to remember.

### 3.11 — **Builder-state undo/redo** (Lyra's `redux-undo`) — _low priority_

Lyra makes the whole design undoable (`redux-undo`, global `vis.present`/`past`). Our builder
modal has **no undo** (Monaco covers the _editor_, not builder config). A session-scoped
undo/redo — or even a lighter **"reset to smart defaults"** — would soften experimentation in
the builder. Low priority: the builder is a short-lived modal and the smart default already
gives a sane starting point. Noted for completeness, not urgency.

### 3.12 — **Dataset-level transforms: Filter / Calculate** (Lyra's data pipeline) — _the cleanest gap_

The transform dimension where Astrolabe and Lyra differ most. The key fact is that **Vega-Lite
has two transform layers**:

1. **Inline encoding transforms** — `aggregate` / `bin` / `timeUnit` / `sort` / `stack` written
   straight onto a channel. **This is the layer Astrolabe's builder uses**, and uses _well_ —
   per-channel and guarded, arguably cleaner than Lyra (whose Aggregate is a separate pipeline step).
2. **Top-level `transform: []`** — dataset-level operations applied in sequence before encoding:
   **`filter`**, **`calculate`**, **`lookup`**, window, fold, pivot, regression… **Astrolabe's
   builder exposes _none_ of these.** Lyra's data pipeline is built almost entirely _around_ them
   (`components/pipelines/transforms/`: Filter, Formula, Lookup, Sort — each an ordered card in a
   `TransformList` on `datasets.<id>.transform`).

The operations in layer 2 have **no encoding-level shorthand** — there is no way to express them
except by hand-editing the JSON. The two worth borrowing:

- **Filter _(highest value)_** — row filtering (`transform: [{filter: …}]`). The single most
  common data-shaping need, and the builder is _already telling users to do it_ — three existing
  `builderWarnings` literally say "filter to fewer categories / to non-negative values"
  (`chart-builder.ts:475,476,511`) while offering no way to. Guarded form: a **field + operator +
  value** predicate shelf (Voyager-style), no expression required for the common case; power form:
  a raw `datum.…` expression validated with Lyra's `parseExpr` technique (§3.6) + field
  autocomplete (§3.10). VL applies top-level transforms _before_ encoding aggregation, so "filter
  raw rows, then the chart aggregates" is the natural — and usually wanted — default; filtering on
  an _aggregated_ value (HAVING) is the advanced case, defer it.
- **Calculate / derived field** — `transform: [{calculate: "datum.a / datum.b", as: "ratio"}]`,
  exactly Lyra's `Formula`. The new field then appears in the column dropdowns like any other.
  High value, second to Filter.
- **Lookup (join a second dataset)** — Lyra's `Lookup` joins one pipeline's fields into another by
  key. Astrolabe references **one dataset per snippet**, so this is a larger data-model change —
  note and **defer**.

**Where it lives:** Lyra keeps these in a separate _dataset_ pane, distinct from the encoding
inspector — the right instinct. In Astrolabe they belong in a **"Data" section in the builder's
left pane, above the channels**, paired with the §3.2 data-table preview: _here are your rows
[+ Filter] [+ Calculate] → now encode them_, with the preview table updating live as filters
apply. This composes cleanly and keeps the channel rows about encoding, not data prep.

**Scope fit:** a field+operator+value filter shelf is squarely Tier-B "smart + guarded," closes
the loop on warnings the builder already emits, and is arguably **higher value than faceting (B8)**
— it should enter the §8 backlog as a new first-class item (it is absent today).

---

## 4. Anti-recommendations — what to deliberately **not** take from Lyra

These are as load-bearing as the adopt list; Lyra is a research prototype and several of its
defining features are traps for us.

- **The decomposed GUI-as-document architecture** (`store/factory/*` + one-way `export.ts`).
  It forbids the spec→builder round-trip our product is built on. Our JSON-source-of-truth +
  emit model is correct; keep it.
- **Interaction-by-demonstration** (`ctrl/demonstrations.ts` — **1454 lines** that synthesize
  raw Vega signals/selections from brush/click/hover, plus `signals.ts`/`listeners.ts`).
  Impressive research, wrong layer for us: **Vega-Lite's `params`/selections** are the right
  abstraction. _If_ we ever add interactivity, expose VL selections as a small guarded builder
  action — never port Lyra's signal generator.
- **The direct-manipulation canvas** (handles, connectors, `manipulators.ts`,
  `transforms/manipulators/*`). Enormous surface tied to the Vega-runtime model, and it
  competes with — rather than complements — our JSON-first-with-guardrails value proposition.
- **The stack**: d3 v3, Redux, Immutable.js, `react-modal`, class components, `datalib`. We're
  React + Zustand + CSS Modules with our own modal registry (`architecture/03`). **Take ideas,
  read no code into the repo.** (Per `AGENTS.md`: no shared lib, patterns adapted not imported.)
- **The full data-pipeline editor** (lookup/aggregate/formula transform _chains_ as a
  first-class pipeline UI). Our per-channel transforms cover the in-scope need; a general
  pipeline editor is a different, much larger product.

---

## 5. Recommended sequence (mapped to the existing backlog)

> **Consolidated (2026-06-10):** this sequence is now merged with
> `chart-builder-research.md` §8 into the forward plan at
> [`chart-builder-enhancement-scope.md`](./chart-builder-enhancement-scope.md) (Tier-C
> target). The list below is the original Lyra-side reasoning; the scope doc §4 is the
> authoritative build order.

Slot these into `chart-builder-research.md` §8 rather than inventing a new track:

1. **Actionable hints** (§3.1) — extend `BuilderWarning` with an optional pure `fix`; wire the
   four obvious ones; `/council` the copy. _Small, high-value, pure-core + thin UI._ **Do first.**
2. **Per-chart export** (§3.8) — Copy spec / Download `.vl.json` / PNG / SVG for a snippet, from
   the `view` the renderer already holds. _Highest value-to-effort; near the top._
3. **Filter (+ Calculate) dataset transforms** (§3.12) — a guarded field/operator/value filter
   shelf (then a derived-field calculate) in a "Data" section above the channels. _Closes the gap
   the builder's own warnings point at; not in the backlog today; arguably > faceting._
4. **Data preview** (§3.2) — read-only row sample + header type chips in the builder (and
   Datasets manager); reuse `profile.ts` stats. _Closes a confirmed gap; mostly UI; pairs with #3._
5. **Value-or-field channels via the Property model** (§3.3) — let a channel hold a constant
   `value`, not just a `field`; one consistent control. _Capability gain; medium._ Pairs with the
   §2.1 control-promotion shortlist (colour scheme, `zero`/`log`, axis/legend titles).
6. **Inline expression validation + field autocomplete** (§3.6, §3.10) — record the `parseExpr`
   technique in `architecture/08`; apply when the Filter/Calculate expression mode (#3) lands. _Build with the feature._
7. **Field shelf + in-place type chip** (§3.4, §3.5) — design as the **Tier-C / faceting +
   added-channels substrate** (§2.1: shape/opacity/theta/facets), not standalone. _Largest; with Tier C._
8. **Starter examples** (§3.7) — curated seed snippets, one per covered FT intent. _Cheap, optional._
9. **Scale/axis override panels + builder undo** (§3.9, §3.11) — only alongside control promotion;
   override-panels-that-emit-only-when-touched, with orphan cleanup. _Deferred polish._

Verification for each lands the usual way: pure rules get `chart-builder.test.ts` cases;
UI/affordance changes get a manual pass against the live builder (a green build proves nothing
about what the user sees — `AGENTS.md`). Items 1 and 3 are mostly `src/core/` and squarely fit
our "core-first, tested hardest" rule.

---

_Citations are to `reference/lyra/src/js/…` (cloned, branch `lyra2019`). Lyra and Voyager are
both UW IDL; Voyager is already seated in `chart-builder-research.md`, so this review focuses
on the no-code-authoring ideas Voyager doesn't cover. Lyra is an engineering/interaction
source, not a council seat._
