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 @@