fix(ui): split-view pre-push review round — mode-distinct chip, anchoring, light-theme AA

Dual designer review (one primed on the branch context, one cold), all
measured findings applied:

- The per-pane chip was a mode-error trap: identical glyph at the
  identical locus, reversible in split mode (hide cell) but destructive
  single-pane (close pane). Now − hides, ✕ closes, and the close mode
  wears a danger hover/focus ring so the irreversible action telegraphs
  before the click lands.

- Single-pane chip anchored to the VIEWPORT: an unpositioned section
  resolves absolutes to <body>, so the chip only coincidentally landed
  near the pane corner. .panes > section.pane is now position:relative
  in both modes (all pane-content absolutes verified to anchor to their
  own local relative parents).

- Light-theme AA (measured): .shown tab underline 55% mix composited to
  2.34:1 -> 80% (~3.7:1 light / ~5:1 dark); focused-cell ring 2.60:1 on
  light -> 75% mix override there (dark keeps 55% at 3.75:1).

- Chip: border --hair-2 measured ~1.3:1 (invisible) -> --ink-4; 22px
  target under WCAG 2.5.8's 24px floor -> 28px; right offset clears the
  message scrollbar gutter; light resting glyph one ink step up.

- Focus bar inset 1px from cell sides (no doubled-accent stripe where
  it butted a separator at the T-junction); greyscale font smoothing on
  the tail glyphs (subpixel RGB fringed the box-drawing characters).

Rejected with rationale: aria-pressed on the split buttons (they are
one-shot verbs — splitting again nests — not mode toggles).
This commit is contained in:
Patrick Buckley
2026-06-12 00:00:24 -07:00
parent 44c11efb53
commit ed08986d93
3 changed files with 62 additions and 10 deletions
+8
View File
@@ -751,6 +751,10 @@ def test_pane_manager_split_engine() -> None:
assert "closeCell(paneId)" in pane and "_refreshCellChips()" in pane
assert 'b.className = "cell-unsplit"' in pane
assert '"Close pane"' in pane, "the single-pane chip mode"
# mode-DISTINCT glyphs (designer P1: identical signifier + locus with a
# reversible/destructive divergence is a mode-error trap)
assert 'b.textContent = multi ? "" : ""' in pane
assert '"cell-unsplit--close"' in pane
assert "this._removeCellChip(pane)" in pane
# open-beside: the coordinator child-link placement (split right of the
# focused cell, degrade to the plain swap on deny)
@@ -768,6 +772,10 @@ def test_pane_manager_split_engine() -> None:
# occlusion bug); the ::before bar sits above the ring line
assert ".panes--split > section.pane.split-focused::after" in css
assert ".cell-unsplit" in css, "the per-cell hide-from-split chip style"
assert ".cell-unsplit--close:hover" in css, "destructive mode telegraphs on hover"
# the pane is the chip's containing block in BOTH modes — unpositioned,
# the single-pane chip anchored to the VIEWPORT (offsetParent <body>)
assert ".panes > section.pane" in css
def test_step7_auth_gated_open_pane() -> None:
+7 -1
View File
@@ -857,7 +857,6 @@ export class PaneManager {
b = document.createElement("button");
b.type = "button";
b.className = "cell-unsplit";
b.textContent = "✕";
b.addEventListener("click", () => {
if (this._layout && this._leafFor(pane.id)) this.closeCell(pane.id);
else this.close(pane.id);
@@ -865,6 +864,13 @@ export class PaneManager {
pane.el.append(b);
pane._cellChip = b;
}
// Mode-DISTINCT glyphs — an identical signifier at an identical locus with
// divergent outcomes is a mode-error trap (split-mode muscle memory would
// fire the destructive close): hides the cell (reversible — the tab
// stays), ✕ closes the pane. Close mode also wears a danger hover
// (shell.css .cell-unsplit--close).
b.textContent = multi ? "" : "✕";
b.classList.toggle("cell-unsplit--close", !multi);
const label = multi ? "Hide from split — the tab stays open" : "Close pane";
b.title = label;
b.setAttribute("aria-label", label);
+47 -9
View File
@@ -590,6 +590,10 @@
}
.tb-split .tb-glyph {
display: inline-block;
/* greyscale AA — subpixel RGB rendering fringes these box-drawing glyphs
with blue/amber bleed against the panel */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.tb-split--down .tb-glyph {
transform: rotate(90deg);
@@ -777,6 +781,13 @@
.panes--split {
position: relative;
}
/* The pane is the containing block in BOTH modes: unpositioned, the per-pane
✕ chip's absolute would resolve to the VIEWPORT (offsetParent <body>) and
only coincidentally land near the pane corner. The split rule below wins
on specificity while split. */
.panes > section.pane {
position: relative;
}
.panes--split > section.pane {
position: absolute;
overflow: hidden;
@@ -799,10 +810,18 @@
z-index: 12;
pointer-events: none;
}
/* light needs a hotter mix: 55% measured 2.60:1 there (sub-3:1); 75% clears.
Dark stays 55% (3.75:1) — hotter reads as a heavy border on dark. */
[data-theme="light"] .panes--split > section.pane.split-focused::after {
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--accent) 75%, var(--hair-2));
}
.panes--split > section.pane.split-focused::before {
content: "";
position: absolute;
inset: 0 0 auto 0; /* top edge, full cell width */
/* 1px in from the cell sides so the bar never butts a separator line into
one doubled-accent stripe at the T-junction */
inset: 0 1px auto 1px;
height: 2px;
background: var(--accent);
z-index: 13; /* above the ring overlay — a clean bar, not bar-plus-ring-line */
@@ -815,33 +834,50 @@
the 2px focus bar and the cell corner. */
.cell-unsplit {
position: absolute;
top: 6px;
right: 10px;
top: 5px;
right: 14px; /* clear of the message scroller's scrollbar gutter */
z-index: 14;
width: 22px;
height: 22px;
/* 28px: WCAG 2.5.8's 24px floor + a hair (22px under-shot it) */
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--hair-2);
/* --hair-2 measured ~1.3:1 against the chip bg — an invisible boundary;
--ink-4 clears 3:1 in both themes */
border: 1px solid var(--ink-4);
border-radius: var(--r-sm);
background: var(--panel-2);
color: var(--ink-3);
font-size: 11px;
font-size: 12px;
line-height: 1;
cursor: pointer;
opacity: 0.75;
opacity: 0.85;
}
.cell-unsplit:hover {
opacity: 1;
color: var(--ink);
border-color: var(--accent-dim);
}
/* the destructive mode (single-pane "Close pane" ✕) telegraphs BEFORE the
click lands — split-mode muscle memory must not fire it blind */
.cell-unsplit--close:hover {
color: var(--err);
border-color: var(--err);
}
.cell-unsplit:focus-visible {
opacity: 1;
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.cell-unsplit--close:focus-visible {
outline-color: var(--err);
}
/* light resting glyph at .85 sat right at the 3:1 floor with --ink-3 — one
ink step restores headroom (~4.5:1) without making the chip shout */
[data-theme="light"] .cell-unsplit {
color: var(--ink-2);
}
/* separator — a 7px hit area straddling the cell boundary with a 1px visual
line; drag (pointer capture) or arrow keys resize, see pane.js _wireHandle.
Resting line is --ink-4, the lightest token clearing 3:1 in BOTH themes
@@ -914,7 +950,9 @@
bottom: 2px;
height: 2px;
border-radius: 2px;
background: color-mix(in srgb, var(--accent) 55%, transparent);
/* 80%: 55% composited to 2.34:1 on the light --panel (sub-3:1 for a
load-bearing state mark); 80% = ~3.7:1 light / ~5:1 dark */
background: color-mix(in srgb, var(--accent) 80%, transparent);
}
.pane-head {
display: flex;