From 23980aec9455b3f1b6feef90ec5cac9530d0c75b Mon Sep 17 00:00:00 2001 From: Shreyan C Date: Thu, 8 Oct 2026 14:26:32 +0530 Subject: [PATCH] feat(dialogs): redesign the state and transition editors, and merge dense edge labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two editors become one vocabulary, drawn with the canvas's own code. Transition dialog - From → To is one row at the top, the edge itself; To used to sit at the far end of the dialog with every field between them. - Spaced by gaps: `.modal-row:last-of-type` zeroed the last row of each section, so Move sat flush against To. - Reserved symbols say what they mean in the Read menu (Σ — any symbol, ⊔ — blank cell); the value stays the bare symbol. - Move is a strip of arrows (← • →) with arrow-key navigation; the select stays the field the form reads. - A multi-tape rule is a table, one row per tape (read, write, move), with a key for the reserved symbols, instead of three stacked rows per tape. - A two-way head's Move sits under "Head"; Output gets its own heading after a Stack or Head section. The rule picker says "Copy from" when adding. - Focus starts on Read. State dialog - Titled Edit State, matching Edit Transition; the canvas context menu leads with Edit State (it said Rename, Configure on Moore/Mealy). - Start and Accepting are toggle chips; hiding Accepting no longer leaves the word "Accept" behind. - A Mealy state's outgoing outputs are a table (read, to, output). Previews (js/edit-preview.js) - Both dialogs end in a live drawing of what they will make, built from geometry.js's shapes, render.js's label writers and canvas.css's classes, so theme, label style, name wrapping, bends, badges and loops follow the diagram. The transition preview shows every rule on the edge with the one being edited marked, and the reverse edge that bends it. - labelRestPoint() shares placeLabel's first candidate rather than copying it. Dense edges - A label line is no longer one rule. edgeLabelLines() merges rules that differ only in the symbol read (`b, f, j → q3`; braced when a comma follows), wraps long lists evenly, and caps a label at four lines with `+k more`. Decided per edge at any machine size: four states over a 36-symbol alphabet drew every edge as a tower of characters, and the large-machine profile (a whole-machine performance line) never fires. - The canvas, the layout's label sizes, the preview and the label editor all read it. Double-click on a one-rule line edits in place; on a merged line or `+k more` it opens the dialog with exactly those rules. Fixes - 2DFA/2NFA/2DFT: saving a rule unchanged (dialog or label editor) added a `write` the machine does not have. - Moore and Mealy: an output missing from the menu was replaced by the first option on an untouched save. - mtm-classic.json: one rule carried a stray `symbol: "ε"`. Tests: tests/edit-dialogs.test.js saves every rule and state of every example through the dialogs unchanged and requires them back identical; tests/dense-edges.test.js pins the merge, wrap, cap and the readers' agreement. --- .claude/skills/ui-chrome/SKILL.md | 11 ++ CLAUDE.md | 6 +- css/canvas.css | 18 ++ css/modals.css | 299 +++++++++++++++++++++++++--- css/views.css | 5 +- index.html | 98 +++++++--- js/edge-label-editor.js | 9 +- js/edit-preview.js | 252 ++++++++++++++++++++++++ js/examples/mtm-classic.json | 2 +- js/geometry.js | 39 +++- js/render.js | 288 +++++++++++++++++++++------ js/states-transitions.js | 313 ++++++++++++++++++++++++++---- tests/dense-edges.test.js | 114 +++++++++++ tests/edit-dialogs.test.js | 106 ++++++++++ tests/render-incremental.test.js | 5 +- 15 files changed, 1407 insertions(+), 158 deletions(-) create mode 100644 js/edit-preview.js create mode 100644 tests/dense-edges.test.js create mode 100644 tests/edit-dialogs.test.js diff --git a/.claude/skills/ui-chrome/SKILL.md b/.claude/skills/ui-chrome/SKILL.md index 20de754..fbc5ceb 100644 --- a/.claude/skills/ui-chrome/SKILL.md +++ b/.claude/skills/ui-chrome/SKILL.md @@ -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`). diff --git a/CLAUDE.md b/CLAUDE.md index db36415..c3a9694 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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 diff --git a/css/canvas.css b/css/canvas.css index 31796b3..d131b82 100644 --- a/css/canvas.css +++ b/css/canvas.css @@ -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; diff --git a/css/modals.css b/css/modals.css index 7f606a7..ef21f56 100644 --- a/css/modals.css +++ b/css/modals.css @@ -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 { @@ -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 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 ─── */ diff --git a/css/views.css b/css/views.css index f08da19..e24b00b 100644 --- a/css/views.css +++ b/css/views.css @@ -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; diff --git a/index.html b/index.html index 688044a..f4f9cc4 100644 --- a/index.html +++ b/index.html @@ -1507,10 +1507,21 @@