[eric] canvas: the fill/halves/quarters tiling menus are gone; the green dot is full screen or restore, nothing else

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-09-01 15:59:25 -07:00
co-authored by Claude Fable 5.1
parent d68ee068e6
commit 08f4c03a88
9 changed files with 5 additions and 103 deletions
@@ -6,7 +6,6 @@ import { useCardTiling } from './useCardTiling';
import { useCanvasWindowResize } from './useCanvasWindowResize';
import { useDragEndBackstops } from '../hooks/interaction/useDragEndBackstops';
import { openCardContextMenu, isNativeMenuTarget, type CardMenuRow } from '../desktop/openCardContextMenu';
import { tileMenuRows } from './tileMenuRows';
import type { CardType } from '@/shared/state/dashboardLayoutSlice';
const DRAG_THRESHOLD = 3;
@@ -193,7 +192,6 @@ const CanvasWindowCard: React.FC<CanvasWindowCardProps> = ({
const items: CardMenuRow[] = [
{ kind: 'header', label: selectName },
{ label: tiling.isFullscreen ? 'Exit Full Screen' : 'Full Screen', onClick: () => tiling.applyZone(tiling.isFullscreen ? 'restore' : 'fullscreen') },
{ label: 'Tile to zone', submenu: tileMenuRows(tiling.applyZone, tiling.zone) },
];
if (onMinimize) items.push({ label: minimized ? 'Restore' : 'Minimize', onClick: onMinimize });
if (onBringToFront) items.push({ label: 'Bring to front', onClick: () => onBringToFront(cardId, cardType) });
@@ -1,12 +1,10 @@
import React, { useRef, useState } from 'react';
import React from 'react';
import Box from '@mui/material/Box';
import { TILE_ZONES } from '../canvas/tiledGeometry';
import { TILE_GROUPS, ZONE_LABELS } from './tileZones';
interface WindowControlsProps {
onClose: () => void;
onMinimize: () => void;
onTile: (zone: string) => void; // a TILE_ZONES key, 'fullscreen', or 'restore'
onTile: (zone: string) => void; // 'fullscreen' or 'restore'
tiled?: boolean;
/** Onboarding anchor riding the close dot (the settings flow points its cursor here). */
closeDataOnboarding?: string;
@@ -14,8 +12,7 @@ interface WindowControlsProps {
// macOS-style traffic lights on every card = the "AI OS" window feel. Grey at rest so a canvas
// full of cards isn't a wall of color; they colorize when the parent .osw-card is hovered, and the
// close/minus/plus symbols reveal on hovering the group. Hovering the GREEN dot opens the tiling menu (Fill,
// Halves, Quarters, Thirds), exactly like macOS; clicking green = Full Screen (or restore if tiled).
// close/minus/plus symbols reveal on hovering the group. Clicking green = Full Screen (or restore if tiled).
const RED = '#ff5f57';
const YELLOW = '#febc2e';
const GREEN = '#28c840';
@@ -50,21 +47,6 @@ export const ARC_CHIP_SX: Record<string, unknown> = {
};
function WindowControls({ onClose, onMinimize, onTile, tiled, closeDataOnboarding }: WindowControlsProps): React.ReactElement {
const [menuOpen, setMenuOpen] = useState(false);
// Menu DOM (12 tiles + labels, ~30 nodes) mounts on first green-dot hover, not per card at boot.
const [menuHot, setMenuHot] = useState(false);
// Right-edge hosts (the minimized stack) open the menu leftward so it never clips off-window.
const [alignRight, setAlignRight] = useState(false);
const greenRef = useRef<HTMLDivElement | null>(null);
const closeTimer = useRef<number | null>(null);
const openMenu = (): void => {
if (closeTimer.current) window.clearTimeout(closeTimer.current);
const rect = greenRef.current?.getBoundingClientRect();
if (rect) setAlignRight(rect.left + 224 > window.innerWidth);
if (!menuHot) { setMenuHot(true); requestAnimationFrame(() => setMenuOpen(true)); return; }
setMenuOpen(true);
};
const scheduleClose = (): void => { closeTimer.current = window.setTimeout(() => setMenuOpen(false), 550); };
const stop = (e: React.PointerEvent | React.MouseEvent): void => { e.stopPropagation(); };
const btn = (color: string, symbol: string, onClick: () => void, label: string, slot: string): React.ReactElement => (
@@ -76,7 +58,7 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, closeDataOnboardin
);
return (
<Box className="osw-window-lights" data-tilemenu-open={menuOpen ? '1' : undefined} onPointerDown={stop}
<Box className="osw-window-lights" onPointerDown={stop}
sx={{
display: 'flex', gap: '8px', alignItems: 'center', flex: 'none', '&:hover span': { opacity: 1 },
// Inert until the card is hovered: crossing a card can't hit-test or fire React enter/leave
@@ -85,44 +67,12 @@ function WindowControls({ onClose, onMinimize, onTile, tiled, closeDataOnboardin
}}>
{btn(RED, '×', onClose, 'Close', 'close')}
{btn(YELLOW, '−', onMinimize, 'Minimize', 'minimize')}
<Box ref={greenRef} data-light="zoom" sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}
onMouseEnter={openMenu} onMouseLeave={scheduleClose}>
<Box data-light="zoom" sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Box component="button" type="button" aria-label={tiled ? 'Exit Full Screen' : 'Full Screen'}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onTile(tiled ? 'restore' : 'fullscreen'); }}
onPointerDown={stop} sx={dotSx(GREEN)}>
<span>{tiled ? '−' : '+'}</span>
</Box>
{menuHot && <Box className="osw-tilemenu" onPointerDown={stop} onClick={stop}
onMouseEnter={openMenu} onMouseLeave={scheduleClose}
sx={{
position: 'absolute', top: 19, width: 216, background: '#FFFFFF',
// Invisible runway over the button-to-menu gap: without it the pointer crosses dead
// space, mouseleave fires, and the menu vanishes mid-approach ("baited").
'&::before': { content: '""', position: 'absolute', top: -8, left: 0, right: 0, height: 8 },
...(alignRight ? { right: -8 } : { left: -8 }),
border: '1px solid rgba(0,0,0,0.06)', borderRadius: '12px', boxShadow: '0 .5rem 2rem rgba(0,0,0,.14)',
p: 1.25, zIndex: 1200, transformOrigin: alignRight ? 'top right' : 'top left',
opacity: menuOpen ? 1 : 0, transform: menuOpen ? 'none' : 'translateY(-6px) scale(0.96)',
pointerEvents: menuOpen ? 'auto' : 'none', transition: 'opacity .16s, transform .18s cubic-bezier(.3,.9,.3,1)',
}}>
{TILE_GROUPS.map((g) => (
<Box key={g.label} sx={{ mb: 0.75, '&:last-of-type': { mb: 0 } }}>
<Box sx={{ fontSize: '0.625rem', fontWeight: 700, letterSpacing: '0.08em', color: 'rgba(115,114,108,0.65)', textTransform: 'uppercase', mb: 0.5 }}>{g.label}</Box>
<Box sx={{ display: 'flex', gap: '7px' }}>
{g.zones.map((zone) => {
const z = TILE_ZONES[zone];
return (
<Box key={zone} role="button" aria-label={ZONE_LABELS[zone]}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setMenuOpen(false); onTile(zone); }}
sx={{ position: 'relative', flex: 1, height: 32, border: '1px solid rgba(0,0,0,0.08)', borderRadius: '6px', background: '#F5F4ED', cursor: 'pointer', overflow: 'hidden', transition: 'border-color .12s, background .12s', '&:hover': { borderColor: '#ae5630', background: '#ae56300d' } }}>
<Box sx={{ position: 'absolute', left: `${z.x * 100 + 8}%`, top: `${z.y * 100 + 14}%`, width: `${z.w * 100 - 16}%`, height: `${z.h * 100 - 28}%`, background: '#ae5630', opacity: 0.8, borderRadius: '2px' }} />
</Box>
);
})}
</Box>
</Box>
))}
</Box>}
</Box>
</Box>
);
@@ -8,7 +8,6 @@ import { handleSlashCommand } from '@/app/pages/AgentChat/ChatInput/hooks/slashC
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord';
import { tileMenuRows } from './tileMenuRows';
interface AgentMenuArgs {
session: AgentSession;
@@ -26,7 +25,6 @@ export function agentCardMenuRows({ session, dispatch, expanded, tileZone, expan
return [
{ label: expanded ? 'Collapse' : 'Open', shortcut: chord('enter'), onClick: () => dispatch(expanded ? collapseSession(session.id) : expandSession(session.id)) },
{ label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') },
{ label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) },
{ label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: session.id, type: 'agent' })) },
{ kind: 'separator' },
{
@@ -4,7 +4,6 @@ import { setClipboardCards } from '@/shared/dashboardClipboard';
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord';
import { tileMenuRows } from './tileMenuRows';
interface BrowserNav {
reload: () => void;
@@ -49,7 +48,6 @@ export function browserCardMenuRows({
{ label: 'Copy URL', disabled: !activeUrl, onClick: () => { void navigator.clipboard.writeText(activeUrl); } },
{ kind: 'separator' },
{ label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') },
{ label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) },
{ label: isMinimized ? 'Restore' : 'Minimize', onClick: onMinimize },
{ label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: browserId, type: 'browser' })) },
{
@@ -1,15 +0,0 @@
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { TILE_GROUPS, ZONE_LABELS } from './tileZones';
// The green-dot tiling grid, spelled out as words for the right-click path. Same catalog, so the two
// surfaces always offer the same zones.
export function tileMenuRows(onTile: (zone: string) => void, currentZone?: string): CardMenuRow[] {
const rows: CardMenuRow[] = [];
for (const group of TILE_GROUPS) {
rows.push({ kind: 'header', label: group.label });
for (const zone of group.zones) {
rows.push({ label: ZONE_LABELS[zone], checked: currentZone === zone, onClick: () => onTile(zone) });
}
}
return rows;
}
@@ -1,14 +0,0 @@
// The zone catalog the tiling MENUS render. The geometry itself lives in canvas/tiledGeometry.
export const ZONE_LABELS: Record<string, string> = {
fill: 'Fill', left: 'Left half', right: 'Right half', top: 'Top half', bottom: 'Bottom half',
tl: 'Top left', tr: 'Top right', bl: 'Bottom left', br: 'Bottom right',
};
// The one grid every tiling surface renders: the green-dot hover menu and the right-click "Tile to
// zone" submenu both map this, so they can never drift apart on which zones exist. Thirds were cut
// (Eric 2026-08-09): the geometry still restores an old t3* card fine, the menus just stop offering it.
export const TILE_GROUPS: { label: string; zones: string[] }[] = [
{ label: 'Fill and halves', zones: ['fill', 'left', 'right', 'top', 'bottom'] },
{ label: 'Quarters', zones: ['tl', 'tr', 'bl', 'br'] },
];
@@ -6,7 +6,6 @@ import { API_BASE } from '@/shared/config';
import type { AppDispatch } from '@/shared/state/store';
import type { CardMenuRow } from '../desktop/openCardContextMenu';
import { chord } from '../desktop/chord';
import { tileMenuRows } from './tileMenuRows';
interface ViewMenuArgs {
output: Output;
@@ -31,7 +30,6 @@ export function viewCardMenuRows({
{ label: 'Open another instance', onClick: () => dispatch(addViewCard({ outputId: output.id, newInstance: true })) },
{ kind: 'separator' },
{ label: tileZone === 'fullscreen' ? 'Exit Full Screen' : 'Full Screen', onClick: () => onTile(tileZone === 'fullscreen' ? 'restore' : 'fullscreen') },
{ label: 'Tile to zone', submenu: tileMenuRows(onTile, tileZone) },
{ label: isMinimized ? 'Restore' : 'Minimize', onClick: onMinimize },
{ label: 'Bring to front', onClick: () => dispatch(bringToFront({ id: cardKey, type: 'view' })) },
{ kind: 'separator' },
@@ -12,7 +12,6 @@ import { dropMinimizedShot } from './minimizedShots';
import { buildMinimizedEntries, MinimizedEntry, MinimizedRect } from './minimizedEntries';
import MinimizedTile, { MINIMIZED_TILE_W } from './MinimizedTile';
import { openCardContextMenu } from './openCardContextMenu';
import { tileMenuRows } from '../cards/tileMenuRows';
import type { BrowserCardPosition, ViewCardPosition } from '@/shared/state/dashboardLayoutSlice';
import type { Output } from '@/shared/state/outputsSlice';
@@ -113,9 +112,6 @@ function MinimizedStack({ browserCards, viewCards, outputs, selectedIds, onResto
items: [
{ label: 'Restore', onClick: () => restore(entry) },
{ label: 'Restore full screen', onClick: () => tile(entry, 'fullscreen') },
...(entry.kind === 'browser' || entry.kind === 'view'
? [{ label: 'Tile to zone', submenu: tileMenuRows((zone) => tile(entry, zone)) }]
: []),
{ kind: 'separator' },
{ label: 'Close', danger: true, onClick: () => close(entry) },
],
@@ -5,10 +5,3 @@ body.dashboard-marquee-active * {
-webkit-backdrop-filter: none !important;
}
/* While a traffic-light tile menu is open, its lights cluster stays visible and interactive even
when the hover that revealed it has moved on (to the menu itself); otherwise the menu fades
mid-approach and the click lands on nothing. */
.osw-pill-lights:has(.osw-window-lights[data-tilemenu-open="1"]) {
opacity: 1 !important;
pointer-events: auto !important;
}