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
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { crewStatusLine, crewStatusSentence } from "./crew-status";
const crew = [
{ kind: "coordinator", face: "working" },
{ kind: "worker", face: "needs-you" },
{ kind: "worker", face: "working" },
{ kind: "worker", face: "working" },
{ kind: "worker", face: "working" },
{ kind: "worker", face: "asleep" },
];
describe("crewStatusLine", () => {
test("counts workers only", () => {
expect(crewStatusLine({ bots: crew })).toBe("3 working · 1 needs you");
});
test("pluralises need", () => {
expect(
crewStatusLine({
bots: [
{ kind: "worker", face: "needs-you" },
{ kind: "worker", face: "needs-you" },
],
}),
).toBe("2 need you");
});
test("quiet crew", () => {
expect(crewStatusLine({ bots: [{ kind: "worker", face: "done" }] })).toBe(
"All quiet",
);
});
});
describe("crewStatusSentence", () => {
test("the signature sentence", () => {
expect(crewStatusSentence({ bots: crew })).toBe(
"Three bots are working. One needs you.",
);
});
test("single working bot", () => {
expect(
crewStatusSentence({ bots: [{ kind: "worker", face: "working" }] }),
).toBe("One bot is working.");
});
test("needs you without working bots names the bots", () => {
expect(
crewStatusSentence({
bots: [
{ kind: "worker", face: "needs-you" },
{ kind: "worker", face: "needs-you" },
],
}),
).toBe("Two bots need you.");
});
test("nothing to report", () => {
expect(crewStatusSentence({ bots: [] })).toBe("Nothing needs you.");
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { BotFace, BotKind } from "@/src/dict/bot-dict";
type CrewBot = {
kind?: BotKind | string | null;
face?: BotFace | string | null;
};
type Params = {
bots: CrewBot[];
};
const NUMBER_WORDS = [
"No",
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
"Ten",
] as const;
function countWord({ n }: { n: number }): string {
return NUMBER_WORDS[n] ?? String(n);
}
/**
* Working and needs-you counts across the workers (the coordinator excluded).
*/
export function crewCounts({ bots }: Params) {
const workers = bots.filter((bot) => bot.kind !== "coordinator");
return {
working: workers.filter((bot) => bot.face === "working").length,
needs_you: workers.filter((bot) => bot.face === "needs-you").length,
};
}
/**
* The coordinator's rail subtitle: `3 working · 1 needs you`.
*/
export function crewStatusLine({ bots }: Params): string {
const { working, needs_you } = crewCounts({ bots });
const parts: string[] = [];
if (working) parts.push(`${working} working`);
if (needs_you) parts.push(`${needs_you} need${needs_you == 1 ? "s" : ""} you`);
return parts.length ? parts.join(" · ") : "All quiet";
}
/**
* The signature status sentence: `Three bots are working. One needs you.`
*/
export function crewStatusSentence({ bots }: Params): string {
const { working, needs_you } = crewCounts({ bots });
const sentences: string[] = [];
if (working) {
sentences.push(
working == 1
? `One bot is working.`
: `${countWord({ n: working })} bots are working.`,
);
}
if (needs_you) {
const subject = working
? countWord({ n: needs_you })
: `${countWord({ n: needs_you })} bot${needs_you == 1 ? "" : "s"}`;
sentences.push(`${subject} need${needs_you == 1 ? "s" : ""} you.`);
}
return sentences.length ? sentences.join(" ") : "Nothing needs you.";
}
+2 -2
View File
@@ -1,9 +1,9 @@
import path from "path";
import type { User } from "../types";
import type { BUN_SQLITE_BSQLITE_USERS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS } from "@/db/types/db";
type Params = {
user?: BUN_SQLITE_BSQLITE_USERS | User;
user?: BUN_SQLITE_COBBLE_USERS | User;
};
export default function grabDirNames(params?: Params) {
-28
View File
@@ -1,28 +0,0 @@
import { describe, expect, test } from "bun:test";
import hexToRgbFractions from "./hex-to-rgb-fractions";
describe("hexToRgbFractions", () => {
test("converts 6-digit hex", () => {
expect(hexToRgbFractions({ hex: "#ff0000" })).toEqual({
r: 1,
g: 0,
b: 0,
});
});
test("expands 3-digit hex", () => {
expect(hexToRgbFractions({ hex: "#fff" })).toEqual({
r: 1,
g: 1,
b: 1,
});
});
test("ignores alpha", () => {
expect(hexToRgbFractions({ hex: "#00000080" })).toEqual({
r: 0,
g: 0,
b: 0,
});
});
});
-24
View File
@@ -1,24 +0,0 @@
/**
* Converts `#rgb` / `#rrggbb` hex into `0-1` channel fractions (pdf-lib `rgb()`
* input). Alpha in `#rrggbbaa` is ignored.
*/
export default function hexToRgbFractions({ hex }: { hex: string }): {
r: number;
g: number;
b: number;
} {
let clean = hex.replace("#", "");
if (clean.length == 3 || clean.length == 4) {
clean = clean
.slice(0, 3)
.split("")
.map((char) => char + char)
.join("");
}
const channel = (offset: number) =>
(parseInt(clean.slice(offset, offset + 2), 16) || 0) / 255;
return { r: channel(0), g: channel(2), b: channel(4) };
}
+2 -2
View File
@@ -1,10 +1,10 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isPassThroughUser from "./is-pass-through-user";
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
};
/**
+2 -2
View File
@@ -1,10 +1,10 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isPassThroughUser from "./is-pass-through-user";
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
};
/**
+2 -2
View File
@@ -1,10 +1,10 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isPassThroughUser from "./is-pass-through-user";
import { UserManagementUserTypes } from "@/src/dict/user-types-dict";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
};
/**
+3 -3
View File
@@ -1,10 +1,10 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import userTypeAssignment from "./user-type-assignment";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
target_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
target_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
};
/**
+3 -3
View File
@@ -1,13 +1,13 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import isPassThroughUser from "./is-pass-through-user";
type Params = {
actor_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
actor_user_id?: number | string;
target_user_id?: number | string;
target_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
target_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
/**
* The keys of the fields being updated (e.g. ["first_name", "email"]).
* Used to restrict contact fields on protected users.
@@ -1,9 +1,9 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import userTypeAssignment from "./user-type-assignment";
type Params = {
actor_user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
user_types: UserType[];
};
+2 -2
View File
@@ -1,7 +1,7 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
};
/**
+2 -2
View File
@@ -1,7 +1,7 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
type Params = {
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
};
/**
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test";
import relativeTime from "./relative-time";
const NOW = new Date("2026-10-18T10:45:00").getTime();
describe("relativeTime", () => {
test("under a minute is now", () => {
expect(relativeTime({ at: NOW - 30_000, now: NOW })).toBe("now");
});
test("future timestamps clamp to now", () => {
expect(relativeTime({ at: NOW + 60_000, now: NOW })).toBe("now");
});
test("minutes and hours", () => {
expect(relativeTime({ at: NOW - 2 * 60_000, now: NOW })).toBe("2m");
expect(relativeTime({ at: NOW - 3 * 3_600_000, now: NOW })).toBe("3h");
});
test("within a week is the weekday", () => {
const tuesday = new Date("2026-10-13T09:00:00").getTime();
expect(relativeTime({ at: tuesday, now: NOW })).toBe("Tue");
});
test("weeks and years", () => {
expect(relativeTime({ at: NOW - 21 * 86_400_000, now: NOW })).toBe(
"3w",
);
expect(relativeTime({ at: NOW - 800 * 86_400_000, now: NOW })).toBe(
"2y",
);
});
});
+30
View File
@@ -0,0 +1,30 @@
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
type Params = {
/**
* Unix timestamp in milliseconds.
*/
at: number;
now?: number;
};
/**
* Cobble timestamps are relative, never absolute: `now`, `2m`, `1h`, `Tue`,
* then `3w` and `2y`.
*/
export default function relativeTime({ at, now = Date.now() }: Params): string {
const diff = Math.max(0, now - at);
if (diff < MINUTE) return "now";
if (diff < HOUR) return `${Math.floor(diff / MINUTE)}m`;
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`;
if (diff < WEEK) return WEEKDAYS[new Date(at).getDay()]!;
if (diff < 52 * WEEK) return `${Math.floor(diff / WEEK)}w`;
return `${Math.floor(diff / (365 * DAY))}y`;
}
+2 -2
View File
@@ -1,10 +1,10 @@
import type { ApiReqParams } from "../types";
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "../types/sql-joins";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "../types/sql-joins";
export default function searchableFieldToQueryField({
field,
}: {
field: keyof BUN_SQLITE_BSQLITE_USERS_JOIN;
field: keyof BUN_SQLITE_COBBLE_USERS_JOIN;
}): keyof ApiReqParams {
switch (field) {
case "email":
-20
View File
@@ -1,20 +0,0 @@
import { expect, test } from "bun:test";
import textToSafeHtml from "./text-to-html";
test("textToSafeHtml passes HTML through unchanged", () => {
const html = `<p>Payment may be split: <strong>30% deposit</strong>.</p>`;
expect(textToSafeHtml(html)).toBe(html);
});
test("textToSafeHtml converts legacy plain-text line breaks", () => {
expect(textToSafeHtml("Line one\nLine two")).toBe(
"Line one<br />Line two",
);
expect(textToSafeHtml("A\r\nB\r\nC")).toBe("A<br />B<br />C");
});
test("textToSafeHtml handles empty and plain single-line input", () => {
expect(textToSafeHtml("")).toBe("");
expect(textToSafeHtml("Just a note")).toBe("Just a note");
expect(textToSafeHtml(undefined)).toBe("");
});
-15
View File
@@ -1,15 +0,0 @@
/**
* # Convert a stored text field to an HTML fragment for rendering.
* Documents that switched from plain text to rich HTML (e.g. invoice/quote
* notes) may still hold legacy plain-text rows — those get their line breaks
* converted to `<br />` so they render like they used to.
*/
export default function textToSafeHtml(text?: string): string {
const value = text || "";
if (/<[a-z][^>]*>/i.test(value)) {
return value;
}
return value.replace(/\r?\n/g, "<br />");
}