Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/skills/ui-chrome/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ Two things there are easy to get wrong. **`hidden` is what hides a panel** — t

The face is `--sans`. These are language — "Cancel", "Export", "Download" — and `--mono` is for identifiers and instrumentation, which is the rule the StateMate console already followed and the reason a button there and a button in a dialog did not read as the same control.

**The state and transition editors are one vocabulary**, declared once in `css/modals.css` under "The state and transition editors", because they edit the two halves of one diagram. They are named alike everywhere a reader meets them — **Edit State** and **Edit Transition**, as dialog titles, as the first item of each context menu, and as the δ/Q list buttons. Points worth keeping:

- **`.edit-body` spaces by `gap`, not by row margins.** `.modal-row:last-of-type` zeroes the margin of the last row in *each* wrapper, and here every machine-specific section is a wrapper: Move used to sit flush against To. Rows inside `.edit-body` have no margin at all.
- **The edge is one row**, From → To in `.trans-route`, at the top. They used to sit at opposite ends of the dialog with every field between them.
- **A choice you can see is a `.seg`**, not a menu: a direction strip (`role="radio"`, roving tabindex, arrow keys) or the state's roles (real checkboxes inside their labels, `.seg-check`). The native `select`/`checkbox` stays the field `getTransitionFormValues`/`confirmState` read — the strip only sets it — so tests that write `m-dir.value` still mean what they did.
- **A rule with one row per part is an `.edit-grid`**: a multi-tape rule is a row per tape (read, write, move — the canvas label's order), a Mealy state's outgoing outputs a row per edge. Three stacked rows per tape made a four-tape rule twelve rows tall. Symbol menus there are bare; `.edit-legend` says what the reserved symbols mean once, beneath.
- **A reserved symbol says what it means in a menu** (`Σ — any symbol`, `⊔ — blank cell`; `symbolMeaning()`). The value stays the bare symbol.
- **A section heading names what follows it**, so a row that is not part of the store above gets one of its own: Output after a Stack or Head section, and a two-way head's Move sits under **Head**, not Tape.
- **A select is given its value with `ensureSelectValue`**, never a bare `.value =` after building options, and the current value is always among the options. A value missing from the menu made the select fall back to its first option, and an untouched Save rewrote the rule (Moore and Mealy outputs did exactly that).
- **The preview is drawn by the canvas** — see [js/edit-preview.js](../../../js/edit-preview.js) and *A label line is not a rule* in CLAUDE.md.

### The sidebars

**The two panels are one component twice**, and everything that says so is in one place each. [js/panel-state.js](js/panel-state.js) is the import-free registry of tabs, sides and selection; `syncPanelTabs` / `activatePanelTab` / `showPanelTab` / `revealPanel` / `togglePanelPin` in [js/ui.js](js/ui.js) each take a side or read one, rather than existing twice; and [css/panels.css](css/panels.css) holds both panels plus the chrome they share (`.panel-header`, `.panel-tabs`, `.panel-tab`, `.pin-btn`, `.panel-resizer`, `.mobile-panel-close`).
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ Two rules follow, and breaking either is silent:

Node internals are reached through `node.__parts` (`circle`, `label`, `ring`, `sub`; `pathEl`, `hitEl`, `textEl`, `handle`) rather than `querySelector`. `sub` is the second line under a state's name, shared by the Moore output and the parity priority — they never coexist. Edge labels live in `#trans-lbl-g`, not inside the edge group, so every label paints above every edge — deleting an edge has to detach both.

**A label line is not a rule.** `edgeLabelLines(ts)` in [js/render.js](js/render.js) decides what an edge's label draws, and it is a per-*edge* decision made at any machine size — crowding is a property of an edge, which the large-machine profile (a per-machine performance line, `states > 200 || transitions > 700`) never sees: four states over a 36-symbol alphabet is 144 rules and drew every edge as a tower of single characters. Rules whose label differs only in the symbol read share a row (`b, f, j → q3`; braced, `{a, b}, A → AA`, when the rest opens with a comma; never on a multi-tape rule, which reads a tuple), a long list wraps evenly, and past `LABEL_MAX_LINES` the label ends in `+k more`. Each line carries the `ids` it stands for and the edge node keeps them as `__lines`. **Everything that reads a label goes through it** — the canvas's two drawings, `edgeLabelSizeFor` (the layout must place the box that is drawn), the dialogs' preview, and the label editor: a double-click on a one-rule line edits in place, on a merged line or `+k more` opens the dialog with exactly those rules, and `edgeLabelLineOf` is how the strip finds the line it sits over. A new reader of label rows must not assume row *i* is `ts[i]`.

**The state and transition dialogs draw what they will make with the canvas's own code**, not a likeness of it: [js/edit-preview.js](js/edit-preview.js) builds from `edgeGeometryFor`/`selfLoopPath`/`labelRestPoint` in geometry.js, the label writers and `splitStateLabel` here, and the canvas classes in css/canvas.css, so the theme, label style, wrapping, bends and badges follow the diagram. It lays the part being edited out to fit (From left, To right) and never touches `App.domCache`. `labelRestPoint` is `placeLabel`'s first candidate, shared rather than copied. [tests/edit-dialogs.test.js](tests/edit-dialogs.test.js) saves every rule and state of every example through the dialogs unchanged and requires them back identical.

**A double-click on a state, a block or an edge is not a native `dblclick`.** A press on any of them can end in `wrap.setPointerCapture`, which retargets the second click and the `dblclick` to `#canvas-wrap`, so a `dblclick` listener on the node never runs in a browser — it runs only in the DOM stub, which calls listeners directly. Each node counts its own two presses on `pointerdown` instead (`DOUBLE_PRESS_MS`). Labels are hit-testable with the Select tool only (`css/canvas.css`) and carry their edge's listeners: one press selects the edge, two open [js/edge-label-editor.js](js/edge-label-editor.js) on the row hit — a strip of fields from the machine's `transitionFields` (a multi-tape machine gets a read, write and move per tape, as ids like `tapeSyms.0`), saved through `saveTransition()`, the function the dialog's Save also uses. The strip is HTML placed from `edgeLabelAnchor()` and the camera, so it also opens from the edge line where the label is not drawn (level of detail, the large-machine profile), and `applyCamera` calls `followLabelEditor()` so it follows a pan or zoom.

`updateFastDOM()` is the drag path: geometry only, every frame, sharing the layout pass and `__parts` with the renderer. It walks **`App.domCache.states`, not `App.states`** — the same list on a small machine, and on a windowed one the difference between fifty writes and a thousand map misses to find them.
Expand Down Expand Up @@ -313,7 +317,7 @@ Stated once, at the top of [css/views.css](css/views.css), because it was doing

Three broke the rule and are solid now. **`.tv-cell.is-head.loop`** was the worst of them: a proven loop is a *decision* — the machine never halts, so the input is not accepted, which is strictly more than the step budget can tell you — and drawing the app's firmest verdict as an absence put it in the same visual bracket as an unwritten tape cell. It is `border-style: double` instead, which distinguishes it from an ordinary reject without borrowing the dash. **`.lang-sym.dead`** is a real fact about a real machine: uninteresting, not missing, and quiet is what colour and opacity are for. **`.wiz-chips .chip.is-locked`** is a constraint the machine has, so it takes the disabled idiom the app now owns.

Everything still dashed is genuinely one of the four: `.tv-cell.is-blank`, `.tv-cell.is-ghost`, `.example-chip.is-pending`, `.example-card-add`, `.gram-stub`, `.canvas-info-btn.is-invite`, `.wiz-example`, `.pn-body` — a port, derived from the wiring on every rebuild — StateMate's `.draft-layer`, a machine still being written, and the Behaviour card's `.bh-cell.is-blank` and `.bh-cell.is-any` — a cell backward reasoning's witness never read, where any symbol will do.
Everything still dashed is genuinely one of the four: `.tv-cell.is-blank`, `.tv-cell.is-ghost`, `.example-chip.is-pending`, `.example-card-add`, `.gram-stub`, `.canvas-info-btn.is-invite`, `.wiz-example`, `.pn-body` — a port, derived from the wiring on every rebuild — StateMate's `.draft-layer`, a machine still being written, the Behaviour card's `.bh-cell.is-blank` and `.bh-cell.is-any` — a cell backward reasoning's witness never read, where any symbol will do — the state and transition dialogs' `.edit-preview`, a drawing of what they will make, and `.edge-pill-more`, a count of rules a dense label does not draw.

### Themes

Expand Down
18 changes: 18 additions & 0 deletions css/canvas.css
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,24 @@
stroke-linejoin: round;
}

/* The `+k more` that ends a dense edge's label (render.js, edgeLabelLines):
a count of rules not drawn, so it is set apart from the rules that are. */
.tlbl tspan.tlbl-more {
fill: var(--text3);
font-style: italic;
}

.edge-pill-more rect {
fill: transparent;
stroke: color-mix(in srgb, var(--text3) 45%, transparent);
stroke-dasharray: 3 2;
}

.edge-pill-more text {
fill: var(--text3);
font-style: italic;
}

.edge-pill-label {
overflow: visible;
paint-order: normal;
Expand Down
299 changes: 277 additions & 22 deletions css/modals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2364,29 +2364,11 @@ button.sm-stat:disabled {
margin-top: -8px;
}

/* The Mealy table in the state dialog (see .edit-grid). A state with many
outgoing edges scrolls here rather than pushing the preview off the card. */
.mealy-outputs-list {
max-height: 160px;
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}

.mealy-output-row {
display: flex;
align-items: center;
gap: 10px;
}

.mealy-output-lbl {
font-family: var(--sans);
font-size: .72rem;
color: var(--text2);
flex-shrink: 0;
}

.mealy-output-row .sel {
flex: 1;
}

.modal-lbl {
Expand Down Expand Up @@ -2786,7 +2768,280 @@ button.sm-stat:disabled {
}

.modal.trans-modal-wide {
max-width: 440px;
max-width: 460px;
}

/* ─── The state and transition editors ───
One vocabulary for both dialogs, because they edit the two halves of one
diagram: a gap-spaced body, a narrow label column, segmented controls for
choices you can see, a table where a rule has one row per part, and a live
preview of what will be drawn.

Spaced by gaps, not by row margins. `.modal-row:last-of-type` zeroes the
margin of the last row in each section wrapper, which in these dialogs is
every section: Move used to sit flush against To with nothing between. */
.edit-body,
.edit-sec {
display: flex;
flex-direction: column;
gap: 12px;
}

.edit-body {
gap: 14px;
}

.edit-body .modal-row {
margin: 0;
}

.edit-body .modal-row-tight {
margin-top: -6px;
}

.edit-body .modal-lbl {
min-width: 84px;
}

.edit-body .modal-section-lbl {
margin: 6px 0 0;
}

/* From → To, the edge itself. Labels over the fields so the arrow can sit on
the line between the two states, where it reads as the diagram does. */
.trans-route {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
column-gap: 10px;
row-gap: 6px;
align-items: center;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}

.trans-route > * {
min-width: 0;
}

.trans-route-lbl {
font-family: var(--sans);
font-size: .72rem;
color: var(--text2);
}

.trans-route-arrow {
font-size: 1.1rem;
line-height: 1;
color: var(--accent);
}

/* A segmented control: the options laid out where they can be seen and
pressed. A direction strip is radios (one of them); the state's roles are
checkboxes (any of them), each a real <input> inside its label. */
.seg {
flex: 1;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
min-width: 0;
padding: 2px;
gap: 2px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: var(--field-radius);
}

.seg-btn {
appearance: none;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: calc(var(--field-radius) - 2px);
background: transparent;
color: var(--text2);
font-family: var(--sans);
font-size: .75rem;
cursor: pointer;
white-space: nowrap;
user-select: none;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}

.seg-btn:hover {
color: var(--text);
background: var(--surface3);
}

.seg-btn:focus-visible,
.seg-btn:has(> .seg-check:focus-visible) {
outline: none;
box-shadow: 0 0 0 2px var(--focus-ring);
}

.seg-btn.active,
.seg-btn:has(> .seg-check:checked) {
background: var(--bg2);
color: var(--accent);
border-color: var(--accent-border);
font-weight: 600;
}

/* Present for the keyboard and the accessibility tree, drawn by its label. */
.seg-check {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
pointer-events: none;
}

.seg-glyph {
font-family: var(--mono);
font-size: .95rem;
line-height: 1;
}

.seg-compact .seg-btn {
padding: 4px 9px;
}

/* A rule with one row per part — a tape, an outgoing edge — as a table rather
than a stack of sections. The columns are set per use. */
.edit-grid {
display: grid;
column-gap: 8px;
row-gap: 8px;
align-items: center;
}

.edit-grid > * {
min-width: 0;
}

.mtm-grid {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto;
}

.mealy-grid {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr);
}

.edit-grid-head {
font-family: var(--sans);
font-size: .65rem;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text3);
}

.edit-grid .inp {
width: 100%;
box-sizing: border-box;
}

.edit-grid-sym {
font-family: var(--mono);
font-size: .78rem;
color: var(--text);
}

.edit-grid-to {
font-family: var(--mono);
font-size: .75rem;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.mtm-tape {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
justify-self: center;
border-radius: 50%;
background: var(--surface3);
color: var(--text2);
font-family: var(--mono);
font-size: .72rem;
}

.mtm-move {
display: flex;
}

/* What the reserved symbols mean, said once under a table too narrow to
say it in every menu. */
.edit-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
font-family: var(--sans);
font-size: .68rem;
color: var(--text3);
}

.edit-legend b {
font-family: var(--mono);
font-weight: 600;
color: var(--text2);
margin-right: 2px;
}

/* What will be drawn, drawn by the canvas (js/edit-preview.js): the node and
edge classes in css/canvas.css paint it, on the canvas's own ground, so it
follows the theme and the label style the way the diagram does. Dashed:
derived from the fields, and saved nowhere until the dialog is confirmed
(see the top of css/views.css). */
.edit-preview {
display: flex;
justify-content: center;
border: 1px dashed var(--border2);
border-radius: 10px;
background: var(--bg);
padding: 6px 10px;
overflow: hidden;
}

/* At the canvas's 100% (its width and height attributes are the drawing's
own), scaled down only when the dialog is narrower than the drawing. */
.edit-preview-svg {
display: block;
max-width: 100%;
height: auto;
overflow: visible;
}

/* Nothing in a preview is a target; the canvas's hover rules are for a
diagram you can press. */
.edit-preview-svg * {
pointer-events: none;
}

/* An edge drawn only because it shapes the one being edited — the reverse
edge, which is what bends this one. */
.edit-preview-svg .edit-preview-context {
opacity: .4;
}

/* The row being edited, in a label that stacks several. */
.edit-preview-svg .tlbl tspan.edit-preview-focus {
fill: var(--accent);
font-weight: 700;
}

.edit-preview-svg .edge-pill-row.edit-preview-focus .edge-pill rect {
stroke: var(--accent);
stroke-width: 1.5px;
}

/* ─── TABS ─── */
Expand Down
5 changes: 4 additions & 1 deletion css/views.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@
(tape the machine has not written), .tv-cell.is-ghost (a projected repeat),
.example-chip.is-pending (a word not committed yet), .example-card-add,
.gram-stub, .canvas-info-btn.is-invite, .wiz-example (all "add one here"),
and .pn-body (a port, derived on every rebuild). ══════════════════════════ */
.pn-body (a port, derived on every rebuild), .edit-preview (the state and
transition dialogs' drawing of what they will make, saved nowhere until
confirmed) and .edge-pill-more (a count of rules a dense edge's label does
not draw, not a rule). ═══════════════════════════════════════════════════ */

.view-container {
flex: 1;
Expand Down
Loading
Loading