puck / frontend /src /ui /Desktop.tsx
vu1n's picture
Puck — desktop fairy familiar (HF Build Small)
3c124f3
Raw
History Blame Contribute Delete
11.1 kB
// The macOS desktop scene: menu bar, dock, draggable app windows
// and their simulated contents (what Puck patrols).
import * as React from "react";
import type { EffectId } from "../engine";
import { type Pos, useDrag } from "../lib/useDrag";
export interface WinState {
claude: "running" | "done";
build: "running" | "done" | "fail";
mail: string[];
discordNoise: boolean;
discordMention: boolean;
calSoon: boolean;
}
export const initialWinState = (): WinState => ({
claude: "running",
build: "running",
mail: ["hf"],
discordNoise: true,
discordMention: false,
calSoon: false,
});
/** How an engine effect mutates the simulated windows. Pure; colocated with WinState. */
export function applyEffect(w: WinState, eff: EffectId): WinState {
switch (eff) {
case "claude_done":
return { ...w, claude: "done" };
case "build_done":
return { ...w, build: "done" };
case "build_fail":
return { ...w, build: "fail" };
case "mail_unread":
return { ...w, mail: Array.from(new Set([...w.mail, w.mail.includes("hf") ? "fin" : "hf"])) };
case "discord_mention":
return { ...w, discordMention: true };
case "discord_noise":
return { ...w, discordNoise: true };
case "calendar_soon":
return { ...w, calSoon: true };
case "none":
return w;
}
}
// ---- menu bar ---------------------------------------------------------------
export function MenuBar({
moodLabel,
puckActive,
badge,
onPuck,
clock,
}: {
moodLabel: string;
puckActive: boolean;
badge: number;
onPuck: () => void;
clock: string;
}) {
return (
<div className="menubar">
<span className="mb-apple"></span>
<span className="mb-app">Finder</span>
<span className="mb-menu">File</span>
<span className="mb-menu">Edit</span>
<span className="mb-menu">View</span>
<span className="mb-menu">Go</span>
<span className="mb-menu">Window</span>
<div className="mb-right">
<button type="button" className={`mb-puck${puckActive ? " active" : ""}`} onClick={onPuck}>
<span className="mb-puck-dot" />
<span className="mb-puck-mood">Puck · {moodLabel}</span>
{badge > 0 && <span className="badge">{badge}</span>}
</button>
<span className="mb-ico">􀙇</span>
<span className="mb-ico">􀛨</span>
<span className="mb-ico">􀊫</span>
<span className="mb-clock">{clock}</span>
</div>
</div>
);
}
// ---- dock ---------------------------------------------------------------------
export interface DockApp {
id: string;
name: string;
glyph: string;
running?: boolean;
badge?: number;
tint?: string;
}
export function Dock({ apps, onOpen }: { apps: DockApp[]; onOpen: (id: string) => void }) {
return (
<div className="dock">
{apps.map((a) => (
<div
key={a.id}
className={`dock-ico${a.running ? " run" : ""}`}
title={a.name}
onClick={() => onOpen(a.id)}
style={a.tint ? { color: a.tint } : undefined}
>
<span>{a.glyph}</span>
{(a.badge ?? 0) > 0 && <span className="dk-badge">{a.badge}</span>}
</div>
))}
</div>
);
}
// ---- generic draggable window -------------------------------------------------
function AppWindow({
id,
pos,
onPos,
w,
h,
title,
icon,
sub,
attn,
dimmed,
onFocus,
z,
children,
}: {
id: string;
pos: Pos;
onPos: (id: string, p: Pos) => void;
w: number;
h: number;
title: string;
icon: string;
sub?: string;
attn: boolean;
dimmed: boolean;
onFocus: (id: string) => void;
z: number;
children: React.ReactNode;
}) {
const drag = useDrag(pos, (p) => onPos(id, p));
return (
<div
id={`win-${id}`}
className={`win${attn ? " attn" : ""}${dimmed ? " dimmed" : ""}`}
style={{ left: pos.x, top: pos.y, width: w, height: h, zIndex: z }}
onMouseDown={() => onFocus(id)}
>
<div
className="win-head"
onMouseDown={(e) => {
onFocus(id);
drag(e);
}}
>
<div className="traffic">
<i className="r" />
<i className="y" />
<i className="g" />
</div>
<div className="win-title">
<span className="wt-ico">{icon}</span>
{title}
</div>
{sub && <div className="win-sub">{sub}</div>}
</div>
<div className="win-body">{children}</div>
</div>
);
}
// ---- window bodies --------------------------------------------------------
function ClaudeBody({ st }: { st: WinState }) {
const done = st.claude === "done";
return (
<div>
<div className="cc-line tok-dim">⏵ implement the memory garden panel</div>
<div className="cc-line">
<span className="tok-fn">edit</span> src/garden/Plot.tsx <span className="tok-dim">+48 −6</span>
</div>
<div className="cc-line">
<span className="tok-fn">edit</span> src/garden/seed.ts <span className="tok-dim">+12 −0</span>
</div>
<div className="cc-line">
<span className="tok-fn">write</span> src/garden/decay.ts <span className="tok-dim">new</span>
</div>
<div className={`cc-task${done ? " done" : ""}`}>
{done ? <span className="spin"></span> : <span className="spin" />}
<span>{done ? "Memory Garden — completed · 7 files" : "Memory Garden — running…"}</span>
<span className="pct">{done ? "done" : "····"}</span>
</div>
</div>
);
}
function TermBody({ st }: { st: WinState }) {
const s = st.build;
return (
<div className="term">
<div>
<span className="prompt">vu@workshop</span> ~/puck $ npm run build
</div>
<div className="tok-dim">vite v5.3.1 building for production…</div>
<div>
transforming modules <span className="tok-dim">·····</span>
</div>
{s === "running" && (
<>
<div>bundling…</div>
<div className="bar">
<i style={{ width: "64%" }} />
</div>
</>
)}
{s === "done" && (
<>
<div className="ok">✓ built in 2m 14s — 0 errors</div>
<div className="bar">
<i style={{ width: "100%" }} />
</div>
</>
)}
{s === "fail" && (
<>
<div className="err">✗ 3 tests failing — auth.test.ts</div>
<div className="warn"> expected 200, received 401</div>
</>
)}
</div>
);
}
interface ListRow {
from: string;
sub: string;
time: string;
unread?: boolean;
mention?: boolean;
noise?: boolean;
muted?: boolean;
av: string;
}
function MsgList({ rows }: { rows: ListRow[] }) {
return (
<div className="list">
{rows.map((r) => (
<div key={r.from + r.sub} className={`list-row${r.unread || r.mention ? " unread" : ""}`}>
<div className={`avatar${r.muted || (r.noise && !r.mention) ? " muted" : ""}`}>{r.av}</div>
<div className="lr-main">
<div className="lr-top">
<span className="lr-from">{r.from}</span>
<span className="lr-time">{r.time}</span>
</div>
<div className="lr-sub">{r.sub}</div>
</div>
<div className={`lr-dot${r.unread || r.mention ? "" : " hidden"}`} />
</div>
))}
</div>
);
}
function MailBody({ st }: { st: WinState }) {
return (
<MsgList
rows={[
{
from: "Hugging Face",
sub: "Build Smallsubmissions close Sunday",
time: "9:41",
unread: st.mail.includes("hf"),
av: "H",
},
{
from: "Fidelity",
sub: "Your June statement is ready",
time: "8:02",
unread: st.mail.includes("fin"),
av: "F",
muted: true,
},
{ from: "Mira Okafor", sub: "re: puck demo video", time: "Yest", av: "M" },
{ from: "GitHub", sub: "[puck] CI passed on main", time: "Yest", av: "G", muted: true },
]}
/>
);
}
function DiscordBody({ st }: { st: WinState }) {
return (
<MsgList
rows={[
{
from: "#general",
sub: st.discordNoise ? "12 new messages · gif wars again" : "quiet for now",
time: "now",
noise: true,
av: "#",
},
{
from: "Mira",
sub: "@you can you push the demo branch?",
time: "now",
mention: st.discordMention,
av: "M",
},
{ from: "#puck-build", sub: "anyone seen the holo weights?", time: "2m", av: "P" },
{ from: "#random", sub: "🐸🐸🐸", time: "5m", noise: true, av: "R" },
]}
/>
);
}
function CalBody({ st }: { st: WinState }) {
return (
<div>
<div className="cal-day">Today · Thu Jun 5</div>
<div className="cal-ev" style={st.calSoon ? { borderLeftColor: "#ff5b54" } : undefined}>
<div className="ce-t">Demo sync</div>
<div className="ce-time">{st.calSoon ? "in 10 min · 11:00" : "11:00 – 11:30"}</div>
</div>
<div className="cal-ev" style={{ opacity: 0.6 }}>
<div className="ce-t">Deep work — Puck UI</div>
<div className="ce-time">14:00 – 16:00</div>
</div>
</div>
);
}
// ---- the set of windows -----------------------------------------------------
interface WinDef {
id: string;
title: string;
icon: string;
sub: string;
w: number;
h: number;
Body: React.ComponentType<{ st: WinState }>;
}
export const WIN_DEFS: WinDef[] = [
{ id: "claude", title: "Claude Code", icon: "✳", sub: "Desktop 2", w: 432, h: 296, Body: ClaudeBody },
{ id: "terminal", title: "workshop — build", icon: "▦", sub: "zsh", w: 404, h: 224, Body: TermBody },
{ id: "mail", title: "Mail", icon: "✉", sub: "Inbox", w: 364, h: 318, Body: MailBody },
{ id: "discord", title: "Discord", icon: "◍", sub: "Social Bog", w: 332, h: 236, Body: DiscordBody },
{ id: "calendar", title: "Calendar", icon: "◷", sub: "", w: 252, h: 184, Body: CalBody },
];
// memo: SimApp re-renders every sprite flight; the window scene only depends on these props
export const DesktopWindows = React.memo(function DesktopWindows({
positions,
onPos,
onFocus,
focused,
attnId,
winState,
}: {
positions: Record<string, Pos>;
onPos: (id: string, p: Pos) => void;
onFocus: (id: string) => void;
focused: string | null;
attnId: string | null;
winState: WinState;
}) {
return (
<>
{WIN_DEFS.map((d, i) => (
<AppWindow
key={d.id}
id={d.id}
pos={positions[d.id]}
onPos={onPos}
w={d.w}
h={d.h}
title={d.title}
icon={d.icon}
sub={d.sub || undefined}
attn={attnId === d.id}
dimmed={!!focused && focused !== d.id && attnId !== d.id}
onFocus={onFocus}
z={focused === d.id ? 30 : 10 + i}
>
<d.Body st={winState} />
</AppWindow>
))}
</>
);
});