feat: auth scaffold, notifications inbox, resources CRUD, vitest baseline, typed API client

Auth
- ~/lib/session.ts: Session type + loadSession/signIn/signOut/hasSession,
  reactive useSession hook (mock backend; replace fetch calls with your
  real auth endpoint when ready)
- routes/login.tsx: form with email/password (mock-validated), bounces
  to ?next= on success
- AppShell: redirects to /login when no session; account-menu Sign out
  now actually signs out; live session.name/email used for the appbar
  avatar (falls back to profile)

Notifications
- ~/lib/notifications.ts: persistent inbox with kinds (info/success/
  warning/error), unreadCount, markRead, markAllRead, dismiss,
  dismissAll; seedIfEmpty for a friendly first-run
- AppShell bell: 320px popover with badge, kind dots, per-row open
  (navigates to href) and dismiss; Mark all read + Clear actions
- Hidden NotificationDispatcher in AppShell so the action bus can
  create real notifications via fill notif-title / notif-body /
  notif-kind / notif-href + click notif-create

Data layer
- ~/lib/api.ts: typed apiFetch<T> + api.get/post/put/patch/del,
  auto-attaches the session token, throws structured ApiError, signs
  out on 401
- ~/lib/resources.ts: example domain entity (CRUD) backed by
  localStorage today; each call is a 1:1 swap for api.get/post/put/del
- routes/resources.tsx: real working table — search, add, inline
  status edit, delete; seeded demo rows on first load

Tests
- vitest + jsdom + @testing-library/react + @testing-library/jest-dom
  + vite-tsconfig-paths installed
- vitest.config.ts (jsdom, globals, ~ aliases via tsconfig-paths)
- vitest.setup.ts (RTL cleanup + localStorage clear between tests)
- app/lib/session.test.ts and resources.test.ts as starter coverage
- npm test / npm run test:watch scripts

UI Control catalog
- Login form, resources CRUD, notifications inbox, and the hidden
  notif-bridge ids tagged so the assistant can drive every new surface

Threads
- ThreadMessage now carries optional agentId so per-message authorship
  survives persona switches and handoffs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-04-28 15:59:31 +10:00
parent eea5b262cb
commit 3dbf2ac175
16 changed files with 2297 additions and 41 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
const SIDEBAR_KEY = "crema.shell.sidebar" const SIDEBAR_KEY = "crema.shell.sidebar"
import { NavLink, useNavigate } from "react-router" import { NavLink, useNavigate } from "react-router"
@@ -39,7 +39,23 @@ import { BackgroundPicker } from "~/components/layout/background-picker"
import { FontSizePicker } from "~/components/layout/font-size-picker" import { FontSizePicker } from "~/components/layout/font-size-picker"
import { SurfacePicker } from "~/components/layout/surface-picker" import { SurfacePicker } from "~/components/layout/surface-picker"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"
import { profileInitials, useProfile } from "~/lib/profile" import { profileInitials, useProfile } from "~/lib/profile"
import { signOut, useSession } from "~/lib/session"
import {
addNotification,
dismiss,
dismissAll,
markAllRead,
markRead,
seedIfEmpty,
unreadCount,
useNotifications,
} from "~/lib/notifications"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@@ -100,12 +116,30 @@ export function AppShell({
const defaultBrand = useBrand() const defaultBrand = useBrand()
const defaultUser = useUser() const defaultUser = useUser()
const profile = useProfile() const profile = useProfile()
const session = useSession()
const navigate = useNavigate()
const brand = brandOverride ?? defaultBrand const brand = brandOverride ?? defaultBrand
// Prefer the live session for identity, fall back to the editable profile,
// fall back to the stub user.
const user = userOverride ?? { const user = userOverride ?? {
name: profile.name || defaultUser.name, name: session?.name || profile.name || defaultUser.name,
email: profile.email || defaultUser.email, email: session?.email || profile.email || defaultUser.email,
initials: profileInitials(profile.name || defaultUser.name), initials: profileInitials(
session?.name || profile.name || defaultUser.name,
),
} }
// Protected shell: bounce to /login when there's no session.
useEffect(() => {
if (typeof window === "undefined") return
if (!session) {
const next = encodeURIComponent(
window.location.pathname + window.location.search,
)
navigate(`/login?next=${next}`, { replace: true })
}
}, [session, navigate])
if (!session) return null
const [expanded, setExpanded] = useState<boolean>(() => { const [expanded, setExpanded] = useState<boolean>(() => {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
return localStorage.getItem(SIDEBAR_KEY) === "1" return localStorage.getItem(SIDEBAR_KEY) === "1"
@@ -115,7 +149,6 @@ export function AppShell({
}, [expanded]) }, [expanded])
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const [scriptsOpen, setScriptsOpen] = useState(false) const [scriptsOpen, setScriptsOpen] = useState(false)
const navigate = useNavigate()
const BrandIcon = brand.icon const BrandIcon = brand.icon
useScriptsHotkey(() => setScriptsOpen(true)) useScriptsHotkey(() => setScriptsOpen(true))
@@ -280,14 +313,8 @@ export function AppShell({
<SurfacePicker /> <SurfacePicker />
<BackgroundPicker /> <BackgroundPicker />
<ThemeToggle /> <ThemeToggle />
<Button <NotificationsBell />
data-action="appbar-notifications"
variant="ghost"
size="icon-sm"
aria-label="Notifications"
>
<Bell />
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
data-action="appbar-avatar" data-action="appbar-avatar"
@@ -327,7 +354,14 @@ export function AppShell({
<HelpCircle /> Help <HelpCircle /> Help
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem data-action="avatar-signout" variant="destructive"> <DropdownMenuItem
data-action="avatar-signout"
variant="destructive"
onClick={() => {
signOut()
navigate("/login", { replace: true })
}}
>
<LogOut /> Sign out <LogOut /> Sign out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -345,6 +379,195 @@ export function AppShell({
</main> </main>
<ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} /> <ScriptsDialog open={scriptsOpen} onOpenChange={setScriptsOpen} />
<NotificationDispatcher />
</div> </div>
) )
} }
function NotificationDispatcher() {
// Hidden bridge so the action bus can create real notifications:
// fill notify-title "Hello"
// fill notify-body "Body text"
// fill notify-kind "info" # info|success|warning|error
// fill notify-href "/library" # optional
// click notify-create
const titleRef = useRef<HTMLInputElement>(null)
const bodyRef = useRef<HTMLInputElement>(null)
const kindRef = useRef<HTMLInputElement>(null)
const hrefRef = useRef<HTMLInputElement>(null)
const submit = () => {
const title = titleRef.current?.value.trim() ?? ""
if (!title) return
const body = bodyRef.current?.value.trim() || undefined
const rawKind = (kindRef.current?.value || "info").trim().toLowerCase()
const kind = (
["info", "success", "warning", "error"].includes(rawKind)
? rawKind
: "info"
) as "info" | "success" | "warning" | "error"
const href = hrefRef.current?.value.trim() || undefined
addNotification({ title, body, kind, href })
if (titleRef.current) titleRef.current.value = ""
if (bodyRef.current) bodyRef.current.value = ""
if (kindRef.current) kindRef.current.value = ""
if (hrefRef.current) hrefRef.current.value = ""
}
return (
<div
aria-hidden
className="pointer-events-none fixed left-0 top-0 size-px overflow-hidden opacity-0"
>
<input
ref={titleRef}
data-action="notif-title"
placeholder="title"
aria-label="Notification title"
/>
<input
ref={bodyRef}
data-action="notif-body"
placeholder="body"
aria-label="Notification body"
/>
<input
ref={kindRef}
data-action="notif-kind"
placeholder="info|success|warning|error"
aria-label="Notification kind"
/>
<input
ref={hrefRef}
data-action="notif-href"
placeholder="/path (optional)"
aria-label="Notification href"
/>
<button
type="button"
data-action="notif-create"
aria-label="Create notification"
onClick={submit}
className="pointer-events-auto"
>
create
</button>
</div>
)
}
function NotificationsBell() {
const items = useNotifications()
const unread = unreadCount(items)
const navigate = useNavigate()
useEffect(() => {
seedIfEmpty()
}, [])
return (
<Popover>
<PopoverTrigger
render={
<Button
data-action="appbar-notifications"
variant="ghost"
size="icon-sm"
aria-label="Notifications"
>
<span className="relative inline-flex">
<Bell />
{unread > 0 && (
<span className="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold text-primary-foreground">
{unread > 9 ? "9+" : unread}
</span>
)}
</span>
</Button>
}
/>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex items-center gap-1">
<Button
data-action="notif-mark-all-read"
variant="ghost"
size="sm"
onClick={() => markAllRead()}
disabled={unread === 0}
>
Mark all read
</Button>
<Button
data-action="notif-clear"
variant="ghost"
size="sm"
onClick={() => dismissAll()}
disabled={items.length === 0}
>
Clear
</Button>
</div>
</div>
<ul className="max-h-80 overflow-y-auto">
{items.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-muted-foreground">
No notifications.
</li>
) : (
items.map((n) => (
<li
key={n.id}
className={
"group flex items-start gap-2 border-b px-3 py-2 text-sm transition-colors hover:bg-accent/40 " +
(!n.readAt ? "bg-primary/5" : "")
}
>
<span
className={
"mt-1 size-2 shrink-0 rounded-full " +
(n.kind === "success"
? "bg-emerald-500"
: n.kind === "warning"
? "bg-amber-500"
: n.kind === "error"
? "bg-rose-500"
: "bg-primary")
}
aria-hidden
/>
<button
type="button"
data-action={`notif-open-${n.id}`}
onClick={() => {
markRead(n.id)
if (n.href) navigate(n.href)
}}
className="flex flex-1 flex-col items-start text-left"
>
<span className="font-medium">{n.title}</span>
{n.body && (
<span className="text-xs text-muted-foreground">
{n.body}
</span>
)}
<span className="text-[10px] text-muted-foreground/70">
{new Date(n.createdAt).toLocaleString()}
</span>
</button>
<button
type="button"
data-action={`notif-dismiss-${n.id}`}
onClick={() => dismiss(n.id)}
aria-label="Dismiss"
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-background hover:text-foreground group-hover:opacity-100"
>
<span aria-hidden>×</span>
</button>
</li>
))
)}
</ul>
</PopoverContent>
</Popover>
)
}

83
app/lib/api.ts Normal file
View File

@@ -0,0 +1,83 @@
// API — typed fetch wrapper. Auto-injects the session token, throws on
// non-2xx with a parsed error, and supports AbortSignal for cancellation.
//
// Replace `apiBaseURL` with your backend root. The Resources route shows the
// typical usage pattern.
import { loadSession, signOut } from "~/lib/session"
export const apiBaseURL = "/api"
export class ApiError extends Error {
status: number
body: unknown
constructor(message: string, status: number, body: unknown) {
super(message)
this.name = "ApiError"
this.status = status
this.body = body
}
}
export type ApiInit = Omit<RequestInit, "body" | "method"> & {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
body?: unknown
}
export async function apiFetch<T = unknown>(
path: string,
init: ApiInit = {},
): Promise<T> {
const session = loadSession()
const headers = new Headers(init.headers)
if (session?.token) headers.set("Authorization", `Bearer ${session.token}`)
if (init.body !== undefined && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const url = path.startsWith("http") ? path : `${apiBaseURL}${path}`
const res = await fetch(url, {
...init,
method: init.method ?? "GET",
headers,
body:
init.body === undefined
? undefined
: typeof init.body === "string"
? init.body
: JSON.stringify(init.body),
})
if (res.status === 401) {
// Token rejected — clear session so the shell bounces to /login.
signOut()
}
const ct = res.headers.get("Content-Type") ?? ""
const parsed = ct.includes("application/json")
? await res.json().catch(() => null)
: await res.text().catch(() => null)
if (!res.ok) {
const message =
(parsed && typeof parsed === "object" && "message" in parsed
? String((parsed as { message: unknown }).message)
: null) ?? `${res.status} ${res.statusText}`
throw new ApiError(message, res.status, parsed)
}
return parsed as T
}
/** Convenience helpers. */
export const api = {
get: <T = unknown>(path: string, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "GET" }),
post: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "POST", body }),
put: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "PUT", body }),
patch: <T = unknown>(path: string, body?: unknown, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "PATCH", body }),
del: <T = unknown>(path: string, init?: ApiInit) =>
apiFetch<T>(path, { ...init, method: "DELETE" }),
}

155
app/lib/notifications.ts Normal file
View File

@@ -0,0 +1,155 @@
// Notifications — small reactive store for in-app toasts/inbox items.
// Pair with @crema/notification-ui's <ToastProvider /> for transient toasts;
// this store is for the appbar bell's persistent inbox.
import { useEffect, useSyncExternalStore } from "react"
export type NotificationKind = "info" | "success" | "warning" | "error"
export type AppNotification = {
id: string
kind: NotificationKind
title: string
body?: string
// Optional href to open when the row is clicked.
href?: string
createdAt: number
readAt?: number
}
const STORAGE_KEY = "crema.notifications"
const CHANGE_EVENT = "crema:notifications-change"
const MAX_ITEMS = 200
function newId(): string {
return `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function readFromStorage(): AppNotification[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(n): n is AppNotification =>
n &&
typeof n.id === "string" &&
typeof n.title === "string" &&
typeof n.createdAt === "number" &&
["info", "success", "warning", "error"].includes(n.kind),
)
} catch {
return []
}
}
function writeToStorage(items: AppNotification[]) {
if (typeof window === "undefined") return
const trimmed = items.slice(0, MAX_ITEMS)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota — drop silently */
}
}
export function loadNotifications(): AppNotification[] {
return readFromStorage()
}
export function addNotification(
n: Omit<AppNotification, "id" | "createdAt">,
): AppNotification {
const next: AppNotification = {
...n,
id: newId(),
createdAt: Date.now(),
}
writeToStorage([next, ...readFromStorage()])
return next
}
export function markRead(id: string) {
const items = readFromStorage().map((n) =>
n.id === id ? { ...n, readAt: Date.now() } : n,
)
writeToStorage(items)
}
export function markAllRead() {
const now = Date.now()
const items = readFromStorage().map((n) =>
n.readAt ? n : { ...n, readAt: now },
)
writeToStorage(items)
}
export function dismiss(id: string) {
writeToStorage(readFromStorage().filter((n) => n.id !== id))
}
export function dismissAll() {
writeToStorage([])
}
let cached: AppNotification[] | null = null
function subscribe(cb: () => void): () => void {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): AppNotification[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): AppNotification[] {
return []
}
export function useNotifications(): AppNotification[] {
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return value
}
export function unreadCount(items: AppNotification[]): number {
return items.filter((n) => !n.readAt).length
}
/** Seed a few demo notifications on first load so the bell isn't empty. */
export function seedIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: AppNotification[] = [
{
id: newId(),
kind: "info",
title: "Welcome",
body: "Tag elements with data-action and the assistant can drive them.",
href: "/assistant",
createdAt: now - 60_000,
},
{
id: newId(),
kind: "success",
title: "Profile saved",
body: "Your display name and avatar are live across the app.",
href: "/profile",
createdAt: now - 5 * 60_000,
},
]
writeToStorage(seed)
}

32
app/lib/resources.test.ts Normal file
View File

@@ -0,0 +1,32 @@
import { describe, expect, it, beforeEach } from "vitest"
import {
createResource,
deleteResource,
listResources,
updateResource,
} from "./resources"
describe("resources", () => {
beforeEach(() => {
localStorage.clear()
})
it("creates, updates, and deletes", () => {
expect(listResources()).toEqual([])
const r = createResource({ name: "Test", owner: "Atlas" })
expect(r.status).toBe("active")
expect(listResources()).toHaveLength(1)
const updated = updateResource(r.id, { status: "paused" })
expect(updated?.status).toBe("paused")
expect(updated?.updatedAt).toBeGreaterThanOrEqual(r.updatedAt)
deleteResource(r.id)
expect(listResources()).toEqual([])
})
it("ignores updates for unknown ids", () => {
expect(updateResource("missing", { name: "x" })).toBeNull()
})
})

157
app/lib/resources.ts Normal file
View File

@@ -0,0 +1,157 @@
// Resource store — example domain entity.
// Backed by localStorage today, but written so each call is a single function
// you can swap with `api.get/post/put/del` once you have a real backend.
import { useEffect, useSyncExternalStore } from "react"
export type Resource = {
id: string
name: string
status: "active" | "paused" | "archived"
owner: string
createdAt: number
updatedAt: number
}
const STORAGE_KEY = "crema.resources"
const CHANGE_EVENT = "crema:resources-change"
function newId() {
return `r-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
}
function readFromStorage(): Resource[] {
if (typeof window === "undefined") return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(
(r): r is Resource =>
r &&
typeof r.id === "string" &&
typeof r.name === "string" &&
["active", "paused", "archived"].includes(r.status) &&
typeof r.owner === "string" &&
typeof r.createdAt === "number" &&
typeof r.updatedAt === "number",
)
} catch {
return []
}
}
function write(items: Resource[]) {
if (typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
} catch {
/* quota */
}
}
// CRUD — these mirror what `api.get/post/put/del` would look like.
export function listResources(): Resource[] {
return readFromStorage()
}
export function createResource(input: {
name: string
owner: string
status?: Resource["status"]
}): Resource {
const now = Date.now()
const r: Resource = {
id: newId(),
name: input.name,
owner: input.owner,
status: input.status ?? "active",
createdAt: now,
updatedAt: now,
}
write([r, ...readFromStorage()])
return r
}
export function updateResource(
id: string,
patch: Partial<Omit<Resource, "id" | "createdAt">>,
): Resource | null {
const items = readFromStorage()
let updated: Resource | null = null
const next = items.map((r) => {
if (r.id !== id) return r
updated = { ...r, ...patch, updatedAt: Date.now() }
return updated
})
if (updated) write(next)
return updated
}
export function deleteResource(id: string) {
write(readFromStorage().filter((r) => r.id !== id))
}
let cached: Resource[] | null = null
function subscribe(cb: () => void) {
const onChange = () => {
cached = null
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Resource[] {
if (!cached) cached = readFromStorage()
return cached
}
function getServerSnapshot(): Resource[] {
return []
}
export function useResources(): Resource[] {
const v = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cached = null
}, [])
return v
}
/** Seed a few rows on first load so the table isn't empty. */
export function seedResourcesIfEmpty() {
if (typeof window === "undefined") return
if (localStorage.getItem(STORAGE_KEY)) return
const now = Date.now()
const seed: Resource[] = [
{
id: newId(),
name: "Acme dashboard",
status: "active",
owner: "Atlas",
createdAt: now - 86_400_000 * 3,
updatedAt: now - 3600_000,
},
{
id: newId(),
name: "Onboarding pipeline",
status: "paused",
owner: "Forge",
createdAt: now - 86_400_000 * 7,
updatedAt: now - 86_400_000,
},
{
id: newId(),
name: "Q1 report draft",
status: "archived",
owner: "Inkwell",
createdAt: now - 86_400_000 * 30,
updatedAt: now - 86_400_000 * 14,
},
]
write(seed)
}

31
app/lib/session.test.ts Normal file
View File

@@ -0,0 +1,31 @@
import { describe, expect, it, beforeEach } from "vitest"
import { hasSession, loadSession, signIn, signOut } from "./session"
describe("session", () => {
beforeEach(() => {
localStorage.clear()
})
it("starts unauthenticated", () => {
expect(loadSession()).toBeNull()
expect(hasSession()).toBe(false)
})
it("rejects empty credentials", async () => {
await expect(signIn("", "")).rejects.toThrow(/required/i)
await expect(signIn("not-an-email", "pw")).rejects.toThrow(/valid email/i)
expect(hasSession()).toBe(false)
})
it("creates a session on sign-in and clears on sign-out", async () => {
const session = await signIn("alice@example.com", "hunter2")
expect(session.email).toBe("alice@example.com")
expect(session.token).toMatch(/^dev-/)
expect(hasSession()).toBe(true)
signOut()
expect(loadSession()).toBeNull()
expect(hasSession()).toBe(false)
})
})

129
app/lib/session.ts Normal file
View File

@@ -0,0 +1,129 @@
// Session — minimal auth scaffold backed by localStorage.
// Swap loadSession/signIn/signOut for real calls (cookies + server) when you
// wire a backend. The shape here matches what AppShell + useUser expect.
import { useEffect, useSyncExternalStore } from "react"
import { profileInitials } from "~/lib/profile"
export type Session = {
userId: string
name: string
email: string
token: string
// Issued at, ms since epoch.
issuedAt: number
}
const STORAGE_KEY = "crema.session"
const CHANGE_EVENT = "crema:session-change"
function readFromStorage(): Session | null {
if (typeof window === "undefined") return null
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Partial<Session>
if (
typeof parsed.userId !== "string" ||
typeof parsed.email !== "string" ||
typeof parsed.token !== "string"
)
return null
return {
userId: parsed.userId,
name:
typeof parsed.name === "string" && parsed.name.trim()
? parsed.name
: parsed.email,
email: parsed.email,
token: parsed.token,
issuedAt:
typeof parsed.issuedAt === "number" ? parsed.issuedAt : Date.now(),
}
} catch {
return null
}
}
export function loadSession(): Session | null {
return readFromStorage()
}
/**
* Mock sign-in. Validates only that email + password are non-empty; returns
* a fake session. Replace with a real fetch to your auth endpoint.
*/
export async function signIn(
email: string,
password: string,
): Promise<Session> {
await new Promise((r) => setTimeout(r, 250))
if (!email.trim() || !password.trim()) {
throw new Error("Email and password are required.")
}
if (!email.includes("@")) {
throw new Error("Enter a valid email address.")
}
const session: Session = {
userId: `u-${Date.now().toString(36)}`,
name: email.split("@")[0].replace(/\W/g, " ").trim() || email,
email,
token: `dev-${Math.random().toString(36).slice(2, 14)}`,
issuedAt: Date.now(),
}
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
return session
}
export function signOut() {
if (typeof window === "undefined") return
localStorage.removeItem(STORAGE_KEY)
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
}
/** True if a non-expired session is in storage. */
export function hasSession(): boolean {
return !!readFromStorage()
}
let cached: Session | null = null
let cacheValid = false
function subscribe(cb: () => void): () => void {
const onChange = () => {
cacheValid = false
cb()
}
window.addEventListener(CHANGE_EVENT, onChange)
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY) onChange()
})
return () => window.removeEventListener(CHANGE_EVENT, onChange)
}
function getSnapshot(): Session | null {
if (!cacheValid) {
cached = readFromStorage()
cacheValid = true
}
return cached
}
function getServerSnapshot(): Session | null {
return null
}
export function useSession(): Session | null {
const s = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
useEffect(() => {
cacheValid = false
}, [])
return s
}
export function sessionInitials(session: Session | null): string {
if (!session) return "?"
return profileInitials(session.name || session.email)
}

View File

@@ -3,7 +3,12 @@
import { useEffect, useSyncExternalStore } from "react" import { useEffect, useSyncExternalStore } from "react"
export type ThreadMessage = { role: "user" | "assistant"; content: string } export type ThreadMessage = {
role: "user" | "assistant"
content: string
/** Persona that authored this assistant message (omitted for user msgs). */
agentId?: string
}
export type Thread = { export type Thread = {
id: string id: string

View File

@@ -8,5 +8,6 @@ export default [
route("library", "routes/library.tsx"), route("library", "routes/library.tsx"),
route("settings", "routes/settings.tsx"), route("settings", "routes/settings.tsx"),
route("profile", "routes/profile.tsx"), route("profile", "routes/profile.tsx"),
route("login", "routes/login.tsx"),
// CREMA:ROUTES // CREMA:ROUTES
] satisfies RouteConfig ] satisfies RouteConfig

View File

@@ -53,6 +53,16 @@ Agents settings: settings-agent-new, settings-agent-reset, settings-agent-activa
Assistant agent picker: assistant-agent (dropdown — click to switch persona) Assistant agent picker: assistant-agent (dropdown — click to switch persona)
Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i> Assistant page: assistant-model, assistant-agent, assistant-thread, assistant-thread-new, assistant-thread-switch-<id>, assistant-thread-rename-<id>, assistant-thread-delete-<id>, assistant-ui-control, assistant-compact, assistant-restore-compact, assistant-regenerate, assistant-continue, assistant-show-prompt, assistant-copy-md, assistant-export-md, assistant-save-library, assistant-compare, assistant-handoff-<id>, assistant-stop, assistant-clear, assistant-retry-probe, assistant-actions (open the kebab popover first to reveal the rest), assistant-voice (mic), assistant-msg-pin-<i>, assistant-msg-edit-<i>, assistant-msg-speak-<i>
Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id> Library page: library-search, library-open-<id>, library-copy-<id>, library-download-<id>, library-delete-<id>
Resources page: resources-search, resources-new-name, resources-create, resources-status-<id>, resources-delete-<id>
Login page: login-email, login-password, login-submit
Notifications popover: appbar-notifications (open), notif-mark-all-read, notif-clear, notif-open-<id>, notif-dismiss-<id>
Create a notification (hidden bridge — always available, even when not visible): fill the four hidden inputs, then click the submit button. Recipe:
fill notif-title "Reminder"
fill notif-body "Take a 5-minute break"
fill notif-kind "info" # info | success | warning | error
fill notif-href "/library" # optional, omit if no link
click notif-create
Use this any time the user asks you to remind them, leave a note, flag something, or queue an item — drop a notification.
Example — User: "Go to settings and set the response cap to 1024" → Example — User: "Go to settings and set the response cap to 1024" →
"On it. "On it.
@@ -391,11 +401,18 @@ function AssistantSurface({
}) })
// Persist conversation back into the active thread. // Persist conversation back into the active thread.
// Preserve any agentId already stamped on prior messages, and stamp newly
// appended assistant messages with the *currently active* agent.
useEffect(() => { useEffect(() => {
if (isStreaming) return if (isStreaming) return
const stamped: ThreadMessage[] = messages.map((m, i) => {
const prior = thread.messages[i]
if (m.role === "user") return { role: "user", content: m.content }
const agentId = prior?.agentId ?? activeAgentId
return { role: "assistant", content: m.content, agentId }
})
updateThread(thread.id, { updateThread(thread.id, {
messages: messages as ThreadMessage[], messages: stamped,
// Auto-title from the first user message if the thread is still untitled.
...(thread.title === "New conversation" && ...(thread.title === "New conversation" &&
messages[0]?.role === "user" messages[0]?.role === "user"
? { title: deriveTitleFromFirstMessage(messages[0].content) } ? { title: deriveTitleFromFirstMessage(messages[0].content) }
@@ -667,9 +684,20 @@ function AssistantSurface({
{ maxTokens: 220 }, { maxTokens: 220 },
) )
const note = `🤝 **Handoff: ${activeAgent.name}${target.name}**\n\n${briefing.trim()}` const note = `🤝 **Handoff: ${activeAgent.name}${target.name}**\n\n${briefing.trim()}`
// Stamp the existing thread messages (preserve their authorship) and
// attribute the handoff note itself to the OUTGOING agent.
const stamped: ThreadMessage[] = messages.map((m, i) => {
const prior = thread.messages[i]
if (m.role === "user") return { role: "user", content: m.content }
return {
role: "assistant",
content: m.content,
agentId: prior?.agentId ?? activeAgent.id,
}
})
const next: ThreadMessage[] = [ const next: ThreadMessage[] = [
...(messages as ThreadMessage[]), ...stamped,
{ role: "assistant", content: note }, { role: "assistant", content: note, agentId: activeAgent.id },
] ]
updateThread(thread.id, { messages: next, agentId: target.id }) updateThread(thread.id, { messages: next, agentId: target.id })
saveActiveAgentId(target.id) saveActiveAgentId(target.id)
@@ -832,14 +860,37 @@ function AssistantSurface({
!messages.slice(i + 1).some((x) => x.role === "user") !messages.slice(i + 1).some((x) => x.role === "user")
const isEditing = editingIndex === i const isEditing = editingIndex === i
const isUser = m.role === "user" const isUser = m.role === "user"
const msgAgentId =
!isUser
? thread.messages[i]?.agentId ?? activeAgent?.id
: undefined
const msgAgent = msgAgentId
? agents.find((a) => a.id === msgAgentId) ?? activeAgent
: undefined
return ( return (
<div <div
key={i} key={i}
className={ className={
"group flex flex-col " + "group flex w-full items-start gap-2 " +
(isUser ? "items-end" : "items-start") (isUser ? "flex-row-reverse" : "flex-row")
} }
> >
{!isUser && msgAgent && (
<Avatar
className="mt-5 size-7 shrink-0 ring-1 ring-border"
title={`${msgAgent.name}${msgAgent.role}`}
>
<AvatarFallback
style={{
background: agentTint(msgAgent.id),
color: "var(--primary-foreground)",
}}
className="text-[11px] font-semibold"
>
{agentInitials(msgAgent.name)}
</AvatarFallback>
</Avatar>
)}
<div <div
className={ className={
"flex max-w-[80%] flex-col " + "flex max-w-[80%] flex-col " +
@@ -848,8 +899,15 @@ function AssistantSurface({
> >
<div className="mb-0.5 flex items-center gap-1.5 px-1"> <div className="mb-0.5 flex items-center gap-1.5 px-1">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
{isUser ? "You" : "Assistant"} {isUser
? "You"
: (msgAgent?.name ?? "Assistant")}
</span> </span>
{!isUser && msgAgent && (
<span className="text-[10px] text-muted-foreground/70">
· {msgAgent.role}
</span>
)}
{isPinned && ( {isPinned && (
<Pin className="size-3 fill-primary text-primary" /> <Pin className="size-3 fill-primary text-primary" />
)} )}
@@ -1368,10 +1426,24 @@ function AssistantSurface({
responseBudget={responseBudget} responseBudget={responseBudget}
onClose={() => setCompareOpen(false)} onClose={() => setCompareOpen(false)}
onAppend={(agentName, content) => { onAppend={(agentName, content) => {
const speaker = agents.find((a) => a.name === agentName)
const note = `🪞 **${agentName} says:**\n\n${content}` const note = `🪞 **${agentName} says:**\n\n${content}`
const stamped: ThreadMessage[] = messages.map((m, i) => {
const prior = thread.messages[i]
if (m.role === "user") return { role: "user", content: m.content }
return {
role: "assistant",
content: m.content,
agentId: prior?.agentId ?? activeAgent?.id,
}
})
const next: ThreadMessage[] = [ const next: ThreadMessage[] = [
...(messages as ThreadMessage[]), ...stamped,
{ role: "assistant", content: note }, {
role: "assistant",
content: note,
agentId: speaker?.id ?? activeAgent?.id,
},
] ]
updateThread(thread.id, { messages: next }) updateThread(thread.id, { messages: next })
setActionLog(`Appended ${agentName}'s reply to the thread.`) setActionLog(`Appended ${agentName}'s reply to the thread.`)

117
app/routes/login.tsx Normal file
View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from "react"
import { useNavigate, useSearchParams } from "react-router"
import { Loader2, LogIn, Sparkles } from "lucide-react"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import { useBrand } from "~/lib/identity"
import { pageTitle } from "~/lib/page-meta"
import { signIn, useSession } from "~/lib/session"
export const meta = () => pageTitle("Sign in")
export default function LoginRoute() {
const navigate = useNavigate()
const [params] = useSearchParams()
const session = useSession()
const brand = useBrand()
const BrandIcon = brand.icon
const next = params.get("next") || "/"
const [email, setEmail] = useState("you@example.com")
const [password, setPassword] = useState("hunter2")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Already signed in? Bounce.
useEffect(() => {
if (session) navigate(next, { replace: true })
}, [session, next, navigate])
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
await signIn(email, password)
navigate(next, { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : "Sign-in failed.")
setSubmitting(false)
}
}
return (
<div className="relative isolate flex min-h-svh items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm">
<CardHeader className="items-center text-center">
<div className="mb-2 flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<BrandIcon className="size-5" />
</div>
<CardTitle>Sign in to {brand.name}</CardTitle>
<CardDescription>
Mock auth any email + non-empty password works. Wire{" "}
<code className="font-mono text-xs">~/lib/session.ts</code> to your
real backend.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Email</span>
<Input
data-action="login-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoFocus
required
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Password</span>
<Input
data-action="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
{error && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{error}
</p>
)}
<Button
data-action="login-submit"
type="submit"
disabled={submitting}
className="mt-1"
>
{submitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LogIn className="size-4" />
)}
Sign in
</Button>
<p className="mt-1 text-center text-xs text-muted-foreground">
<Sparkles className="mr-1 inline size-3" />
No account needed in dev credentials aren't checked.
</p>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import { Boxes } from "lucide-react" import { useEffect, useMemo, useState } from "react"
import { Plus, Search, Trash2 } from "lucide-react"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { Button } from "~/components/ui/button"
import { import {
Card, Card,
CardContent, CardContent,
@@ -8,35 +10,172 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card" } from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import {
createResource,
deleteResource,
seedResourcesIfEmpty,
updateResource,
useResources,
type Resource,
} from "~/lib/resources"
import { pageTitle } from "~/lib/page-meta" import { pageTitle } from "~/lib/page-meta"
export const meta = () => pageTitle("Resources") export const meta = () => pageTitle("Resources")
const statuses: Resource["status"][] = ["active", "paused", "archived"]
export default function ResourcesRoute() { export default function ResourcesRoute() {
const items = useResources()
const [query, setQuery] = useState("")
const [draftName, setDraftName] = useState("")
useEffect(() => {
seedResourcesIfEmpty()
}, [])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return q
? items.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.owner.toLowerCase().includes(q) ||
r.status.includes(q),
)
: items
}, [items, query])
const create = () => {
const name = draftName.trim()
if (!name) return
createResource({ name, owner: "You" })
setDraftName("")
}
return ( return (
<AppShell title="Resources"> <AppShell title="Resources">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Resources</CardTitle> <CardTitle>Resources</CardTitle>
<CardDescription> <CardDescription>
A list/detail surface for the entities your app manages. Example domain entity. CRUD goes through{" "}
<code className="font-mono text-xs">~/lib/resources.ts</code>
swap that file's calls for{" "}
<code className="font-mono text-xs">api.get/post/put/del</code>{" "}
from <code className="font-mono text-xs">~/lib/api.ts</code> when
you have a backend.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex flex-col gap-4">
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/30 p-12 text-center"> <div className="flex flex-wrap items-center gap-2">
<div className="flex size-12 items-center justify-center rounded-xl bg-background text-muted-foreground"> <div className="relative flex-1 min-w-48">
<Boxes className="size-6" /> <Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
</div> <Input
<div className="max-w-md"> data-action="resources-search"
<p className="font-medium">No resources yet</p> value={query}
<p className="mt-1 text-sm text-muted-foreground"> onChange={(e) => setQuery(e.target.value)}
This route is the canonical "traditional" surface. Drop in{" "} placeholder="Search name, owner, status…"
<code className="font-mono text-xs">@crema/table-ui</code> or{" "} className="pl-8"
<code className="font-mono text-xs">@crema/data-ui</code> when />
you have data to show.
</p>
</div> </div>
<Input
data-action="resources-new-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") create()
}}
placeholder="New resource name…"
className="max-w-64"
/>
<Button
data-action="resources-create"
onClick={create}
disabled={!draftName.trim()}
>
<Plus className="size-4" /> Add
</Button>
</div> </div>
<div className="overflow-hidden rounded-lg border bg-card/40">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Name</th>
<th className="px-3 py-2 text-left font-medium">Owner</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-left font-medium">Updated</th>
<th className="w-10 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td
colSpan={5}
className="px-3 py-8 text-center text-muted-foreground"
>
{items.length === 0
? "No resources yet — add one above."
: "No matches."}
</td>
</tr>
) : (
filtered.map((r) => (
<tr
key={r.id}
className="border-t transition-colors hover:bg-accent/30"
>
<td className="px-3 py-2 font-medium">{r.name}</td>
<td className="px-3 py-2 text-muted-foreground">
{r.owner}
</td>
<td className="px-3 py-2">
<select
data-action={`resources-status-${r.id}`}
value={r.status}
onChange={(e) =>
updateResource(r.id, {
status: e.target.value as Resource["status"],
})
}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs"
>
{statuses.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{new Date(r.updatedAt).toLocaleDateString()}
</td>
<td className="px-2 py-2 text-right">
<Button
data-action={`resources-delete-${r.id}`}
variant="ghost"
size="icon-sm"
aria-label="Delete"
onClick={() => {
if (window.confirm(`Delete "${r.name}"?`))
deleteResource(r.id)
}}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">
{items.length} total · {filtered.length} shown
</p>
</CardContent> </CardContent>
</Card> </Card>
</AppShell> </AppShell>

1087
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@
"dev": "react-router dev", "dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js", "start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc", "typecheck": "react-router typegen && tsc",
"test": "vitest run",
"test:watch": "vitest",
"sync-libs": "node scripts/sync-libs.mjs" "sync-libs": "node scripts/sync-libs.mjs"
}, },
"dependencies": { "dependencies": {
@@ -33,15 +35,19 @@
"devDependencies": { "devDependencies": {
"@react-router/dev": "7.13.1", "@react-router/dev": "7.13.1",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/d3-geo": "^3.1.0", "@types/d3-geo": "^3.1.0",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5", "@types/topojson-client": "^3.1.5",
"@types/topojson-specification": "^1.0.5", "@types/topojson-specification": "^1.0.5",
"jsdom": "^29.1.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.5"
} }
} }

13
vitest.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config"
import tsconfigPaths from "vite-tsconfig-paths"
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["app/**/*.{test,spec}.{ts,tsx}"],
css: false,
},
})

8
vitest.setup.ts Normal file
View File

@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest"
import { afterEach } from "vitest"
import { cleanup } from "@testing-library/react"
afterEach(() => {
cleanup()
if (typeof localStorage !== "undefined") localStorage.clear()
})