diff --git a/.claude/skills/ui-chrome/SKILL.md b/.claude/skills/ui-chrome/SKILL.md index 237d537..20de754 100644 --- a/.claude/skills/ui-chrome/SKILL.md +++ b/.claude/skills/ui-chrome/SKILL.md @@ -60,7 +60,9 @@ Two more worth keeping straight. **A strip holds one tab or two** — `grid-auto Below `WINDOW_MIN_ROWS` the list is drawn whole, spacers and measurement included, so the small-machine path is exactly what it was. -**The docked left panel gives its spare height to one list.** Every list is capped at `--lp-list-max-h`, which left ten transitions scrolling in a 168px box above two hundred pixels of empty panel. `syncDockFill(side)` in [js/panel-sections-ui.js](../../../js/panel-sections-ui.js) marks the *last* open section that declares a `fill` region, and that region takes the leftover — the cap becomes its floor, so a panel with no room scrolls exactly as it did. The last, because growing it moves nothing but the collapsed headers under it. It is asked of the DOM on every collapse, reorder, tear-off, dock and machine switch, because an id stopped being the right key the moment sections could move (it used to be `#lp-transitions { flex: 1 }`). The region is `flex: 1 1 0`, sized to the leftover alone: a windowed list slices its rows from its own height, and a box as tall as its content would draw every row. Left panel only (`dockFill` in the registry) — the right panel's regions are drawn boxes, and a stretched trace log is a tall empty card. +**The docked left panel gives its spare height to one list.** Every list is capped at `--lp-list-max-h`, which left ten transitions scrolling in a 168px box above two hundred pixels of empty panel. `syncDockFill(side)` in [js/panel-sections-ui.js](../../../js/panel-sections-ui.js) marks the *last* open section that declares a `fill` region, and that region may grow past the cap into the leftover — **as far as its own rows reach, and no further**. It used to take the leftover whatever it held, so folding Transitions left five states standing over five hundred pixels of empty panel with the folded header pinned to the bottom edge. The section is `flex: 0 1 auto` (content-sized, shrinking only when its rows are taller than the room) and a windowed list is still bounded: its spacers make it tall, the shrink takes it back to the leftover, and it slices its window from that. It is asked of the DOM on every collapse, reorder, tear-off, dock and machine switch, because an id stopped being the right key the moment sections could move (it used to be `#lp-transitions { flex: 1 }`). + +The floors are **published by `publishListHeight` in [js/panel-list.js](../../../js/panel-list.js)**, not left to the browser, and that is the part easy to undo. The list's floor (`--lw-floor`) is the cap or its own height, whichever is less — not a size CSS can take from a scroll container. The section's (`--dock-fill-min`) is that plus its header and search row, because a flex or grid item's automatic minimum is its *content's* height: without it a 200-state list held the section at twelve thousand pixels and the panel scrolled instead of the list. The section floor is measured with the old one lifted, or a stale floor reads itself back as padding. The Blocks list is not drawn by `panel-list.js` and calls `publishListHeight` itself. Left panel only (`dockFill` in the registry) — the right panel's regions are drawn boxes, and a stretched trace log is a tall empty card. **A tab name is an element, not a text node.** `text-overflow` has nothing to apply to on the anonymous flex item a loose string becomes, so `.panel-tab-label` is what ellipsizes; before it, a name simply drew over the next tab, since the tracks are `minmax(0, 1fr)` and a tab is *allowed* to be narrower than its own text. Below 300px — measured by `@container`, because these panels are resized and pinned independently of the window — the tab icon is dropped rather than the words, two glyphs that repeat the names being cheaper to lose than the ends of both names. diff --git a/css/chrome-polish.css b/css/chrome-polish.css index f62be5a..037a8ff 100644 --- a/css/chrome-polish.css +++ b/css/chrome-polish.css @@ -46,9 +46,16 @@ .lp-section-header:hover, .rp-section-header:hover { background: color-mix(in srgb, var(--surface) 78%, transparent); } +/* No `scrollbar-gutter: stable`. A reserved gutter is outside the scrollport, + where nothing inside can paint, so every section rule and every header's + hover fill stopped ~10px short of the panel edge while the panel header's + rule above them ran the full width. And the strip was nearly always empty: + the docked panel hands its spare height to one list (syncDockFill) rather + than overflowing, so the bar only appears once the open sections really are + taller than the panel — and then it takes its width beside them the + ordinary way. */ .lpanel-content, .rpanel-content { - scrollbar-gutter: stable; scrollbar-color: var(--sb-thumb) transparent; } diff --git a/css/modals.css b/css/modals.css index bd3438f..931c15e 100644 --- a/css/modals.css +++ b/css/modals.css @@ -3235,6 +3235,9 @@ button.sm-stat:disabled { background: var(--surface); border: 1px solid var(--border2); border-radius: 20px; + /* The one piece of floating canvas chrome without a shadow: the toolbox, + the minimap, the zoom bar and the scope bar all lift off the well. */ + box-shadow: var(--shadow-sm); color: var(--text2); pointer-events: none; opacity: 0; diff --git a/css/panels.css b/css/panels.css index 7eadc2a..4b51cc4 100644 --- a/css/panels.css +++ b/css/panels.css @@ -13,6 +13,17 @@ .lp-/.rp- rules below. */ :root { --sec-head-pad: 9px 13px 7px; + /* A docked header's left padding is wider than the token's, because the + reorder grip lives in it (see .panel-sec-grip): at 13px the grip's dots + ended ~3px from the title and read as part of the label. 20px leaves a + clear gap and puts the title in line with the text of the rows under it. + A window's title bar has no grip and keeps the token. */ + --sec-head-pad-start: 20px; + /* And the body under the header, in both panels. The left's was + 3px 14px 12px and the right's 4px 14px 14px, so a card's content sat a + pixel higher and ended two pixels sooner depending on which side it was + docked to. */ + --sec-body-pad: 4px 14px 14px; --sec-head-gap: 8px; --sec-label-size: .68rem; --sec-label-tracking: .085em; @@ -104,9 +115,32 @@ box-shadow: inset 0 0 0 2px var(--focus-ring); } +/* Every docked header carries the grip in its left padding. */ +:is(.lpanel-content, .rpanel-content) :is(.lp-section-header, .rp-section-header) { + padding-left: var(--sec-head-pad-start); +} + +/* The title is the last thing in the row to give way, but it can give: with + `flex: 1` it had a basis of zero and a minimum of its own text, so a row + that did not fit overflowed instead — at the 220px minimum, with a + three-digit count, the collapse arrow went out past the panel edge. + + Shrink is shared in proportion to factor × basis, so the status chip and + the machine badge shrink at 100 against the title's 1: a narrow row + truncates those first (the chip carries its full text on the tooltip), and + clips the title only once they are gone. The title's factor stays at 1 and + not below it on purpose — when the factors in a row sum to less than one, + only that fraction of the overflow is taken up at all, and the rest still + spills past the edge. */ +:is(.lpanel-content, .rpanel-content) .lp-section-header .sec-lbl, +.rp-section-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + :is(.lpanel-content, .rpanel-content) .lp-section-header .sec-lbl { margin-bottom: 0; - flex: 1; font-size: var(--sec-label-size); letter-spacing: var(--sec-label-tracking); } @@ -170,7 +204,8 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 0 1 auto; + /* 100, against the title's 1 — see the note over the title's flex. */ + flex: 0 100 auto; } .sec-status[data-tone="acc"] { @@ -196,7 +231,8 @@ /* Beside it, the title gives up its trailing rule rather than the status giving up its text: the rule is ornament, and it is what a narrow header has to spare. */ -.collapsed > .rp-section-header:has(> .sec-status:not(:empty)) .rp-section-title::after { +.collapsed > .rp-section-header:has(> .sec-status:not(:empty)) .rp-section-title::after, +.collapsed > .lp-section-header:has(> .sec-status:not(:empty)) .sec-lbl::after { display: none; } @@ -219,7 +255,7 @@ } :is(.lpanel-content, .rpanel-content) .lp-section-body { - padding: 3px 14px 12px; + padding: var(--sec-body-pad); } .lp-collapsible.collapsed .lp-section-body { @@ -755,14 +791,23 @@ } /* Long symbol names (whole-word alphabet entries) must never wrap the row - or clip off-panel — each segment truncates on its own, full text on hover. */ + or clip off-panel — each segment truncates on its own, full text on hover. + + The names do not shrink until they pass 40% of the row each; the label + gives way first. All three shrinking together shared the shortfall by + width, so a multi-tape label three times a name's length still took a cut + out of the names — and any cut at all is an ellipsis, which is how a row + read `ca… –0 → 0, R | 0 →… ca…` with "carry 0" and "carry 1" both lost, + while the label was the part the row could spare. The cap is what keeps a + long subset-construction name from doing the same to the label. */ .ti-from, .ti-to { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 3ch; - flex: 0 1 auto; + max-width: 40%; + flex: 0 0 auto; } /* Shrinks but does not grow: growing is what used to strand the destination @@ -1326,32 +1371,56 @@ body:has(.is-reordering) .tooltip { } /* The docked panel's spare height, to exactly one list. syncDockFill marks the - last open section that declares a growable region, and the region is what - grows — the section and its body only pass the height down. Both are - `1 0 auto`: grow into what is spare, never shrink past what they were drawn - at, so a panel with no room scrolls exactly as it always has. The region is - `1 1 0`, which sizes it to the leftover alone rather than to its content: a - windowed list slices its rows from its own height, and a box as tall as a - thousand rows would draw a thousand rows. + last open section that declares a growable region, and that region may grow + past --lp-list-max-h into the height the panel has left — **as far as its + own rows reach, and no further**. + + It used to grow to the bottom of the panel whatever it held: section and + body `1 0 auto`, region `1 1 0`. With Transitions open and last, that was + invisible, since nothing sits under the region. But folding Transitions + hands the fill to States Q, and five states then stood over five hundred + pixels of empty panel with the folded TRANSITIONS δ header pinned to the + bottom edge — the one card in the panel that did not size to what it held. + + So the section is content-sized (`0 1 auto`) and shrinks only when its + content is taller than the room, and the list's row takes whatever height + the section is left with, between its floor and its rows. Section and body + are grids with `align-content: start`, so a grid with room to spare does + not stretch its search row to use it. + + The floors are set by js/panel-list.js (publishListHeight), because the + browser's would be wrong in both places. The list's floor is the cap, or + its own height when that is less, so a short panel cannot crush it — "as + tall as the content but no more than 168px" is not a size CSS can take from + a scroll container. And the section's automatic minimum is its content's + height — in a grid as much as a flex column — so a 200-state list held the + section at twelve thousand pixels and the whole panel scrolled instead of + the list. The section's floor is published beside the list's (its header + and search row plus the list's floor), the body takes `min-height: 0`, and + a panel with no room still scrolls rather than squeezing the list. + + A windowed list is still bounded by the room rather than drawing every row: + its spacers make its content tall, the shrink takes it back to the + leftover, and it slices its window from that. Scoped to children of the panel's container, so a section torn off into a window is not addressed here — the window has its own fill, and the selector carries .lp-collapsible to outrank the `:last-child` reset above. */ :is(.lpanel-content, .rpanel-content) > .sec.lp-collapsible.panel-dock-fill { - flex: 1 0 auto; - display: flex; - flex-direction: column; + flex: 0 1 auto; + min-height: var(--dock-fill-min, auto); + display: grid; + align-content: start; } :is(.lpanel-content, .rpanel-content) > .panel-dock-fill > .lp-section-body { - flex: 1 0 auto; - display: flex; - flex-direction: column; + min-height: 0; + display: grid; + align-content: start; } :is(.lpanel-content, .rpanel-content) > .panel-dock-fill .panel-dock-fill-region { - flex: 1 1 0; - min-height: var(--lp-list-max-h); + min-height: var(--lw-floor, var(--lp-list-max-h)); max-height: none; /* A wrapping flex container shares spare height among its lines by default, which turned every alphabet chip into a tall pill. Same @@ -1365,12 +1434,20 @@ body:has(.is-reordering) .tooltip { symbols), the placeholder in the UI face, and a clear button that appears only once there is something to clear. type="search" gives Escape-to-clear for free; the native cancel glyph is redrawn as a mask so it takes the - theme's text colour. */ + theme's text colour. + + `flex: none` here, and the stretch only where it is meant — beside the δ + view switch, in `.lp-search-row`. A bare `flex: 1` is a grow on whichever + axis the parent runs, and States Q's filter sits straight in its section + body: whenever that body becomes a column — States taking the spare height + with Transitions folded, or torn off into a tall window — the field split + the slack with the list, and the input floated in a band of empty panel + with the rows stranded far below it. */ .search-field { position: relative; display: flex; align-items: center; - flex: 1; + flex: none; min-width: 0; margin-bottom: 6px; } @@ -1395,12 +1472,19 @@ body:has(.is-reordering) .tooltip { border: 1px solid var(--border); color: var(--text); font-family: var(--mono); - font-size: .72rem; + /* The entry fields' size (.inp): a filter typed into the States list read a + size smaller than a symbol typed into Σ two cards above it. */ + font-size: .78rem; padding: 0 6px 0 28px; border-radius: var(--field-radius); outline: none; -webkit-appearance: none; appearance: none; + /* Beside the δ view switch in a narrow panel "Filter transitions…" does not + fit, and without this it was cut through a glyph ("Filter transitions.") + rather than ending in an ellipsis. Set on the input, not on ::placeholder, + which does not take it; the input's applies to its placeholder. */ + text-overflow: ellipsis; transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast); } @@ -1516,18 +1600,31 @@ body:has(.is-reordering) .tooltip { /* The header row and the rows are separate grids — the rows are drawn by the windowed list and rewritten on scroll, the header is not — so both read one template, set as a custom property by the renderer. The header scrolls with - the host from js/delta-table.js; both reserve the same scrollbar gutter, or - the host's vertical bar would take width the header still has and every - column would sit a scrollbar's width out of line. */ + the host from js/delta-table.js, which also pads it by the width of the + host's vertical bar when there is one, or that bar would take width the + header still has and every column would sit a scrollbar's width out of line. + + That used to be a `scrollbar-gutter: stable` on both, and it went wrong two + ways. The header's border was drawn outside its gutter while each row's was + drawn inside the host's, so the header's frame ran ~10px past the rows under + it; and the reserved strip was there with nothing to scroll, leaving the + whole table that much narrower than the fields above it. The frame is the + header *row's* now, so it ends wherever the rows do. */ .dt-head { overflow: hidden; - scrollbar-gutter: stable; +} + +.dt-hrow { border: 1px solid var(--border); border-bottom: none; border-radius: 6px 6px 0 0; background: var(--bg3); } +.dt-hq { + border-top-left-radius: 5px; +} + .dt-head[hidden] { display: none; } @@ -1572,7 +1669,6 @@ body:has(.is-reordering) .tooltip { .tlist.is-table { gap: 0; overflow: auto; - scrollbar-gutter: stable; } /* The frame is drawn by the rows, not by the host. The host is the panel's @@ -2035,7 +2131,7 @@ button.dt-cell:hover { display: flex; align-items: center; gap: 8px; - flex: 1; + /* flex: set with the left panel's title, near the top of this file. */ } .rp-section-title::after { @@ -2061,13 +2157,25 @@ button.dt-cell:hover { } /* Match the left panel's count chip: fill only, no ring. Scoped to the - header so the .badge/.bd-* pills used elsewhere keep their own look. */ + header so the .badge/.bd-* pills used elsewhere keep their own look. + + It gives way before the title does (100 against the title's 1 — see the + note over the title's flex): the Language card names the machine here, and + some names are phrases — "Pushdown Transducer" pushed the PDT's collapse + arrow 17px past the right panel's edge at its default width. */ .rp-section-header .badge { border-color: transparent; font-size: .62rem; padding: 1px 7px; letter-spacing: .03em; - flex-shrink: 0; + flex: 0 100 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + /* 12 + 1px padding + 1px border each side: the count chip's 16px. On its + own line height the badge's sans face drew it a pixel taller. */ + line-height: 12px; } .rp-collapsible.collapsed .rp-toggle-arrow { @@ -2085,7 +2193,7 @@ button.dt-cell:hover { } .rp-section-body { - padding: 4px 14px 14px; + padding: var(--sec-body-pad); } /* ─── ALGO SEARCH ─── */ @@ -2183,15 +2291,29 @@ button.dt-cell:hover { be guessed at from pointer travel and the keyboard would be left with no way to reorder at all. See js/panel-sections-ui.js. - It follows .lp-row-btn's rule — revealed on :hover and :focus-within, - and always shown where there is no hover to reveal it with. */ + It follows .lp-row-btn's rule — revealed on :hover and on keyboard focus, + and always shown where there is no hover to reveal it with. Keyboard focus + is `:focus-visible`, not `:focus-within`: the header is `tabindex="0"`, so + a click that collapses it also focuses it, and nothing on the canvas takes + focus back — the grip and the pop-out stayed lit on whichever header was + clicked last. + + It sits in the header's left padding rather than in the row. In the row it + held 18px — its width and a gap — through every moment it was invisible, + which pushed each title in from the body below it and, at the panel's + 220px minimum, pushed TRANSITIONS δ's collapse arrow out past the edge. + Out of the flow it costs the row nothing and still appears without moving + anything. The header is `position: relative` (css/chrome-polish.css). */ .panel-sec-grip { + position: absolute; + left: 2px; + top: 50%; + translate: 0 -50%; display: inline-flex; align-items: center; justify-content: center; - width: 14px; + width: 12px; height: 18px; - margin-left: -4px; padding: 0; border: none; border-radius: 3px; @@ -2206,9 +2328,9 @@ button.dt-cell:hover { } .lp-section-header:hover .panel-sec-grip, -.lp-section-header:focus-within .panel-sec-grip, +.lp-section-header:is(:focus-visible, :has(:focus-visible)) .panel-sec-grip, .rp-section-header:hover .panel-sec-grip, -.rp-section-header:focus-within .panel-sec-grip { +.rp-section-header:is(:focus-visible, :has(:focus-visible)) .panel-sec-grip { opacity: 1; } @@ -2597,9 +2719,9 @@ button.dt-cell:hover { } .lp-section-header:hover .panel-float-btn, -.lp-section-header:focus-within .panel-float-btn, +.lp-section-header:is(:focus-visible, :has(:focus-visible)) .panel-float-btn, .rp-section-header:hover .panel-float-btn, -.rp-section-header:focus-within .panel-float-btn { +.rp-section-header:is(:focus-visible, :has(:focus-visible)) .panel-float-btn { opacity: 1; } diff --git a/css/variables.css b/css/variables.css index b2cde6d..74e8443 100644 --- a/css/variables.css +++ b/css/variables.css @@ -1,104 +1,4 @@ :root { - color-scheme: dark; - --bg: #12151b; - --bg2: #0c0e13; - --bg3: #171b23; - --surface: #1c212b; - --surface2: #232935; - --surface3: #2c3341; - --border: rgba(230, 233, 240, 0.07); - --border2: rgba(230, 233, 240, 0.13); - --sb-thumb: rgba(230, 233, 240, 0.16); - --sb-thumb-hover: rgba(230, 233, 240, 0.26); - --sb-thumb-active: rgba(230, 233, 240, 0.36); - --text: #e6e9f0; - --text2: #a7afbe; - --text3: #737d90; - --accent: #5ea1ff; - --violet: #a78bfa; - --indigo: #8b93ff; - --blue: #5ea1ff; - --gold: #f0c14b; - --green: #4dca8b; - --red: #f26d6d; - --orange: #f59a45; - /* Turing machines in the Library: an orange of its own, because --orange - already means "no verdict" and a machine family must not read as a warning. - Each theme derives it from its own orange and red. */ - --coral: #f48855; - --purple: #d68cf2; - --on-accent: #0c0e13; - --mono: 'JetBrains Mono', monospace; - --sans: 'DM Sans', sans-serif; - --serif: 'Crimson Pro', serif; - --r: 8px; - /* Single unified header bar; there is no longer a second toolbar row. */ - --hdr: 48px; - /* The header and both sidebars are one unbroken --bg2 surface, and the canvas - is a well cut into it. This is the radius of that cutout — so the curve - belongs to the canvas, and what each panel shows at its inner corners is - the concave outside of it. mobile.css zeroes it where the panels stop - being rails and the well stops having chrome beside it. */ - --canvas-radius: 14px; - /* The well had chrome on three sides only — header above, a sidebar either - side, and nothing under it, which left the bottom corners curving into a - window edge instead of into a frame. This is the sill that closes it: the - canvas stops short by this much and the panels carry --bg2 across the gap, - so the frame runs all the way round. */ - --canvas-sill: 14px; - --overlay: rgba(7, 8, 11, 0.7); - --overlay-blur: 3px; - --overlay-ring: rgba(255, 255, 255, 0.025); - --focus-ring: rgba(94, 161, 255, 0.14); - --panel-shadow-left: 2px 0 12px rgba(0, 0, 0, 0.22); - --panel-shadow-right: -2px 0 12px rgba(0, 0, 0, 0.22); - --accent-soft: rgba(94, 161, 255, 0.12); - --accent-soft-strong: rgba(94, 161, 255, 0.2); - --accent-border: rgba(94, 161, 255, 0.3); - --accent-border-strong: rgba(94, 161, 255, 0.45); - --accent-glow: 0 0 14px rgba(94, 161, 255, 0.32); - --tab-hover-bg: rgba(230, 233, 240, 0.045); - --tab-active-bg: rgba(94, 161, 255, 0.13); - --tab-active-border: rgba(94, 161, 255, 0.3); - --tab-active-line: rgba(94, 161, 255, 0.95); - --tab-overflow-shadow: rgba(8, 10, 13, 0.62); - --violet-soft: rgba(167, 139, 250, 0.14); - --violet-border: rgba(167, 139, 250, 0.3); - --indigo-soft: rgba(139, 147, 255, 0.14); - --indigo-border: rgba(139, 147, 255, 0.3); - --blue-soft: rgba(94, 161, 255, 0.12); - --blue-border: rgba(94, 161, 255, 0.3); - --white-note: #f8f8fb; - --white-note-soft: rgba(248, 248, 251, 0.92); - --white-note-border: rgba(248, 248, 251, 0.72); - --white-note-text: #0c0e13; - --purple-soft: rgba(214, 140, 242, 0.14); - --purple-border: rgba(214, 140, 242, 0.3); - --green-soft: rgba(77, 202, 139, 0.12); - --green-border: rgba(77, 202, 139, 0.3); - --gold-soft: rgba(240, 193, 75, 0.12); - --gold-border: rgba(240, 193, 75, 0.3); - --note-yellow: #f5dc6e; - --note-yellow-soft: rgba(245, 220, 110, 0.18); - --note-yellow-border: rgba(245, 220, 110, 0.42); - --orange-soft: rgba(245, 154, 69, 0.12); - --orange-border: rgba(245, 154, 69, 0.3); - --red-soft: rgba(242, 109, 109, 0.12); - --red-border: rgba(242, 109, 109, 0.3); - --canvas-glow-a: rgba(94, 161, 255, 0.04); - --canvas-glow-b: rgba(167, 139, 250, 0.03); - --state-active-fill: rgba(94, 161, 255, 0.2); - --state-active-shadow: drop-shadow(0 0 7px rgba(94, 161, 255, 0.45)); - --state-reject-fill: rgba(242, 109, 109, 0.12); - --marquee-fill: rgba(94, 161, 255, 0.1); - --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.35); - --shadow-md: 0 4px 18px rgba(0, 0, 0, 0.45); - --shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.6); - --transition-fast: 0.12s ease; - --transition-base: 0.18s ease; -} - -:root[data-theme="light"] { color-scheme: light; --bg: #f7f8fa; --bg2: #eef0f4; @@ -122,6 +22,9 @@ --green: #16855a; --red: #cc3434; --orange: #c95f0c; + /* Turing machines in the Library: an orange of its own, because --orange + already means "no verdict" and a machine family must not read as a warning. + Each theme derives it from its own orange and red. */ --coral: #ca4e1c; --purple: #9a3fc0; --on-accent: #ffffff; @@ -172,6 +75,106 @@ --shadow-sm: 0 2px 10px rgba(23, 28, 38, 0.08); --shadow-md: 0 10px 28px rgba(23, 28, 38, 0.12); --shadow-lg: 0 20px 56px rgba(23, 28, 38, 0.16); + /* Shared by every theme: type, geometry and timing. The colours above are + the light theme, which is the default — every other theme, dark + included, is a block below keyed on its data-theme id that restates them. */ + --mono: 'JetBrains Mono', monospace; + --sans: 'DM Sans', sans-serif; + --serif: 'Crimson Pro', serif; + --r: 8px; + /* Single unified header bar; there is no longer a second toolbar row. */ + --hdr: 48px; + /* The header and both sidebars are one unbroken --bg2 surface, and the canvas + is a well cut into it. This is the radius of that cutout — so the curve + belongs to the canvas, and what each panel shows at its inner corners is + the concave outside of it. mobile.css zeroes it where the panels stop + being rails and the well stops having chrome beside it. */ + --canvas-radius: 14px; + /* The well had chrome on three sides only — header above, a sidebar either + side, and nothing under it, which left the bottom corners curving into a + window edge instead of into a frame. This is the sill that closes it: the + canvas stops short by this much and the panels carry --bg2 across the gap, + so the frame runs all the way round. */ + --canvas-sill: 14px; + --overlay-blur: 3px; + --transition-fast: 0.12s ease; + --transition-base: 0.18s ease; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #12151b; + --bg2: #0c0e13; + --bg3: #171b23; + --surface: #1c212b; + --surface2: #232935; + --surface3: #2c3341; + --border: rgba(230, 233, 240, 0.07); + --border2: rgba(230, 233, 240, 0.13); + --sb-thumb: rgba(230, 233, 240, 0.16); + --sb-thumb-hover: rgba(230, 233, 240, 0.26); + --sb-thumb-active: rgba(230, 233, 240, 0.36); + --text: #e6e9f0; + --text2: #a7afbe; + --text3: #737d90; + --accent: #5ea1ff; + --violet: #a78bfa; + --indigo: #8b93ff; + --blue: #5ea1ff; + --gold: #f0c14b; + --green: #4dca8b; + --red: #f26d6d; + --orange: #f59a45; + --coral: #f48855; + --purple: #d68cf2; + --on-accent: #0c0e13; + --overlay: rgba(7, 8, 11, 0.7); + --overlay-ring: rgba(255, 255, 255, 0.025); + --focus-ring: rgba(94, 161, 255, 0.14); + --panel-shadow-left: 2px 0 12px rgba(0, 0, 0, 0.22); + --panel-shadow-right: -2px 0 12px rgba(0, 0, 0, 0.22); + --accent-soft: rgba(94, 161, 255, 0.12); + --accent-soft-strong: rgba(94, 161, 255, 0.2); + --accent-border: rgba(94, 161, 255, 0.3); + --accent-border-strong: rgba(94, 161, 255, 0.45); + --accent-glow: 0 0 14px rgba(94, 161, 255, 0.32); + --tab-hover-bg: rgba(230, 233, 240, 0.045); + --tab-active-bg: rgba(94, 161, 255, 0.13); + --tab-active-border: rgba(94, 161, 255, 0.3); + --tab-active-line: rgba(94, 161, 255, 0.95); + --tab-overflow-shadow: rgba(8, 10, 13, 0.62); + --violet-soft: rgba(167, 139, 250, 0.14); + --violet-border: rgba(167, 139, 250, 0.3); + --indigo-soft: rgba(139, 147, 255, 0.14); + --indigo-border: rgba(139, 147, 255, 0.3); + --blue-soft: rgba(94, 161, 255, 0.12); + --blue-border: rgba(94, 161, 255, 0.3); + --white-note: #f8f8fb; + --white-note-soft: rgba(248, 248, 251, 0.92); + --white-note-border: rgba(248, 248, 251, 0.72); + --white-note-text: #0c0e13; + --purple-soft: rgba(214, 140, 242, 0.14); + --purple-border: rgba(214, 140, 242, 0.3); + --green-soft: rgba(77, 202, 139, 0.12); + --green-border: rgba(77, 202, 139, 0.3); + --gold-soft: rgba(240, 193, 75, 0.12); + --gold-border: rgba(240, 193, 75, 0.3); + --note-yellow: #f5dc6e; + --note-yellow-soft: rgba(245, 220, 110, 0.18); + --note-yellow-border: rgba(245, 220, 110, 0.42); + --orange-soft: rgba(245, 154, 69, 0.12); + --orange-border: rgba(245, 154, 69, 0.3); + --red-soft: rgba(242, 109, 109, 0.12); + --red-border: rgba(242, 109, 109, 0.3); + --canvas-glow-a: rgba(94, 161, 255, 0.04); + --canvas-glow-b: rgba(167, 139, 250, 0.03); + --state-active-fill: rgba(94, 161, 255, 0.2); + --state-active-shadow: drop-shadow(0 0 7px rgba(94, 161, 255, 0.45)); + --state-reject-fill: rgba(242, 109, 109, 0.12); + --marquee-fill: rgba(94, 161, 255, 0.1); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.35); + --shadow-md: 0 4px 18px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.6); } :root[data-theme="nord"] { diff --git a/css/views.css b/css/views.css index 742b7ea..1754d77 100644 --- a/css/views.css +++ b/css/views.css @@ -471,6 +471,39 @@ margin-bottom: var(--canvas-sill); } +/* The chrome's shadow on the well. The header, both panels and the sill sit + around the canvas as one raised surface, and the well had no edge but its + radius — so on a dark theme, where --bg2 and --bg are a few levels apart, + nothing said which was above which. A soft inset shade along each edge says + it: strongest under the header, a little lighter off the panels, faintest + up from the sill, the way light from above would fall. + + An overlay rather than a shadow on .canvas-area itself, because an inset + shadow paints in the box's own background — under the grid, the diagram + and everything else in the well — and a shadow cast by the chrome has to + fall across what is under it. z-index 40 is above the diagram and below + all of the floating chrome (49 and up), so the toolbox, the minimap and the + label editor are never shaded. It takes no pointer, and it is static: a + pan or zoom never touches it. + + The ink is --tab-overflow-shadow, the app's "there is more past this edge" + colour, which every theme already tunes for its own ground — a literal + black is invisible on the dark palettes and a smudge on the light ones. */ +.canvas-area::after { + --well-ink: color-mix(in srgb, var(--tab-overflow-shadow) 72%, transparent); + content: ''; + position: absolute; + inset: 0; + z-index: 40; + border-radius: inherit; + pointer-events: none; + box-shadow: + inset 0 9px 10px -9px var(--well-ink), + inset 8px 0 10px -8px var(--well-ink), + inset -8px 0 10px -8px var(--well-ink), + inset 0 -6px 8px -7px var(--well-ink); +} + /* A real element, not .canvas-area::before, and that is the point of it. The camera variables below change on every frame of a pan or zoom, and custom properties inherit: set on #canvas-wrap they changed an inherited value on diff --git a/electron/main.cjs b/electron/main.cjs index 512b08f..4ccd900 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -530,7 +530,10 @@ function createWindow() { height: 900, minWidth: 960, minHeight: 600, - backgroundColor: '#0f0f14', + // The default theme's --bg2, so the window does not open on a dark frame + // before the page paints. A reader who chose a dark theme sees one light + // frame instead, which is the cheaper of the two to get wrong. + backgroundColor: '#eef0f4', show: false, frame: false, webPreferences: { diff --git a/index.html b/index.html index f12e009..d9b9d0a 100644 --- a/index.html +++ b/index.html @@ -21,7 +21,7 @@ AutomataStudio - + diff --git a/js/delta-table.js b/js/delta-table.js index 2620ad6..dadd57b 100644 --- a/js/delta-table.js +++ b/js/delta-table.js @@ -306,7 +306,15 @@ function setVars(el, tpl) { function syncHeadScroll(host) { const head = $('trans-table-head'); - if (head && host) head.scrollLeft = host.scrollLeft || 0; + if (!head || !host) return; + head.scrollLeft = host.scrollLeft || 0; + // The header stops where the rows do: short of the host's vertical bar when + // there is one, at the panel's edge when there is not. Both used to reserve + // a gutter instead, which kept the columns in line but left the whole table + // a scrollbar's width narrower than the fields above it whether or not + // anything scrolled. + const bar = Math.max(0, (host.offsetWidth || 0) - (host.clientWidth || 0)); + if (head.style) head.style.paddingRight = bar ? `${bar}px` : ''; } // ── the host's events ───────────────────────────────────────────── @@ -365,6 +373,14 @@ function wireHost(host) { // A picker is anchored to a cell, and the cell has just moved. closeDeltaPicker(); }, { passive: true }); + // The vertical bar comes and goes without a redraw — a section folding + // elsewhere hands this host more or less height, a filter shortens it — and + // its arrival narrows the host's content box, which is what this observes. + if (typeof ResizeObserver === 'function') { + new ResizeObserver(() => { + if (host.classList.contains('is-table')) syncHeadScroll(host); + }).observe(host); + } } // ── editing a cell ──────────────────────────────────────────────── diff --git a/js/panel-list.js b/js/panel-list.js index e23e06d..f2f0b82 100644 --- a/js/panel-list.js +++ b/js/panel-list.js @@ -117,6 +117,7 @@ function draw(rec, force) { host.innerHTML = rec.empty; host.dataset.lwState = 'empty'; rec.start = rec.end = -1; + publishListHeight(rec.host); } return; } @@ -131,6 +132,7 @@ function draw(rec, force) { rec.start = 0; rec.end = n; host.scrollTop = keep; measure(rec); + publishListHeight(rec.host); return; } @@ -155,6 +157,60 @@ function draw(rec, force) { rec.start = first; rec.end = last; host.scrollTop = keep; measure(rec); + // A scroll moves the window, not the spacers' total, so only a draw that + // changed what the list holds has a new height to report. + if (force) publishListHeight(rec.host); +} + +// The list's floor, for the dock fill in css/panels.css: the fill grows into +// the panel's spare height only as far as it has rows, and is never squeezed +// below this — the cap, or the list's own height when that is less. A short +// list keeps every row; a long one keeps the cap and scrolls. +// +// CSS cannot take "as tall as the content but no more than 168px" from a +// scroll container, so it is measured: from the first child's top to the last +// child's bottom rather than from scrollHeight, which never reports less than +// the box — a floor read from that could only ratchet up, and a list filtered +// down to three rows would keep the height it had at three hundred. +// +// When the list is the fill, its *section's* floor is set too — this list's +// floor plus everything else in the section (header, search row, padding), +// which is content-sized, so it is the section's height less the list's. That +// cannot be left to the browser: a flex item's automatic minimum is its +// content's height, which for a 200-state list is twelve thousand pixels, so +// the section takes `min-height: 0` and this number in its place. +// +// Exported for a list drawn some other way: the Blocks list is one innerHTML +// string with no windowing, and it takes the dock fill as readily as these do. +export function publishListHeight(host) { + if (!host || !host.style || typeof host.style.setProperty !== 'function') return; + const kids = host.children || []; + const first = kids[0]; + const last = kids[kids.length - 1]; + if (!first || typeof first.getBoundingClientRect !== 'function') return; + let h = last.getBoundingClientRect().bottom - first.getBoundingClientRect().top; + if (!(h > 0)) return; + const view = host.ownerDocument && host.ownerDocument.defaultView; + let cap = Infinity; + if (view && typeof view.getComputedStyle === 'function') { + const cs = view.getComputedStyle(host); + h += (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); + const c = parseFloat(cs.getPropertyValue('--lp-list-max-h')); + if (c > 0) cap = c; + } + const floor = Math.ceil(Math.min(h, cap)); + host.style.setProperty('--lw-floor', floor + 'px'); + + const fill = host.classList && host.classList.contains('panel-dock-fill-region') && typeof host.closest === 'function' + ? host.closest('.panel-dock-fill') : null; + if (fill && fill.style && typeof fill.getBoundingClientRect === 'function') { + // Measured with the old floor lifted: the section's height includes it, so + // a stale floor (a longer list a moment ago, another machine) would be read + // back as header and padding and keep itself alive. + if (typeof fill.style.removeProperty === 'function') fill.style.removeProperty('--dock-fill-min'); + const rest = fill.getBoundingClientRect().height - host.getBoundingClientRect().height; + if (rest >= 0) fill.style.setProperty('--dock-fill-min', Math.ceil(rest + floor) + 'px'); + } } // Pitch is row height plus the flex gap, taken from two consecutive drawn rows diff --git a/js/panel-sections-ui.js b/js/panel-sections-ui.js index 362b51f..235be35 100644 --- a/js/panel-sections-ui.js +++ b/js/panel-sections-ui.js @@ -52,7 +52,7 @@ import { beginFloatSnap, commitFloatGeom, dockSection, endFloatSnap, floatLayerRect, floatSection, floatingEnabled, moveFloatTo, syncPanelEmpty } from './panel-float.js'; -import { redrawAllLists } from './panel-list.js'; +import { publishListHeight, redrawAllLists } from './panel-list.js'; /** Pointer travel, in px, before a press becomes a drag. */ const DRAG_THRESHOLD = 3; @@ -214,6 +214,10 @@ export function syncDockFill(side) { const on = id === fill; el.classList.toggle('panel-dock-fill', on); if (region && region.classList) region.classList.toggle('panel-dock-fill-region', on); + // The fill grows only as far as its rows, and its floor is their height + // (css/panels.css). A list drawn while its section was folded measured + // nothing then, so it is measured again now that it is open. + if (on && region) publishListHeight(region); } // A list is windowed against its own height at draw time, and a collapse // elsewhere in the panel changes that height without the list scrolling or diff --git a/js/render.js b/js/render.js index 1893f07..7627485 100644 --- a/js/render.js +++ b/js/render.js @@ -3,7 +3,7 @@ import { applyEdgeDirectionHighlight, clearEdgeDirectionHighlight, clearSelectio import { renderDividers } from './dividers.js'; import { PILL_GAP, PILL_HEIGHT, PILL_ROW_H, buildLayoutContext, edgeGeometryFor, estimatePillLabelSize, estimateTextLabelSize, pillPartWidth, selfLoopLabelPoint, selfLoopPath, startNodeId } from './geometry.js'; import { commit, snapshot } from './history.js'; -import { setListItems } from './panel-list.js'; +import { publishListHeight, setListItems } from './panel-list.js'; import { leaveTransTable, renderTransTable, syncTransViewToggle, transView } from './delta-table.js'; import { cullNeedsRepaint, cullViewport, cullingActive, edgeLabelLOD, invalidateCull, rectHasPoint, stateLabelLOD, suspendCulling } from './viewport.js'; import { scheduleMinimap } from './minimap.js'; @@ -21,6 +21,7 @@ import { openEdgeLabelEditor } from './edge-label-editor.js'; import { edgeTipFor, getState, showContextMenu, transLabel, transLabelDescriptive, transLabelParts } from './states-transitions.js'; import { Change, changed, emit, subscribe } from './store.js'; import { createMemo, reactiveRoot } from './reactive.js'; +import { setSectionStatus } from './section-status.js'; import { followCanvas } from './draft-layer.js'; import { triggerMath } from './reference.js'; import { filterStates, filterTransitions } from './ui.js'; @@ -1839,6 +1840,18 @@ export function updateLPanelSectionMeta() { setCount('alpha-count-sigma', App.sigma?.size || 0); setCount('lp-count-stack', App.stackAlpha?.size || 0); setCount('lp-count-output', App.outputAlpha?.size || 0); + // Folded, a section of two or three alphabets said only "ALPHABETS": the + // header's count is hidden once each row carries its own (a total over Σ, Γ + // and Δ would be a number about nothing), and the rows fold away with the + // body. So the folded header names each one — the same test for "more than + // one" that syncAlphabetSection draws the rows by. + const cfg = getMachineConfig(App.machine); + const alphabets = [['Σ', App.sigma]]; + if (cfg.hasStack) alphabets.push(['Γ', App.stackAlpha]); + if (cfg.isTransducer) alphabets.push(['Δ', App.outputAlpha]); + setSectionStatus('lp-alphabet', alphabets.length > 1 + ? alphabets.map(([name, set]) => `${name}${set?.size || 0}`).join(' · ') + : ''); // The counts follow the lists under them, which show this level rather than // the whole machine. The formal definition beside them still reports |Q| for // the machine, which is the honest number there: a block is a drawing, and @@ -1897,6 +1910,7 @@ export function updateBlockList() { const rows = allBlocks(); if (!rows.length) { host.innerHTML = '
No blocks
'; + publishListHeight(host); return; } const here = (App.scope || [])[(App.scope || []).length - 1] || null; @@ -1911,6 +1925,8 @@ export function updateBlockList() {
${b.path ? escapeHtml(b.path) + ' · ' : ''}${b.members} state${b.members === 1 ? '' : 's'}${b.children ? ` · ${b.children} block${b.children === 1 ? '' : 's'}` : ''}
`).join(''); + // Its height, for the dock fill's floor — see publishListHeight. + publishListHeight(host); } // Every row in the States Q and Transitions δ lists ends in the same pair of diff --git a/js/state.js b/js/state.js index 08a63f8..4064fda 100644 --- a/js/state.js +++ b/js/state.js @@ -282,7 +282,7 @@ export const App = { edgeHighlight: null, // Configuration constants config: { - theme: 'dark', + theme: 'light', transducerAccepts: false, // Whether a Turing machine's tape extends left of its input. This is a // property of the tape, not of the machine, so it is a setting rather diff --git a/js/themes.js b/js/themes.js index 7f6fa95..10e96b6 100644 --- a/js/themes.js +++ b/js/themes.js @@ -778,4 +778,4 @@ export const Themes = { } }; -export const DEFAULT_THEME = 'dark'; +export const DEFAULT_THEME = 'light'; diff --git a/tests/panel-sections-css.test.js b/tests/panel-sections-css.test.js new file mode 100644 index 0000000..78649b5 --- /dev/null +++ b/tests/panel-sections-css.test.js @@ -0,0 +1,172 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +// The panel sections' cascade, which no assertion about the DOM can reach. +// +// Every bug pinned here drew the right DOM and the wrong picture: a filter box +// stretched to three hundred pixels tall, a grip left lit on a header nobody +// was focusing, a collapse arrow pushed past the panel edge, a table header +// framed wider than its rows. The stub computes no styles, so the rules are +// read from the stylesheet instead — the way tests/panel-float.test.js reads +// the window's. + +const PANELS = readFileSync(new URL('../css/panels.css', import.meta.url), 'utf8'); +const POLISH = readFileSync(new URL('../css/chrome-polish.css', import.meta.url), 'utf8'); + +/** Every `selector { … }` rule in a stylesheet, comments stripped. */ +function rules(source) { + const text = source.replace(/\/\*[\s\S]*?\*\//g, ''); + return [...text.matchAll(/([^{}]+)\{([^{}]*)\}/g)] + .map(m => ({ selector: m[1].trim().replace(/\s+/g, ' '), body: m[2] })); +} + +/** A selector list's members — split on its own commas, not those inside `:is(…)`. */ +function selectorList(selector) { + const out = []; + let depth = 0, cur = ''; + for (const ch of selector) { + if (ch === '(') depth++; + if (ch === ')') depth--; + if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; } else cur += ch; + } + out.push(cur.trim()); + return out; +} + +/** The declarations of the rules whose selector list includes exactly `sel`. */ +function bodiesFor(source, sel) { + return rules(source) + .filter(r => selectorList(r.selector).includes(sel)) + .map(r => r.body); +} + +test('a search field does not grow unless a row asks it to', () => { + // States Q's filter sits straight in its section body, and that body becomes + // a flex column whenever States takes the panel's spare height (Transitions + // folded) or is torn off into a tall window. A bare `flex: 1` on the field + // grew it on that axis: it split the slack with the list, the input floated + // in the middle of a 300px band, and the rows sat far below it. + const base = bodiesFor(PANELS, '.search-field'); + assert.ok(base.length, 'the field\'s own rule is still here'); + base.forEach(body => assert.doesNotMatch(body, /flex\s*:\s*1\b|flex-grow\s*:\s*[1-9]/, + '.search-field grows on whatever axis its parent runs')); + assert.ok(bodiesFor(PANELS, '.lp-search-row .search-field').some(b => /flex\s*:\s*1\b/.test(b)), + 'beside the δ view switch it still takes the row'); +}); + +test('a header\'s grip and pop-out are revealed by keyboard focus, not by any focus', () => { + // The header is tabindex="0", so a click that folds a section also focuses + // it, and nothing on the canvas takes focus back: on `:focus-within` the + // grip and the pop-out stayed lit on whichever header was clicked last. + const reveals = rules(PANELS).filter(r => + /section-header/.test(r.selector) && /\.panel-sec-grip|\.panel-float-btn/.test(r.selector) && /opacity\s*:\s*1/.test(r.body)); + assert.ok(reveals.length, 'the reveal rules are still here'); + reveals.forEach(r => assert.doesNotMatch(r.selector, /section-header:focus-within/, + `${r.selector} reveals the controls on a mouse click`)); + assert.ok(reveals.some(r => /:focus-visible/.test(r.selector)), 'and a keyboard still reveals them'); +}); + +test('the grip takes no room in the header row', () => { + // In the row it held 18px through every moment it was invisible, which + // pushed TRANSITIONS δ's collapse arrow past the edge at the panel's 220px + // minimum and set every title in from the body under it. + const grip = bodiesFor(PANELS, '.panel-sec-grip').join('\n'); + assert.match(grip, /position\s*:\s*absolute/); + assert.match(POLISH, /\.lp-section-header,\s*\.rp-section-header\s*\{[^}]*position\s*:\s*relative/, + 'and the header it is placed against is positioned'); +}); + +test('a narrow header gives way, the chips first and the title last', () => { + // Shrink is shared by factor × basis, and when the factors in a row sum to + // less than one only that fraction of the overflow is taken up — the rest + // still spills. So the title shrinks at 1 (not below it), and the status + // chip and the machine badge at far more than the title. + const shrinkOf = body => { + const flex = body.match(/flex\s*:\s*([\d.]+)\s+([\d.]+)/); + if (flex) return Number(flex[2]); + const fs = body.match(/flex-shrink\s*:\s*([\d.]+)/); + return fs ? Number(fs[1]) : null; + }; + const title = bodiesFor(PANELS, '.rp-section-title').map(shrinkOf).filter(v => v != null); + assert.deepEqual(title, [1], 'the title shrinks, at exactly one'); + const status = bodiesFor(PANELS, '.sec-status').map(shrinkOf).filter(v => v != null); + const badge = bodiesFor(PANELS, '.rp-section-header .badge').map(shrinkOf).filter(v => v != null); + assert.ok(status.length && status.every(v => v >= 50), 'the status chip gives way well before the title'); + assert.ok(badge.length && badge.every(v => v >= 50), 'so does the machine badge'); + // Docked headers only: a window's title bar (`.panel-float > …`) is its own + // layout, with its own controls. + rules(PANELS).filter(r => !/panel-float/.test(r.selector) + && /\.rp-section-title$|\.lp-section-header \.sec-lbl$/.test(selectorList(r.selector).at(-1))) + .forEach(r => assert.doesNotMatch(r.body, /flex\s*:\s*1\s*;/, `${r.selector} goes back to a basis of zero`)); +}); + +test('the δ table\'s header is framed where its rows are', () => { + // A border on `.dt-head` is drawn outside its scrollbar gutter, and each + // row's inside the host's, so the header ran ~10px past every row. The frame + // is the header row's; the header's padding for the host's bar is measured + // (js/delta-table.js) rather than reserved, which also stopped the table + // sitting a gutter narrower than the fields above it with nothing to scroll. + const head = bodiesFor(PANELS, '.dt-head').join('\n'); + assert.doesNotMatch(head, /border\s*:/, '.dt-head draws a frame of its own'); + assert.doesNotMatch(head, /scrollbar-gutter/); + assert.match(bodiesFor(PANELS, '.dt-hrow').join('\n'), /border\s*:/, 'the header row draws it'); + assert.doesNotMatch(bodiesFor(PANELS, '.tlist.is-table').join('\n'), /scrollbar-gutter/); +}); + +test('the panel bodies reserve no gutter for a bar they rarely have', () => { + // A reserved gutter is outside the scrollport, where nothing can paint: every + // section rule and hover fill stopped ~10px short of the edge while the + // panel header's rule ran the full width. + for (const sel of ['.lpanel-content', '.rpanel-content']) { + [...bodiesFor(POLISH, sel), ...bodiesFor(PANELS, sel)].forEach(body => + assert.doesNotMatch(body, /scrollbar-gutter\s*:\s*stable/, `${sel} reserves a gutter`)); + } +}); + +test('the docked fill grows only as far as its rows', () => { + // It grew to the bottom of the panel whatever it held: fold Transitions and + // five states stood over five hundred pixels of empty panel, with the folded + // TRANSITIONS δ header pinned to the bottom edge. Content-sized now, shrinking + // only when the rows are taller than the room — and with explicit floors, + // because a section's automatic minimum is its whole content's height. + const sec = bodiesFor(PANELS, ':is(.lpanel-content, .rpanel-content) > .sec.lp-collapsible.panel-dock-fill').join('\n'); + assert.match(sec, /flex\s*:\s*0\s+1\s+auto/, 'the fill section does not grow past its content'); + assert.match(sec, /min-height\s*:\s*var\(--dock-fill-min/, 'and its floor is the published one'); + const body = bodiesFor(PANELS, ':is(.lpanel-content, .rpanel-content) > .panel-dock-fill > .lp-section-body').join('\n'); + assert.match(body, /min-height\s*:\s*0/, 'the body may shrink, so the list inside it can'); + const region = bodiesFor(PANELS, ':is(.lpanel-content, .rpanel-content) > .panel-dock-fill .panel-dock-fill-region').join('\n'); + assert.doesNotMatch(region, /flex\s*:\s*1\s+1\s+0/, 'the list is not sized to the leftover alone'); + assert.match(region, /min-height\s*:\s*var\(--lw-floor/, 'its floor is the cap or its own rows, whichever is less'); + + const LIST = readFileSync(new URL('../js/panel-list.js', import.meta.url), 'utf8'); + assert.match(LIST, /setProperty\('--lw-floor'/, 'panel-list publishes the list\'s floor'); + assert.match(LIST, /setProperty\('--dock-fill-min'/, 'and the section\'s'); + assert.match(LIST, /removeProperty\('--dock-fill-min'\)/, + 'measured with the old floor lifted, or a stale floor reads itself back as padding'); +}); + +test('a card is padded the same on either side of the canvas', () => { + // The left panel's bodies were 3px 14px 12px and the right's 4px 14px 14px. + const docked = bodiesFor(PANELS, ':is(.lpanel-content, .rpanel-content) .lp-section-body').join('\n'); + const right = bodiesFor(PANELS, '.rp-section-body').join('\n'); + assert.match(docked, /padding\s*:\s*var\(--sec-body-pad\)/); + assert.match(right, /padding\s*:\s*var\(--sec-body-pad\)/); + assert.ok(rules(PANELS).some(r => /:is\(\.lpanel-content, \.rpanel-content\) :is\(\.lp-section-header, \.rp-section-header\)/.test(r.selector) + && /padding-left\s*:\s*var\(--sec-head-pad-start\)/.test(r.body)), + 'and both panels\' headers leave the grip the same room before the title'); +}); + +test('the chrome\'s shadow on the canvas lies over the diagram and under the controls', () => { + // An inset shadow on .canvas-area itself would paint under the grid and the + // diagram; the overlay casts it across them. It must take no pointer, or the + // canvas stops receiving clicks, and must sit below the floating chrome (49 + // and up: toolbox, minimap, label editor, status), or it shades them too. + const VIEWS = readFileSync(new URL('../css/views.css', import.meta.url), 'utf8'); + const well = bodiesFor(VIEWS, '.canvas-area::after').join('\n'); + assert.match(well, /pointer-events\s*:\s*none/); + assert.match(well, /box-shadow\s*:[^;]*inset/); + const z = Number((well.match(/z-index\s*:\s*(\d+)/) || [])[1]); + assert.ok(z > 0 && z < 49, `z-index ${z} is not between the diagram and the chrome`); + assert.match(well, /--tab-overflow-shadow/, 'its ink is the theme-tuned edge colour, not a literal'); +}); diff --git a/tests/themes.test.js b/tests/themes.test.js index d864495..11a076a 100644 --- a/tests/themes.test.js +++ b/tests/themes.test.js @@ -32,7 +32,8 @@ const THEMED_SHEETS = readdirSync(fileURLToPath(new URL('css/', root))) .filter(f => f.endsWith('.css') && f !== 'variables.css') .sort(); -// The base `:root` block IS the dark theme; every other theme is an override. +// The base `:root` block IS the default theme (light); every other theme, +// dark included, is an override. const cssThemeIds = [...VARIABLES.matchAll(/:root\[data-theme="([^"]+)"\]/g)].map(m => m[1]); function themeBlock(id) { @@ -79,14 +80,18 @@ test('every registry theme has a stylesheet block, and vice versa', () => { }); test('every theme block overrides the same variables', () => { - // The `light` block is the reference: it lists exactly what varies per - // theme. A theme missing one inherits dark's value and renders wrong in - // that one spot only. - const reference = Object.keys(themeBlock('light').vars).sort(); + // The `dark` block is the reference: an override lists exactly what varies + // per theme. A theme missing one inherits the default's (light) value and + // renders wrong in that one spot only — a light panel fill on a dark theme. + // The base block holds the same set plus what no theme varies (type, + // geometry, timing), so it is checked for coverage rather than equality. + const reference = Object.keys(themeBlock('dark').vars).sort(); for (const id of cssThemeIds) { assert.deepEqual(Object.keys(themeBlock(id).vars).sort(), reference, - `theme "${id}" must override exactly the same variables as "light"`); + `theme "${id}" must override exactly the same variables as "dark"`); } + const base = themeBlock(DEFAULT_THEME).vars; + for (const k of reference) assert.ok(k in base, `the default theme's base block has no ${k}`); }); test('color-scheme matches each theme\'s background', () => { diff --git a/tests/workspace-sections.test.js b/tests/workspace-sections.test.js index fcc3242..a0e5e1e 100644 --- a/tests/workspace-sections.test.js +++ b/tests/workspace-sections.test.js @@ -113,6 +113,29 @@ test('one alphabet reads as it always did; several become labelled rows', () => assert.equal(context.$('stack-sec').style.display, 'none'); }); +test('a folded section of several alphabets names each one, since its count is hidden', () => { + // `.is-multi` hides the header's count in favour of one per row, and the + // rows fold away with the body — so folded, the header said only + // "ALPHABETS". One alphabet keeps its count and says nothing extra. + harness.resetApp(); + withHeader('lp-alphabet'); + context.applyMachineSwitch('DFA'); + context.App.sigma = new Set(['a', 'b']); + context.updateLPanelSectionMeta(); + assert.equal(context.sectionStatus('lp-alphabet').text, '', 'one alphabet: the count already says it'); + + context.applyMachineSwitch('PDT'); + context.App.sigma = new Set(['a', 'b']); + context.App.stackAlpha = new Set(['Z', 'A', 'B']); + context.App.outputAlpha = new Set(['0', '1']); + context.updateLPanelSectionMeta(); + assert.equal(context.sectionStatus('lp-alphabet').text, 'Σ2 · Γ3 · Δ2'); + + context.applyMachineSwitch('DPDA'); + context.updateLPanelSectionMeta(); + assert.match(context.sectionStatus('lp-alphabet').text, /^Σ\d+ · Γ\d+$/, 'only the alphabets this machine has'); +}); + test('undoing across a machine switch puts the rows back the way the switch does', () => { harness.resetApp(); context.applyMachineSwitch('DFA');