feat: merged title bar — drag bands, owner's mark, title-width rename hit-box
The system title bar disappears (titleBarStyle hiddenInset +
trafficLightPosition {16,24}): the app's own 60px header bands become the
window's drag surface, and the traffic lights float over the sidebar's
mark band, vertically centered on the same line as the chat header's
phase pill (pixel-verified: circle centers y 24–36.5pt, pill box center
30pt).
- electron/main.cjs: hiddenInset chrome; dev shell (LH_DEV_URL) gets its
own userData (-dev suffix) so it never fights the packaged app's
single-instance lock — verified: the lock silently killed the dev
shell while the packaged app was open (that also proved the lock works
across separately-launched builds).
- app/globals.css: .lh-electron-gated utilities — drag/no-drag bands,
92px traffic-light gutter (.lh-lights-gutter / .lh-closed-toggle:
lights end ~75pt + the lights' own 16px edge inset as the gap),
home's floating strip becomes a drag surface only in the shell.
Everything is inert in the plain browser.
- sidebar.tsx: SidebarProvider sets <html class=lh-electron> post-mount
(UA detection; no hydration mismatch, absent in browser); LH wordmark
text replaced by the owner's mark (components/lh-mark.tsx, inlined
icon-no-padding.svg, 24px, home link kept); SidebarToggle gains the
92px margin when closed so it never slides under the static lights.
- chat.tsx: the rename hit-box shrinks to the title text itself —
flex-1 spanned the whole bar, so every click started a rename and the
window lost its grip. Shrink-to-fit + a flex-1 drag spacer (chips stay
right) + hover:bg-accent affordance (clear when hover renames vs
drags; -mx/px cancel for zero shift). The renaming input keeps flex-1
for edit room.
- home.tsx / new-session.tsx / phase-divider.tsx: drag bands + carve-outs.
Live-verified in the dev shell (pixel analysis) and headless browser:
lights/pill/mark centered, 17pt lights→mark gap, rename click→select-all
→Escape roundtrip, browser layout untouched.
Gates: typecheck, eslint, 500/500 tests, build.
This commit is contained in:
@@ -305,3 +305,39 @@ body {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Electron merged chrome (owner, 2026-09-17). The system title bar is
|
||||
hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's
|
||||
own 60px header bands are the drag surface. Everything here is inert
|
||||
in the plain browser — it keys off <html class="lh-electron">, which
|
||||
SidebarProvider sets after mount when it detects the Electron shell.
|
||||
|
||||
- .lh-drag window-drag region (must sit on pointer-receiving els)
|
||||
- .lh-no-drag carve-out for interactive elements inside a drag band
|
||||
- .lh-lights-gutter the sidebar LH band's traffic-light gutter: lights
|
||||
end at x≈68, the wordmark starts at 80px (5rem)
|
||||
- .lh-closed-toggle same gutter for the sidebar toggle when the sidebar
|
||||
is closed — the lights are static, the toggle moves
|
||||
- .lh-float-drag the home header strip floats pointer-events-none over
|
||||
the graph; only in Electron does it claim events and
|
||||
become the drag surface for that page
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
.lh-electron .lh-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.lh-electron .lh-no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
/* 92px = measured lights' right edge (~75pt) + the lights' own 16px
|
||||
inset from the window edge — comfortable, symmetric air (owner). */
|
||||
.lh-electron .lh-lights-gutter {
|
||||
padding-left: 92px;
|
||||
}
|
||||
.lh-electron .lh-closed-toggle {
|
||||
margin-left: 92px;
|
||||
}
|
||||
.lh-electron .lh-float-drag {
|
||||
pointer-events: auto;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
+10
-5
@@ -637,7 +637,7 @@ export function Chat({
|
||||
60px is an ALIGNMENT, not just a size: it equals the sidebar's
|
||||
LH wordmark band, so the header's border-b continues that
|
||||
band's bottom edge as one line across the app (owner, round 6). */}
|
||||
<header className="flex h-15 items-center gap-3 border-b border-border px-3">
|
||||
<header className="lh-drag flex h-15 items-center gap-3 border-b border-border px-3">
|
||||
<SidebarToggle />
|
||||
{renaming ? (
|
||||
<input
|
||||
@@ -651,9 +651,10 @@ export function Chat({
|
||||
onBlur={() => void commitRename(titleDraft)}
|
||||
spellCheck={false}
|
||||
aria-label="Session title"
|
||||
className="min-w-0 flex-1 bg-transparent font-title text-3xl tracking-tight caret-current outline-none"
|
||||
className="lh-no-drag min-w-0 flex-1 bg-transparent font-title text-3xl tracking-tight caret-current outline-none"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="Rename session"
|
||||
@@ -661,10 +662,14 @@ export function Chat({
|
||||
setTitleDraft(titleText);
|
||||
setRenaming(true);
|
||||
}}
|
||||
className="min-w-0 flex-1 cursor-text truncate text-left font-title text-3xl tracking-tight"
|
||||
className="lh-no-drag -mx-1.5 min-w-4 shrink cursor-text truncate rounded-md px-1.5 text-left font-title text-3xl tracking-tight transition-colors hover:bg-accent"
|
||||
>
|
||||
{titleText}
|
||||
</button>
|
||||
</button>
|
||||
{/* flex-1 spacer: keeps the chips at the right edge AND stays a
|
||||
drag surface (no carve-out) — the freed bar is grippable. */}
|
||||
<div className="min-w-4 flex-1" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
{/* The plan chip (owner, plan-placement round): session-level
|
||||
structure beside the phase chip — access to the full-DAG popup
|
||||
@@ -675,7 +680,7 @@ export function Chat({
|
||||
data-testid="plan-chip"
|
||||
onClick={() => setPlanOpen(true)}
|
||||
title="Open the full plan"
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
>
|
||||
<WaypointsIcon className="size-3.5 text-primary" aria-hidden />
|
||||
Plan
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export function Home() {
|
||||
|
||||
{/* Header floats over the graph — pointer-events-none except the
|
||||
toggle, so the canvas stays draggable/hoverable around it. */}
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 flex items-center gap-2 px-3 py-2">
|
||||
<header className="lh-float-drag pointer-events-none absolute inset-x-0 top-0 flex items-center gap-2 px-3 py-2">
|
||||
<div className="pointer-events-auto">
|
||||
<SidebarToggle />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* LhMark — the app mark (owner's icon-no-padding.svg, 2026-09-17), inlined
|
||||
* as JSX so it rides along with the components (no network fetch, recolorable,
|
||||
* crisp at any size). 806×806 viewBox, no padding: sizing/padding is the
|
||||
* consumer's job. The gray path is the harness spiral; the white ring is
|
||||
* the mark's own stroke on dark backgrounds.
|
||||
*
|
||||
* Replaces the "LH" wordmark text in the sidebar band (also the traffic-light
|
||||
* gutter's resident in the merged desktop chrome).
|
||||
*/
|
||||
export function LhMark({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 806 806"
|
||||
className={className}
|
||||
style={{ fillRule: "evenodd", clipRule: "evenodd", strokeLinejoin: "round", strokeMiterlimit: 2 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,-109.002299,-109.002299)">
|
||||
<g transform="matrix(0.939904,0,0,0.939904,30.769428,30.766742)">
|
||||
<g transform="matrix(-0.232199,0,0,0.232199,1384.403797,-416.791824)">
|
||||
<g transform="matrix(0.573992,-2.142166,2.142166,0.573992,-5516.07298,4432.117471)">
|
||||
<path
|
||||
d="M1234.728,3305.937C871.642,3324.965 587,3624.915 587,3988.5C587,4365.734 893.266,4672 1270.5,4672C1647.734,4672 1954,4365.734 1954,3988.5C1954,3880.637 1928.472,3774.304 1879.503,3678.197L1794.243,3721.64C1836.356,3804.292 1858.31,3895.737 1858.31,3988.5C1858.31,4312.921 1594.921,4576.31 1270.5,4576.31C946.079,4576.31 682.69,4312.921 682.69,3988.5C682.69,3675.817 927.482,3417.86 1239.736,3401.496L1234.728,3305.937Z"
|
||||
style={{ fill: "rgb(148,148,148)" }}
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1.261034,-0,-0,1.261034,1170.373407,-2014.261188)">
|
||||
<path
|
||||
d="M1270.5,3305C893.266,3305 587,3611.266 587,3988.5C587,4365.734 893.266,4672 1270.5,4672C1647.734,4672 1954,4365.734 1954,3988.5C1954,3611.266 1647.734,3305 1270.5,3305ZM1270.5,3471.537C1555.819,3471.537 1787.463,3703.181 1787.463,3988.5C1787.463,4273.819 1555.819,4505.463 1270.5,4505.463C985.181,4505.463 753.537,4273.819 753.537,3988.5C753.537,3703.181 985.181,3471.537 1270.5,3471.537Z"
|
||||
style={{ fill: "#fff" }}
|
||||
/>
|
||||
</g>
|
||||
<g transform="matrix(1.261034,0,-0,1.261034,3139.627182,-45.007413)">
|
||||
<circle cx="1270.5" cy="3988.5" r="683.5" style={{ fill: "#fff" }} />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function DraftShell({
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* h-15 (60px) matches the chat header and the sidebar's LH wordmark
|
||||
band (owner, round 6). */}
|
||||
<header className="relative flex h-15 items-center gap-3 px-3">
|
||||
<header className="lh-drag relative flex h-15 items-center gap-3 px-3">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight">New session</div>
|
||||
<PhaseChip phase="probe" />
|
||||
|
||||
@@ -39,7 +39,7 @@ export function PhaseChip({ phase }: { phase: Phase3 }) {
|
||||
data-testid="phase-chip"
|
||||
data-phase={phase}
|
||||
title={`Session phase: ${CHIP_LABEL[phase]}`}
|
||||
className="flex shrink-0 items-center rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
className="lh-no-drag flex shrink-0 items-center rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{CHIP_LABEL[phase]}
|
||||
</span>
|
||||
|
||||
+34
-7
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { ActivitySection } from "@/components/activity-dock";
|
||||
import { LhMark } from "@/components/lh-mark";
|
||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -36,6 +37,15 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Electron shell detection (merged chrome, 2026-09-17): keys the
|
||||
// .lh-electron CSS (app/globals.css) that turns the header bands
|
||||
// into the window's drag surface. Post-mount only — server HTML
|
||||
// and first client render must agree (no hydration mismatch),
|
||||
// and in the plain browser the class never appears.
|
||||
if (typeof navigator !== "undefined" && navigator.userAgent.includes("Electron")) {
|
||||
document.documentElement.classList.add("lh-electron");
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
// Default: open (first run introduces the sidebar).
|
||||
setOpen(stored === null ? true : stored === "1");
|
||||
@@ -74,8 +84,8 @@ export function SidebarToggle() {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"sidebar-motion relative flex h-9 shrink-0 overflow-hidden duration-200",
|
||||
open ? "w-0 -mr-3" : "w-9 mr-0",
|
||||
"sidebar-motion lh-no-drag relative flex h-9 shrink-0 overflow-hidden duration-200",
|
||||
open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
@@ -245,12 +255,29 @@ function SessionsSidebar() {
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="flex h-full w-72 flex-col border-r bg-background">
|
||||
{/* LH wordmark — the owner's header; close toggle sits at the edge. */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Link href="/" className="text-sm font-bold uppercase tracking-widest hover:opacity-80">
|
||||
LH
|
||||
{/* LH wordmark — the owner's header; close toggle sits at the edge.
|
||||
Merged chrome (2026-09-17): in Electron this band is the
|
||||
window's drag surface and starts after the traffic lights
|
||||
(.lh-lights-gutter); in the browser those classes are inert. */}
|
||||
<div className="lh-drag lh-lights-gutter flex items-center justify-between px-4 py-3">
|
||||
{/* The owner's mark (2026-09-17) replaces the "LH" text — still the
|
||||
home link, still vertically centered by the band's items-center.
|
||||
The 92px .lh-lights-gutter keeps it clear of the traffic lights
|
||||
with the lights' own edge-inset as the gap. */}
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Home"
|
||||
className="lh-no-drag shrink-0 rounded-md transition-opacity hover:opacity-80"
|
||||
>
|
||||
<LhMark className="size-6" />
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" aria-label="Close sidebar" onClick={toggle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Close sidebar"
|
||||
onClick={toggle}
|
||||
className="lh-no-drag"
|
||||
>
|
||||
<PanelLeftCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,14 @@ function serverEntry() {
|
||||
}
|
||||
|
||||
// ─── Single instance: two instances would fight over the data dir ─────────
|
||||
// Dev shell (LH_DEV_URL) runs BESIDE the packaged app, not instead of it:
|
||||
// its own userData so (a) the single-instance lock — checked below — doesn't
|
||||
// silently kill it, and (b) it never reads/writes the real app's
|
||||
// localStorage, auth.json, or logs. Must run before the lock check.
|
||||
if (process.env.LH_DEV_URL) {
|
||||
app.setPath("userData", `${app.getPath("userData")}-dev`);
|
||||
}
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit();
|
||||
} else {
|
||||
@@ -176,6 +184,14 @@ function createWindow(url) {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
title: PRODUCT_NAME,
|
||||
// Merged chrome (owner, 2026-09-17): the system title bar is gone;
|
||||
// the app's own 60px header bands ARE the title bar. The traffic
|
||||
// lights float over the sidebar's LH wordmark band, vertically
|
||||
// centered on its 60px line (12px circles: y 24–36), with the LH
|
||||
// wordmark starting at x≈80 (components/sidebar.tsx gutter).
|
||||
// Dragging comes from the renderer's .lh-drag bands (app/globals.css).
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 16, y: 24 },
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
|
||||
Reference in New Issue
Block a user