diff --git a/app/frontend/src/components/sidebar/window-row.test.tsx b/app/frontend/src/components/sidebar/window-row.test.tsx index 959e7795..af14f727 100644 --- a/app/frontend/src/components/sidebar/window-row.test.tsx +++ b/app/frontend/src/components/sidebar/window-row.test.tsx @@ -944,9 +944,9 @@ describe("WindowRow", () => { const stripe = zone.querySelector('[style*="border-left"]') as HTMLElement | null; expect(stripe).not.toBeNull(); expect(stripe!.style.borderLeft).toContain("solid"); - // Edge-anchored: a small 2px inset from the sidebar edge (full-bleed + // Edge-anchored: a small 4px inset from the sidebar edge (full-bleed // rows), not the old 17px post-icon-zone placement. - expect(stripe!.style.left).toBe("2px"); + expect(stripe!.style.left).toBe("4px"); // No next-state ghost preview element exists anymore. expect(zone.querySelectorAll('[style*="border-left"]').length).toBe(1); // Container must not be present twice (single stripe). @@ -961,10 +961,10 @@ describe("WindowRow", () => { // stripe carry no aria-hidden). const icon = zone.querySelector('[aria-hidden="true"]') as HTMLElement | null; expect(icon).not.toBeNull(); - // Inset ICON_EDGE_INSET (10px) off the sidebar edge — past the widest - // (double) stripe's 8px right edge — with the 12px icon-zone width + // Inset ICON_EDGE_INSET (12px) off the sidebar edge — past the widest + // (double) stripe's 10px right edge — with the 12px icon-zone width // unchanged, so the hover icon sits beside the stripe, not over it. - expect(icon!.style.left).toBe("10px"); + expect(icon!.style.left).toBe("12px"); expect(icon!.style.width).toBe("12px"); }); }); diff --git a/app/frontend/src/components/sidebar/window-row.tsx b/app/frontend/src/components/sidebar/window-row.tsx index b71cf239..396f1ee9 100644 --- a/app/frontend/src/components/sidebar/window-row.tsx +++ b/app/frontend/src/components/sidebar/window-row.tsx @@ -469,13 +469,13 @@ function WindowRowInner({ * marker stripe anchors near-flush at the sidebar edge (`STRIPE_EDGE_INSET`); * the hover palette-icon zone is inset `ICON_EDGE_INSET`px off that edge, * spanning to `ICON_EDGE_INSET + ICON_ZONE_WIDTH` — past the widest (double, - * 2+6=8px) stripe, so the hover icon sits beside the stripe rather than over + * 4+6=10px) stripe, so the hover icon sits beside the stripe rather than over * it. The icon keeps an explicit `z-10` (layering below) as a guard for any * residual sub-pixel overlap. */ const LABEL_ZONE_WIDTH = 26; // full zone: icon home + clearance before the dot const ICON_ZONE_WIDTH = 12; // 12px icon zone: home of the hover palette icon -const ICON_EDGE_INSET = 10; // icon-zone inset off the physical sidebar edge — clears the widest (double, 2+6=8px) marker stripe so the hover icon sits beside it, not over it -const STRIPE_EDGE_INSET = 2; // stripe inset from the zone's/sidebar's left edge (near-flush per the full-bleed spec) +const ICON_EDGE_INSET = 12; // icon-zone inset off the physical sidebar edge — clears the widest (double, 4+6=10px) marker stripe so the hover icon sits beside it, not over it +const STRIPE_EDGE_INSET = 4; // stripe inset from the zone's/sidebar's left edge (near-flush per the full-bleed spec) type LabelZoneProps = { marker?: string; @@ -497,7 +497,7 @@ type LabelZoneProps = { * The marker stripe is DISPLAY-ONLY, anchored near-flush at the sidebar's left * edge (`STRIPE_EDGE_INSET`px). The icon zone is inset `ICON_EDGE_INSET`px off * the physical edge (spanning to `ICON_EDGE_INSET + ICON_ZONE_WIDTH`) so the - * hover icon clears both the sidebar boundary and the widest (8px) stripe, + * hover icon clears both the sidebar boundary and the widest (10px) stripe, * sitting beside it (explicit `z-10` on the icon container guards residual * overlap; the zone's own `z-20` scopes the stack). `cursor: pointer` (menu-opener * semantics). Active on coarse pointers — touch gets direct label access. diff --git a/docs/memory/run-kit/ui-patterns.md b/docs/memory/run-kit/ui-patterns.md index b64ed7c9..88459758 100644 --- a/docs/memory/run-kit/ui-patterns.md +++ b/docs/memory/run-kit/ui-patterns.md @@ -1450,11 +1450,11 @@ The whole 26px to the left of the status dot on each window row is ONE interacti **One 26px target, opens the picker — never cycles, never selects**: clicking anywhere in the zone opens the combined Label picker anchored at the row's bottom-left (an `absolute left-0 top-full z-50` wrapper; the row root stays overflow-free so the `top-full` popover is not clipped even on a selected+double row). The click `stopPropagation`s so it does not select the row, and coexists with drag-reorder. Cursor is `pointer` (menu-opener semantics). The zone is enabled on non-ghost rows wired with both the color and marker write seams plus a `server` (`labelZoneEnabled`). -**Zone geometry**: an absolute `z-20` sibling positioned `left-0` spanning `LABEL_ZONE_WIDTH = 26`px (full row height). Because the row box is full-bleed (§ Row Anatomy), `left-0` IS the physical sidebar edge — the zone needs no negative offset, so the coordinate expression is a plain `left-0` overlay. The zone's absolute span (sidebar x = 0…26) is 26px of the true sidebar edge. The palette-icon home is a 12px band (`ICON_ZONE_WIDTH = 12`) inset `ICON_EDGE_INSET = 10`px off the physical edge (sidebar x = 10…22) — past the widest (double, 2+6=8px) marker stripe, so the hover icon sits beside the stripe rather than over it. The button content is padded clear with `pl-[30px]` (12px hierarchy indent + 18px content padding) so the leading StatusDot + name start 4px past the zone's inner edge — the dot keeps its own hover-card and click-to-select, and the dot + name keep their exact x-positions (no content shift). Active on coarse pointers — touch gets direct label access (no `coarse:pointer-events-none`). +**Zone geometry**: an absolute `z-20` sibling positioned `left-0` spanning `LABEL_ZONE_WIDTH = 26`px (full row height). Because the row box is full-bleed (§ Row Anatomy), `left-0` IS the physical sidebar edge — the zone needs no negative offset, so the coordinate expression is a plain `left-0` overlay. The zone's absolute span (sidebar x = 0…26) is 26px of the true sidebar edge. The palette-icon home is a 12px band (`ICON_ZONE_WIDTH = 12`) inset `ICON_EDGE_INSET = 12`px off the physical edge (sidebar x = 12…24) — past the widest (double, 4+6=10px) marker stripe, so the hover icon sits beside the stripe rather than over it. The button content is padded clear with `pl-[30px]` (12px hierarchy indent + 18px content padding) so the leading StatusDot + name start 4px past the zone's inner edge — the dot keeps its own hover-card and click-to-select, and the dot + name keep their exact x-positions (no content shift). Active on coarse pointers — touch gets direct label access (no `coarse:pointer-events-none`). -**Hover affordance — palette icon + two-stage zone glow**: at rest the zone is transparent with no icon. On ROW hover (`group-hover`) the shipped `PaletteIcon` (~11px, in the 12px icon zone at `left: ICON_EDGE_INSET = 10`px, beside the edge-anchored stripe rather than over it) fades to ~65% and the whole zone glows ~12% family color; when the ZONE ITSELF is hovered the icon reaches full opacity and the glow deepens to ~24% (`color-mix`). The icon is drawn in the row's guarded family color (`markerColor`) on colored rows and inherited monochrome on uncolored rows. The icon container keeps an explicit `z-10` (the stripe is a DOM sibling rendered before it) as a guard so any residual overlap layers icon-over-stripe deterministically inside the zone's own `z-20` stacking context, not by incidental sibling order. `aria-label="Set window label"` names the zone for pointer AT users and test selection. +**Hover affordance — palette icon + two-stage zone glow**: at rest the zone is transparent with no icon. On ROW hover (`group-hover`) the shipped `PaletteIcon` (~11px, in the 12px icon zone at `left: ICON_EDGE_INSET = 12`px, beside the edge-anchored stripe rather than over it) fades to ~65% and the whole zone glows ~12% family color; when the ZONE ITSELF is hovered the icon reaches full opacity and the glow deepens to ~24% (`color-mix`). The icon is drawn in the row's guarded family color (`markerColor`) on colored rows and inherited monochrome on uncolored rows. The icon container keeps an explicit `z-10` (the stripe is a DOM sibling rendered before it) as a guard so any residual overlap layers icon-over-stripe deterministically inside the zone's own `z-20` stacking context, not by incidental sibling order. `aria-label="Set window label"` names the zone for pointer AT users and test selection. -**Marker stripe is display-only**: the marker renders as a left-edge stripe (3px dotted / 3px solid / 6px double in the guarded family color, gray uncolored) via the shared `markerStripeStyle` helper, anchored near-flush at the sidebar's physical left edge — `left: STRIPE_EDGE_INSET = 2`px (`window-row.tsx:474`, `:530`), `right: 0`. `dotted` is a fixed-rhythm `repeating-linear-gradient` (3px dash / 3px gap, `background-size: 3px 100%`), NOT `border-left: dotted` — a dotted border distributes dots per-element and restarts at every row, so stacked dotted rows showed seams while solid/double merged; the gradient's 6px period divides the 24px (36px coarse) row height exactly, so the rhythm carries across adjacent rows. It is purely a display of current state — clicking it opens the picker; it does not cycle. `themes.ts` owns `MARKER_STATES = ["", "dotted", "solid", "double"]` and the `markerStripeStyle(state, color)` helper, shared by this stripe and the Label picker's marker cells so the stripe vocabulary lives in exactly one place. Markers are available on all rows including uncolored ones. +**Marker stripe is display-only**: the marker renders as a left-edge stripe (3px dotted / 3px solid / 6px double in the guarded family color, gray uncolored) via the shared `markerStripeStyle` helper, anchored near-flush at the sidebar's physical left edge — `left: STRIPE_EDGE_INSET = 4`px (`window-row.tsx:474`, `:530`), `right: 0`. `dotted` is a fixed-rhythm `repeating-linear-gradient` (3px dash / 3px gap, `background-size: 3px 100%`), NOT `border-left: dotted` — a dotted border distributes dots per-element and restarts at every row, so stacked dotted rows showed seams while solid/double merged; the gradient's 6px period divides the 24px (36px coarse) row height exactly, so the rhythm carries across adjacent rows. It is purely a display of current state — clicking it opens the picker; it does not cycle. `themes.ts` owns `MARKER_STATES = ["", "dotted", "solid", "double"]` and the `markerStripeStyle(state, color)` helper, shared by this stripe and the Label picker's marker cells so the stripe vocabulary lives in exactly one place. Markers are available on all rows including uncolored ones. **Marker/color plumbing**: `WindowInfo.marker?: string` (`src/types.ts`) carries the state from the `@rk_marker` window option (architecture.md § `@rk_marker`). `setWindowMarker(server, windowId, marker | null)` in `client.ts` delegates to `setWindowOptions({"@rk_marker": ...})` (empty/`null` clears). `sidebar/index.tsx` owns `onColorChange` + `onMarkerChange` handlers, prop-drilled through `ServerGroup` to `WindowRow`; the Label picker passes the **exact** picked color and marker state straight to these (no cycling — any state is one click). The `Window: Label` palette action opens this same picker via an imperative `label-popover:open` CustomEvent that the matching `WindowRow` listens for (`{ detail: { server, windowId } }`, mirroring the `pin-popover:open` pattern); the row registers both listeners in one `useEffect`. **Scope: window rows only** — session rows and server tiles get the owned-palette color affordance but no marker and no left-edge label zone.