Computer panel: absolute overlay in ALL states + proportional widths + rail collapse
The previous pass left 'full' as an in-flow flex sibling, so it fought the chat for layout space and overlapped (chat crushed to a sliver, panel relative on top). Now the panel is an absolute right-anchored overlay in EVERY state (never in-flow) — the chat keeps full width and never reflows; opening the panel just covers the right N%: - phone → md:w-[448px] - tablet → md:w-[67%] (≈ the measured 847px at 1440) - full → md:w-full (covers everything right of the rail) Transition is now plain transition-[width] on the absolute aside. Rail collapse in full mode: LeftRail reads ?device, and when the panel is open in 'full' it collapses 176→80px, icon-only — labels/credits/user-text hidden (md+) via a group/rail data-collapsed flag, animated in parallel with the panel via transition-[transform,width]. Verified at 1440: tablet panel=847 rail=176, phone=448, full panel=1360 rail=80; chat composer.left stays 472 in phone/tablet (no shift). 24px card inset + 32px radius kept throughout. 86 unit + 31 functional E2E green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
dab08e47aa
commit
4d9bcc74ec
@@ -15,20 +15,19 @@ import { clawThemeStyle, WallpaperSurface } from "./ClawTheme";
|
|||||||
import { HomeScreen } from "./HomeScreen";
|
import { HomeScreen } from "./HomeScreen";
|
||||||
import { AppRouter, appTitle } from "./AppRouter";
|
import { AppRouter, appTitle } from "./AppRouter";
|
||||||
|
|
||||||
/* Device-state sizing + positioning model (measured). Phone/tablet anchor
|
/* Device-state sizing + positioning model (measured at 1440). The panel is
|
||||||
the panel as an ABSOLUTE right-edge overlay so the chat keeps its full
|
ALWAYS an absolute, right-anchored overlay (never an in-flow sibling) so
|
||||||
width and doesn't reflow when the panel opens (448/550px aside → 400/502
|
the chat keeps its full width and never reflows — opening the panel just
|
||||||
card). Only "full" is an in-flow flex sibling that lets the chat shrink
|
covers the right N% of the chat. Widths are proportional to the post-rail
|
||||||
(basis-0 grow-[4] → fluid, fills the row). The card is w-full inside a
|
area: phone 448px, tablet 67% (≈ the measured 847px), full 100% (and in
|
||||||
px-6 wrapper, so card width = aside − 48. */
|
full the rail collapses to 80px in parallel, giving the panel the room).
|
||||||
|
The card stays w-full inside the px-6 wrapper → 24px inset + 32px radius
|
||||||
|
throughout. */
|
||||||
const OVERLAY = "md:absolute md:top-0 md:right-0 md:z-30 md:h-full";
|
const OVERLAY = "md:absolute md:top-0 md:right-0 md:z-30 md:h-full";
|
||||||
const SIZING: Record<DeviceSize, { open: string; closed: string }> = {
|
const WIDTH: Record<DeviceSize, string> = {
|
||||||
phone: { open: `${OVERLAY} md:w-[448px]`, closed: `${OVERLAY} md:w-0` },
|
phone: "md:w-[448px]",
|
||||||
tablet: { open: `${OVERLAY} md:w-[550px]`, closed: `${OVERLAY} md:w-0` },
|
tablet: "md:w-[67%]",
|
||||||
full: {
|
full: "md:w-full",
|
||||||
open: "md:relative md:basis-0 md:grow-[4] md:shrink-0",
|
|
||||||
closed: "md:relative md:basis-0 md:grow-0",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The right-hand "Computer" slide-out (§7): a per-agent themed screen card
|
/** The right-hand "Computer" slide-out (§7): a per-agent themed screen card
|
||||||
@@ -54,9 +53,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
|
|||||||
if (
|
if (
|
||||||
!open &&
|
!open &&
|
||||||
event.target === event.currentTarget &&
|
event.target === event.currentTarget &&
|
||||||
(event.propertyName === "width" ||
|
event.propertyName === "width"
|
||||||
event.propertyName === "flex-basis" ||
|
|
||||||
event.propertyName === "flex-grow")
|
|
||||||
) {
|
) {
|
||||||
setMounted(false);
|
setMounted(false);
|
||||||
}
|
}
|
||||||
@@ -82,8 +79,8 @@ export function DevicePanel({ agent }: { agent: Agent }) {
|
|||||||
aria-label="Computer"
|
aria-label="Computer"
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
onTransitionEnd={handleTransitionEnd}
|
onTransitionEnd={handleTransitionEnd}
|
||||||
className={`pointer-events-none overflow-hidden transition-[width,flex-basis,flex-grow] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh ${
|
className={`pointer-events-none overflow-hidden transition-[width] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh ${OVERLAY} ${
|
||||||
open ? SIZING[device].open : SIZING[device].closed
|
open ? WIDTH[device] : "md:w-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function AgentRosterItem({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`max-w-full truncate text-xs transition-colors duration-(--duration-normal) ease-app ${
|
className={`max-w-full truncate text-xs transition-colors duration-(--duration-normal) ease-app md:group-data-[collapsed=true]/rail:hidden ${
|
||||||
active
|
active
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground group-hover:text-foreground"
|
: "text-muted-foreground group-hover:text-foreground"
|
||||||
@@ -58,6 +58,7 @@ export function AgentRosterItem({
|
|||||||
{agent.name}
|
{agent.name}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="md:group-data-[collapsed=true]/rail:hidden">
|
||||||
<AgentRowMenu
|
<AgentRowMenu
|
||||||
agentId={agent.id}
|
agentId={agent.id}
|
||||||
agentName={agent.name}
|
agentName={agent.name}
|
||||||
@@ -65,5 +66,6 @@ export function AgentRosterItem({
|
|||||||
onTogglePin={onTogglePin}
|
onTogglePin={onTogglePin}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function GlobalNavItem({
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className={`flex h-8 items-center gap-3 rounded-full px-3 text-sm transition-colors duration-(--duration-normal) ease-app ${
|
className={`flex h-8 items-center gap-3 rounded-full px-3 text-sm transition-colors duration-(--duration-normal) ease-app md:group-data-[collapsed=true]/rail:justify-center md:group-data-[collapsed=true]/rail:px-0 ${
|
||||||
active
|
active
|
||||||
? "text-foreground"
|
? "text-foreground"
|
||||||
: "text-muted-foreground hover:bg-hover-bg hover:text-foreground"
|
: "text-muted-foreground hover:bg-hover-bg hover:text-foreground"
|
||||||
@@ -36,9 +36,14 @@ export function GlobalNavItem({
|
|||||||
size={16}
|
size={16}
|
||||||
className={`shrink-0 ${active ? "text-coral" : ""}`}
|
className={`shrink-0 ${active ? "text-coral" : ""}`}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate md:group-data-[collapsed=true]/rail:hidden">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
{trailing != null && (
|
{trailing != null && (
|
||||||
<span aria-hidden className="ml-auto text-sm">
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="ml-auto text-sm md:group-data-[collapsed=true]/rail:hidden"
|
||||||
|
>
|
||||||
{trailing}
|
{trailing}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,14 +2,20 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useQueryStates } from "nuqs";
|
||||||
import { Menu, PawPrint, Plus, X } from "lucide-react";
|
import { Menu, PawPrint, Plus, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { Agent, User } from "@/lib/api/schemas";
|
import type { Agent, User } from "@/lib/api/schemas";
|
||||||
|
import { panelParsers } from "@/lib/url/panel-params";
|
||||||
import { RosterList } from "./RosterList";
|
import { RosterList } from "./RosterList";
|
||||||
import { ShellNav } from "./ShellNav";
|
import { ShellNav } from "./ShellNav";
|
||||||
import { UserMenu } from "./UserMenu";
|
import { UserMenu } from "./UserMenu";
|
||||||
|
|
||||||
|
/* Labels hide (md+) when the rail collapses in full-panel mode. Applied to
|
||||||
|
every text node via the group/rail data flag on the aside. */
|
||||||
|
const HIDE_WHEN_COLLAPSED = "md:group-data-[collapsed=true]/rail:hidden";
|
||||||
|
|
||||||
interface LeftRailProps {
|
interface LeftRailProps {
|
||||||
user: User;
|
user: User;
|
||||||
roster: Agent[];
|
roster: Agent[];
|
||||||
@@ -22,6 +28,10 @@ interface LeftRailProps {
|
|||||||
export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
|
export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [{ app, device }] = useQueryStates(panelParsers, { shallow: true });
|
||||||
|
// In full-panel mode the rail collapses to an 80px icon-only strip (md+),
|
||||||
|
// handing the extra room to the panel overlay.
|
||||||
|
const collapsed = app !== null && device === "full";
|
||||||
// Close the drawer when the route changes (render-phase prev-state
|
// Close the drawer when the route changes (render-phase prev-state
|
||||||
// pattern — no effect, no ref).
|
// pattern — no effect, no ref).
|
||||||
const [seenPath, setSeenPath] = useState(pathname);
|
const [seenPath, setSeenPath] = useState(pathname);
|
||||||
@@ -49,13 +59,14 @@ export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<aside
|
<aside
|
||||||
className={`z-50 flex h-dvh w-rail shrink-0 flex-col px-1 transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:bg-background max-md:shadow-edge ${
|
data-collapsed={collapsed ? "true" : "false"}
|
||||||
|
className={`group/rail z-50 flex h-dvh w-rail shrink-0 flex-col px-1 transition-[transform,width] duration-(--duration-normal) ease-app max-md:fixed max-md:bg-background max-md:shadow-edge ${
|
||||||
open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
|
open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
|
||||||
}`}
|
} ${collapsed ? "md:w-20" : ""}`}
|
||||||
>
|
>
|
||||||
{/* The logo lives in its own 80px header row (measured): a 48px
|
{/* The logo lives in its own 80px header row (measured): a 48px
|
||||||
black squircle brand tile with our coral mark. */}
|
black squircle brand tile with our coral mark. */}
|
||||||
<header className="flex h-20 items-center pl-4">
|
<header className="flex h-20 items-center pl-4 md:group-data-[collapsed=true]/rail:justify-center md:group-data-[collapsed=true]/rail:pl-0">
|
||||||
<Link href="/" aria-label="clawmates" className="inline-flex">
|
<Link href="/" aria-label="clawmates" className="inline-flex">
|
||||||
<span className="flex size-12 items-center justify-center rounded-2xl bg-black">
|
<span className="flex size-12 items-center justify-center rounded-2xl bg-black">
|
||||||
<PawPrint aria-hidden size={26} className="text-coral" />
|
<PawPrint aria-hidden size={26} className="text-coral" />
|
||||||
@@ -87,7 +98,9 @@ export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
|
|||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground transition-colors duration-(--duration-normal) ease-app group-hover:text-foreground">
|
<span
|
||||||
|
className={`text-xs text-muted-foreground transition-colors duration-(--duration-normal) ease-app group-hover:text-foreground ${HIDE_WHEN_COLLAPSED}`}
|
||||||
|
>
|
||||||
New claw
|
New claw
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ export function UserMenu({ user }: { user: User }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-t border-border px-2 pt-3">
|
<div className="flex items-center gap-2 border-t border-border px-2 pt-3 md:group-data-[collapsed=true]/rail:justify-center">
|
||||||
<Avatar name={user.display_name} size="sm" />
|
<Avatar name={user.display_name} size="sm" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 md:group-data-[collapsed=true]/rail:hidden">
|
||||||
<p className="truncate text-xs text-foreground">{user.display_name}</p>
|
<p className="truncate text-xs text-foreground">{user.display_name}</p>
|
||||||
<p className="truncate text-xxs text-muted-foreground">{user.role}</p>
|
<p className="truncate text-xxs text-muted-foreground">{user.role}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,7 +29,7 @@ export function UserMenu({ user }: { user: User }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
className="rounded-(--radius-button) px-2 py-1 text-xxs text-muted-foreground hover:bg-surface-warm hover:text-foreground disabled:opacity-50"
|
className="rounded-(--radius-button) px-2 py-1 text-xxs text-muted-foreground hover:bg-surface-warm hover:text-foreground disabled:opacity-50 md:group-data-[collapsed=true]/rail:hidden"
|
||||||
>
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user