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
+60
View File
@@ -0,0 +1,60 @@
import type { BotFace } from "@/src/dict/bot-dict";
import { StoneEyes } from "@/src/data/stone-geometry";
import StoneEyeDot from "./stone-eye-dot";
import StoneEyeLidded from "./stone-eye-lidded";
import StoneEyeArc from "./stone-eye-arc";
import StoneEyeFlat from "./stone-eye-flat";
type Props = {
face: BotFace;
/**
* Fill class for solid eyes.
*/
fill_class: string;
/**
* Stroke class for arc eyes.
*/
stroke_class: string;
};
/**
* The face is the status. Geometry follows the handoff face states:
* idle dots, working half-lidded, done upward arcs, asleep one flattened
* dot, needs-you dot plus arc.
*/
export default function BotStoneFace({ face, fill_class, stroke_class }: Props) {
const { left, right, centre } = StoneEyes;
switch (face) {
case "working":
return (
<>
<StoneEyeLidded {...left} className={fill_class} />
<StoneEyeLidded {...right} className={fill_class} />
</>
);
case "done":
return (
<>
<StoneEyeArc {...left} className={stroke_class} />
<StoneEyeArc {...right} className={stroke_class} />
</>
);
case "asleep":
return <StoneEyeFlat {...centre} className={fill_class} />;
case "needs-you":
return (
<>
<StoneEyeDot {...left} className={fill_class} />
<StoneEyeArc {...right} className={stroke_class} />
</>
);
default:
return (
<>
<StoneEyeDot {...left} className={fill_class} />
<StoneEyeDot {...right} className={fill_class} />
</>
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { twMerge } from "tailwind-merge";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import { BotHueFillClasses } from "@/src/dict/bot-hue-classes-dict";
import {
CoordinatorBodyPath,
StoneBodyPaths,
StoneViewBox,
} from "@/src/data/stone-geometry";
import BotStoneFace from "./bot-stone-face";
type Props = {
/**
* The bot's identity hue. Omit (or `null`) for the coordinator, which
* renders hue-less: ink body, paper eyes, inverted in dark mode.
*/
hue?: BotHue | null;
face?: BotFace;
size?: number;
/**
* Accessible name. Leave empty when a visible bot name sits beside the
* stone (the default), so the stone is decorative.
*/
label?: string;
className?: string;
};
/**
* # Bot Stone
* @description A Cobble bot avatar: a stone with exactly one flat cut and two
* eyes. Never put it in a circle, ring or badge, or add an outline or shadow.
*/
export default function BotStone({
hue,
face = "idle",
size = 32,
label,
className,
}: Props) {
const is_coordinator = !hue;
const body_path = hue ? StoneBodyPaths[hue] : CoordinatorBodyPath;
const body_class = hue ? BotHueFillClasses[hue] : "fill-ink";
const eye_fill = is_coordinator ? "fill-paper" : "fill-dark";
const eye_stroke = is_coordinator ? "stroke-paper" : "stroke-dark";
return (
<svg
width={size}
height={size}
viewBox={StoneViewBox}
role={label ? "img" : undefined}
aria-label={label || undefined}
aria-hidden={label ? undefined : true}
className={twMerge("shrink-0", className)}
>
<path d={body_path} className={body_class} />
<BotStoneFace
face={face}
fill_class={eye_fill}
stroke_class={eye_stroke}
/>
</svg>
);
}
@@ -1,131 +0,0 @@
import Loading from "@moduletrace/twui/elements/Loading";
import Select from "@moduletrace/twui/form/Select";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import type { TableType } from "@/src/types";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import Card from "@moduletrace/twui/elements/Card";
import H3 from "@moduletrace/twui/layout/H3";
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
import useStatus from "@moduletrace/twui/hooks/useStatus";
import H4 from "@moduletrace/twui/layout/H4";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import Row from "@moduletrace/twui/layout/Row";
import dayjs from "dayjs";
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
export type CopyableRecord = {
id?: number | "";
title?: string;
subject?: string;
};
type Props<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = {
table: TableType;
title: string;
onSelect?: (record: T) => void;
onClear?: () => void;
};
export default function CopyFromExistingSelect<
T extends BUN_SQLITE_BSQLITE_ALL_TYPEDEFS =
BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
>({ table, title, onSelect, onClear }: Props<T>) {
const { res: records, ready } = useAdminCrudGet<T>({
table,
});
const { open, setOpen } = useStatus();
return (
<Row>
<Button
title="Clear Form"
onClick={(e) => {
e.preventDefault();
onClear?.();
}}
variant="outlined"
color="gray"
>
Clear
</Button>
<Modal
open={open}
setOpen={setOpen}
target={
<Button
title="Duplicate Existing Record"
onClick={(e) => {
e.preventDefault();
}}
variant="outlined"
>
Create from an Existing Record
</Button>
}
className="xl:p-6"
>
<Stack className="gap-6 w-full">
<Stack className="gap-1">
<H3 className="text-2xl! font-semibold">
{title || "Select Record"}
</H3>
<Span>Select a record to duplicate</Span>
</Stack>
<Stack className="w-full items-stretch">
{records?.[0] ? (
records?.map((rec, index) => {
const date = dayjs(
twuiNumberfy(rec.created_at),
);
return (
<Card
key={index}
onClick={() => {
onSelect?.(rec);
setOpen(false);
}}
className="w-full cursor-pointer"
>
<Row className="w-full justify-between">
<Row>
<LucideIcon
name="Database"
size={17}
opacity={0.5}
/>
<H4 className="text-base! font-semibold">
{rec.title}
</H4>
</Row>
<Row>
<Span
size="smaller"
variant="faded"
className="text-nowrap"
>
{date
.toString()
.substring(0, 16)}
</Span>
</Row>
</Row>
</Card>
);
})
) : (
<EmptyContent title="No Records Found!" />
)}
</Stack>
</Stack>
</Modal>
</Row>
);
}
+11 -24
View File
@@ -1,35 +1,22 @@
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
import BotStone from "./bot-stone";
type Props = {
icon_size?: number;
img_props?: Pick<ComponentProps<"svg">, "className">;
};
/**
* The Cobble mark: the ember stone. Source of truth is
* `public/logos/logo.svg` (copied from `public/brand/icons/ember.svg`).
*/
export default function LogoIcon({ icon_size = 40, img_props }: Props) {
return (
<svg
width={icon_size}
height={icon_size}
viewBox="0 0 512 512"
role="img"
aria-label="Logo Icon"
className={twMerge("shrink-0", img_props?.className)}
>
<rect
x="16"
y="16"
width="480"
height="480"
rx="120"
className="fill-slate-900 dark:fill-slate-800"
/>
<path
d="M290 100 L144 286 H244 L222 412 L368 226 H268 Z"
strokeWidth="28"
strokeLinejoin="round"
className="fill-white stroke-white"
/>
</svg>
<BotStone
hue="ember"
size={icon_size}
label="Logo Icon"
className={img_props?.className}
/>
);
}
+15 -29
View File
@@ -1,17 +1,23 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import type { ComponentProps, PropsWithChildren } from "react";
import type Span from "@moduletrace/twui/layout/Span";
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
import Stack from "@moduletrace/twui/layout/Stack";
import LogoIcon from "./logo-icon";
import { SiteData } from "@/src/data/site-data";
import Wordmark from "./wordmark";
type Props = {
icon_size?: number;
text_wrapper_props?: ComponentProps<typeof Stack>;
/**
* Only `className` applies: it sets the wordmark colour.
*/
text_props?: ComponentProps<typeof Span>;
};
/**
* Horizontal lockup: the ember stone beside the `cobble` wordmark.
*/
export default function Logo({
icon_size = 36,
text_props,
@@ -19,38 +25,18 @@ export default function Logo({
}: Props) {
return (
<a href="/" className="logo-link">
<Row className="gap-3! flex-nowrap items-center">
<Row className="gap-2.5! flex-nowrap items-center">
<LogoIcon icon_size={icon_size} />
<Stack
{...text_wrapper_props}
className={twMerge("gap-1!", text_wrapper_props?.className)}
className={twMerge("gap-0!", text_wrapper_props?.className)}
>
<Stack className="gap-0!">
<HeroText text_props={text_props}>
{SiteData["SiteName"]}
</HeroText>
</Stack>
<Wordmark
height={Math.round(icon_size * 0.5)}
className={text_props?.className}
/>
</Stack>
</Row>
</a>
);
}
function HeroText({
text_props,
children,
}: PropsWithChildren & {
text_props?: ComponentProps<typeof Span>;
}) {
return (
<Span
{...text_props}
className={twMerge(
"text-[17px] font-semibold tracking-tight text-dark dark:text-white heading leading-[24px] whitespace-nowrap",
text_props?.className,
)}
>
{children}
</Span>
);
}
-107
View File
@@ -1,107 +0,0 @@
import Loading from "@moduletrace/twui/elements/Loading";
import Center from "@moduletrace/twui/layout/Center";
import Span from "@moduletrace/twui/layout/Span";
import { useEffect, useState } from "react";
type Props = {
url: string;
body: any;
download_name?: string;
height?: number;
};
export default function PdfPreview({
url,
body,
download_name,
height = 1200,
}: Props) {
const [blob_url, setBlobUrl] = useState<string>();
const [error, setError] = useState<string>();
useEffect(() => {
let object_url: string | undefined;
let cancelled = false;
setBlobUrl(undefined);
setError(undefined);
(async () => {
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body || {}),
});
const content_type = res.headers.get("Content-Type") || "";
if (content_type.includes("application/json")) {
const json = await res.json();
throw new Error(json.msg || "Couldn't generate PDF");
}
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
const blob = await res.blob();
if (cancelled) return;
object_url = URL.createObjectURL(blob);
setBlobUrl(object_url);
} catch (error: any) {
if (!cancelled) {
setError(error.message || "Couldn't generate PDF preview");
}
}
})();
return () => {
cancelled = true;
if (object_url) {
URL.revokeObjectURL(object_url);
}
};
}, [url]);
if (error) {
return (
<Center className="py-10">
<Span className="text-warning">{error}</Span>
</Center>
);
}
if (!blob_url) {
return (
<Center className="py-10">
<Loading />
</Center>
);
}
return (
<div className="w-full flex flex-col gap-2 items-end">
{download_name ? (
<a
href={blob_url}
download={download_name}
className="text-sm text-link-500 hover:opacity-70 underline"
>
Download PDF
</a>
) : null}
<iframe
src={blob_url}
title="PDF Preview"
className="w-full rounded-default border border-slate-300 dark:border-white/20 bg-white"
style={{ height }}
/>
</div>
);
}
@@ -1,185 +0,0 @@
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
import Input from "@moduletrace/twui/form/Input";
import Button from "@moduletrace/twui/layout/Button";
import Paper from "@moduletrace/twui/elements/Paper";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Border from "@moduletrace/twui/elements/Border";
import { formatNaira } from "@/src/utils/money";
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
export type SortableLineItemFormObject = {
description?: string;
quantity?: string;
/** Naira amount as a plain number string (converted to kobo on submit). */
unit_price?: string;
/** Client-side row identity for stable React keys during reorder. */
_key?: string;
};
type Props<T extends SortableLineItemFormObject> = {
item: T;
index: number;
placeholder: string;
is_dragging: boolean;
is_drop_target: boolean;
/** Whether a drag is currently in progress anywhere in the list. */
drag_active: boolean;
can_move_up: boolean;
can_move_down: boolean;
onChange: (item: T) => void;
onRemove: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onDragStart: () => void;
onDragOver: () => void;
onDragLeave: () => void;
/** `drop_after` is true when dropped on the lower half of this row. */
onDrop: (drop_after: boolean) => void;
onDragEnd: () => void;
};
export default function SortableLineItemRow<
T extends SortableLineItemFormObject,
>({
item,
index,
placeholder,
is_dragging,
is_drop_target,
drag_active,
can_move_up,
can_move_down,
onChange,
onRemove,
onMoveUp,
onMoveDown,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
}: Props<T>) {
const line_total_kobo =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0) * 100;
return (
<Paper
className={[
"w-full gap-3!",
is_dragging && "opacity-60",
is_drop_target && "ring-2 ring-primary/70",
]
.filter(Boolean)
.join(" ")}
onDragOver={(e) => {
if (!drag_active) return;
e.preventDefault();
onDragOver();
}}
onDragLeave={() => {
if (drag_active) onDragLeave();
}}
onDrop={(e) => {
if (!drag_active) return;
e.preventDefault();
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
onDrop(e.clientY - rect.top > rect.height / 2);
}}
>
<Row className="w-full justify-between items-center gap-2!">
<Row className="gap-2! items-center">
<Span
title="Drag to reorder"
className="cursor-grab select-none p-1 -m-1!"
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<LucideIcon name="GripVertical" size={16} />
</Span>
<Span className="text-xs font-semibold opacity-60">
Item #{index + 1}
</Span>
</Row>
<Row className="gap-1! items-center">
<Button
title="Move Up"
variant="ghost"
color="gray"
size="smaller"
disabled={!can_move_up}
onClick={onMoveUp}
>
<LucideIcon name="ArrowUp" size={14} />
</Button>
<Button
title="Move Down"
variant="ghost"
color="gray"
size="smaller"
disabled={!can_move_down}
onClick={onMoveDown}
>
<LucideIcon name="ArrowDown" size={14} />
</Button>
<Button
title="Remove Line Item"
variant="ghost"
color="error"
size="smaller"
onClick={onRemove}
>
<LucideIcon name="Trash2" size={14} />
</Button>
</Row>
</Row>
<Input
title="Description"
placeholder={placeholder}
defaultValue={item.description}
onChange={(e) => {
onChange({ ...item, description: e.target.value });
}}
showLabel
/>
<Row className="w-full grid xl:grid-cols-3 items-stretch">
<Input
title="Quantity"
type="number"
min={1}
defaultValue={item.quantity}
onChange={(e) => {
onChange({ ...item, quantity: e.target.value });
}}
showLabel
/>
<Input
title="Unit Price (₦)"
type="number"
step="0.01"
min={0}
defaultValue={item.unit_price}
onChange={(e) => {
onChange({
...item,
unit_price: Math.round(
twuiNumberfy(e.target.value),
).toString(),
});
}}
showLabel
/>
<Border className="py-1 px-4">
<Span className="text-sm font-semibold">
{formatNaira(line_total_kobo)}
</Span>
</Border>
</Row>
</Paper>
);
}
@@ -1,155 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
import H3 from "@moduletrace/twui/layout/H3";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { useEffect, useState } from "react";
import { formatNaira, sumInvoiceItemsKobo } from "@/src/utils/money";
import SortableLineItemRow, {
type SortableLineItemFormObject,
} from "./sortable-line-item-row";
let row_key_counter = 0;
export const makeRowKey = (): string =>
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `row-${Date.now()}-${row_key_counter++}`;
type Props<T extends SortableLineItemFormObject> = {
items: T[];
empty_item: T;
item_placeholder: string;
onChange: (items: T[]) => void;
};
export default function SortableLineItemsEditor<
T extends SortableLineItemFormObject,
>({ items, empty_item, item_placeholder, onChange }: Props<T>) {
const [drag_source, set_drag_source] = useState<number | undefined>();
const [drag_target, set_drag_target] = useState<number | undefined>();
useEffect(() => {
if (items.some((item) => !item._key)) {
onChange(
items.map((item) => ({ ...item, _key: makeRowKey() })),
);
}
}, []);
const display_items = items.length ? items : [empty_item];
const can_remove = items.length > 1;
const setItems = (next_items: T[]) => onChange(next_items);
const endDrag = () => {
set_drag_source(undefined);
set_drag_target(undefined);
};
const moveTo = (from: number | undefined, target: number, drop_after: boolean) => {
if (from === undefined || from === target) return;
let to = drop_after ? target + 1 : target;
if (from < to) to -= 1;
if (to === from) return;
const next_items = items.length ? [...items] : [{ ...empty_item }];
const moved_item: T = next_items[from] as T;
next_items.splice(from, 1);
next_items.splice(to, 0, moved_item);
setItems(next_items);
};
const moveBy = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= (items.length ? items.length : 1)) return;
moveTo(index, target, delta === 1);
};
const total_kobo = sumInvoiceItemsKobo(
items.map((item) => ({
quantity: item.quantity,
unit_price_kobo: (Number(item.unit_price) || 0) * 100,
})),
);
return (
<Stack className="w-full gap-4">
<H3 className="!mb-0">Line Items</H3>
{display_items.map((item, index) => (
<SortableLineItemRow
key={item._key || index}
item={item}
index={index}
placeholder={item_placeholder}
is_dragging={drag_source === index}
is_drop_target={
drag_source !== undefined &&
drag_source !== index &&
drag_target === index
}
drag_active={drag_source !== undefined}
can_move_up={index > 0}
can_move_down={index < items.length - 1}
onChange={(next_item) => {
const next_items = items.length
? [...items]
: [{ ...empty_item }];
next_items[index] = next_item;
setItems(next_items);
}}
onRemove={() => {
if (!can_remove) return;
setItems(items.filter((_, i) => i !== index));
}}
onMoveUp={() => moveBy(index, -1)}
onMoveDown={() => moveBy(index, 1)}
onDragStart={() => {
if (items.length < 2 || drag_source !== undefined) return;
set_drag_source(index);
}}
onDragOver={() => {
if (
drag_source === undefined ||
drag_source === index ||
drag_target === index
) return;
set_drag_target(index);
}}
onDragLeave={() => {
if (drag_target === index) {
set_drag_target(undefined);
}
}}
onDrop={(drop_after) => {
moveTo(drag_source, index, drop_after);
endDrag();
}}
onDragEnd={endDrag}
/>
))}
<Row className="w-full justify-between items-center">
<Button
title="Add Line Item"
color="gray"
onClick={(e) => {
e.preventDefault();
setItems([
...items,
{ ...empty_item, _key: makeRowKey() },
]);
}}
>
Add Line Item
</Button>
<Span className="text-base font-semibold">
Total: {formatNaira(total_kobo)}
</Span>
</Row>
</Stack>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { StoneEyes } from "@/src/data/stone-geometry";
type Props = {
cx: number;
cy: number;
className?: string;
};
/**
* Upward arc eye, drawn as a stroke in the eye colour.
*/
export default function StoneEyeArc({ cx, cy, className }: Props) {
const r = StoneEyes.radius;
const base = cy + r * 0.4;
return (
<path
d={`M ${cx - r} ${base} A ${r} ${r} 0 0 1 ${cx + r} ${base}`}
fill="none"
strokeWidth={2.6}
strokeLinecap="round"
className={className}
/>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { StoneEyes } from "@/src/data/stone-geometry";
type Props = {
cx: number;
cy: number;
className?: string;
};
export default function StoneEyeDot({ cx, cy, className }: Props) {
return <circle cx={cx} cy={cy} r={StoneEyes.radius} className={className} />;
}
+22
View File
@@ -0,0 +1,22 @@
import { StoneEyes } from "@/src/data/stone-geometry";
type Props = {
cx: number;
cy: number;
className?: string;
};
/**
* Flattened dot, used once and centred for the asleep face.
*/
export default function StoneEyeFlat({ cx, cy, className }: Props) {
return (
<ellipse
cx={cx}
cy={cy}
rx={StoneEyes.radius + 1}
ry={1.8}
className={className}
/>
);
}
@@ -0,0 +1,21 @@
import { StoneEyes } from "@/src/data/stone-geometry";
type Props = {
cx: number;
cy: number;
className?: string;
};
/**
* Half-lidded eye: the lower half of the dot, flat edge on top.
*/
export default function StoneEyeLidded({ cx, cy, className }: Props) {
const r = StoneEyes.radius;
return (
<path
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 0 ${cx + r} ${cy} Z`}
className={className}
/>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { twMerge } from "tailwind-merge";
import { SiteData } from "@/src/data/site-data";
import { WordmarkPaths, WordmarkViewBox } from "@/src/data/wordmark-paths";
type Props = {
/**
* Rendered height in px. The plain wordmark is for 16 to 48 px.
*/
height?: number;
className?: string;
};
const [, , VIEWBOX_WIDTH, VIEWBOX_HEIGHT] = WordmarkViewBox.split(" ").map(
Number,
) as [number, number, number, number];
/**
* # Wordmark
* @description The `cobble` wordmark as vector paths, never live text. Takes
* the current text colour.
*/
export default function Wordmark({ height = 15, className }: Props) {
const width = (height * VIEWBOX_WIDTH) / VIEWBOX_HEIGHT;
return (
<svg
width={width}
height={height}
viewBox={WordmarkViewBox}
role="img"
aria-label={SiteData["SiteName"]}
className={twMerge("shrink-0 fill-current text-ink", className)}
>
{WordmarkPaths.map((d) => (
<WordmarkPath key={d.slice(0, 32)} d={d} />
))}
</svg>
);
}
function WordmarkPath({ d }: { d: string }) {
return <path d={d} />;
}