Build Cobble project structure from the handoff

- Remove the preset document suite (letters, emails, invoices, quotes,
  PDF generation, line items) with its routes, API handlers and tables
- Rebrand to Cobble: site data, Lato, handoff design tokens (light and
  dark) in main.css, brand assets in public/brand, ember stone logo and
  regenerated favicons
- Add bots, bot_messages, bot_tasks and bot_task_steps tables, regenerate
  DB types (BUN_SQLITE_COBBLE_*) and add a sample crew seed (seed:bots)
- Add the chat shell at /admin/bots: rail with pinned coordinator and
  user popover, bottom-anchored conversation with composer, working-on
  panel, and a send-message API scoped to the session user
- Add BotStone and Wordmark components, crew status and relative time
  utils with tests, and update AGENTS.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 17:10:36 +01:00
co-authored by Claude Opus 5.5
parent 81e4a8aecc
commit 3286b80029
340 changed files with 3161 additions and 12060 deletions
@@ -0,0 +1,34 @@
import {
Activity,
CircleHelp,
Clock,
LogOut,
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
export type RailUserMenuItem = {
title: string;
/**
* Unset for destinations that are not built yet; the item renders
* disabled.
*/
url?: string;
Icon: LucideIcon;
/**
* Rendered last, after a rule.
*/
destructive?: boolean;
};
/**
* Exactly the five popover items. Nothing else lives in the rail: these
* destinations are only reachable from here.
*/
export const RailUserMenuItems: RailUserMenuItem[] = [
{ title: "Activity", url: "/admin/logs", Icon: Activity },
{ title: "Routines", Icon: Clock },
{ title: "Settings", url: "/admin/settings", Icon: SlidersHorizontal },
{ title: "Help", Icon: CircleHelp },
{ title: "Sign out", url: "/auth/logout", Icon: LogOut, destructive: true },
];
+61
View File
@@ -0,0 +1,61 @@
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import BotStone from "@/src/components/general/bot-stone";
import type { BotWithLastMessage } from "@/src/types";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import relativeTime from "@/src/utils/relative-time";
import UnreadDot from "./unread-dot";
type Props = {
bot: BotWithLastMessage;
selected?: boolean;
/**
* Replaces the last-message snippet (the coordinator shows crew status).
*/
subtitle?: string;
};
/**
* 48px rail row. Status is the face: no badges, spinners or state dots.
* Selected is ink at 5%, never a hue.
*/
export default function BotRow({ bot, selected, subtitle }: Props) {
const hue = (bot.hue || null) as BotHue | null;
const snippet = subtitle ?? bot.last_message_text ?? bot.role ?? "";
const at = bot.last_message_at || bot.last_activity_at;
return (
<Link
href={`/admin/bots/${bot.id}`}
aria-current={selected ? "page" : undefined}
title={bot.name}
className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<BotStone hue={hue} face={(bot.face || "idle") as BotFace} />
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{bot.name}
</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{snippet}
</Span>
</Stack>
<Stack className="hidden lg:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5">
{at ? (
<Span className="text-[11px] leading-4 text-ink-secondary">
{relativeTime({ at: Number(at) })}
</Span>
) : null}
{bot.unread && hue ? <UnreadDot hue={hue} /> : null}
</Stack>
</Link>
);
}
@@ -0,0 +1,27 @@
import Divider from "@moduletrace/twui/layout/Divider";
import type { BotWithLastMessage } from "@/src/types";
import { crewStatusLine } from "@/src/utils/crew-status";
import BotRow from "./bot-row";
type Props = {
bot: BotWithLastMessage;
bots: BotWithLastMessage[];
selected?: boolean;
};
/**
* Pinned first. Hue-less, and its subtitle is the crew status, not a
* snippet.
*/
export default function CoordinatorRow({ bot, bots, selected }: Props) {
return (
<>
<BotRow
bot={bot}
selected={selected}
subtitle={crewStatusLine({ bots })}
/>
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
</>
);
}
@@ -0,0 +1,21 @@
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
/**
* 28px add-bot control. The 1px strong border is required for WCAG 1.4.11.
* Add-bot flow is not designed yet, so this has no action.
*/
export default function NewBotButton() {
return (
<Button
title="Add a bot"
color="gray"
className="hidden lg:flex size-7 p-0! rounded-default-sm bg-surface dark:bg-surface hover:bg-sunken hover:dark:bg-sunken text-ink dark:text-ink border border-solid border-hairline-strong transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Span className="relative size-3" aria-hidden>
<Span className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[1.5px] bg-ink" />
<Span className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-[1.5px] bg-ink" />
</Span>
</Button>
);
}
@@ -0,0 +1,20 @@
import Row from "@moduletrace/twui/layout/Row";
import Link from "@moduletrace/twui/layout/Link";
import Wordmark from "@/src/components/general/wordmark";
import LogoIcon from "@/src/components/general/logo-icon";
import NewBotButton from "./new-bot-button";
export default function RailHeader() {
return (
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5">
<Link
href="/admin/bots"
className="border-0 mt-1 rounded-default-xs hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Wordmark height={15} className="hidden lg:block" />
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} />
</Link>
<NewBotButton />
</Row>
);
}
@@ -0,0 +1,36 @@
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type { RailUserMenuItem } from "../(dict)/rail-user-menu-dict";
const RowClassName =
"w-full h-9 gap-2.5! flex-nowrap items-center px-4";
export default function RailUserMenuEntry({ title, url, Icon }: RailUserMenuItem) {
if (!url) {
return (
<Row
role="menuitem"
aria-disabled="true"
title={`${title} is not available yet`}
className={`${RowClassName} text-ink-disabled cursor-not-allowed`}
>
<Icon size={16} aria-hidden />
<Span className="text-sm leading-5">{title}</Span>
</Row>
);
}
return (
<Link
href={url}
role="menuitem"
className="block w-full border-0 leading-normal hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink"
>
<Row className={`${RowClassName} text-ink`}>
<Icon size={16} aria-hidden />
<Span className="text-sm leading-5 text-ink">{title}</Span>
</Row>
</Link>
);
}
@@ -0,0 +1,27 @@
import Paper from "@moduletrace/twui/elements/Paper";
import Divider from "@moduletrace/twui/layout/Divider";
import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict";
import RailUserMenuEntry from "./rail-user-menu-entry";
/**
* 220px popover: the only element in the shell with elevation.
*/
export default function RailUserMenu() {
const items = RailUserMenuItems.filter((item) => !item.destructive);
const destructive = RailUserMenuItems.filter((item) => item.destructive);
return (
<Paper
role="menu"
className="w-[220px] mb-1 p-0! py-1 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline shadow-popover! dark:shadow-popover!"
>
{items.map((item) => (
<RailUserMenuEntry key={item.title} {...item} />
))}
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
{destructive.map((item) => (
<RailUserMenuEntry key={item.title} {...item} />
))}
</Paper>
);
}
@@ -0,0 +1,61 @@
import { useContext } from "react";
import Dropdown from "@moduletrace/twui/elements/Dropdown";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { ChevronRight } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import RailUserMenu from "./rail-user-menu";
/**
* The signed-in user, opening the popover above. Its top rule sits at the
* same height as the composer rule, so the two read as one line.
*/
export default function RailUserRow() {
const { pageProps } = useContext(AppContext);
const user = pageProps?.user;
const name = user?.first_name || user?.email || "";
const initials = [user?.first_name, user?.last_name]
.filter(Boolean)
.map((part) => part!.charAt(0).toUpperCase())
.join("");
return (
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 lg:px-3 mx-0 lg:mx-3 border-0 border-t border-solid border-hairline">
<Dropdown
position="top-left"
className="w-full"
targetWrapperProps={{ className: "w-full" }}
target={
<Row
role="button"
tabIndex={0}
aria-haspopup="menu"
aria-label={`Account menu for ${name}`}
className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Span className="size-6 shrink-0 rounded-full bg-sunken flex items-center justify-center text-[11px] font-bold leading-none text-ink">
{initials}
</Span>
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{name}
</Span>
<Span className="text-[11px] leading-4 text-ink-secondary">
Free
</Span>
</Stack>
<ChevronRight
size={16}
className="hidden lg:block shrink-0 text-ink"
aria-hidden
/>
</Row>
}
>
<RailUserMenu />
</Dropdown>
</Stack>
);
}
@@ -0,0 +1,19 @@
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import type { BotHue } from "@/src/dict/bot-dict";
import { BotHueBgClasses } from "@/src/dict/bot-hue-classes-dict";
/**
* 6px unread dot in the bot's own hue. Redundant by design (the snippet and
* time carry the same information), which is why it may fail 3:1 on some
* hues. Never remove the snippet or time.
*/
export default function UnreadDot({ hue }: { hue: BotHue }) {
return (
<Span
className={twMerge("size-1.5 rounded-full mr-1", BotHueBgClasses[hue])}
aria-label="Unread"
role="img"
/>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import RailHeader from "../(partials)/rail-header";
import CoordinatorRow from "../(partials)/coordinator-row";
import BotRow from "../(partials)/bot-row";
import RailUserRow from "../(partials)/rail-user-row";
/**
* Left rail: wordmark, the pinned coordinator, then the crew. Collapses to a
* 64px icon rail below 1024px.
*/
export default function BotsRail() {
const { pageProps } = useContext(AppContext);
const bots = pageProps?.bots || [];
const selected_id = pageProps?.bot?.id;
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator");
return (
<Stack
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
aria-label="Bots"
>
<RailHeader />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1">
{coordinator ? (
<CoordinatorRow
bot={coordinator}
bots={bots}
selected={coordinator.id === selected_id}
/>
) : null}
{workers.map((bot) => (
<BotRow
key={bot.id}
bot={bot}
selected={bot.id === selected_id}
/>
))}
</Stack>
<RailUserRow />
</Stack>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
import Row from "@moduletrace/twui/layout/Row";
import BotsRail from "./(sections)/bots-rail";
/**
* Full-bleed chat shell for `/admin/bots/*`: the rail of bot conversations,
* then the page (conversation + working-on panel). No cards, no shadows;
* regions are separated by 1px hairlines.
*/
export default function BotsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Row className="w-full h-screen gap-0! flex-nowrap items-stretch overflow-hidden bg-paper text-ink">
<BotsRail />
<Row className="grow min-w-[480px] h-full gap-0! flex-nowrap items-stretch">
{children}
</Row>
</Row>
);
}