-
My apps
-
- Every switch is one TigerApp PI can{" "}
- read and act on — flip them anytime.
-
+
+
+
+ My Apps
+
+
+ Nothing is switched on until you switch it on here. TigerJunction
+ carries course ratings and seat watches too, so PI reads those
+ through it.
+
+
+
+
+ 0 ? "toggle on" : "toggle"}
+ role="switch"
+ aria-checked={engineOn.length > 0}
+ aria-label="app connectivity"
+ aria-busy={pending === "all"}
+ disabled={pending === "all"}
+ onClick={toggleAll}
+ />
+
+ app connectivity
+
+ One switch for all of them. {connected} connected right now.
+
+
+
+
{callbackError && (
-
-
that didn't finish —
- connecting Google Calendar failed: {callbackError}. Toggle it off
- and on to get a fresh link, then try again.
+
+ that didn't finish.
+ Google didn't hand your calendar over. Switch it off and back on for
+ a fresh link, then try once more.
)}
-
-
Signed in
-
-
- {identity.netid.slice(0, 1).toUpperCase()}
-
-
- {identity.name}
-
- {identity.netid} · {identity.email}
-
-
-
-
- Sign out
+ {saveError && (
+
+
{saveError.text}
+
apply(retryRef.current ?? settings, "all")}
+ >
+ Try again
+ )}
+
+
+
+ TigerApps
+
+
+ {ENGINE_APPS.map(rowFor)}
+ {SOON_APPS.map((soon) => (
+
+ ))}
+
- PI is now connected with the TigerApps you've used in the past
+ The arrow opens the app itself. That only works for apps TigerApps
+ builds.
-
+
-
- {PI_APPS.map((app) => {
- const on = settings.apps.includes(app.key);
- const status = statusFor(app.key);
- return (
-
-
-
-
-
-
- {app.name}
- {app.tagline}
-
-
-
{app.detail}
-
-
{status.text}
- {on && desk.deskState?.authUrls?.[app.key] && (
-
- Connect with Google
-
- )}
-
toggleApp(app.key)}
- />
-
-
- );
- })}
-
+
+
+ My Calendars
+
+
+
+ {CALENDAR_APPS.map(rowFor)}
+ {SOON_CALENDARS.map((soon) => (
+
+ ))}
+
+
+
+
+
+ Bring your own app
+
+
+
-
- Connections speak MCP to the TigerApps engine. New chats pick up
- whatever's switched on here; open chats follow along on your next
- message.
+
+ Flip something on and any new chat picks it up straight away. A chat
+ you already have open catches up on your next message.
+
+
+
+ {identity.netid.slice(0, 1).toUpperCase()}
+
+
+ {identity.name}
+
+ {identity.netid} · {identity.email}
+
+
+
+ Sign out
+
+
);
}
+
+/** One live app: mark, personality line, what it reads, switch, launch. */
+function AppRow({
+ app,
+ on,
+ busy,
+ note,
+ logoBroken,
+ onLogoError,
+ onToggle,
+}: {
+ app: PiApp;
+ on: boolean;
+ busy: boolean;
+ note: ReactNode;
+ logoBroken: boolean;
+ onLogoError: () => void;
+ onToggle: () => void;
+}) {
+ return (
+
+
+ {logoBroken ? (
+ app.glyph
+ ) : (
+
+ )}
+
+
+
+
{app.name}
+
{app.detail}
+
+ {app.personal
+ ? "reads things that are yours"
+ : "reads public course data"}
+
+ {note}
+
+
+
+
+ );
+}
+
+/** A row for something PI can't talk to yet: lettermark, blurb, no switch. */
+function SoonRow({ soon }: { soon: SoonApp }) {
+ return (
+
+
+ {soon.lettermark}
+
+
+
{soon.name}
+
{soon.blurb}
+
+
+ coming soon
+
+
+ );
+}
diff --git a/app/src/client/pages/ChatPage.tsx b/app/src/client/pages/ChatPage.tsx
index 90e0ebc..e5c204d 100644
--- a/app/src/client/pages/ChatPage.tsx
+++ b/app/src/client/pages/ChatPage.tsx
@@ -1,74 +1,211 @@
import { useAgentChat } from "@cloudflare/think/react";
import { AgentClient } from "agents/client";
import { useAgent } from "agents/react";
-import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ Suspense,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import type { UIMessage } from "ai";
-import { PI_MODELS, type PiSettings } from "../../shared/apps";
+import {
+ PI_APPS,
+ PI_MODELS,
+ type AppKey,
+ type PiModel,
+ type PiSettings,
+} from "../../shared/apps";
import { Composer } from "../components/Composer";
+import {
+ IconArrowUp,
+ IconCheck,
+ IconCopy,
+ IconFork,
+ IconInfo,
+ IconRetry,
+ IconRewind,
+ IconSpark,
+ PiMark,
+} from "../components/Icons";
import { Markdown } from "../components/Markdown";
import { ToolRender } from "../components/ToolCards";
-import { userInstance, type Identity } from "../lib/auth";
-import { newChatId, savePrefs, upsertChat, useChats } from "../lib/store";
-import { parseToolPart } from "../lib/tools";
-
-const SUGGESTIONS = [
- "What does my fall schedule look like?",
- "Find me a highly-rated LA distribution course with no Friday classes",
- "Which COS courses are trending this semester?",
- "Does anything in my schedule conflict?",
+import { reportUnauthorized, userInstance, type Identity } from "../lib/auth";
+import {
+ dismissNotes,
+ newChatId,
+ readNotesDismissed,
+ savePrefs,
+ upsertChat,
+ useChats,
+} from "../lib/store";
+import {
+ APP_INK,
+ APP_REQUEST_PART,
+ CHOICES_PART,
+ appDisplayName,
+ parseAppRequest,
+ parseChoicesAsk,
+ parseToolPart,
+ type AppRequest,
+ type ChoicesAsk,
+} from "../lib/tools";
+import "../styles/chat.css";
+
+/** Stable empty array: a draft chat has nothing to hydrate from. */
+const NO_MESSAGES: UIMessage[] = [];
+
+/** How close to the bottom still counts as "following along", in px. */
+const STICK_SLACK = 80;
+
+/**
+ * Starters, each tagged with the TigerApp that has to be on for PI to answer
+ * it. Filtering by the student's own switches is what keeps a chip from being
+ * a guaranteed dead end.
+ */
+const SUGGESTIONS: Array<{ needs: AppKey; text: string }> = [
+ { needs: "junction", text: "What does my schedule look like this semester?" },
+ {
+ needs: "princetoncourses",
+ text: "Find me a well-rated LA course with no Friday classes",
+ },
+ { needs: "path", text: "What's left before I graduate?" },
+ {
+ needs: "snatch",
+ text: "Which COS courses is everyone fighting over right now?",
+ },
+ { needs: "junction", text: "Does anything in my schedule collide?" },
+ {
+ needs: "gcal",
+ text: "Would a 10am section clash with anything on my calendar?",
+ },
+ {
+ needs: "princetoncourses",
+ text: "What did people actually say about COS 226?",
+ },
];
/**
- * A stream interruption + recovery can split one utterance across several
- * consecutive text parts (often mid-sentence). Rendering each part as its
- * own markdown block tears sentences and lists apart, so stitch runs of
- * text parts back together before rendering.
+ * What one more switched-off app would add, in the order we'd suggest it.
+ * PrincetonCourses isn't here: its tools arrive through TigerJunction's scope
+ * either way (see setup in server/pi.ts), so offering it would be selling a
+ * student something they already have.
*/
-function coalesceParts(parts: UIMessage["parts"]): UIMessage["parts"] {
- const out: UIMessage["parts"] = [];
- for (const part of parts) {
- const last = out[out.length - 1];
- if (part.type === "text" && last?.type === "text") {
- out[out.length - 1] = { ...last, text: last.text + part.text };
- } else {
- out.push(part);
- }
- }
- return out;
-}
+const OFFERS: Array<{ key: AppKey; line: string }> = [
+ {
+ key: "path",
+ line: "TigerPath would let PI answer what's actually left before you graduate.",
+ },
+ {
+ key: "gcal",
+ line: "Google Calendar would let PI plan around what's already in your week.",
+ },
+];
-function messageText(m: UIMessage): string {
- return m.parts
- .map((p) => (p.type === "text" ? p.text : ""))
- .join("")
- .trim();
-}
+/** One placeholder per chat, picked from the id so it never shuffles mid-chat. */
+const PLACEHOLDERS = [
+ "Ask like you'd text the friend who already took it…",
+ "Say it messy. PI will sort it out…",
+ "What's the plan, or what's the problem?",
+];
-export function ChatPage({
- chatId,
- isDraft,
- identity,
- settings,
- navigate,
-}: {
+/** The slice of the agent's state a chat listens to (see PiState on the server). */
+type ChatAgentState = { connectError?: string; gcalReady?: boolean };
+
+type ChatPageProps = {
chatId: string;
/** True when this chat hasn't been sent to yet (the "/" home state). */
isDraft: boolean;
identity: Identity;
settings: PiSettings;
navigate: (path: string, replace?: boolean) => void;
-}) {
- const agent = useAgent({ agent: "pi", name: userInstance(identity.netid, chatId) });
- const chat = useAgentChat({ agent });
- const { messages, sendMessage, status, stop, regenerate, connectionError } =
- chat;
+};
+
+export function ChatPage(props: ChatPageProps) {
+ // Decided once, at mount. A draft that turns into a real chat mid-sentence
+ // must not suddenly suspend on a history fetch for a transcript it wrote.
+ const [skipHistory] = useState(props.isDraft);
+ return (
+
}>
+
+
+ );
+}
+
+function ChatBody({
+ chatId,
+ isDraft,
+ identity,
+ settings,
+ navigate,
+ skipHistory,
+}: ChatPageProps & { skipHistory: boolean }) {
+ // The server flags a turn that ran without its TigerApps and names them. To
+ // a student that's the same failure as a setup push that didn't land, so
+ // both light the one strip below. `gcalReady` rides along on the same state:
+ // only the desk knows whether Google actually handed the calendar over, and
+ // a consent card must not call it on until it has.
+ const [engineNote, setEngineNote] = useState
(null);
+ const [gcalReady, setGcalReady] = useState(false);
+ const agent = useAgent({
+ agent: "pi",
+ name: userInstance(identity.netid, chatId),
+ onStateUpdate: (state: ChatAgentState | null) => {
+ setEngineNote(state?.connectError ?? null);
+ setGcalReady(state?.gcalReady === true);
+ },
+ });
+ // `getInitialMessages: null` short-circuits the get-messages fetch, so a
+ // draft never pays a cold Durable Object round trip for an empty transcript.
+ const chat = useAgentChat(
+ skipHistory
+ ? { agent, getInitialMessages: null, messages: NO_MESSAGES }
+ : { agent }
+ );
+ const {
+ messages,
+ sendMessage,
+ status,
+ stop,
+ regenerate,
+ connectionError,
+ error,
+ clearError,
+ isStreaming,
+ isRecovering,
+ } = chat;
const chats = useChats(identity.netid);
const scrollRef = useRef(null);
+ /** False once the student scrolls away from the bottom to read back. */
+ const stickRef = useRef(true);
const settingsHash = useMemo(() => JSON.stringify(settings), [settings]);
const appliedRef = useRef(null);
const startedRef = useRef(false);
- const [busyAction, setBusyAction] = useState(null);
+ const [busyAction, setBusyAction] = useState(null);
+ const [branchFailed, setBranchFailed] = useState(false);
+ const [setupFailed, setSetupFailed] = useState(false);
+ const [sendFailure, setSendFailure] = useState(null);
+ const [detached, setDetached] = useState(false);
+
+ // Settings the next `setup` push should carry. Held in a ref so a consent
+ // card can flip an app on and send in the same tick, before React re-renders.
+ const settingsRef = useRef(settings);
+ settingsRef.current = settings;
+
+ /** Turns already on screen at mount don't replay their entry animation. */
+ const seenRef = useRef | null>(null);
+ if (seenRef.current === null) {
+ seenRef.current = new Set(messages.map((m) => m.id));
+ }
+
+ // Locally echoed sends, shown between Enter and the hook's own append.
+ const [pending, setPending] = useState([]);
+ const echoSeq = useRef(0);
+ // Send times for messages written in this session. History gets none —
+ // the transcript carries no timestamps, and inventing them would be a lie.
+ const [stamps, setStamps] = useState>({});
// A rewind from another chat may have left a draft for this one.
const [draft] = useState(() => {
@@ -86,14 +223,18 @@ export function ChatPage({
* beforeTurn, not here).
*/
async function ensureSetup() {
- if (appliedRef.current === settingsHash) return;
- appliedRef.current = settingsHash;
+ const wanted = settingsRef.current;
+ const hash = JSON.stringify(wanted);
+ if (appliedRef.current === hash) return;
+ appliedRef.current = hash;
try {
await agent.ready;
- await agent.call("setup", [settings]);
+ await agent.call("setup", [wanted]);
+ setSetupFailed(false);
} catch (err) {
console.warn("PI setup failed", err);
- appliedRef.current = null;
+ if (appliedRef.current === hash) appliedRef.current = null;
+ setSetupFailed(true);
}
}
@@ -107,10 +248,77 @@ export function ChatPage({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsHash, messages.length > 0]);
+ // A dropped socket is the first thing that notices a lapsed session: the
+ // upgrade gets a 401 and the browser reports a plain abnormal close, with no
+ // status to read. So ask the shell to re-check who's signed in. It's
+ // self-correcting — a live session re-confirms and nothing on screen moves,
+ // a lapsed one raises the sign-back-in card instead of leaving this strip
+ // and a dead composer sitting there.
+ // Once per outage, not once per retry: the socket backs off and retries, and
+ // each failure is a fresh error object.
+ const askedWhoRef = useRef(false);
useEffect(() => {
+ if (!connectionError) {
+ askedWhoRef.current = false;
+ return;
+ }
+ if (askedWhoRef.current) return;
+ askedWhoRef.current = true;
+ reportUnauthorized();
+ }, [connectionError]);
+
+ // Open a saved chat already scrolled to the end, with no replay of the
+ // whole conversation on the way down.
+ useLayoutEffect(() => {
const el = scrollRef.current;
- if (el) el.scrollTop = el.scrollHeight;
- }, [messages, status]);
+ if (el) el.scrollTo({ top: el.scrollHeight, behavior: "instant" });
+ }, []);
+
+ // Follow the stream only while the student is actually at the bottom.
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (el && stickRef.current) el.scrollTop = el.scrollHeight;
+ }, [messages, status, pending]);
+
+ function onScroll() {
+ const el = scrollRef.current;
+ if (!el) return;
+ const atBottom =
+ el.scrollHeight - el.scrollTop - el.clientHeight < STICK_SLACK;
+ stickRef.current = atBottom;
+ setDetached((was) => (was === !atBottom ? was : !atBottom));
+ }
+
+ function jumpToLatest() {
+ const el = scrollRef.current;
+ if (!el) return;
+ stickRef.current = true;
+ setDetached(false);
+ // An explicit behavior wins over the stylesheet's reduced-motion rule, so
+ // this is the one place that has to ask for the preference itself.
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ el.scrollTo({
+ top: el.scrollHeight,
+ behavior: still ? "instant" : "smooth",
+ });
+ }
+
+ // Retire an echo once its real message has landed, and keep its send time.
+ useEffect(() => {
+ if (pending.length === 0) return;
+ const sent = messages.filter((m) => m.role === "user");
+ const landed = pending.filter((p) => sent.length > p.ordinal);
+ if (landed.length === 0) return;
+ setStamps((prev) => {
+ const next = { ...prev };
+ for (const p of landed) {
+ const m = sent[p.ordinal];
+ if (m && next[m.id] == null) next[m.id] = p.at;
+ }
+ return next;
+ });
+ setPending((prev) => prev.filter((p) => sent.length <= p.ordinal));
+ }, [messages, pending]);
function send(text: string) {
startedRef.current = true;
@@ -120,22 +328,67 @@ export function ChatPage({
title: firstTitle(messages) ?? text.slice(0, 48),
at: Date.now(),
});
- sendChain.current = sendChain.current.then(async () => {
- // The first message of a chat needs settings on the server; bound the
- // wait so a slow push can never swallow a message silently.
- await Promise.race([
- ensureSetup(),
- new Promise((resolve) => setTimeout(resolve, 4000)),
- ]);
- void sendMessage({ text });
- });
+ setSendFailure(null);
+ // The words go on screen now, not after the settings push resolves. The
+ // ordinal is the slot this message will occupy among the user's turns,
+ // which is how the echo knows when its real counterpart has landed.
+ const echo = { key: `echo-${echoSeq.current++}`, text, at: Date.now() };
+ const landed = messages.filter((m) => m.role === "user").length;
+ setPending((prev) => [
+ ...prev,
+ { ...echo, ordinal: landed + prev.length },
+ ]);
+ sendChain.current = sendChain.current
+ .then(async () => {
+ // The first message of a chat needs settings on the server; bound the
+ // wait so a slow push can never swallow a message silently.
+ await Promise.race([
+ ensureSetup(),
+ new Promise((resolve) => setTimeout(resolve, 4000)),
+ ]);
+ void sendMessage({ text });
+ })
+ // A throw here would poison the chain and kill every later send, so it
+ // stops at this link and surfaces instead.
+ .catch((err) => {
+ console.warn("send failed", err);
+ setPending([]);
+ setSendFailure(text);
+ });
+ }
+
+ /** Chips can't queue a duplicate turn during the silent send window. */
+ function sendSuggestion(text: string) {
+ if (busy || pending.length > 0) return;
+ send(text);
+ }
+
+ function grantApp(app: AppKey) {
+ const apps = settings.apps.includes(app)
+ ? settings.apps
+ : [...settings.apps, app];
+ settingsRef.current = { ...settings, apps };
+ savePrefs(identity.netid, { apps, model: settings.model });
+ // Google Calendar takes a second yes, Google's own, and only My apps can
+ // ask for it. The switch alone would leave PI reaching for a calendar it
+ // still can't read, so walk the student over instead of reporting success.
+ if (app === "gcal") {
+ navigate("/apps");
+ return;
+ }
+ send(`I turned on ${appDisplayName(app)}, go ahead.`);
}
/** Copy history up to `endIndex` (exclusive) into a fresh chat. */
- async function branch(endIndex: number, draftText?: string): Promise {
+ async function branch(
+ endIndex: number,
+ action: BusyAction,
+ draftText?: string
+ ): Promise {
const slice = messages.slice(0, endIndex);
const id = newChatId();
- setBusyAction("branch");
+ setBranchFailed(false);
+ setBusyAction(action);
try {
const client = new AgentClient({
agent: "pi",
@@ -144,15 +397,13 @@ export function ChatPage({
});
await client.ready;
if (slice.length > 0) await client.call("importHistory", [slice]);
- await client.call("setup", [settings]);
+ await client.call("setup", [settingsRef.current]);
client.close();
const current = chats.find((c) => c.id === chatId);
+ const base = current?.title ?? firstTitle(messages) ?? "this chat";
upsertChat(identity.netid, {
id,
- title: `⑂ ${current?.title ?? firstTitle(messages) ?? "fork"}`.slice(
- 0,
- 48
- ),
+ title: `${base} (offshoot)`.slice(0, 48),
at: Date.now(),
});
if (draftText) {
@@ -165,27 +416,40 @@ export function ChatPage({
navigate(`/chat/${id}`);
} catch (err) {
console.warn("branch failed", err);
+ setBranchFailed(true);
} finally {
setBusyAction(null);
}
}
- const busy = status === "submitted" || status === "streaming";
- const empty = messages.length === 0;
- const name = (identity.name.split(" ")[0] || identity.netid).toLowerCase();
+ const busy = status === "submitted" || isStreaming || isRecovering;
+ const turnFailed = status === "error" || error != null;
+ const empty = messages.length === 0 && pending.length === 0;
+ const name = greetingName(identity);
+ const prompts = useMemo(() => pickPrompts(settings.apps), [settings.apps]);
const lastAssistantId = [...messages]
.reverse()
.find((m) => m.role === "assistant")?.id;
+ const lastUserIndex = messages.reduce(
+ (acc, m, i) => (m.role === "user" ? i : acc),
+ -1
+ );
+
+ function retryTurn() {
+ clearError();
+ setSendFailure(null);
+ void regenerate();
+ }
const modelSwitch = (
savePrefs(identity.netid, {
apps: settings.apps,
- model: e.target.value as PiSettings["model"],
+ model: e.target.value as PiModel,
})
}
>
@@ -200,75 +464,220 @@ export function ChatPage({
return (
<>
{connectionError && (
-
- Connection hiccup — retrying. ({connectionError.reason || "socket closed"})
+
+
+ PI can’t stay connected to this chat.
+
+ location.reload()}
+ >
+ reload
+
)}
-
-
- {empty ? (
-
-
- Hello {name} ,
-
-
how can I help you today?
-
- {SUGGESTIONS.map((s) => (
+
+
+
+ {empty ? (
+ <>
+
+
+
+
+
+ Hello {name} ,
+
+
+ what are we working on?{" "}
+
+ pick one below, or just start typing
+
+
+
+ {prompts.length > 0 ? (
+ prompts.map((s) => (
+ 0}
+ onClick={() => sendSuggestion(s.text)}
+ >
+
+ {s.text}
+
+ ))
+ ) : (
+ navigate("/apps")}
+ >
+
+ Nothing is switched on yet. Pick your apps first.
+
+ )}
+
+
+
+ >
+ ) : (
+
+ {messages.map((m, i) => (
+
0}
+ enabledApps={settings.apps}
+ gcalReady={gcalReady}
+ actionsDisabled={busy || busyAction != null}
+ isLastAssistant={m.id === lastAssistantId}
+ running={busyAction?.id === m.id ? busyAction.kind : null}
+ onReply={send}
+ onGrant={grantApp}
+ onRewind={
+ m.role === "user"
+ ? () =>
+ void branch(
+ i,
+ { kind: "rewind", id: m.id },
+ messageText(m)
+ )
+ : undefined
+ }
+ onFork={
+ m.role === "assistant"
+ ? () => void branch(i + 1, { kind: "fork", id: m.id })
+ : undefined
+ }
+ onRegenerate={
+ m.role === "assistant" && m.id === lastAssistantId
+ ? () => void regenerate()
+ : undefined
+ }
+ />
+ ))}
+ {pending.map((p) => (
+
+
{p.text}
+
{fmtClock(p.at)}
+
+ ))}
+
+ )}
+ {/* an echoed send counts as work in flight: the nib must never
+ vanish between Enter and the first token */}
+ {(pending.length > 0 || (busy && lastIsUser(messages))) && (
+
+
+ {isRecovering ? "picking that back up…" : "pi is writing…"}
+
+ )}
+ {/* the visible progress lives on the button that was clicked, so
+ forking from the top of a long chat isn't announced off-screen */}
+ {busyAction && (
+
+ copying this conversation…
+
+ )}
+ {turnFailed && (
+
+
+
that one didn’t land
+
+ PI stopped partway through. Nothing you wrote was lost.
+
+
+
+
+ try again
+
+
+
+ )}
+ {sendFailure && (
+
+
+
that message never went out
+
+ PI couldn’t get it to your desk.
+
+
+
void send(s)}
+ className="btn btn-ghost btn-sm"
+ onClick={() => {
+ const again = sendFailure;
+ setSendFailure(null);
+ send(again);
+ }}
>
-
- ↳
-
- {s}
+ send it again
- ))}
+
-
- ) : (
- messages.map((m, i) => (
-
void branch(i, messageText(m))
- : undefined
- }
- onFork={
- m.role === "assistant"
- ? () => void branch(i + 1)
- : undefined
- }
- onRegenerate={
- m.role === "assistant" && m.id === lastAssistantId
- ? () => void regenerate()
- : undefined
- }
- />
- ))
- )}
- {busy && lastIsUser(messages) && (
-
- pi is writing…
-
- )}
- {busyAction === "branch" && (
-
- copying this conversation…
-
- )}
+ )}
+ {branchFailed && (
+
+
+
that copy didn’t take
+
+ PI couldn’t open the new chat. This one is untouched.
+
+
+
+ setBranchFailed(false)}
+ >
+ dismiss
+
+
+
+ )}
+ {(setupFailed || engineNote) && (
+
+ {/* the server names the apps it couldn't use; a setup push that
+ never landed has no names to give, so it falls back */}
+
+ {engineNote ??
+ "PI couldn’t reach your TigerApps just now, so answers may be missing course data."}
+
+ {
+ appliedRef.current = null;
+ setEngineNote(null);
+ void ensureSetup();
+ }}
+ >
+ try again
+
+
+ )}
+
+ {detached && busy && (
+
+ jump to latest
+
+ )}
void send(t)}
+ onSend={send}
onStop={stop}
busy={busy}
- placeholder="Ask about courses, schedules, ratings, your degree…"
+ placeholder={placeholderFor(chatId)}
initialText={draft}
accessory={modelSwitch}
/>
@@ -276,6 +685,161 @@ export function ChatPage({
);
}
+/* ── the second beat of the home screen ──────────────────────────── */
+
+/**
+ * Below the greeting: a note about what today's summary holds, one suggestion
+ * drawn from the student's own switches, and the line about what PI is allowed
+ * to read. Every word here comes from the date or their preferences — this is
+ * not a place to print campus news PI hasn't actually read.
+ */
+function HomeNotes({
+ netid,
+ apps,
+ navigate,
+}: {
+ netid: string;
+ apps: AppKey[];
+ navigate: (path: string, replace?: boolean) => void;
+}) {
+ const now = new Date();
+ // Local calendar day, so a dismissal lasts until tomorrow rather than until
+ // UTC midnight lands mid-afternoon.
+ const day = now.toLocaleDateString("en-CA");
+ const [dismissed, setDismissed] = useState(
+ () => readNotesDismissed(netid) === day
+ );
+
+ const junctionOn = apps.includes("junction");
+ // With TigerJunction off, the note is already asking for TigerJunction —
+ // a second ask underneath it would just be nagging.
+ const offer = junctionOn
+ ? OFFERS.find((o) => !apps.includes(o.key))
+ : undefined;
+
+ return (
+
+ {!dismissed && (
+
+
+ {now.toLocaleDateString(undefined, {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ })}
+
+ Today’s summary, one page over.
+
+ {junctionOn
+ ? "Your classes for today, the seats you’re watching and what’s trending on campus sit together there."
+ : "Switch TigerJunction on and it fills up: today’s classes, the seats you’re watching, what’s trending on campus."}
+
+ {offer && {offer.line}
}
+
+ navigate("/agenda")}
+ >
+ read more
+
+ {
+ dismissNotes(netid, day);
+ setDismissed(true);
+ }}
+ >
+ dismiss
+
+
+
+ )}
+
+
+ PI only uses data from the apps and calendars{" "}
+ you approve of .
+
+ navigate("/apps")}>
+ take me to the app library
+
+
+ );
+}
+
+/* ── the shape of a chat, while its transcript is on the way ─────── */
+
+function ChatSkeleton() {
+ return (
+ <>
+
+
+
+
Opening this chat.
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+/* ── helpers ─────────────────────────────────────────────────────── */
+
+type Echo = { key: string; text: string; at: number; ordinal: number };
+
+type BusyAction = { kind: "rewind" | "fork"; id: string };
+
+/**
+ * A stream interruption + recovery can split one utterance across several
+ * consecutive text parts (often mid-sentence). Rendering each part as its
+ * own markdown block tears sentences and lists apart, so stitch runs of
+ * text parts back together before rendering.
+ */
+function coalesceParts(parts: UIMessage["parts"]): UIMessage["parts"] {
+ const out: UIMessage["parts"] = [];
+ for (const part of parts) {
+ const last = out[out.length - 1];
+ if (part.type === "text" && last?.type === "text") {
+ out[out.length - 1] = { ...last, text: last.text + part.text };
+ } else {
+ out.push(part);
+ }
+ }
+ return out;
+}
+
+function messageText(m: UIMessage): string {
+ return m.parts
+ .map((p) => (p.type === "text" ? p.text : ""))
+ .join("")
+ .trim();
+}
+
function firstTitle(messages: UIMessage[]): string | null {
const first = messages.find((m) => m.role === "user");
if (!first) return null;
@@ -290,60 +854,178 @@ function lastIsUser(messages: UIMessage[]): boolean {
return !last.parts.some((p) => p.type === "text" && p.text.trim() !== "");
}
+/**
+ * Directories hand back "Doe, Jane" about as often as "Jane Doe", and the
+ * netid fallback ("Hello jd1234,") is the coldest possible greeting — so a
+ * name PI can't vouch for becomes no name at all.
+ */
+function greetingName(identity: Identity): string {
+ const raw = identity.name?.trim() ?? "";
+ const afterComma = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw;
+ const first = (afterComma.trim().split(/\s+/)[0] ?? "").replace(
+ /[^\p{L}\p{M}'-]/gu,
+ ""
+ );
+ if (!first || first.toLowerCase() === identity.netid.toLowerCase()) {
+ return "there";
+ }
+ // Only normalize the shouted and the all-lowercase; leave McKay alone.
+ return first === first.toLowerCase() || first === first.toUpperCase()
+ ? first[0].toUpperCase() + first.slice(1).toLowerCase()
+ : first;
+}
+
+function pickPrompts(apps: AppKey[]): Array<{ needs: AppKey; text: string }> {
+ const on = new Set(apps);
+ const usable = SUGGESTIONS.filter((s) => on.has(s.needs));
+ const picked: typeof usable = [];
+ const covered = new Set();
+ // One per app first, so four chips never all lean on the same TigerApp.
+ for (const s of usable) {
+ if (covered.has(s.needs)) continue;
+ covered.add(s.needs);
+ picked.push(s);
+ }
+ for (const s of usable) {
+ if (picked.length >= 4) break;
+ if (!picked.includes(s)) picked.push(s);
+ }
+ return picked.slice(0, 4);
+}
+
+function placeholderFor(chatId: string): string {
+ let sum = 0;
+ for (let i = 0; i < chatId.length; i++) sum += chatId.charCodeAt(i);
+ return PLACEHOLDERS[sum % PLACEHOLDERS.length];
+}
+
+/** Send time from the message itself, else the one we recorded on send. */
+function messageTime(
+ m: UIMessage,
+ stamps: Record
+): number | null {
+ const raw = (m.metadata as { createdAt?: unknown } | undefined)?.createdAt;
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
+ if (typeof raw === "string") {
+ const parsed = Date.parse(raw);
+ if (!Number.isNaN(parsed)) return parsed;
+ }
+ return stamps[m.id] ?? null;
+}
+
+function fmtClock(at: number): string {
+ return new Date(at).toLocaleTimeString([], {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+function humanTool(base: string): string {
+ return base.replace(/[_-]+/g, " ").trim();
+}
+
+/* ── one turn ────────────────────────────────────────────────────── */
+
function Turn({
message,
+ fresh,
+ time,
+ answered,
+ enabledApps,
+ gcalReady,
actionsDisabled,
isLastAssistant,
+ running,
+ onReply,
+ onGrant,
onRewind,
onFork,
onRegenerate,
}: {
message: UIMessage;
+ fresh: boolean;
+ time: number | null;
+ /** True once the student has spoken again, which settles any open card. */
+ answered: boolean;
+ enabledApps: AppKey[];
+ /** Whether the desk actually holds Google tokens. */
+ gcalReady: boolean;
actionsDisabled: boolean;
isLastAssistant: boolean;
+ running: BusyAction["kind"] | null;
+ onReply: (text: string) => void;
+ onGrant: (app: AppKey) => void;
onRewind?: () => void;
onFork?: () => void;
onRegenerate?: () => void;
}) {
- const [copied, setCopied] = useState(false);
+ const [copyState, setCopyState] = useState<"idle" | "done" | "failed">(
+ "idle"
+ );
function copy() {
- void navigator.clipboard.writeText(messageText(message)).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- });
+ const settle = (next: "done" | "failed") => {
+ setCopyState(next);
+ window.setTimeout(() => setCopyState("idle"), 1800);
+ };
+ try {
+ navigator.clipboard.writeText(messageText(message)).then(
+ () => settle("done"),
+ () => settle("failed")
+ );
+ } catch {
+ settle("failed");
+ }
}
const actions = (
-
-
- {copied ? "✓ copied" : "⧉ copy"}
+
+
+ {copyState === "done" ? (
+ <>
+ copied
+ >
+ ) : copyState === "failed" ? (
+ <>
+ couldn’t copy
+ >
+ ) : (
+ <>
+ copy
+ >
+ )}
{onRewind && (
- ↺ rewind
+
+ {running === "rewind" ? "copying…" : "rewind"}
)}
{onFork && (
- ⑂ fork
+
+ {running === "fork" ? "copying…" : "fork"}
)}
{onRegenerate && isLastAssistant && (
- ↻ retry
+ retry
)}
@@ -351,36 +1033,320 @@ function Turn({
if (message.role === "user") {
return (
-
-
-
{messageText(message)}
- {actions}
-
+
+
{messageText(message)}
+ {time != null &&
{fmtClock(time)} }
+ {actions}
);
}
+ const parts = coalesceParts(message.parts);
+ const body = parts
+ .map((part, i): React.ReactElement | null => {
+ const ask = parseChoicesAsk(part);
+ if (ask) {
+ return (
+
+ );
+ }
+ const request = parseAppRequest(part);
+ if (request) {
+ // The calendar is on only once Google has handed it over: the local
+ // switch is half the yes, and the desk holds the other half.
+ const on =
+ request.app === "gcal"
+ ? enabledApps.includes("gcal") && gcalReady
+ : enabledApps.includes(request.app);
+ return (
+
+ onReply(
+ `Not right now, answer without ${appDisplayName(request.app)}.`
+ )
+ }
+ />
+ );
+ }
+ // The two elicitation parts are drawn above or not at all — they are
+ // never tool chips, the way WorkedFrom already treats them. One that
+ // arrived unusable (no options, an app key PI doesn't have) falls
+ // through to the stalled note below.
+ if (part.type === CHOICES_PART || part.type === APP_REQUEST_PART) {
+ return null;
+ }
+ if (part.type === "text") {
+ return part.text.trim() ? : null;
+ }
+ if (part.type === "reasoning") {
+ const text = (part as { text?: string }).text?.trim();
+ if (!text) return null;
+ return (
+
+ Thought process
+ {text}
+
+ );
+ }
+ const tool = parseToolPart(part);
+ if (tool) return ;
+ return null;
+ })
+ .filter((node): node is React.ReactElement => node !== null);
+
+ // An unusable ask ends the turn on the server (see stopWhen in server/pi.ts),
+ // so nothing else is coming for this message. Say that, rather than leaving
+ // a turn with nothing in it.
+ const stalledAsk =
+ body.length === 0 &&
+ parts.some(
+ (part) =>
+ (part.type === CHOICES_PART || part.type === APP_REQUEST_PART) &&
+ (part as { state?: string }).state !== "input-streaming"
+ );
+
return (
-
- {coalesceParts(message.parts).map((part, i) => {
- if (part.type === "text") {
- return part.text.trim() ?
: null;
- }
- if (part.type === "reasoning") {
- const text = (part as { text?: string }).text?.trim();
- if (!text) return null;
+
+
+ {body}
+ {stalledAsk && (
+
+
+
that question didn’t come through
+
+ PI meant to ask you something and it arrived empty.
+
+
+ {onRegenerate && isLastAssistant && (
+
+
+ ask again
+
+
+ )}
+
+ )}
+ {messageText(message) !== "" && actions}
+
+ );
+}
+
+/** "Worked from ● TigerJunction ● PrincetonCourses", derived from the calls. */
+function WorkedFrom({ message }: { message: UIMessage }) {
+ const calls = useMemo(() => {
+ const out: Array<{ app: AppKey | null; base: string }> = [];
+ for (const part of message.parts) {
+ if (part.type === CHOICES_PART || part.type === APP_REQUEST_PART) {
+ continue;
+ }
+ const tool = parseToolPart(part);
+ if (tool) out.push({ app: tool.app, base: tool.base });
+ }
+ return out;
+ }, [message.parts]);
+
+ if (calls.length === 0) return null;
+
+ const apps: Array
= [];
+ for (const c of calls) if (!apps.includes(c.app)) apps.push(c.app);
+
+ return (
+
+
Worked from
+ {apps.map((app) => (
+
+
+ {appDisplayName(app)}
+
+ ))}
+
+
+
+
+
+ {calls.map((c, i) => (
+
+ {appDisplayName(c.app)}
+ {humanTool(c.base)}
+
+ ))}
+
+
+
+ );
+}
+
+/* ── elicitation ─────────────────────────────────────────────────── */
+
+/** Option rows for `offer_choices`. A pick is sent as an ordinary message. */
+function Choices({
+ ask,
+ answered,
+ onReply,
+}: {
+ ask: ChoicesAsk;
+ answered: boolean;
+ onReply: (text: string) => void;
+}) {
+ const [picked, setPicked] = useState([]);
+ const [other, setOther] = useState("");
+
+ function toggle(label: string) {
+ setPicked((prev) =>
+ prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label]
+ );
+ }
+
+ return (
+
+
{ask.question}
+
+ {ask.options.map((o) => {
+ const on = picked.includes(o.label);
return (
-
- margin notes
- {text}
-
+
+ (ask.multi ? toggle(o.label) : onReply(o.label))}
+ >
+
+ {o.label}
+ {o.detail && {o.detail} }
+
+ {ask.multi && }
+
+
);
- }
- const tool = parseToolPart(part);
- if (tool) return ;
- return null;
- })}
- {messageText(message) !== "" && actions}
+ })}
+
+ {ask.allowOther && !answered && (
+
+ )}
+ {ask.multi && !answered && (
+
onReply(picked.join(", "))}
+ >
+ {picked.length > 1 ? "those are my picks" : "that’s my pick"}
+
+ )}
+ {answered &&
answered
}
+
+ );
+}
+
+/** The consent card for `request_app`: PI needs a TigerApp that's switched off. */
+function Consent({
+ request,
+ settled,
+ alreadyOn,
+ halfGranted,
+ onGrant,
+ onDecline,
+}: {
+ request: AppRequest;
+ settled: boolean;
+ alreadyOn: boolean;
+ /** Switched on, but Google never finished handing the calendar over. */
+ halfGranted: boolean;
+ onGrant: (app: AppKey) => void;
+ onDecline: () => void;
+}) {
+ const app = PI_APPS.find((a) => a.key === request.app);
+ if (!app) return null;
+ // The calendar is the one app a switch can't finish on its own.
+ const needsGoogle = request.app === "gcal";
+ return (
+
+
+
+
+ {alreadyOn
+ ? `${app.name} is on`
+ : halfGranted
+ ? `${app.name} still needs Google`
+ : `Turn on ${app.name}?`}
+
+ {!settled && (
+
+ {halfGranted ? (
+ <>
+ It’s switched on here, but Google hasn’t handed the
+ calendar over yet. That last step lives in My apps.
+ >
+ ) : (
+ <>
+ PI needs this to {request.reason || `use ${app.name}`}.{" "}
+ {needsGoogle
+ ? "Google has to say yes too, so this opens My apps."
+ : "You can switch it off anytime in the app library."}
+ >
+ )}
+
+ )}
+
+ {!settled && (
+
+
+ not now
+
+ onGrant(request.app)}
+ >
+ {halfGranted
+ ? "finish in My apps"
+ : needsGoogle
+ ? "set it up"
+ : "turn on"}
+
+
+ )}
);
}
diff --git a/app/src/client/pages/PlannerPage.tsx b/app/src/client/pages/PlannerPage.tsx
index f75d7e5..97649eb 100644
--- a/app/src/client/pages/PlannerPage.tsx
+++ b/app/src/client/pages/PlannerPage.tsx
@@ -1,18 +1,28 @@
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { PI_APPS, type PiSettings } from "../../shared/apps";
+import { IconChevron, IconExternal } from "../components/Icons";
import { WeekGrid } from "../components/WeekGrid";
-import { useDesk } from "../lib/desk";
+import { friendlyError, rawError, useDesk } from "../lib/desk";
import { extractSchedule, type ScheduleView } from "../lib/tools";
+import "../styles/desk.css";
type SchedMeta = { id: number; title: string; term: number; termName?: string };
type Load =
| { kind: "loading" }
- | { kind: "blocked"; reason: string; cta: string }
+ | { kind: "blocked" }
| { kind: "fresh" }
- | { kind: "error"; message: string }
+ | { kind: "error"; err: unknown }
| { kind: "ready"; schedules: SchedMeta[] };
+type Detail =
+ | { kind: "loading" }
+ | { kind: "empty" }
+ | { kind: "error"; err: unknown }
+ | { kind: "ready"; schedule: ScheduleView };
+
+const JUNCTION = PI_APPS.find((a) => a.key === "junction")!;
+
export function PlannerPage({
settings,
navigate,
@@ -24,144 +34,153 @@ export function PlannerPage({
const [load, setLoad] = useState({ kind: "loading" });
const [term, setTerm] = useState(null);
const [selected, setSelected] = useState(null);
- const [schedule, setSchedule] = useState(null);
- const [detailError, setDetailError] = useState(null);
+ const [detail, setDetail] = useState({ kind: "loading" });
const junctionOn = settings.apps.includes("junction");
+ const gen = useRef({ list: 0, detail: 0 });
- useEffect(() => {
- let cancelled = false;
+ const loadSchedules = useCallback(async () => {
+ const id = ++gen.current.list;
setLoad({ kind: "loading" });
- setSchedule(null);
+ setDetail({ kind: "loading" });
setSelected(null);
-
- if (!junctionOn) {
- setLoad({
- kind: "blocked",
- reason: "TigerJunction is switched off, so there's no schedule to draw.",
- cta: "Turn it on in My apps",
- });
- return;
- }
-
- (async () => {
- try {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const data = (await desk.callApp(
- "junction",
- "get_user_schedules",
- {}
- )) as any;
- if (cancelled) return;
- const schedules: SchedMeta[] = Array.isArray(data?.schedules)
- ? data.schedules
- : [];
- if (schedules.length === 0) {
- setLoad({ kind: "fresh" });
- return;
- }
- schedules.sort((a, b) => b.term - a.term);
- setLoad({ kind: "ready", schedules });
- setTerm(schedules[0].term);
- setSelected(schedules[0].id);
- } catch (err) {
- if (!cancelled) {
- setLoad({
- kind: "error",
- message: err instanceof Error ? err.message : String(err),
- });
- }
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const data = (await desk.callApp(
+ "junction",
+ "get_user_schedules",
+ {}
+ )) as any;
+ if (gen.current.list !== id) return;
+ const schedules: SchedMeta[] = Array.isArray(data?.schedules)
+ ? data.schedules
+ : [];
+ if (schedules.length === 0) {
+ setLoad({ kind: "fresh" });
+ return;
}
- })();
-
- return () => {
- cancelled = true;
- };
+ schedules.sort((a, b) => b.term - a.term);
+ setLoad({ kind: "ready", schedules });
+ setTerm(schedules[0].term);
+ setSelected(schedules[0].id);
+ } catch (err) {
+ if (gen.current.list === id) setLoad({ kind: "error", err });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [desk.settingsHash]);
- useEffect(() => {
- if (selected == null) return;
- let cancelled = false;
- setSchedule(null);
- setDetailError(null);
- (async () => {
+ const loadDetail = useCallback(
+ async (scheduleId: number) => {
+ const id = ++gen.current.detail;
+ setDetail({ kind: "loading" });
try {
const data = await desk.callApp("junction", "get_schedule_details", {
- scheduleId: selected,
+ scheduleId,
});
- if (cancelled) return;
+ if (gen.current.detail !== id) return;
const view = extractSchedule(data);
- if (view) setSchedule(view);
- else setDetailError("This schedule has no scheduled meetings yet.");
+ setDetail(view ? { kind: "ready", schedule: view } : { kind: "empty" });
} catch (err) {
- if (!cancelled) {
- setDetailError(err instanceof Error ? err.message : String(err));
- }
+ if (gen.current.detail === id) setDetail({ kind: "error", err });
}
- })();
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [desk.settingsHash]
+ );
+
+ useEffect(() => {
+ if (!junctionOn) {
+ gen.current.list += 1;
+ gen.current.detail += 1;
+ setLoad({ kind: "blocked" });
+ return;
+ }
+ void loadSchedules();
return () => {
- cancelled = true;
+ gen.current.list += 1;
+ gen.current.detail += 1;
};
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [desk.settingsHash]);
+
+ useEffect(() => {
+ // Wait for the list: on a settings change `selected` still holds the old
+ // schedule for one render, and fetching it would be a wasted round trip.
+ if (selected == null || load.kind !== "ready") return;
+ void loadDetail(selected);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected, desk.settingsHash]);
return (
-
Planner
+
+ My Planner
+
- Your week, in highlighter — straight
- from TigerJunction.
+ Straight from TigerJunction, in highlighter .
{load.kind === "loading" && (
-
fetching your week…
+
+
+
+
+
+ fetching your week
+
)}
{load.kind === "blocked" && (
- {load.reason}
+ TigerJunction is switched off, so there's no week to draw.
-
- navigate(load.cta.includes("Ask PI") ? "/" : "/apps")
- }
- >
- {load.cta} →
+ navigate("/apps")}>
+ Turn it on in My apps
+
)}
- {(load.kind === "error" || load.kind === "fresh") && (
+ {load.kind === "fresh" && (
+ )}
+
+ {load.kind === "error" && (
+
+
{friendlyError(load.err, "TigerJunction")}
+
+ Try again
)}
{load.kind === "ready" && (
<>
-
+
+
+ Term
+
{
const t = Number(e.target.value);
@@ -170,21 +189,20 @@ export function PlannerPage({
if (first) setSelected(first.id);
}}
>
- {[...new Map(load.schedules.map((s) => [s.term, s])).values()].map(
- (s) => (
-
- {s.termName ?? s.term}
-
- )
- )}
+ {[
+ ...new Map(load.schedules.map((s) => [s.term, s])).values(),
+ ].map((s) => (
+
+ {s.termName ?? s.term}
+
+ ))}
{load.schedules
.filter((s) => s.term === term)
.map((s) => (
))}
+
- {schedule ? (
-
- ) : detailError ? (
-
{detailError}
- ) : (
+ {detail.kind === "ready" && (
+
+
+
+ )}
+ {detail.kind === "loading" && (
inking it in…
)}
+ {detail.kind === "empty" && (
+
+ )}
+ {detail.kind === "error" && (
+
+
{friendlyError(detail.err, "that schedule")}
+
selected != null && loadDetail(selected)}
+ >
+ Try again
+
+
+ )}
+
+
+ PI reads this from TigerJunction. Changes you make there show up
+ the next time you open this page.{" "}
+
+ Open TigerJunction
+
+
+
>
)}
diff --git a/app/src/client/pages/SignInPage.tsx b/app/src/client/pages/SignInPage.tsx
index 253d3f1..da9b67b 100644
--- a/app/src/client/pages/SignInPage.tsx
+++ b/app/src/client/pages/SignInPage.tsx
@@ -1,28 +1,118 @@
+import { useEffect, useState } from "react";
+import { PI_APPS } from "../../shared/apps";
+import { PiMark } from "../components/Icons";
import { signIn } from "../lib/auth";
-export function SignInPage() {
+/**
+ * Human copy for anything the sign-in round trip can hand back. The Worker
+ * still answers some failures with a plain text page; this covers the cases
+ * that come back as a query param, and gives the rest a kind default.
+ */
+const SIGNIN_TROUBLE: Record
= {
+ access_denied: "You backed out of the Princeton sign-in. Nothing happened.",
+ expired: "That sign-in attempt sat too long. Start it again.",
+ state: "That sign-in attempt got out of step. Start it again.",
+ domain: "PI is for princeton.edu accounts. Try again with your NetID.",
+ config: "PI's sign-in is misconfigured right now. TigerApps has been told.",
+};
+
+/** Reads ?error= once, then scrubs it so a refresh doesn't replay it. */
+function useSignInTrouble(): string | null {
+ const [trouble, setTrouble] = useState(null);
+ useEffect(() => {
+ const params = new URLSearchParams(location.search);
+ const code = params.get("error");
+ if (!code) return;
+ params.delete("error");
+ params.delete("error_description");
+ const rest = params.toString();
+ history.replaceState(null, "", location.pathname + (rest ? `?${rest}` : ""));
+ setTrouble(
+ SIGNIN_TROUBLE[code] ?? "Princeton's sign-in didn't finish. Try once more."
+ );
+ }, []);
+ return trouble;
+}
+
+export function SignInPage({
+ unreachable = false,
+ onRetry,
+}: {
+ /** /auth/me never answered: say so instead of implying a sign-out. */
+ unreachable?: boolean;
+ onRetry?: () => void;
+}) {
+ const trouble = useSignInTrouble();
+
return (
-
+
-
-
-
π
-
+
+
+
+
Princeton Intelligence
- Hello tiger ,
+ Ask once. PI reads the rest .
-
- PI is your Princeton desk — courses, schedules, ratings, and your
- degree, all in one chat.
+
+ Course reviews, your schedule, what's left on your degree. PI reads
+ the TigerApps you switch on, and nothing else.
+
+
+
+ “which COS courses do people actually rate well?”
+ “does anything on my schedule clash on Tuesdays?”
+
+
+
apps you can switch on
+
+ {PI_APPS.map((app) => (
+
+ {app.name}
+
+ ))}
+
+
+ {trouble && (
+
+ {trouble}
+
+ )}
+
+ {unreachable ? (
+ <>
+
+ PI can't reach the desk right now. That's on us, not on your
+ sign-in.
+
+
+ Try again
+
+ >
+ ) : (
+
signIn()}>
+ Continue with your NetID
+
+ )}
+
+
+ PI only reads the TigerApps you switch on. Google Calendar stays
+ read-only, and only after you say yes.
-
- Sign in with Princeton
-
- Princeton accounts only. Your netid comes straight from your
- @princeton.edu sign-in — nothing to type, nothing to fake.
+ princeton.edu accounts only. Your NetID comes straight from the
+ Princeton sign-in, so there is nothing to type and nothing to fake.
+
+
+
+ made by TigerApps
+
-
+
);
}
diff --git a/app/src/client/styles.css b/app/src/client/styles.css
index f216c3f..6344656 100644
--- a/app/src/client/styles.css
+++ b/app/src/client/styles.css
@@ -1,37 +1,150 @@
-/* ── PI design system: stationery — highlighters, sticky notes, ink pens ── */
+/* ── PI design system: stationery — highlighters, sticky notes, ink pens ──
+ *
+ * One material story: warm paper, warm graphite, one cyan marker. Everything
+ * here is tokens + shared primitives; per-page CSS lives beside its component
+ * (e.g. styles/chat.css) and is loaded after this file, so lane rules win ties.
+ */
:root {
- /* paper + ink */
+ color-scheme: light;
+
+ /* paper + ink — every neutral is warm (h≈40-50) so nothing reads cold on
+ cream. --ink keeps a trace of blue-black, the way real ink does. */
--paper: #fdfcf9;
- --paper-raised: #ffffff;
- --ink: #1c2440;
- --ink-soft: #5a6382;
- --ink-faint: #9ba3ba;
- --rule: #e9e6de;
- --rule-soft: #f2f0ea;
-
- /* PI's marker */
- --pi: #14b8ce;
- --pi-deep: #0d93a6;
- --pi-wash: #e3f7fa;
-
- /* highlighters */
- --hl-cyan: #aee9f1;
- --hl-orange: #ffd9a8;
- --hl-violet: #dccff7;
- --hl-pink: #fbcfe0;
- --hl-mint: #c5eed3;
- --hl-lemon: #fbeba6;
- --sticky: #fef3c0;
-
- --hand: "Caveat", "Segoe Print", cursive;
- --sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Inter,
+ --paper-raised: #fffefa;
+ --paper-sunk: #f5f2ea;
+ --ink: #22262e;
+ --ink-soft: #5f5b52;
+ --ink-faint: #767063; /* 4.8:1 on paper — safe for body-size text */
+ --ink-ghost: #a9a08c; /* decorative only: rules, dividers, disabled marks */
+ --rule: #e7e2d7;
+ --rule-soft: #f3f1ea;
+ --on-ink: #fbf9f3;
+
+ /* PI's marker — the one primary accent */
+ --pi: #1fb0c4; /* fills, borders, focus ring */
+ --pi-deep: #0a7180; /* 5.5:1 — the only cyan safe for text */
+ --pi-wash: #e4f4f6;
+
+ /* second accent: active + selected states only, never decoration */
+ --accent-2: #5f51ad;
+ --accent-2-soft: #6b5bc4;
+ --accent-2-wash: #eeeafb;
+
+ /* highlighters — one pack, one lightness band (s≈61%, l≈84%) */
+ --hl-cyan: #bde8ef;
+ --hl-orange: #efdabd;
+ --hl-violet: #cfbdef;
+ --hl-pink: #efbdd0;
+ --hl-mint: #bdefd3;
+ --hl-lemon: #efe6bd;
+ --sticky: #f8ecb6;
+
+ /* semantic inks, all readable on both paper and their own highlighter */
+ --ink-good: #257447;
+ --ink-rose: #742543;
+ --ink-amber: #745f25;
+ --ink-danger: #8c3f28;
+ --ink-cyan: #256474;
+
+ /* paper stocks + edges */
+ --stock-sidebar: #faf8f1;
+ --stock-note: #fbf9f2;
+ --edge-sticky: #e6d9a0;
+ --edge-danger: #e8c5b8;
+ --edge-cyan: #c8e6ea;
+ --edge-warn: #d6976f;
+
+ /* type */
+ --hand: "Caveat", "Caveat Fallback", "Bradley Hand", "Segoe Print", cursive;
+ --sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
system-ui, sans-serif;
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
- --shadow-card: 0 1px 2px rgba(28, 36, 64, 0.05), 0 4px 16px rgba(28, 36, 64, 0.06);
- --shadow-pop: 0 2px 6px rgba(28, 36, 64, 0.08), 0 12px 32px rgba(28, 36, 64, 0.12);
- --radius: 12px;
+ /* type scale. Handwriting sits one step up from the sans it sits beside —
+ Caveat's x-height is 0.40em against the system stack's ~0.52em. */
+ --t-2xs: 10px;
+ --t-xs: 11px;
+ --t-sm: 12px;
+ --t-md: 13px;
+ --t-nav: 14px;
+ --t-base: 15px;
+ --t-lg: 17px;
+ --t-xl: 20px;
+ --t-2xl: 26px;
+ --t-3xl: 34px;
+ --t-4xl: 44px;
+
+ /* spacing — 4px grid */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
+ --space-6: 32px;
+ --space-7: 48px;
+
+ /* radii. Controls are pills, sheets are --r-lg, inner parts are --r-sm.
+ The only irregular radii in the app are hand-cut edges: .swipe,
+ .sticky-note, .hand-underline. */
+ --r-sm: 6px;
+ --r-md: 12px;
+ --r-lg: 18px;
+ --r-pill: 999px;
+
+ /* motion */
+ --dur-1: 120ms;
+ --dur-2: 200ms;
+ --dur-3: 320ms;
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+
+ /* shadows — warm brown-black, short, always cast downward */
+ --shadow-sheet: 0 1px 1px rgba(74, 60, 40, 0.05),
+ 0 6px 14px -6px rgba(74, 60, 40, 0.1);
+ --shadow-card: 0 1px 2px rgba(74, 60, 40, 0.06),
+ 0 8px 20px -10px rgba(74, 60, 40, 0.12);
+ --shadow-note: 0 1px 1px rgba(74, 60, 40, 0.08),
+ 0 5px 12px -5px rgba(74, 60, 40, 0.18);
+ --shadow-pop: 0 2px 6px rgba(74, 60, 40, 0.1),
+ 0 18px 40px -16px rgba(74, 60, 40, 0.2);
+ --shadow-press: inset 0 1px 2px rgba(74, 60, 40, 0.14);
+
+ /* paper grain — one 140px tile. --grain is the desk (dimmed by the overlay's
+ own opacity); --grain-sheet is the tooth of a single sheet of stock. */
+ --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23g)' opacity='0.5'/%3E%3C/svg%3E");
+ /* one uneven pen stroke, stretched to whatever it underlines */
+ --stroke-wobble: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 8' preserveAspectRatio='none'%3E%3Cpath d='M2 5.6C30 3.4 56 6.4 84 4.6c28-1.8 54 1.2 80-.4 16-1 26 .4 34-1' fill='none' stroke='%23fff' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E");
+ --grain-sheet: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23g)' opacity='0.06'/%3E%3C/svg%3E");
+
+ --z-scrim: 25;
+ --z-sidebar: 30;
+ --z-grain: 90;
+}
+
+/* Caveat, self-hosted (latin only, variable 400-700) — no third-party
+ round trip on the critical path. */
+@font-face {
+ font-family: "Caveat";
+ font-style: normal;
+ font-weight: 400 700;
+ font-display: swap;
+ src: url("/fonts/caveat-latin.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
+ U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
+ U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+/* Metric-matched stand-in so the swap doesn't move the highlighter swipe,
+ which is sized in em off the text it sits behind. Tuned to Bradley Hand
+ (avg advance 0.589em vs Caveat's 0.431em). */
+@font-face {
+ font-family: "Caveat Fallback";
+ src: local("Bradley Hand"), local("Bradley Hand Bold"), local("Segoe Print"),
+ local("Chalkboard SE"), local("Apple Chancery");
+ size-adjust: 73%;
+ ascent-override: 131%;
+ descent-override: 41%;
+ line-gap-override: 0%;
}
* {
@@ -46,14 +159,25 @@ body,
body {
margin: 0;
- background: var(--paper);
+ background-color: var(--paper);
color: var(--ink);
font-family: var(--sans);
- font-size: 15px;
+ font-size: var(--t-base);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
+/* the desk's grain: one fixed tile over everything, never inside a scroller */
+body::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-grain);
+ pointer-events: none;
+ opacity: 0.09;
+ background-image: var(--grain);
+}
+
button {
font: inherit;
color: inherit;
@@ -64,18 +188,44 @@ button {
}
input,
-textarea {
+textarea,
+select {
font: inherit;
color: inherit;
}
+p {
+ text-wrap: pretty;
+}
+
:focus-visible {
outline: 2px solid var(--pi);
outline-offset: 2px;
- border-radius: 4px;
+ border-radius: var(--r-sm);
+}
+
+/* keyboard-only label for icon buttons and skip links */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
}
-/* ── the signature: a highlighter swipe ───────────────────────────── */
+/* ── shared primitives ────────────────────────────────────────────── */
+
+/* inline SVG icons (components/Icons.tsx) */
+.icon {
+ flex-shrink: 0;
+ vertical-align: -0.18em;
+}
+
+/* the signature: a highlighter swipe. Set --swipe-color per instance; add
+ .v2 / .v3 so no two strokes on a screen are the same shape. */
.swipe {
position: relative;
display: inline-block;
@@ -89,16 +239,303 @@ textarea {
right: -0.22em;
top: 0.12em;
bottom: 0.02em;
- background: var(--swipe-color, var(--hl-cyan));
+ background-color: var(--swipe-color, var(--hl-cyan));
+ /* ink deposits heavier where the marker starts and stops */
+ background-image: linear-gradient(
+ 96deg,
+ rgba(0, 0, 0, 0.07),
+ rgba(0, 0, 0, 0) 16%,
+ rgba(0, 0, 0, 0) 74%,
+ rgba(0, 0, 0, 0.06)
+ );
border-radius: 0.45em 0.3em 0.5em 0.35em / 0.5em 0.4em 0.3em 0.55em;
transform: rotate(-0.6deg);
+ opacity: 0.9;
+}
+.swipe.v2::before {
+ border-radius: 0.3em 0.55em 0.35em 0.5em / 0.4em 0.5em 0.55em 0.3em;
+ transform: rotate(0.7deg);
+ left: -0.22em;
+ right: -0.16em;
+}
+.swipe.v3::before {
+ border-radius: 0.55em 0.35em 0.3em 0.5em / 0.3em 0.55em 0.4em 0.5em;
+ transform: rotate(-1.1deg);
+ bottom: 0.08em;
+}
+
+/* a written underline — for headings and named things. The wobble is a mask,
+ so the stroke takes any colour via --underline-color. */
+.hand-underline {
+ position: relative;
+ display: inline-block;
+}
+.hand-underline::after,
+.hand-rule {
+ background: var(--underline-color, var(--pi-deep));
+ -webkit-mask-image: var(--stroke-wobble);
+ mask-image: var(--stroke-wobble);
+ -webkit-mask-size: 100% 100%;
+ mask-size: 100% 100%;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+}
+.hand-underline::after {
+ content: "";
+ position: absolute;
+ left: -0.08em;
+ right: -0.12em;
+ bottom: -0.2em;
+ height: 0.22em;
opacity: 0.85;
}
+/* the same stroke as a block rule — sits under a nav or a section head */
+.hand-rule {
+ height: 6px;
+ border: 0;
+ margin: var(--space-2) 0;
+ --underline-color: var(--ink-ghost);
+ opacity: 0.7;
+}
+
+/* the π mark, on its lozenge */
+.pi-lozenge {
+ display: inline-grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ border-radius: var(--r-pill);
+ background: var(--pi-wash);
+ color: var(--pi-deep);
+ flex-shrink: 0;
+}
+.pi-lozenge-lg {
+ width: 52px;
+ height: 52px;
+}
+
+/* buttons. One filled ink, one filled marker, one ghost, one quiet. */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ padding: 9px 16px;
+ border-radius: var(--r-pill);
+ font-size: var(--t-md);
+ font-weight: 600;
+ line-height: 1.2;
+ text-decoration: none;
+ white-space: nowrap;
+ transition: transform var(--dur-1) var(--ease-out),
+ box-shadow var(--dur-2) var(--ease-out),
+ background-color var(--dur-1) var(--ease-out),
+ border-color var(--dur-1) var(--ease-out);
+}
+.btn-ink {
+ background: var(--ink);
+ color: var(--on-ink);
+}
+.btn-fill {
+ background: var(--pi-deep);
+ color: var(--on-ink);
+}
+.btn-ghost {
+ background: var(--paper-raised);
+ color: var(--ink);
+ border: 1px solid var(--rule);
+}
+.btn-quiet {
+ color: var(--ink-soft);
+ padding-left: var(--space-3);
+ padding-right: var(--space-3);
+}
+.btn-sm {
+ padding: 6px 13px;
+ font-size: var(--t-sm);
+}
+.btn:hover {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-sheet);
+}
+.btn-ghost:hover {
+ border-color: var(--ink-ghost);
+}
+.btn-quiet:hover {
+ background: var(--rule-soft);
+ color: var(--ink);
+ box-shadow: none;
+ transform: none;
+}
+.btn:active {
+ transform: translateY(1px);
+ box-shadow: var(--shadow-press);
+}
+.btn:disabled,
+.btn[aria-disabled="true"] {
+ opacity: 0.45;
+ cursor: default;
+ transform: none;
+ box-shadow: none;
+}
+
+/* a cream note, torn off and set down slightly crooked */
+.sticky-note {
+ position: relative;
+ padding: var(--space-4) var(--space-5);
+ background: var(--sticky);
+ border: 1.5px solid var(--ink);
+ border-radius: 3px 14px 5px 16px;
+ box-shadow: var(--shadow-note);
+ transform: rotate(-0.6deg);
+}
+.sticky-note .lead {
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ font-weight: 600;
+ line-height: 1.25;
+}
+
+/* two more sheets under this one. The host needs its own background and must
+ not set z-index / transform, or the sheets paint on top of it. */
+.paper-stack {
+ position: relative;
+}
+.paper-stack::before,
+.paper-stack::after {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ border-radius: inherit;
+ background: var(--paper-raised);
+ border: 1px solid var(--rule);
+ box-shadow: var(--shadow-sheet);
+}
+.paper-stack::before {
+ inset: 10px 8px -9px;
+ transform: rotate(-1.5deg);
+}
+.paper-stack::after {
+ inset: 18px 14px -16px;
+ background: var(--stock-note);
+ transform: rotate(1.9deg);
+}
+
+/* "coming soon" — a pencilled label, never a shipped-feature badge */
+.soon-chip {
+ display: inline-block;
+ padding: 0 var(--space-2) 1px;
+ border: 1px dashed var(--ink-ghost);
+ border-radius: var(--r-pill);
+ background: var(--stock-note);
+ color: var(--ink-faint);
+ font-family: var(--hand);
+ font-size: var(--t-base);
+ line-height: 1.5;
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+}
+
+/* a coloured tag — dining halls, sections, anything with a key colour.
+ Set --tag-color on the element. */
+.hall-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 2px var(--space-2) 3px;
+ border-radius: var(--r-pill);
+ border: 1px solid rgba(74, 60, 40, 0.12);
+ background: var(--tag-color, var(--hl-lemon));
+ color: var(--ink);
+ font-size: var(--t-sm);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+ transform: rotate(-0.5deg);
+}
+
+/* small muted time label */
+.timestamp {
+ font-size: var(--t-xs);
+ color: var(--ink-faint);
+ letter-spacing: 0.02em;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+/* skeletons — a sheet of blank paper with light passing over it */
+.skel {
+ position: relative;
+ overflow: hidden;
+ background: var(--rule-soft);
+ border-radius: var(--r-sm);
+}
+.skel::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.8),
+ transparent
+ );
+ animation: skel-sweep 1.5s var(--ease-out) infinite;
+}
+.skel-line {
+ height: 10px;
+ border-radius: var(--r-pill);
+}
+@keyframes skel-sweep {
+ to {
+ transform: translateX(100%);
+ }
+}
+
+/* a turn that failed — paper, a torn-looking dashed edge, room for a retry */
+.turn-error {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ margin: var(--space-3) 0;
+ padding: var(--space-3) var(--space-4);
+ border: 1px dashed var(--edge-danger);
+ border-radius: var(--r-md);
+ background: var(--stock-note);
+}
+.turn-error .what {
+ flex: 1;
+ min-width: 0;
+}
+.turn-error .lead {
+ font-family: var(--hand);
+ font-size: var(--t-lg);
+ color: var(--ink-danger);
+ line-height: 1.3;
+}
+.turn-error .detail {
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+ margin-top: 2px;
+}
+.turn-error .fix {
+ flex-shrink: 0;
+}
+
+/* small all-purpose label — tracking opens up below 13px */
+.label-xs {
+ font-size: var(--t-xs);
+ letter-spacing: 0.02em;
+ color: var(--ink-faint);
+}
+
/* ── app shell ────────────────────────────────────────────────────── */
.shell {
display: flex;
height: 100%;
+ height: 100dvh;
}
.main {
@@ -114,152 +551,92 @@ textarea {
width: 264px;
flex-shrink: 0;
border-right: 1px solid var(--rule);
- background: #faf9f4;
+ background: var(--stock-sidebar);
display: flex;
flex-direction: column;
- padding: 18px 14px 14px;
- gap: 4px;
+ padding: var(--space-4) var(--space-3) var(--space-3);
+ padding-left: calc(var(--space-3) + env(safe-area-inset-left, 0px));
+ gap: var(--space-1);
}
.wordmark {
display: flex;
align-items: center;
- gap: 11px;
- padding: 2px 8px 14px;
-}
-.wordmark .mark {
- position: relative;
- width: 34px;
- height: 34px;
- flex-shrink: 0;
-}
-.wordmark .mark img {
- width: 100%;
- height: 100%;
- border-radius: 9px;
- display: block;
-}
-.wordmark .pi-badge {
- position: absolute;
- right: -6px;
- bottom: -6px;
- width: 18px;
- height: 18px;
- border-radius: 50%;
- background: var(--pi);
- color: #fff;
- border: 2px solid #faf9f4;
- display: grid;
- place-items: center;
- font-family: Georgia, serif;
- font-size: 11px;
- line-height: 1;
+ gap: var(--space-3);
+ padding: 2px var(--space-2) var(--space-3);
}
.wordmark .name {
font-weight: 700;
- font-size: 17px;
+ font-size: var(--t-lg);
letter-spacing: 0.01em;
}
.wordmark .sub {
font-family: var(--hand);
- font-size: 15px;
+ font-size: var(--t-lg);
color: var(--ink-soft);
- margin-top: -2px;
+ margin-top: -3px;
}
.new-chat {
display: flex;
align-items: center;
- gap: 8px;
- margin: 0 4px 12px;
- padding: 9px 14px;
- border-radius: 999px;
+ gap: var(--space-2);
+ margin: 0 var(--space-1) var(--space-3);
+ padding: 9px var(--space-4);
+ border-radius: var(--r-pill);
background: var(--ink);
- color: #fff;
+ color: var(--on-ink);
font-weight: 600;
- font-size: 14px;
- transition: transform 0.12s ease, box-shadow 0.12s ease;
+ font-size: var(--t-nav);
+ transition: transform var(--dur-1) var(--ease-out),
+ box-shadow var(--dur-2) var(--ease-out);
}
.new-chat:hover {
transform: translateY(-1px);
- box-shadow: var(--shadow-card);
-}
-
-.nav-item {
- display: flex;
- align-items: center;
- gap: 10px;
- width: 100%;
- padding: 8px 12px;
- border-radius: 9px;
- font-size: 14px;
- font-weight: 500;
- color: var(--ink-soft);
- text-align: left;
-}
-.nav-item:hover {
- background: var(--rule-soft);
- color: var(--ink);
-}
-.nav-item.active {
- color: var(--ink);
- font-weight: 600;
-}
-.nav-item.active .nav-label {
- position: relative;
- z-index: 0;
+ box-shadow: var(--shadow-sheet);
}
-.nav-item.active .nav-label::before {
- content: "";
- position: absolute;
- z-index: -1;
- left: -0.25em;
- right: -0.3em;
- top: 0.08em;
- bottom: 0;
- background: var(--hl-cyan);
- border-radius: 0.45em 0.3em 0.5em 0.35em / 0.5em 0.4em 0.3em 0.55em;
- transform: rotate(-0.8deg);
- opacity: 0.8;
-}
-.nav-icon {
- width: 18px;
- text-align: center;
- flex-shrink: 0;
+.new-chat:active {
+ transform: translateY(1px);
+ box-shadow: var(--shadow-press);
}
.chat-list {
flex: 1;
overflow-y: auto;
- margin-top: 14px;
- padding-top: 10px;
+ margin-top: var(--space-3);
+ padding-top: var(--space-3);
border-top: 1px dashed var(--rule);
min-height: 0;
}
.chat-list-label {
font-family: var(--hand);
- font-size: 15px;
+ font-size: var(--t-lg);
color: var(--ink-faint);
- padding: 0 12px 6px;
+ padding: 0 var(--space-3) var(--space-1);
}
.chat-row {
display: flex;
align-items: center;
width: 100%;
- padding: 6px 12px;
- border-radius: 8px;
- font-size: 13.5px;
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--r-sm);
+ font-size: var(--t-md);
color: var(--ink-soft);
text-align: left;
- gap: 8px;
+ gap: var(--space-2);
+ transition: background-color var(--dur-1) var(--ease-out);
}
.chat-row:hover {
background: var(--rule-soft);
}
+.chat-row:active {
+ background: var(--rule);
+}
.chat-row.active {
- background: var(--pi-wash);
+ background: var(--accent-2-wash);
color: var(--ink);
font-weight: 500;
+ box-shadow: inset 2px 0 0 var(--accent-2);
}
.chat-row .title {
flex: 1;
@@ -268,30 +645,48 @@ textarea {
white-space: nowrap;
}
.chat-row .del {
- opacity: 0;
+ display: grid;
+ place-items: center;
color: var(--ink-faint);
- font-size: 12px;
- padding: 2px 4px;
-}
-.chat-row:hover .del {
- opacity: 1;
+ font-size: var(--t-sm);
+ padding: 2px var(--space-1);
+ border-radius: var(--r-sm);
+ transition: color var(--dur-1) var(--ease-out),
+ opacity var(--dur-1) var(--ease-out);
}
.chat-row .del:hover {
- color: #c0392b;
+ color: var(--ink-danger);
+}
+/* hidden until hover only where hover exists — on touch it stays reachable */
+@media (hover: hover) {
+ .chat-row .del {
+ opacity: 0;
+ }
+ .chat-row:hover .del,
+ .chat-row .del:focus-visible {
+ opacity: 1;
+ }
}
.identity-chip {
display: flex;
align-items: center;
- gap: 10px;
- margin-top: 10px;
- padding: 8px 10px;
- border-radius: 10px;
+ gap: var(--space-3);
+ margin-top: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--r-sm);
border: 1px solid var(--rule);
background: var(--paper-raised);
- font-size: 13px;
+ font-size: var(--t-md);
text-align: left;
width: 100%;
+ transition: border-color var(--dur-1) var(--ease-out);
+}
+.identity-chip:hover {
+ border-color: var(--ink-ghost);
+}
+.identity-chip:active {
+ box-shadow: var(--shadow-press);
}
.identity-chip .avatar {
width: 26px;
@@ -301,7 +696,7 @@ textarea {
display: grid;
place-items: center;
font-family: var(--hand);
- font-size: 15px;
+ font-size: var(--t-lg);
font-weight: 700;
flex-shrink: 0;
}
@@ -313,85 +708,109 @@ textarea {
}
.identity-chip .hint {
color: var(--ink-faint);
- font-size: 11.5px;
+ font-size: var(--t-xs);
+ letter-spacing: 0.02em;
}
/* ── topbar (mobile) ──────────────────────────────────────────────── */
.topbar {
display: none;
align-items: center;
- gap: 10px;
- padding: 10px 14px;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--rule);
- background: #faf9f4;
+ background: var(--stock-sidebar);
}
/* ── chat page ────────────────────────────────────────────────────── */
.chat-scroll {
flex: 1;
overflow-y: auto;
- scroll-behavior: smooth;
+ overscroll-behavior: contain;
}
.chat-column {
max-width: 760px;
margin: 0 auto;
- padding: 32px 24px 24px;
+ padding: var(--space-6) var(--space-5) var(--space-5);
}
.hello {
- padding: 9vh 0 24px;
+ padding: 9vh 0 var(--space-5);
}
.hello h1 {
- font-size: 34px;
+ font-size: var(--t-3xl);
font-weight: 700;
- margin: 0 0 6px;
+ margin: 0 0 var(--space-1);
letter-spacing: -0.02em;
+ text-wrap: balance;
}
.hello h1 .name {
font-family: var(--hand);
font-weight: 700;
- font-size: 42px;
+ font-size: var(--t-4xl);
color: var(--pi-deep);
- --swipe-color: var(--hl-cyan);
}
.hello p {
color: var(--ink-soft);
- margin: 0 0 28px;
- font-size: 16px;
+ margin: 0 0 var(--space-6);
+ font-size: var(--t-lg);
}
.prompts {
display: flex;
flex-direction: column;
- gap: 10px;
+ gap: var(--space-3);
align-items: flex-start;
}
.prompt-chip {
- font-size: 14.5px;
- padding: 9px 16px;
- border-radius: 999px;
+ position: relative;
+ z-index: 0;
+ font-size: var(--t-nav);
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--r-pill);
border: 1px solid var(--rule);
background: var(--paper-raised);
color: var(--ink);
- transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s;
text-align: left;
+ transition: border-color var(--dur-2) var(--ease-out),
+ color var(--dur-1) var(--ease-out);
+}
+/* the marker sweeps across instead of the chip levitating */
+.prompt-chip::before {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ inset: 3px 5px;
+ border-radius: 0.5em 0.35em 0.45em 0.3em / 0.4em 0.5em 0.35em 0.5em;
+ background: var(--hl-cyan);
+ transform: scaleX(0);
+ transform-origin: left center;
+ opacity: 0.75;
+ transition: transform var(--dur-3) var(--ease-out);
}
.prompt-chip:hover {
- transform: translateY(-1px);
border-color: var(--pi);
- box-shadow: var(--shadow-card);
}
+.prompt-chip:hover::before,
+.prompt-chip:focus-visible::before {
+ transform: scaleX(1);
+}
+.prompt-chip:active {
+ box-shadow: var(--shadow-press);
+}
+/* the nib in front of a starter — an inline SVG, so it takes currentColor */
.prompt-chip .pen {
- font-family: var(--hand);
color: var(--pi-deep);
- margin-right: 8px;
- font-size: 16px;
+ margin-right: var(--space-2);
}
-/* messages */
+/* messages. `.turn-in` is applied by ChatPage only to turns that arrive after
+ mount, so opening a saved transcript doesn't replay the whole conversation. */
.turn {
- margin: 18px 0;
- animation: turn-in 0.25s ease both;
+ margin: var(--space-4) 0;
+}
+.turn-in {
+ animation: turn-in var(--dur-3) var(--ease-out) both;
}
@keyframes turn-in {
from {
@@ -407,92 +826,216 @@ textarea {
display: flex;
justify-content: flex-end;
}
-.turn.user .bubble {
- max-width: 82%;
- background: var(--pi-wash);
- border: 1px solid #cdeef3;
- padding: 10px 16px;
- border-radius: 16px 16px 4px 16px;
- white-space: pre-wrap;
-}
+/* ── markdown prose ───────────────────────────────────────────────── */
.turn.assistant .md {
max-width: 100%;
}
-.turn.assistant .md p {
- margin: 0 0 10px;
+.md p {
+ margin: 0 0 var(--space-3);
}
-.turn.assistant .md p:last-child {
+.md p:last-child {
margin-bottom: 0;
}
-.turn.assistant .md ul,
-.turn.assistant .md ol {
- margin: 4px 0 10px;
+.md ul,
+.md ol {
+ margin: var(--space-1) 0 var(--space-3);
padding-left: 22px;
}
-.turn.assistant .md li {
+.md li {
margin: 3px 0;
}
-.turn.assistant .md code {
+.md li > ul,
+.md li > ol {
+ margin: 3px 0 var(--space-1);
+}
+.md code {
font-family: var(--mono);
- font-size: 13px;
+ font-size: var(--t-md);
background: var(--rule-soft);
padding: 1px 5px;
- border-radius: 4px;
+ border-radius: var(--r-sm);
}
-.turn.assistant .md pre {
- background: #f6f5f0;
+.md pre {
+ background: var(--paper-sunk);
border: 1px solid var(--rule);
- border-radius: 10px;
- padding: 12px 14px;
+ border-radius: var(--r-md);
+ padding: var(--space-3) var(--space-4);
overflow-x: auto;
}
-.turn.assistant .md pre code {
+.md pre code {
background: none;
padding: 0;
}
-.turn.assistant .md strong {
- font-weight: 650;
+.md strong {
+ font-weight: 700;
+}
+/* three real levels, not three names for body text */
+.md h1,
+.md h2,
+.md h3,
+.md h4 {
+ line-height: 1.25;
+ letter-spacing: -0.01em;
+}
+.md h1 {
+ font-size: var(--t-xl);
+ font-weight: 700;
+ margin: var(--space-5) 0 var(--space-2);
+}
+.md h2 {
+ font-size: var(--t-lg);
+ font-weight: 700;
+ margin: var(--space-4) 0 var(--space-2);
+}
+.md h3,
+.md h4 {
+ font-size: var(--t-base);
+ font-weight: 600;
+ color: var(--ink-soft);
+ margin: var(--space-4) 0 var(--space-1);
+ letter-spacing: 0;
+}
+.md > :first-child {
+ margin-top: 0;
+}
+/* links are things PI has highlighted; hovering fills the marker in */
+.md a,
+.md a:visited {
+ color: var(--pi-deep);
+ text-decoration: none;
+ background-image: linear-gradient(var(--hl-cyan), var(--hl-cyan));
+ background-repeat: no-repeat;
+ background-position: 0 88%;
+ background-size: 100% 0.22em;
+ border-radius: 2px;
+ transition: background-size var(--dur-2) var(--ease-out),
+ color var(--dur-1) var(--ease-out);
+}
+.md a:hover {
+ background-size: 100% 0.95em;
+ color: var(--ink);
+}
+.md a .icon {
+ margin-left: 3px;
+ opacity: 0.7;
+}
+.md blockquote {
+ margin: var(--space-3) 0;
+ padding: 2px 0 2px var(--space-3);
+ border-left: 3px solid var(--hl-lemon);
+ font-family: var(--hand);
+ font-size: var(--t-lg);
+ line-height: 1.4;
+ color: var(--ink-soft);
+}
+.md blockquote p {
+ margin: 0;
+}
+.md hr {
+ height: 2px;
+ border: 0;
+ margin: var(--space-5) 0;
+ background: var(--rule);
+ border-radius: 40% 60% 55% 45% / 100% 100% 100% 100%;
+ transform: rotate(-0.3deg);
+}
+.md .table-wrap {
+ overflow-x: auto;
+ margin: var(--space-3) 0 var(--space-4);
+}
+.md table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: var(--t-md);
+ font-variant-numeric: tabular-nums;
+}
+.md th,
+.md td {
+ text-align: left;
+ padding: 7px var(--space-3) 7px 0;
+ border-bottom: 1px solid var(--rule-soft);
+ vertical-align: top;
+}
+.md th {
+ font-size: var(--t-sm);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--ink-soft);
+ border-bottom: 1px solid var(--rule);
+ white-space: nowrap;
+}
+.md tbody tr:last-child td {
+ border-bottom: none;
+}
+.md td:last-child,
+.md th:last-child {
+ padding-right: 0;
}
-.turn.assistant .md h1,
-.turn.assistant .md h2,
-.turn.assistant .md h3 {
- font-size: 15px;
- margin: 14px 0 6px;
+.md .ta-r {
+ text-align: right;
+}
+.md .ta-c {
+ text-align: center;
}
+/* PI's own working notes, on dotted note paper */
.reasoning {
- margin: 8px 0;
- font-size: 13px;
+ margin: var(--space-2) 0;
+ font-size: var(--t-md);
color: var(--ink-faint);
}
.reasoning summary {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
cursor: pointer;
font-family: var(--hand);
- font-size: 15px;
+ font-size: var(--t-lg);
list-style: none;
+ transition: color var(--dur-1) var(--ease-out);
}
.reasoning summary::-webkit-details-marker {
display: none;
}
+.reasoning summary::before {
+ content: "";
+ width: 9px;
+ height: 9px;
+ border-right: 1.5px solid currentColor;
+ border-bottom: 1.5px solid currentColor;
+ transform: rotate(-45deg);
+ transition: transform var(--dur-2) var(--ease-out);
+}
+.reasoning summary:hover {
+ color: var(--ink-soft);
+}
.reasoning[open] summary {
color: var(--ink-soft);
}
+.reasoning[open] summary::before {
+ transform: rotate(45deg);
+}
.reasoning .body {
- border-left: 2px solid var(--rule);
- padding-left: 12px;
- margin-top: 4px;
+ margin-top: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ border: 1.5px dashed var(--rule);
+ border-radius: var(--r-md);
+ background: var(--stock-note);
+ font-family: var(--hand);
+ font-size: var(--t-lg);
+ line-height: 1.4;
+ color: var(--ink-soft);
white-space: pre-wrap;
- font-style: italic;
}
.writing {
display: inline-flex;
align-items: center;
- gap: 8px;
+ gap: var(--space-2);
color: var(--ink-faint);
font-family: var(--hand);
- font-size: 16px;
- margin: 8px 0;
+ font-size: var(--t-lg);
+ margin: var(--space-2) 0;
}
.writing .nib {
width: 8px;
@@ -514,21 +1057,24 @@ textarea {
}
/* ── tool cards ───────────────────────────────────────────────────── */
+/* an index card: hairline, header band, optional --card-accent spine */
.tool-card {
border: 1px solid var(--rule);
- border-radius: var(--radius);
+ border-radius: var(--r-md);
background: var(--paper-raised);
box-shadow: var(--shadow-card);
- margin: 12px 0;
+ margin: var(--space-3) 0;
overflow: hidden;
}
.tool-card .card-head {
display: flex;
align-items: center;
- gap: 8px;
- padding: 10px 14px;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ background: var(--stock-note);
border-bottom: 1px solid var(--rule-soft);
- font-size: 13px;
+ box-shadow: inset 3px 0 0 var(--card-accent, transparent);
+ font-size: var(--t-md);
color: var(--ink-soft);
}
.tool-card .card-head .app-dot {
@@ -547,7 +1093,7 @@ textarea {
.tool-card .card-head .card-link {
color: var(--pi-deep);
font-weight: 600;
- font-size: 12px;
+ font-size: var(--t-sm);
text-decoration: none;
white-space: nowrap;
}
@@ -556,26 +1102,27 @@ textarea {
}
.tool-card .card-head .tname {
font-family: var(--mono);
- font-size: 12px;
+ font-size: var(--t-sm);
}
.tool-card .card-head .spacer {
flex: 1;
}
.tool-card .card-body {
- padding: 12px 14px;
+ padding: var(--space-3) var(--space-4);
}
.tool-chip {
display: inline-flex;
align-items: center;
- gap: 8px;
- margin: 6px 0;
- padding: 5px 12px;
- border-radius: 999px;
+ gap: var(--space-2);
+ margin: var(--space-1) 0;
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--r-pill);
border: 1px dashed var(--rule);
- background: #fbfaf6;
+ background: var(--stock-note);
color: var(--ink-soft);
- font-size: 12.5px;
+ font-size: var(--t-sm);
+ letter-spacing: 0.02em;
}
.tool-chip .app-dot {
width: 9px;
@@ -585,34 +1132,39 @@ textarea {
}
.tool-chip .tname {
font-family: var(--mono);
- font-size: 11.5px;
+ font-size: var(--t-xs);
}
-.tool-chip.running .app-dot {
+/* real apps render a logo, unknown ones a dot — pulse whichever is there */
+.tool-chip.running .app-dot,
+.tool-chip.running .app-logo-sm {
animation: nib 1s ease-in-out infinite;
}
.tool-chip.error {
- border-color: #eec5bc;
- color: #a4442e;
+ border-color: var(--edge-danger);
+ color: var(--ink-danger);
}
.tool-chip button {
color: var(--ink-faint);
- font-size: 11px;
+ font-size: var(--t-xs);
}
.chip-link {
color: var(--pi-deep);
font-weight: 600;
text-decoration: none;
- margin-left: 8px;
+ margin-left: var(--space-2);
white-space: nowrap;
}
+.chip-link:hover {
+ text-decoration: underline;
+}
.tool-raw {
- margin: 4px 0 8px;
- background: #f6f5f0;
+ margin: var(--space-1) 0 var(--space-2);
+ background: var(--paper-sunk);
border: 1px solid var(--rule);
- border-radius: 10px;
- padding: 10px 12px;
+ border-radius: var(--r-md);
+ padding: var(--space-3);
font-family: var(--mono);
- font-size: 11.5px;
+ font-size: var(--t-xs);
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
@@ -627,10 +1179,10 @@ textarea {
.course-row {
display: flex;
align-items: baseline;
- gap: 10px;
+ gap: var(--space-3);
padding: 7px 2px;
border-bottom: 1px solid var(--rule-soft);
- font-size: 13.5px;
+ font-size: var(--t-md);
}
.course-row:last-child {
border-bottom: none;
@@ -646,7 +1198,7 @@ textarea {
}
.course-row .code {
font-family: var(--mono);
- font-size: 12.5px;
+ font-size: var(--t-sm);
font-weight: 600;
white-space: nowrap;
}
@@ -659,41 +1211,42 @@ textarea {
}
.course-row .meta {
color: var(--ink-faint);
- font-size: 12px;
+ font-size: var(--t-sm);
white-space: nowrap;
}
.pill {
- font-size: 11px;
+ font-size: var(--t-xs);
font-weight: 600;
+ letter-spacing: 0.02em;
padding: 2px 9px;
- border-radius: 999px;
+ border-radius: var(--r-pill);
white-space: nowrap;
}
.pill.open {
background: var(--hl-mint);
- color: #1d5c34;
+ color: var(--ink-good);
}
.pill.closed {
background: var(--hl-pink);
- color: #8c2f4e;
+ color: var(--ink-rose);
}
.pill.waitlist,
.pill.other {
background: var(--hl-lemon);
- color: #7a5d10;
+ color: var(--ink-amber);
}
.rating-chip {
font-family: var(--mono);
- font-size: 12px;
+ font-size: var(--t-sm);
font-weight: 700;
- padding: 2px 8px;
- border-radius: 6px;
+ padding: 2px var(--space-2);
+ border-radius: var(--r-sm);
background: var(--hl-cyan);
- color: #0b5560;
+ color: var(--ink-cyan);
}
.card-more {
- padding-top: 8px;
- font-size: 12px;
+ padding-top: var(--space-2);
+ font-size: var(--t-sm);
color: var(--ink-faint);
}
@@ -711,10 +1264,10 @@ textarea {
}
.week .axis .tick {
position: absolute;
- right: 6px;
+ right: var(--space-1);
transform: translateY(-50%);
font-family: var(--mono);
- font-size: 10px;
+ font-size: var(--t-2xs);
color: var(--ink-faint);
}
.week .day {
@@ -723,15 +1276,15 @@ textarea {
.week .day-name {
text-align: center;
font-family: var(--hand);
- font-size: 18px;
+ font-size: var(--t-xl);
font-weight: 600;
color: var(--ink-soft);
- padding-bottom: 8px;
+ padding-bottom: var(--space-2);
white-space: nowrap;
overflow: hidden;
}
.week.compact .day-name {
- font-size: 15px;
+ font-size: var(--t-lg);
}
.week .day-col {
position: relative;
@@ -757,10 +1310,10 @@ textarea {
border-left: 4px solid;
padding: 3px 7px;
overflow: hidden;
- font-size: 12px;
+ font-size: var(--t-sm);
line-height: 1.3;
- animation: block-in 0.25s ease both;
- box-shadow: 0 1px 2px rgba(28, 36, 64, 0.08);
+ animation: block-in var(--dur-3) var(--ease-out) both;
+ box-shadow: 0 1px 2px rgba(74, 60, 40, 0.14);
}
.week.compact .block {
padding: 1px 5px;
@@ -778,23 +1331,23 @@ textarea {
}
}
.block .btime {
- font-size: 10.5px;
+ font-size: var(--t-2xs);
font-weight: 400;
opacity: 0.85;
white-space: nowrap;
}
.block .bcode {
font-family: var(--mono);
- font-size: 11.5px;
+ font-size: var(--t-xs);
font-weight: 700;
white-space: nowrap;
}
.week.compact .block .bcode {
- font-size: 10px;
+ font-size: var(--t-2xs);
}
.block .bwhere {
opacity: 0.75;
- font-size: 10px;
+ font-size: var(--t-2xs);
white-space: nowrap;
}
/* unpicked section option — TJ's stripes-and-fade treatment */
@@ -809,19 +1362,19 @@ textarea {
);
}
.block.conflicted {
- outline: 2px dashed #d95f4a;
+ outline: 2px dashed var(--edge-warn);
outline-offset: -2px;
}
.cal-legend {
display: flex;
flex-wrap: wrap;
- gap: 8px 18px;
- margin-top: 14px;
- padding-top: 12px;
+ gap: var(--space-2) var(--space-4);
+ margin-top: var(--space-3);
+ padding-top: var(--space-3);
border-top: 1px dashed var(--rule);
font-family: var(--mono);
- font-size: 12px;
+ font-size: var(--t-sm);
}
.legend-chip {
display: inline-flex;
@@ -836,7 +1389,7 @@ textarea {
}
.legend-pending {
font-family: var(--hand);
- font-size: 14px;
+ font-size: var(--t-base);
font-style: normal;
color: var(--ink-faint);
}
@@ -845,24 +1398,28 @@ textarea {
font-weight: 600;
text-decoration: none;
}
+.tba-note a:hover {
+ text-decoration: underline;
+}
.conflict-note {
- margin-top: 10px;
- padding: 8px 12px;
+ margin-top: var(--space-3);
+ padding: var(--space-2) var(--space-3);
background: var(--sticky);
+ border: 1px solid var(--edge-sticky);
border-radius: 2px 10px 4px 12px;
transform: rotate(-0.5deg);
- box-shadow: 0 2px 6px rgba(28, 36, 64, 0.12);
- font-size: 13px;
+ box-shadow: var(--shadow-note);
+ font-size: var(--t-md);
}
.conflict-note .lead {
font-family: var(--hand);
- font-size: 16px;
+ font-size: var(--t-lg);
font-weight: 600;
}
.tba-note {
- margin-top: 8px;
- font-size: 12px;
+ margin-top: var(--space-2);
+ font-size: var(--t-sm);
color: var(--ink-faint);
}
@@ -874,72 +1431,98 @@ textarea {
.page-inner {
max-width: 880px;
margin: 0 auto;
- padding: 36px 28px 48px;
+ padding: var(--space-6) var(--space-6) var(--space-7);
}
.page-title {
- font-size: 26px;
+ font-size: var(--t-2xl);
font-weight: 700;
letter-spacing: -0.02em;
- margin: 0 0 4px;
+ margin: 0 0 var(--space-1);
+ text-wrap: balance;
}
.page-sub {
color: var(--ink-soft);
- margin: 0 0 26px;
- font-size: 15px;
+ margin: 0 0 var(--space-5);
+ font-size: var(--t-base);
}
.page-sub .hand {
font-family: var(--hand);
- font-size: 17px;
+ font-size: var(--t-lg);
color: var(--pi-deep);
}
+/* a sheet: no heavy hairline, a warm stock, a short contact shadow, grain */
.paper-card {
- border: 1px solid var(--rule);
- border-radius: var(--radius);
- background: var(--paper-raised);
- box-shadow: var(--shadow-card);
- padding: 20px;
- margin-bottom: 20px;
+ border: 1px solid var(--rule-soft);
+ border-radius: var(--r-lg);
+ background-color: var(--paper-raised);
+ background-image: var(--grain-sheet);
+ box-shadow: var(--shadow-sheet);
+ padding: var(--space-5);
+ margin-bottom: var(--space-5);
}
.paper-card h3 {
- margin: 0 0 12px;
- font-size: 15px;
+ margin: 0 0 var(--space-3);
+ font-size: var(--t-base);
+}
+/* opt-in: ruled note paper with a red margin rule down the left */
+.paper-card.ruled {
+ padding-left: 46px;
+ background-image: var(--grain-sheet),
+ linear-gradient(
+ to right,
+ transparent 32px,
+ var(--hl-pink) 32px,
+ var(--hl-pink) 33.5px,
+ transparent 33.5px
+ ),
+ repeating-linear-gradient(
+ to bottom,
+ transparent 0,
+ transparent 27px,
+ var(--rule-soft) 27px,
+ var(--rule-soft) 28px
+ );
+ background-position: 0 0, 0 0, 0 var(--space-5);
}
.empty-hand {
font-family: var(--hand);
- font-size: 20px;
+ font-size: var(--t-xl);
color: var(--ink-faint);
text-align: center;
- padding: 48px 20px;
+ padding: var(--space-7) var(--space-5);
}
.empty-hand .cta {
display: inline-block;
- margin-top: 10px;
+ margin-top: var(--space-3);
font-family: var(--sans);
- font-size: 13.5px;
+ font-size: var(--t-md);
font-weight: 600;
color: var(--pi-deep);
}
/* schedule picker */
-.sched-tabs {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-bottom: 16px;
-}
.sched-tab {
- padding: 5px 14px;
- border-radius: 999px;
+ padding: 5px var(--space-3);
+ border-radius: var(--r-pill);
border: 1px solid var(--rule);
background: var(--paper-raised);
- font-size: 13px;
+ font-size: var(--t-md);
color: var(--ink-soft);
+ transition: border-color var(--dur-1) var(--ease-out),
+ background-color var(--dur-1) var(--ease-out);
+}
+.sched-tab:hover {
+ border-color: var(--ink-ghost);
+ color: var(--ink);
+}
+.sched-tab:active {
+ box-shadow: var(--shadow-press);
}
.sched-tab.active {
- border-color: var(--pi);
- background: var(--pi-wash);
+ border-color: var(--accent-2);
+ background: var(--accent-2-wash);
color: var(--ink);
font-weight: 600;
}
@@ -948,10 +1531,10 @@ textarea {
.agenda-row {
display: flex;
align-items: center;
- gap: 12px;
- padding: 10px 2px;
+ gap: var(--space-3);
+ padding: var(--space-3) 2px;
border-bottom: 1px solid var(--rule-soft);
- font-size: 14px;
+ font-size: var(--t-nav);
}
.agenda-row:last-child {
border-bottom: none;
@@ -959,13 +1542,13 @@ textarea {
.agenda-row .swatch {
width: 12px;
height: 12px;
- border-radius: 4px;
+ border-radius: var(--r-sm);
transform: rotate(-4deg);
flex-shrink: 0;
}
.agenda-row .code {
font-family: var(--mono);
- font-size: 12.5px;
+ font-size: var(--t-sm);
font-weight: 600;
min-width: 84px;
}
@@ -977,103 +1560,19 @@ textarea {
}
.agenda-row .when {
color: var(--ink-faint);
- font-size: 12.5px;
+ font-size: var(--t-sm);
+ font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ── my apps page ─────────────────────────────────────────────────── */
-.apps-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 16px;
-}
-.app-card {
- border: 1px solid var(--rule);
- border-radius: var(--radius);
- background: var(--paper-raised);
- box-shadow: var(--shadow-card);
- padding: 18px;
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-.app-card .head {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-.app-card .glyph {
- width: 38px;
- height: 38px;
- border-radius: 10px;
- display: grid;
- place-items: center;
- font-weight: 800;
- font-size: 15px;
- transform: rotate(-2deg);
- color: rgba(28, 36, 64, 0.8);
- flex-shrink: 0;
-}
-.app-card .glyph.has-logo img {
- width: 30px;
- height: 30px;
- object-fit: contain;
- display: block;
-}
-.app-card .aname {
- font-weight: 650;
- font-size: 15px;
-}
-.app-card .atag {
- font-size: 12px;
- color: var(--ink-faint);
-}
-.app-card .adetail {
- font-size: 13px;
- color: var(--ink-soft);
- flex: 1;
-}
-.app-card .foot {
- display: flex;
- align-items: center;
- gap: 10px;
- justify-content: space-between;
- padding-top: 4px;
-}
-.app-card .connect-btn {
- margin-left: auto;
- padding: 7px 14px;
- border-radius: 999px;
- background: var(--ink);
- color: #fff;
- font-size: 13px;
- font-weight: 600;
- text-decoration: none;
- white-space: nowrap;
- transition: transform 0.12s ease, box-shadow 0.12s ease;
-}
-.app-card .connect-btn:hover {
- transform: translateY(-1px);
- box-shadow: var(--shadow-card);
-}
-.app-card .status {
- font-size: 12px;
- color: var(--ink-faint);
-}
-.app-card .status.on {
- color: #1d7a44;
-}
-.app-card .status.err {
- color: #a4442e;
-}
-
.toggle {
width: 42px;
height: 24px;
- border-radius: 999px;
+ border-radius: var(--r-pill);
background: var(--rule);
position: relative;
- transition: background 0.15s ease;
+ transition: background-color var(--dur-2) var(--ease-out);
flex-shrink: 0;
}
.toggle::after {
@@ -1084,9 +1583,13 @@ textarea {
width: 18px;
height: 18px;
border-radius: 50%;
- background: #fff;
- box-shadow: 0 1px 3px rgba(28, 36, 64, 0.25);
- transition: transform 0.15s ease;
+ background: var(--paper-raised);
+ box-shadow: 0 1px 3px rgba(74, 60, 40, 0.3);
+ transition: transform var(--dur-2) var(--ease-out),
+ width var(--dur-2) var(--ease-out);
+}
+.toggle:active::after {
+ width: 22px;
}
.toggle.on {
background: var(--pi);
@@ -1094,25 +1597,32 @@ textarea {
.toggle.on::after {
transform: translateX(18px);
}
+.toggle.on:active::after {
+ transform: translateX(14px);
+}
.field-row {
display: flex;
align-items: center;
- gap: 12px;
+ gap: var(--space-3);
flex-wrap: wrap;
}
.field-row label {
- font-size: 13px;
+ font-size: var(--t-md);
font-weight: 600;
min-width: 64px;
}
.text-input {
border: 1px solid var(--rule);
- border-radius: 9px;
- padding: 8px 12px;
+ border-radius: var(--r-sm);
+ padding: var(--space-2) var(--space-3);
background: var(--paper);
- font-size: 14px;
+ font-size: var(--t-nav);
width: 200px;
+ transition: border-color var(--dur-1) var(--ease-out);
+}
+.text-input:hover {
+ border-color: var(--ink-ghost);
}
.text-input:focus {
outline: 2px solid var(--pi);
@@ -1126,54 +1636,62 @@ textarea {
display: grid;
place-items: center;
font-family: var(--hand);
- font-size: 22px;
+ font-size: var(--t-xl);
font-weight: 700;
flex-shrink: 0;
}
.seg {
display: inline-flex;
border: 1px solid var(--rule);
- border-radius: 999px;
+ border-radius: var(--r-pill);
overflow: hidden;
background: var(--paper);
}
.seg button {
- padding: 6px 14px;
- font-size: 13px;
+ padding: var(--space-1) var(--space-3);
+ font-size: var(--t-md);
color: var(--ink-soft);
+ transition: background-color var(--dur-1) var(--ease-out);
+}
+.seg button:hover {
+ background: var(--rule-soft);
}
.seg button.active {
- background: var(--ink);
- color: #fff;
+ background: var(--accent-2);
+ color: var(--on-ink);
font-weight: 600;
}
.footnote {
- font-size: 12.5px;
+ font-size: var(--t-sm);
+ letter-spacing: 0.02em;
color: var(--ink-faint);
- margin-top: 8px;
+ margin-top: var(--space-2);
}
/* ── composer ─────────────────────────────────────────────────────── */
.composer-wrap {
border-top: 1px solid var(--rule);
background: linear-gradient(to bottom, rgba(253, 252, 249, 0.6), var(--paper));
- padding: 14px 24px 18px;
+ padding: var(--space-3) var(--space-5) var(--space-4);
+ padding-bottom: calc(var(--space-4) + env(safe-area-inset-bottom, 0px));
}
.composer {
max-width: 760px;
margin: 0 auto;
display: flex;
align-items: flex-end;
- gap: 10px;
+ gap: var(--space-3);
border: 1.5px solid var(--rule);
border-radius: 22px;
background: var(--paper-raised);
- box-shadow: var(--shadow-card);
- padding: 8px 8px 8px 18px;
- transition: border-color 0.15s ease;
+ box-shadow: var(--shadow-sheet);
+ padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
+ transition: border-color var(--dur-2) var(--ease-out),
+ box-shadow var(--dur-2) var(--ease-out);
}
.composer:focus-within {
border-color: var(--pi);
+ box-shadow: var(--shadow-card);
}
.composer textarea {
flex: 1;
@@ -1181,26 +1699,33 @@ textarea {
resize: none;
background: none;
max-height: 160px;
- padding: 6px 0;
+ padding: var(--space-1) 0;
line-height: 1.45;
}
.composer textarea:focus {
outline: none;
}
+.composer textarea::placeholder {
+ color: var(--ink-faint);
+}
.composer .send {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--pi);
- color: #fff;
+ color: var(--on-ink);
display: grid;
place-items: center;
- font-size: 17px;
flex-shrink: 0;
- transition: transform 0.12s ease, background 0.12s ease;
+ transition: transform var(--dur-1) var(--ease-out),
+ background-color var(--dur-1) var(--ease-out);
}
.composer .send:hover {
transform: translateY(-1px);
+ background: var(--pi-deep);
+}
+.composer .send:active {
+ transform: translateY(1px) scale(0.96);
}
.composer .send:disabled {
background: var(--rule);
@@ -1213,9 +1738,10 @@ textarea {
}
.composer-note {
max-width: 760px;
- margin: 8px auto 0;
+ margin: var(--space-2) auto 0;
text-align: center;
- font-size: 11.5px;
+ font-size: var(--t-xs);
+ letter-spacing: 0.02em;
color: var(--ink-faint);
}
@@ -1224,26 +1750,40 @@ textarea {
display: flex;
gap: 2px;
margin-top: 3px;
- opacity: 0;
- transition: opacity 0.12s ease;
+ transition: opacity var(--dur-2) var(--ease-out);
}
-.turn:hover .msg-actions,
-.msg-actions:focus-within {
- opacity: 1;
+/* hidden-until-hover only where a pointer exists; on touch they stay visible */
+@media (hover: hover) {
+ .msg-actions {
+ opacity: 0;
+ }
+ .turn:hover .msg-actions,
+ .msg-actions:focus-within {
+ opacity: 1;
+ }
}
.turn.user .msg-actions {
justify-content: flex-end;
}
.msg-actions button {
- font-size: 11.5px;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: var(--t-xs);
+ letter-spacing: 0.02em;
color: var(--ink-faint);
- padding: 3px 8px;
- border-radius: 6px;
+ padding: 3px var(--space-2);
+ border-radius: var(--r-sm);
+ transition: background-color var(--dur-1) var(--ease-out),
+ color var(--dur-1) var(--ease-out);
}
.msg-actions button:hover {
background: var(--rule-soft);
color: var(--ink);
}
+.msg-actions button:active {
+ background: var(--rule);
+}
.msg-actions button:disabled {
opacity: 0.4;
cursor: default;
@@ -1251,86 +1791,95 @@ textarea {
/* model switcher inside the composer */
.model-select {
- border: none;
- background: var(--rule-soft);
- border-radius: 999px;
- font-size: 12px;
+ border: 1px solid var(--rule);
+ background-color: var(--rule-soft);
+ border-radius: var(--r-pill);
+ font-size: var(--t-sm);
font-weight: 600;
color: var(--ink-soft);
- padding: 6px 10px;
+ padding: var(--space-1) 26px var(--space-1) var(--space-3);
align-self: flex-end;
margin-bottom: 3px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
+ /* a chevron, so it reads as a control rather than a label */
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235f5b52' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5.5 9 6.5 6.5L18.5 9'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 9px center;
+ transition: border-color var(--dur-1) var(--ease-out);
}
.model-select:hover {
color: var(--ink);
+ border-color: var(--ink-ghost);
}
/* planner term dropdown */
.term-select {
border: 1px solid var(--rule);
background: var(--paper-raised);
- border-radius: 999px;
- font-size: 13px;
+ border-radius: var(--r-pill);
+ font-size: var(--t-md);
font-weight: 600;
color: var(--ink);
- padding: 5px 12px;
+ padding: 5px var(--space-3);
cursor: pointer;
+ transition: border-color var(--dur-1) var(--ease-out);
+}
+.term-select:hover {
+ border-color: var(--ink-ghost);
}
-/* sign-in */
+/* ── sign-in ──────────────────────────────────────────────────────── */
.signin {
height: 100%;
display: grid;
place-items: center;
- padding: 24px;
+ padding: var(--space-5);
}
+/* the one sheet with a torn bottom edge */
.signin-card {
+ position: relative;
max-width: 420px;
text-align: center;
- border: 1px solid var(--rule);
- border-radius: 16px;
- background: var(--paper-raised);
+ border: 1px solid var(--rule-soft);
+ border-bottom: none;
+ border-radius: var(--r-lg) var(--r-lg) 0 0;
+ background-color: var(--paper-raised);
+ background-image: var(--grain-sheet);
box-shadow: var(--shadow-card);
- padding: 40px 36px 28px;
-}
-.signin-card .mark {
- position: relative;
- width: 56px;
- height: 56px;
- margin: 0 auto 18px;
+ padding: 40px 36px var(--space-6);
}
-.signin-card .mark img {
- width: 100%;
- height: 100%;
- border-radius: 14px;
- display: block;
-}
-.signin-card .pi-badge {
+.signin-card::after {
+ content: "";
position: absolute;
- right: -8px;
- bottom: -8px;
- width: 26px;
- height: 26px;
- border-radius: 50%;
- background: var(--pi);
- color: #fff;
- border: 3px solid var(--paper-raised);
- display: grid;
- place-items: center;
- font-family: Georgia, serif;
- font-size: 15px;
+ left: 0;
+ right: 0;
+ bottom: -9px;
+ height: 10px;
+ background-color: var(--paper-raised);
+ background-image: var(--grain-sheet);
+ /* a deckled tear */
+ -webkit-mask-image: radial-gradient(
+ circle at 6px 0,
+ #000 5.4px,
+ transparent 5.8px
+ );
+ mask-image: radial-gradient(circle at 6px 0, #000 5.4px, transparent 5.8px);
+ -webkit-mask-size: 12px 10px;
+ mask-size: 12px 10px;
+ -webkit-mask-repeat: repeat-x;
+ mask-repeat: repeat-x;
}
.signin-card h1 {
- font-size: 26px;
- margin: 0 0 8px;
+ font-size: var(--t-2xl);
+ margin: 0 0 var(--space-2);
letter-spacing: -0.02em;
+ text-wrap: balance;
}
.signin-card h1 .name {
font-family: var(--hand);
- font-size: 32px;
+ font-size: var(--t-3xl);
color: var(--pi-deep);
}
.signin-card p {
@@ -1340,17 +1889,154 @@ textarea {
.signin-btn {
display: inline-flex;
justify-content: center;
- margin: 0 0 16px;
+ margin: 0 0 var(--space-4);
padding: 11px 22px;
}
/* connection error strip */
.conn-strip {
background: var(--sticky);
- padding: 6px 16px;
- font-size: 12.5px;
+ padding: var(--space-1) var(--space-4);
+ font-size: var(--t-md);
+ text-align: center;
+ border-bottom: 1px solid var(--edge-sticky);
+}
+
+/* ── boot shell ───────────────────────────────────────────────────────
+ * Mirrored inline in index.html so first paint is PI's furniture, not white.
+ * App.tsx's auth-loading state renders the same markup; keep them in step.
+ */
+.boot-shell {
+ pointer-events: none;
+}
+.boot-sidebar {
+ gap: var(--space-2);
+}
+.boot-wordmark {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: 2px var(--space-2) var(--space-4);
+}
+.boot-bones {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+.boot-bones .skel-line:first-child {
+ width: 34px;
+}
+.boot-bones .skel-line:last-child {
+ width: 84px;
+ height: 8px;
+}
+.boot-newchat {
+ height: 36px;
+ border-radius: var(--r-pill);
+ margin: 0 var(--space-1) var(--space-3);
+}
+.boot-rows {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: 0 var(--space-3);
+}
+.boot-rows.divided {
+ margin-top: var(--space-4);
+ padding-top: var(--space-4);
+ border-top: 1px dashed var(--rule);
+}
+/* uneven widths so the bones read as rows of writing, not a template */
+.boot-rows .skel-line:nth-child(1) {
+ width: 62%;
+}
+.boot-rows .skel-line:nth-child(2) {
+ width: 78%;
+}
+.boot-rows .skel-line:nth-child(3) {
+ width: 68%;
+}
+.boot-rows .skel-line:nth-child(4) {
+ width: 84%;
+}
+.boot-rows .skel-line:nth-child(5) {
+ width: 71%;
+}
+.boot-noscript {
+ max-width: 30rem;
+ margin: 12vh auto 0;
+ padding: 0 var(--space-5);
text-align: center;
- border-bottom: 1px solid #eadfa8;
+}
+.boot-main {
+ align-items: center;
+ justify-content: center;
+}
+.boot-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-3);
+ margin-top: -8vh;
+}
+.boot-note {
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ color: var(--ink-faint);
+ margin: 0;
+}
+
+/* ── the error boundary's card ────────────────────────────────────── */
+.lost-thread {
+ display: grid;
+ place-items: center;
+ min-height: 100%;
+ padding: var(--space-5);
+}
+.lost-thread .sheet {
+ max-width: 460px;
+ padding: var(--space-5) var(--space-5) var(--space-4);
+ border: 1px solid var(--rule-soft);
+ border-radius: var(--r-lg);
+ background-color: var(--paper-raised);
+ background-image: var(--grain-sheet);
+ box-shadow: var(--shadow-card);
+}
+.lost-thread h1 {
+ font-family: var(--hand);
+ font-size: var(--t-2xl);
+ font-weight: 700;
+ margin: 0 0 var(--space-2);
+}
+.lost-thread p {
+ color: var(--ink-soft);
+ margin: 0 0 var(--space-4);
+}
+.lost-thread .row {
+ display: flex;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+.lost-thread details {
+ margin-top: var(--space-4);
+ font-size: var(--t-sm);
+ color: var(--ink-faint);
+}
+.lost-thread summary {
+ cursor: pointer;
+ letter-spacing: 0.02em;
+}
+.lost-thread pre {
+ margin: var(--space-2) 0 0;
+ padding: var(--space-2) var(--space-3);
+ background: var(--paper-sunk);
+ border-radius: var(--r-sm);
+ font-family: var(--mono);
+ font-size: var(--t-xs);
+ max-height: 160px;
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
}
/* ── responsive ───────────────────────────────────────────────────── */
@@ -1358,9 +2044,9 @@ textarea {
.sidebar {
position: fixed;
inset: 0 auto 0 0;
- z-index: 30;
+ z-index: var(--z-sidebar);
transform: translateX(-100%);
- transition: transform 0.2s ease;
+ transition: transform var(--dur-3) var(--ease-out);
box-shadow: var(--shadow-pop);
}
.sidebar.open {
@@ -1372,26 +2058,50 @@ textarea {
.scrim {
position: fixed;
inset: 0;
- z-index: 25;
- background: rgba(28, 36, 64, 0.25);
+ z-index: var(--z-scrim);
+ background: rgba(34, 30, 22, 0.28);
}
.hello {
padding-top: 4vh;
}
.hello h1 {
- font-size: 27px;
+ font-size: var(--t-2xl);
}
.hello h1 .name {
- font-size: 33px;
+ font-size: var(--t-3xl);
+ }
+ .chat-column,
+ .page-inner {
+ padding-left: var(--space-4);
+ padding-right: var(--space-4);
+ }
+ .composer-wrap {
+ padding-left: var(--space-4);
+ padding-right: var(--space-4);
}
}
@media (prefers-reduced-motion: reduce) {
- * {
+ *,
+ *::before,
+ *::after {
animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+ /* things that signalled with motion need a static appearance instead */
+ .writing .nib,
+ .tool-chip.running .app-dot,
+ .tool-chip.running .app-logo-sm {
+ opacity: 1;
+ transform: none;
+ }
+ .skel::after {
+ display: none;
}
- .chat-scroll {
- scroll-behavior: auto;
+ .prompt-chip:hover::before,
+ .prompt-chip:focus-visible::before {
+ transform: scaleX(1);
}
}
diff --git a/app/src/client/styles/chat.css b/app/src/client/styles/chat.css
new file mode 100644
index 0000000..881e72e
--- /dev/null
+++ b/app/src/client/styles/chat.css
@@ -0,0 +1,680 @@
+/**
+ * Chat-lane styles. Imported from ChatPage, so it lands after styles.css in
+ * the bundle (main.tsx imports the design system first) and wins ties.
+ * Everything here builds on the shared tokens and primitives — no new colours,
+ * no new spacing values, no new easing.
+ */
+
+/* ── scroller + jump-to-latest ────────────────────────────────────── */
+
+/* holds the scroller so the jump pill can float over it */
+.chat-body {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.jump-latest {
+ position: absolute;
+ left: 50%;
+ bottom: var(--space-4);
+ z-index: 2;
+ transform: translateX(-50%);
+ background: var(--paper-raised);
+ border: 1px solid var(--rule);
+ box-shadow: var(--shadow-pop);
+ animation: jump-in var(--dur-2) var(--ease-out) both;
+}
+.jump-latest:hover {
+ transform: translateX(-50%) translateY(-1px);
+}
+.jump-latest:active {
+ transform: translateX(-50%) translateY(1px);
+}
+.jump-latest .icon {
+ transform: rotate(90deg);
+}
+@keyframes jump-in {
+ from {
+ opacity: 0;
+ transform: translateX(-50%) translateY(6px);
+ }
+}
+
+/* ── the transcript ───────────────────────────────────────────────── */
+
+/* user turns: no bubble. The words sit on the paper, ranged right against a
+ marker stroke down the margin, the way you'd tab a line in a notebook. */
+.turn.user {
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 3px;
+}
+.turn.user .said {
+ max-width: 62ch;
+ margin: 0;
+ padding: 1px var(--space-4) 1px 0;
+ border-right: 2px solid var(--pi);
+ text-align: right;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-weight: 500;
+}
+.turn.user .timestamp {
+ padding-right: var(--space-4);
+}
+/* echoed locally between "Enter" and the hook's own optimistic append */
+.turn.user.echo .said {
+ border-right-color: var(--ink-ghost);
+}
+
+/* "Worked from" — which apps this answer actually came out of */
+.worked-from {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+ font-size: var(--t-sm);
+ letter-spacing: 0.02em;
+ color: var(--ink-faint);
+}
+.wf-app {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-weight: 600;
+ color: var(--ink-soft);
+}
+.wf-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 3px;
+ transform: rotate(-4deg);
+ background: var(--wf-color, var(--rule));
+ flex-shrink: 0;
+}
+.wf-detail {
+ flex: 0 0 auto;
+}
+.wf-detail[open] {
+ flex-basis: 100%;
+}
+.wf-detail summary {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ color: var(--ink-ghost);
+ list-style: none;
+ transition: color var(--dur-1) var(--ease-out);
+}
+.wf-detail summary::-webkit-details-marker {
+ display: none;
+}
+.wf-detail summary:hover,
+.wf-detail[open] summary {
+ color: var(--ink-soft);
+}
+.wf-list {
+ margin: var(--space-2) 0 0;
+ padding: var(--space-2) var(--space-3);
+ list-style: none;
+ border: 1px dashed var(--rule);
+ border-radius: var(--r-sm);
+ background: var(--stock-note);
+}
+.wf-list li {
+ display: flex;
+ gap: var(--space-2);
+ padding: 2px 0;
+ font-size: var(--t-xs);
+ color: var(--ink-soft);
+}
+.wf-list li .what {
+ color: var(--ink-faint);
+}
+
+/* ── the empty desk ───────────────────────────────────────────────── */
+
+.hello .pi-lozenge-lg {
+ margin-bottom: var(--space-4);
+ box-shadow: var(--shadow-sheet);
+}
+/* the name is written, then struck through with a highlighter under it */
+.hello h1 {
+ margin-bottom: var(--space-3);
+}
+.hello h1 .name {
+ --underline-color: var(--hl-cyan);
+}
+.hello p .hand {
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ color: var(--pi-deep);
+}
+.prompt-chip:disabled {
+ opacity: 0.5;
+ cursor: default;
+ border-color: var(--rule);
+}
+.prompt-chip:disabled::before {
+ transform: scaleX(0);
+}
+/* the "nothing switched on" chip routes to the library instead of a dead end */
+.prompt-chip.to-apps {
+ border-color: var(--pi);
+ font-weight: 600;
+}
+
+/* ── the second beat: today's note + what PI may read ─────────────── */
+
+.home-notes {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-3);
+ margin: var(--space-7) 0 var(--space-6);
+}
+/* .paper-stack supplies the two sheets underneath; the bottom margin is the
+ room they need, since they hang below this one. */
+.today-note {
+ max-width: 48ch;
+ margin-bottom: var(--space-5);
+ padding: var(--space-5);
+ border: 1px solid var(--rule);
+ border-radius: var(--r-md);
+ background-color: var(--paper-raised);
+ background-image: var(--grain-sheet);
+ box-shadow: var(--shadow-sheet);
+}
+.note-day {
+ display: block;
+ margin-bottom: var(--space-2);
+ font-size: var(--t-xs);
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+}
+.today-note h2 {
+ margin: 0 0 var(--space-2);
+ font-size: var(--t-xl);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ text-wrap: balance;
+}
+.today-note p {
+ margin: 0;
+ color: var(--ink-soft);
+}
+/* the suggestion is PI's own handwriting in the margin of the note */
+.today-note .note-offer {
+ margin-top: var(--space-3);
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ line-height: 1.15;
+ color: var(--pi-deep);
+}
+.note-acts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-top: var(--space-4);
+}
+
+.home-privacy {
+ margin: 0;
+ max-width: 48ch;
+ color: var(--ink-soft);
+}
+.home-privacy .hand-underline {
+ --underline-color: var(--hl-lemon);
+ color: var(--ink);
+ font-weight: 600;
+}
+
+/* ── failure states ───────────────────────────────────────────────── */
+
+.conn-strip {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-3);
+}
+
+/* the desk couldn't be set up; the turn still went out without the apps */
+.setup-note {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-3);
+ margin: var(--space-3) 0;
+ padding: var(--space-2) var(--space-3);
+ border: 1px dashed var(--edge-warn);
+ border-radius: var(--r-md);
+ background: var(--stock-note);
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+.setup-note .what {
+ flex: 1;
+ min-width: 12ch;
+}
+
+/* ── message actions ──────────────────────────────────────────────── */
+
+.msg-actions button .icon {
+ color: var(--ink-ghost);
+}
+.msg-actions button:hover .icon {
+ color: inherit;
+}
+.msg-actions .said-it {
+ color: var(--ink-good);
+}
+.msg-actions .no-dice {
+ color: var(--ink-danger);
+}
+
+/* ── consent card (PI needs an app that is switched off) ──────────── */
+
+.consent-card {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-3);
+ margin: var(--space-3) 0;
+ padding: var(--space-4);
+ border: 1px solid var(--rule);
+ border-left: 3px solid var(--app-color, var(--pi));
+ border-radius: var(--r-md);
+ background: var(--paper-raised);
+ box-shadow: var(--shadow-card);
+}
+.consent-card .mark {
+ width: 32px;
+ height: 32px;
+ border-radius: 9px;
+ object-fit: contain;
+ background: var(--stock-note);
+ padding: 3px;
+ flex-shrink: 0;
+}
+.consent-card .body {
+ flex: 1;
+ min-width: 16ch;
+}
+.consent-card .title {
+ font-weight: 600;
+ line-height: 1.35;
+}
+.consent-card .why {
+ margin: 3px 0 0;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+.consent-card .acts {
+ display: flex;
+ gap: var(--space-2);
+ flex-shrink: 0;
+}
+.consent-card.settled {
+ border-left-color: var(--ink-ghost);
+ box-shadow: none;
+ background: var(--stock-note);
+}
+.consent-card.settled .title {
+ color: var(--ink-soft);
+}
+
+/* ── option rows (offer_choices) ──────────────────────────────────── */
+
+.choices {
+ margin: var(--space-3) 0;
+}
+.choices .ask {
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ line-height: 1.25;
+ margin: 0 0 var(--space-3);
+ color: var(--ink);
+}
+.choice-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.choice-row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ width: 100%;
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--rule);
+ border-radius: var(--r-md);
+ background: var(--paper-raised);
+ text-align: left;
+ transition: background-color var(--dur-1) var(--ease-out),
+ border-color var(--dur-1) var(--ease-out),
+ box-shadow var(--dur-1) var(--ease-out);
+}
+.choice-row:hover:not(:disabled) {
+ border-color: var(--accent-2-soft);
+}
+.choice-row:active:not(:disabled) {
+ box-shadow: var(--shadow-press);
+}
+.choice-row[aria-pressed="true"],
+.choice-row.picked {
+ background: var(--accent-2-wash);
+ border-color: transparent;
+ box-shadow: inset 2px 0 0 var(--accent-2);
+}
+.choice-row .lines {
+ flex: 1;
+ min-width: 0;
+}
+.choice-row .lines,
+.choice-row .what,
+.choice-row .aside {
+ display: block;
+}
+.choice-row .what {
+ font-weight: 600;
+ line-height: 1.35;
+}
+.choice-row .aside {
+ margin-top: 2px;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+.choice-row .tick {
+ margin-top: 2px;
+ color: var(--accent-2);
+ opacity: 0;
+ transition: opacity var(--dur-1) var(--ease-out);
+}
+.choice-row[aria-pressed="true"] .tick {
+ opacity: 1;
+}
+.choice-row:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.choice-other {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
+ padding: var(--space-1) var(--space-1) var(--space-1) var(--space-4);
+ border: 1px dashed var(--rule);
+ border-radius: var(--r-md);
+ background: var(--paper-raised);
+ transition: border-color var(--dur-2) var(--ease-out);
+}
+.choice-other:focus-within {
+ border-color: var(--pi);
+ border-style: solid;
+}
+.choice-other input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: none;
+ padding: var(--space-2) 0;
+ font-size: var(--t-nav);
+}
+.choice-other input:focus {
+ outline: none;
+}
+.choice-other .go {
+ width: 30px;
+ height: 30px;
+ border-radius: 10px 12px 10px 12px;
+ display: grid;
+ place-items: center;
+ background: var(--pi);
+ color: var(--on-ink);
+ flex-shrink: 0;
+ transition: background-color var(--dur-1) var(--ease-out);
+}
+.choice-other .go:hover {
+ background: var(--pi-deep);
+}
+.choice-other .go:disabled {
+ background: var(--rule);
+ color: var(--ink-faint);
+ cursor: default;
+}
+.choices .confirm {
+ margin-top: var(--space-3);
+}
+.choices .answered {
+ margin-top: var(--space-2);
+ font-size: var(--t-sm);
+ color: var(--ink-faint);
+}
+
+/* ── tool cards ───────────────────────────────────────────────────── */
+
+.card-title {
+ margin: 0 0 var(--space-3);
+ font-size: var(--t-nav);
+ font-weight: 600;
+}
+.tool-card .card-head .card-link .icon,
+.chip-link .icon {
+ vertical-align: -0.12em;
+}
+
+/* ── PrincetonCourses evidence card ───────────────────────────────── */
+
+.evidence .head-line {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2) var(--space-3);
+ margin-bottom: var(--space-3);
+}
+.course-chip {
+ display: inline-flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ padding: 3px var(--space-3) 4px;
+ border-radius: var(--r-sm);
+ background: var(--hl-orange);
+ color: var(--ink);
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ transform: rotate(-0.6deg);
+}
+.course-chip .of {
+ font-weight: 500;
+ font-size: var(--t-sm);
+ color: var(--ink-soft);
+}
+.rating-pill {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 4px;
+ padding: 2px var(--space-3) 3px;
+ border-radius: var(--r-pill);
+ background: var(--hl-mint);
+ color: var(--ink-good);
+ font-weight: 700;
+ font-size: var(--t-md);
+ font-variant-numeric: tabular-nums;
+}
+.rating-pill .out-of {
+ font-weight: 500;
+ font-size: var(--t-xs);
+ opacity: 0.8;
+}
+.eval-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+.eval-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px var(--space-2) 3px;
+ border-radius: var(--r-sm);
+ font-size: var(--t-xs);
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ text-decoration: none;
+ background: var(--pi-wash);
+ color: var(--pi-deep);
+ transition: filter var(--dur-1) var(--ease-out);
+}
+.eval-link.qcr {
+ background: var(--accent-2-wash);
+ color: var(--accent-2);
+}
+.eval-link.pdf {
+ background: var(--hl-orange);
+ color: var(--ink-amber);
+}
+.eval-link:hover {
+ filter: brightness(0.96);
+ text-decoration: underline;
+}
+
+.quotes {
+ display: flex;
+ flex-direction: column;
+}
+.quote {
+ display: flex;
+ gap: var(--space-3);
+ padding: var(--space-3) 0;
+ border-bottom: 1px dashed var(--rule);
+ font-size: var(--t-nav);
+ line-height: 1.5;
+ color: var(--ink-soft);
+}
+.quote:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+}
+.quote .mark {
+ font-family: var(--hand);
+ font-size: var(--t-3xl);
+ line-height: 0.62;
+ color: var(--pi-deep);
+ flex-shrink: 0;
+}
+.quote .said {
+ flex: 1;
+ min-width: 0;
+}
+.quote .from {
+ display: block;
+ margin-top: 3px;
+ font-size: var(--t-xs);
+ color: var(--ink-faint);
+ letter-spacing: 0.02em;
+}
+
+/* ── loading a saved transcript ───────────────────────────────────── */
+
+.chat-skeleton .sk-turn {
+ margin: var(--space-5) 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+.chat-skeleton .sk-turn.mine {
+ align-items: flex-end;
+}
+.chat-skeleton .skel-line {
+ max-width: 100%;
+}
+.chat-skeleton .sk-turn.mine .skel-line {
+ width: 46%;
+}
+.chat-skeleton .sk-turn.theirs .skel-line:nth-child(1) {
+ width: 88%;
+}
+.chat-skeleton .sk-turn.theirs .skel-line:nth-child(2) {
+ width: 96%;
+}
+.chat-skeleton .sk-turn.theirs .skel-line:nth-child(3) {
+ width: 61%;
+}
+.chat-skeleton .sk-card {
+ height: 84px;
+ width: 100%;
+ border-radius: var(--r-md);
+}
+.sk-composer {
+ flex: 1;
+ height: 36px;
+ border-radius: var(--r-pill);
+}
+.sk-note-row {
+ display: flex;
+ justify-content: flex-end;
+ max-width: 760px;
+ margin: var(--space-2) auto 0;
+}
+.sk-note {
+ height: 11px;
+ width: 340px;
+ max-width: 70%;
+}
+
+/* ── composer ─────────────────────────────────────────────────────── */
+
+/* a rubber stamp, not the round arrow every chat product ships */
+.composer .send {
+ border-radius: 12px 14px 11px 15px;
+}
+.composer .send.stop.nudge {
+ animation: stop-nudge var(--dur-3) var(--ease-out) 2;
+}
+@keyframes stop-nudge {
+ 50% {
+ transform: scale(1.12);
+ box-shadow: 0 0 0 5px var(--pi-wash);
+ }
+}
+.composer.blocked {
+ border-color: var(--edge-warn);
+}
+/* not centred fine print under the box — a note ranged into the right
+ margin, so it reads as PI annotating its own composer */
+.composer-note {
+ max-width: 760px;
+ text-align: right;
+ font-size: var(--t-sm);
+}
+
+@media (max-width: 820px) {
+ .turn.user .said {
+ max-width: 100%;
+ }
+ .consent-card .acts {
+ width: 100%;
+ }
+ .consent-card .acts .btn {
+ flex: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .jump-latest,
+ .composer .send.stop.nudge {
+ animation: none;
+ }
+}
diff --git a/app/src/client/styles/desk.css b/app/src/client/styles/desk.css
new file mode 100644
index 0000000..6cd0b72
--- /dev/null
+++ b/app/src/client/styles/desk.css
@@ -0,0 +1,582 @@
+/**
+ * The desk pages: My apps, Daily summary, Planner. Imported from those three
+ * components, so it lands after the design system in the bundle and wins
+ * ties. Everything here is built from the shared tokens and primitives in
+ * styles.css — no new colours, no new easing.
+ */
+
+/* ── shared page furniture ────────────────────────────────────────── */
+
+/* the second word of a page title, written by hand over a marker swipe */
+.ink-word {
+ font-family: var(--hand);
+ font-size: 1.3em;
+ font-weight: 700;
+ letter-spacing: 0;
+ color: var(--pi-deep);
+}
+.ink-word.swipe {
+ --swipe-color: var(--hl-cyan);
+}
+
+.desk-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-5);
+ flex-wrap: wrap;
+ margin-bottom: var(--space-5);
+}
+.desk-head .page-title,
+.desk-head .page-sub {
+ margin-bottom: var(--space-2);
+}
+.desk-head-copy {
+ flex: 1 1 340px;
+ max-width: 56ch;
+}
+
+/* section heads sit under a hand-drawn rule, like a ruled notebook page */
+.desk-section {
+ margin-top: var(--space-6);
+}
+.desk-section > h2 {
+ font-size: var(--t-lg);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ margin: 0;
+}
+.desk-section > .hand-rule {
+ margin: 6px 0 var(--space-3);
+ --underline-color: var(--ink-ghost);
+}
+
+/* a failed card: says what broke, offers the way out, keeps the raw text in
+ a title attribute rather than in PI's handwriting */
+.card-error {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border: 1px dashed var(--edge-danger);
+ border-radius: var(--r-md);
+ background: var(--stock-note);
+}
+.card-error p {
+ flex: 1;
+ margin: 0;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+.card-error .btn {
+ flex-shrink: 0;
+}
+
+/* ── my apps ──────────────────────────────────────────────────────── */
+
+.master-switch {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--r-md);
+ background: var(--paper-sunk);
+ max-width: 320px;
+}
+.master-switch .master-label {
+ display: block;
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ line-height: 1.1;
+}
+.master-switch .footnote {
+ display: block;
+ margin-top: 2px;
+}
+
+/* a note that interrupts the page: consent failed, settings didn't save */
+.page-note {
+ margin-bottom: var(--space-5);
+}
+
+.app-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: 1fr;
+ column-gap: var(--space-6);
+}
+@media (min-width: 760px) {
+ .app-list.two-up {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.app-row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-4) 0;
+ border-bottom: 1px solid var(--rule-soft);
+}
+
+/* squircle, tilted a hair, so a shelf of them reads as stickers not tiles */
+.app-mark {
+ width: 40px;
+ height: 40px;
+ border-radius: 13px;
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ transform: rotate(-2deg);
+ font-weight: 700;
+ font-size: var(--t-md);
+ letter-spacing: 0.02em;
+ color: rgba(34, 38, 46, 0.78);
+ box-shadow: var(--shadow-sheet);
+}
+.app-mark img {
+ width: 28px;
+ height: 28px;
+ object-fit: contain;
+ display: block;
+}
+.app-row.is-soon .app-mark {
+ box-shadow: none;
+ opacity: 0.72;
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+}
+
+.app-copy {
+ flex: 1;
+ min-width: 0;
+}
+.app-name {
+ margin: 0;
+ font-size: var(--t-base);
+ font-weight: 700;
+ line-height: 1.3;
+}
+.app-blurb {
+ margin: 3px 0 0;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+ text-wrap: pretty;
+}
+.app-reads {
+ margin: var(--space-2) 0 0;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.app-reads .icon {
+ color: var(--ink-ghost);
+}
+.app-note {
+ margin: var(--space-2) 0 0;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ font-size: var(--t-sm);
+ color: var(--ink-soft);
+}
+.app-note.is-err {
+ color: var(--ink-danger);
+}
+.app-note.is-good {
+ color: var(--ink-good);
+}
+
+.app-controls {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-shrink: 0;
+ padding-top: 2px;
+}
+.app-launch {
+ display: grid;
+ place-items: center;
+ width: 26px;
+ height: 26px;
+ border-radius: var(--r-sm);
+ color: var(--ink-faint);
+ transition: color var(--dur-1) var(--ease-out),
+ background-color var(--dur-1) var(--ease-out);
+}
+.app-launch:hover {
+ color: var(--pi-deep);
+ background: var(--pi-wash);
+}
+
+/* toggles in flight: the row says so instead of pretending it already saved */
+.toggle[disabled] {
+ cursor: progress;
+ opacity: 0.6;
+}
+.toggle[aria-busy="true"] {
+ background: var(--hl-cyan);
+}
+
+.connect-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.desk-identity {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+ margin-top: var(--space-6);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--rule-soft);
+}
+.desk-identity .who {
+ flex: 1;
+ min-width: 0;
+}
+.desk-identity .name {
+ font-weight: 600;
+ font-size: var(--t-nav);
+}
+
+/* ── daily summary ────────────────────────────────────────────────── */
+
+.agenda-top {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
+}
+.agenda-note {
+ max-width: 34ch;
+ font-size: var(--t-md);
+ color: var(--ink);
+}
+.agenda-note .lead {
+ display: block;
+ margin-top: 2px;
+}
+
+.module-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-4);
+ align-items: start;
+}
+@media (min-width: 780px) {
+ .module-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ /* the real one gets the wide slot; teasers sit beside each other */
+ .module.is-wide {
+ grid-column: 1 / -1;
+ }
+}
+
+.module {
+ margin-bottom: 0;
+}
+.module-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin: 0 0 var(--space-3);
+ font-size: var(--t-md);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--ink-soft);
+}
+.module-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ border: 1px solid rgba(74, 60, 40, 0.18);
+ flex-shrink: 0;
+}
+.module-head .soon-chip {
+ margin-left: auto;
+}
+
+.module-q {
+ margin: 0;
+ font-size: var(--t-lg);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+.module-q-2 {
+ margin-top: var(--space-5);
+}
+.back-arrow {
+ transform: rotate(180deg);
+}
+.module-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+}
+.row-skel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-3) 0;
+}
+.row-skel .skel-line:nth-child(1) {
+ width: 78%;
+}
+.row-skel .skel-line:nth-child(2) {
+ width: 62%;
+}
+.row-skel .skel-line:nth-child(3) {
+ width: 70%;
+}
+.module-date {
+ margin: 2px 0 var(--space-4);
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+.module-date .day-hand {
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ color: var(--pi-deep);
+}
+
+/* today's classes, as blocks lifted off the week grid */
+.day-blocks {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.day-block {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--r-sm);
+ border-left: 3px solid;
+ font-size: var(--t-md);
+}
+.day-block .btime {
+ font-size: var(--t-sm);
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.day-block .bcode {
+ font-weight: 700;
+ white-space: nowrap;
+}
+.day-block .bwhere {
+ margin-left: auto;
+ font-size: var(--t-sm);
+ opacity: 0.85;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.day-block.is-option {
+ border-left-style: dashed;
+}
+
+/* Scoped: App.tsx's route skeleton ships a taller .skel-block of its own. */
+.day-blocks .skel-block {
+ height: 34px;
+ border-radius: var(--r-sm);
+}
+
+/* the registration countdown. Only ever rendered from a real term date. */
+.reg-callout {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ margin-top: var(--space-4);
+ padding: var(--space-3);
+ border-radius: var(--r-md);
+ background: var(--pi-wash);
+}
+.reg-num {
+ display: grid;
+ place-items: center;
+ min-width: 44px;
+ height: 44px;
+ padding: 0 var(--space-2);
+ border-radius: var(--r-sm);
+ background: var(--paper-raised);
+ color: var(--pi-deep);
+ font-family: var(--hand);
+ font-size: var(--t-2xl);
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ transform: rotate(-1.5deg);
+ flex-shrink: 0;
+}
+.reg-callout .reg-what {
+ font-size: var(--t-md);
+ font-weight: 600;
+}
+.reg-callout .timestamp {
+ display: block;
+ font-weight: 400;
+}
+
+.module-foot {
+ margin-top: var(--space-4);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+.module-foot .hand-line {
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ color: var(--ink-soft);
+ line-height: 1.2;
+}
+
+/* where a module's data actually comes from — read before the data, so it
+ sits above the first question rather than under the last row */
+.module-source {
+ margin: 0 0 var(--space-3);
+}
+
+.module-empty {
+ font-family: var(--hand);
+ font-size: var(--t-xl);
+ color: var(--ink-faint);
+ margin: 0;
+ padding: var(--space-4) 0;
+}
+.module-empty .cta {
+ display: inline-block;
+ margin-top: var(--space-2);
+ font-family: var(--sans);
+ font-size: var(--t-md);
+ font-weight: 600;
+ color: var(--pi-deep);
+}
+
+/* teaser modules: a different material, so nothing reads as shipped */
+.module.is-soon {
+ border: 1px dashed var(--rule);
+ border-radius: var(--r-lg);
+ background: transparent;
+ box-shadow: none;
+ padding: var(--space-4) var(--space-5);
+}
+.module.is-soon .module-head {
+ color: var(--ink-faint);
+}
+.module.is-soon p {
+ margin: 0;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+.hall-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: var(--space-3);
+ padding: 0;
+ list-style: none;
+}
+.hall-tags li {
+ display: contents;
+}
+.ghost-rows {
+ list-style: none;
+ margin: var(--space-3) 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.ghost-row {
+ height: 30px;
+ border: 1px dashed var(--ink-ghost);
+ border-radius: var(--r-sm);
+ opacity: 0.5;
+}
+.ghost-row:nth-child(2) {
+ width: 82%;
+}
+.ghost-row:nth-child(3) {
+ width: 64%;
+}
+
+/* ── planner ──────────────────────────────────────────────────────── */
+
+/* the week grid keeps its shape and scrolls inside itself on a phone */
+.week-scroll {
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+}
+@media (max-width: 700px) {
+ /* only the grid keeps its width; the legend and notes below still wrap */
+ .week-scroll .week {
+ min-width: 600px;
+ }
+}
+
+.picker {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-bottom: var(--space-4);
+}
+.picker .picker-label {
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+
+/* two link-outs sitting side by side in an empty state */
+.empty-hand .cta + .cta {
+ margin-left: var(--space-4);
+}
+
+.week-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--pi-deep);
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.sheet-skel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+.sheet-skel .skel-line:nth-child(1) {
+ width: 40%;
+}
+.sheet-skel .skel-line:nth-child(2) {
+ width: 88%;
+}
+.sheet-skel .skel-line:nth-child(3) {
+ width: 72%;
+}
+.sheet-skel .skel-line:nth-child(4) {
+ width: 80%;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .app-launch,
+ .toggle,
+ .toggle::after {
+ transition: none;
+ }
+}
diff --git a/app/src/client/styles/shell.css b/app/src/client/styles/shell.css
new file mode 100644
index 0000000..0f83d09
--- /dev/null
+++ b/app/src/client/styles/shell.css
@@ -0,0 +1,651 @@
+/**
+ * Shell lane: the sidebar, the mobile drawer, the sign-in front door, route
+ * placeholders, the 404 and the session-lapse card.
+ *
+ * Loaded after src/client/styles.css (main.tsx imports the design system
+ * first), so plain overrides of shared classes land without !important.
+ */
+
+/* ── keyboard entry ───────────────────────────────────────────────── */
+.skip-link {
+ position: fixed;
+ top: var(--space-2);
+ left: var(--space-2);
+ z-index: 100;
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--r-pill);
+ background: var(--ink);
+ color: var(--on-ink);
+ font-size: var(--t-md);
+ font-weight: 600;
+ text-decoration: none;
+ transform: translateY(calc(-100% - var(--space-4)));
+ transition: transform var(--dur-2) var(--ease-out);
+}
+.skip-link:focus-visible {
+ transform: none;
+}
+
+/* the main region takes focus on a route change; it must not draw a ring */
+.main:focus {
+ outline: none;
+}
+
+/* ── wordmark ─────────────────────────────────────────────────────── */
+.sidebar .wordmark {
+ gap: var(--space-2);
+}
+.sidebar .wordmark .name {
+ line-height: 1.1;
+}
+.sidebar .wordmark .sub {
+ margin-top: -1px;
+}
+.drawer-close {
+ display: none;
+ margin-left: auto;
+ padding: var(--space-1);
+ border-radius: var(--r-sm);
+ color: var(--ink-soft);
+}
+.drawer-close:hover {
+ background: var(--rule-soft);
+ color: var(--ink);
+}
+
+/* ── new chat ─────────────────────────────────────────────────────── */
+/* the wireframe's row, not a filled pill: a black dot carries the weight */
+.sidebar .new-chat {
+ background: transparent;
+ color: var(--ink);
+ text-decoration: none;
+ gap: var(--space-3);
+ /* the dot lines up with the nav icons below it */
+ margin: 0 0 var(--space-2);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--r-sm);
+}
+.sidebar .new-chat:hover {
+ background: var(--rule-soft);
+ transform: none;
+ box-shadow: none;
+}
+.sidebar .new-chat:active {
+ transform: none;
+ box-shadow: none;
+}
+.plus-dot {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--r-pill);
+ background: var(--ink);
+ color: var(--on-ink);
+ transition: transform var(--dur-2) var(--ease-out);
+}
+.sidebar .new-chat:hover .plus-dot {
+ transform: rotate(90deg);
+}
+.sidebar .new-chat:active .plus-dot {
+ transform: rotate(90deg) scale(0.94);
+}
+
+/* ── nav tabs ─────────────────────────────────────────────────────── */
+.nav-tabs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 2px;
+}
+.nav-tab {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 7px var(--space-2);
+ border-radius: var(--r-sm);
+ font-size: var(--t-md);
+ font-weight: 500;
+ color: var(--ink-soft);
+ text-decoration: none;
+ transition: background-color var(--dur-1) var(--ease-out),
+ color var(--dur-1) var(--ease-out);
+}
+.nav-tab .nav-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.nav-tab:hover {
+ background: var(--rule-soft);
+ color: var(--ink);
+}
+.nav-tab.active {
+ background: var(--accent-2-wash);
+ color: var(--accent-2);
+ font-weight: 600;
+}
+.sidebar .hand-rule {
+ margin: var(--space-2) var(--space-1) var(--space-3);
+ --underline-color: var(--ink-ghost);
+}
+
+/* ── the pinned general chat ──────────────────────────────────────── */
+.pinned-chat {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--r-sm);
+ font-size: var(--t-md);
+ font-weight: 500;
+ color: var(--ink);
+ text-decoration: none;
+ transition: background-color var(--dur-1) var(--ease-out);
+}
+.pinned-chat .pi-lozenge {
+ width: 22px;
+ height: 22px;
+}
+.pinned-chat .title {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.pinned-chat:hover {
+ background: var(--rule-soft);
+}
+.pinned-chat.active {
+ background: var(--accent-2-wash);
+ box-shadow: inset 2px 0 0 var(--accent-2);
+}
+
+/* ── search ───────────────────────────────────────────────────────── */
+.chat-search {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin: var(--space-3) var(--space-1) var(--space-2);
+ padding: 5px var(--space-3);
+ border: 1px solid var(--rule);
+ border-radius: var(--r-pill);
+ background: var(--accent-2-wash);
+ color: var(--ink-faint);
+}
+.chat-search:focus-within {
+ border-color: var(--accent-2-soft);
+}
+.chat-search input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: none;
+ outline: none;
+ font-size: var(--t-md);
+ color: var(--ink);
+}
+.chat-search input::placeholder {
+ color: var(--ink-faint);
+}
+/* Safari draws its own clear button; the row already has one shape */
+.chat-search input::-webkit-search-decoration,
+.chat-search input::-webkit-search-cancel-button {
+ -webkit-appearance: none;
+}
+
+/* ── chat sections ────────────────────────────────────────────────── */
+.sidebar .chat-list {
+ margin-top: var(--space-1);
+ padding-top: 0;
+ border-top: none;
+}
+.chat-sec {
+ margin-bottom: var(--space-3);
+}
+.sidebar .chat-list-label {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 0 var(--space-2) 2px;
+ font-weight: 400;
+}
+.chat-rows {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.chat-item {
+ position: relative;
+}
+.sidebar .chat-row {
+ padding: 5px var(--space-2);
+ text-decoration: none;
+}
+.sidebar .chat-row .timestamp {
+ transition: opacity var(--dur-1) var(--ease-out);
+}
+.row-actions {
+ position: absolute;
+ top: 50%;
+ right: var(--space-1);
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ gap: 1px;
+}
+.row-btn {
+ display: grid;
+ place-items: center;
+ padding: 3px;
+ border-radius: var(--r-sm);
+ color: var(--ink-faint);
+ font-size: var(--t-xs);
+ transition: color var(--dur-1) var(--ease-out),
+ background-color var(--dur-1) var(--ease-out);
+}
+.row-btn:hover {
+ background: var(--paper-raised);
+ color: var(--ink);
+}
+.row-btn.starred {
+ color: var(--edge-warn);
+}
+.row-btn.starred svg {
+ fill: var(--hl-lemon);
+}
+.row-btn.confirm {
+ padding: 2px var(--space-2);
+ background: var(--stock-note);
+ border: 1px solid var(--edge-danger);
+ color: var(--ink-danger);
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+/* Where there's a pointer, the actions take the timestamp's place on hover, so
+ a title gets the whole row. On touch they are always there, in their own
+ reserved lane, because there is no hover to reveal them. */
+@media (hover: hover) {
+ .row-actions {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--dur-1) var(--ease-out);
+ }
+ .chat-item:hover .row-actions,
+ .chat-item:focus-within .row-actions {
+ opacity: 1;
+ pointer-events: auto;
+ }
+ .chat-item:hover .timestamp,
+ .chat-item:focus-within .timestamp {
+ opacity: 0;
+ }
+}
+@media (hover: none) {
+ .sidebar .chat-row {
+ padding-right: 48px;
+ }
+}
+
+.list-empty {
+ font-family: var(--hand);
+ font-size: var(--t-lg);
+ color: var(--ink-faint);
+ margin: var(--space-2) var(--space-3) var(--space-4);
+}
+
+.browse-all {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin: 0 var(--space-1) var(--space-4);
+ padding: 4px var(--space-3);
+ border: 1px dashed var(--rule);
+ border-radius: var(--r-pill);
+ color: var(--ink-faint);
+ font-size: var(--t-xs);
+ letter-spacing: 0.04em;
+ text-transform: lowercase;
+ transition: border-color var(--dur-1) var(--ease-out),
+ color var(--dur-1) var(--ease-out);
+}
+.browse-all:hover {
+ border-color: var(--ink-ghost);
+ color: var(--ink);
+}
+.browse-all .icon {
+ transform: rotate(90deg);
+}
+.browse-all .icon.up {
+ transform: rotate(-90deg);
+}
+
+/* ── workspaces, not built yet ────────────────────────────────────── */
+.ws {
+ margin-top: var(--space-4);
+ padding-top: var(--space-3);
+ border-top: 1px dashed var(--rule);
+}
+.ws-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0 var(--space-1);
+ padding: 6px var(--space-3);
+ border: 1px dashed var(--ink-ghost);
+ border-radius: var(--r-sm);
+ color: var(--ink-ghost);
+ font-size: var(--t-md);
+}
+.ws-note {
+ margin: var(--space-1) var(--space-3) 0;
+ font-size: var(--t-xs);
+ line-height: 1.45;
+ color: var(--ink-faint);
+}
+
+/* ── sidebar footer ───────────────────────────────────────────────── */
+.sidebar-foot {
+ padding-top: var(--space-2);
+}
+.sidebar .identity-chip {
+ text-decoration: none;
+ color: inherit;
+}
+.identity-chip .who {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+.identity-chip .netid,
+.identity-chip .hint {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.colophon {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin: var(--space-2) 0 0;
+ padding: 0 var(--space-2);
+ font-size: var(--t-xs);
+ letter-spacing: 0.02em;
+}
+.colophon a {
+ color: var(--ink-faint);
+ text-decoration: none;
+}
+.colophon a:hover {
+ color: var(--pi-deep);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.colophon .dot {
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: var(--ink-ghost);
+}
+
+/* ── topbar (mobile) ──────────────────────────────────────────────── */
+.topbar .pi-lozenge {
+ width: 22px;
+ height: 22px;
+}
+.topbar strong {
+ letter-spacing: 0.02em;
+}
+
+/* the scrim is a real button so it answers the keyboard, not just the mouse */
+.scrim {
+ display: none;
+ border: 0;
+}
+
+/* ── route placeholders ───────────────────────────────────────────── */
+.route-skel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.route-skel .chat-column,
+.route-skel .page-inner {
+ flex: 1;
+ width: 100%;
+}
+.skel-turn {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-bottom: var(--space-6);
+}
+.skel-turn .skel-line:nth-child(1) {
+ width: 76%;
+}
+.skel-turn .skel-line:nth-child(2) {
+ width: 92%;
+}
+.skel-turn .skel-line:nth-child(3) {
+ width: 61%;
+}
+.skel-turn.mine {
+ align-items: flex-end;
+}
+.skel-turn.mine .skel-line {
+ width: 42%;
+ height: 12px;
+}
+.skel-composer {
+ height: 52px;
+ max-width: 760px;
+ width: calc(100% - var(--space-5) * 2);
+ margin: 0 auto var(--space-4);
+ border-radius: 22px;
+}
+.skel-title {
+ height: 26px;
+ width: 190px;
+ margin-bottom: var(--space-5);
+}
+/* Scoped to the route skeleton: the pages' own stylesheet ships a smaller
+ .skel-block, and it loads after this one from the lazy route chunks. */
+.route-skel .skel-block {
+ height: 116px;
+ border-radius: var(--r-lg);
+ margin-bottom: var(--space-4);
+}
+
+/* ── 404 ──────────────────────────────────────────────────────────── */
+.page-miss {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-5);
+ padding: var(--space-5);
+ text-align: center;
+}
+.page-miss .sticky-note {
+ max-width: 30rem;
+}
+/* The lead is an h1 here; drop the heading's own margin so the body below
+ sets the gap, exactly as it did when this was a paragraph. */
+.page-miss .lead {
+ margin: 0;
+}
+.page-miss .body {
+ margin: var(--space-2) 0 0;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+
+/* ── session lapse ────────────────────────────────────────────────── */
+.lapse {
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+ display: grid;
+ place-items: center;
+ padding: var(--space-5);
+ background: rgba(34, 30, 22, 0.34);
+}
+.lapse-card {
+ max-width: 26rem;
+ text-align: center;
+ padding: var(--space-6) var(--space-5) var(--space-5);
+ border-radius: var(--r-lg);
+ background-color: var(--paper-raised);
+ background-image: var(--grain-sheet);
+ box-shadow: var(--shadow-pop);
+ animation: lapse-in var(--dur-3) var(--ease-out);
+}
+.lapse-card h2 {
+ font-size: var(--t-xl);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ margin: var(--space-3) 0 var(--space-2);
+ text-wrap: balance;
+}
+.lapse-card p {
+ color: var(--ink-soft);
+ font-size: var(--t-md);
+ margin: 0 0 var(--space-4);
+}
+@keyframes lapse-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+}
+
+/* ── sign-in front door ───────────────────────────────────────────── */
+.signin-card .signin-mark {
+ margin: 0 auto var(--space-3);
+}
+.signin-card .eyebrow {
+ margin: 0 0 var(--space-2);
+ font-size: var(--t-xs);
+ font-weight: 600;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+}
+.signin-card h1 {
+ font-size: var(--t-2xl);
+ line-height: 1.15;
+}
+.signin-card h1 .swipe {
+ --swipe-color: var(--hl-cyan);
+}
+/* at display size the marker wants to sit a touch lower on the baseline */
+.signin-card h1 .swipe::before {
+ top: 0.16em;
+ bottom: -0.04em;
+}
+.signin-card .lede {
+ margin: 0 0 var(--space-4);
+ text-wrap: pretty;
+}
+.signin-asks {
+ list-style: none;
+ margin: 0 0 var(--space-5);
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-family: var(--hand);
+ font-size: var(--t-lg);
+ line-height: 1.35;
+ color: var(--pi-deep);
+ text-wrap: balance;
+}
+.signin-asks li:nth-child(even) {
+ transform: rotate(-0.5deg);
+}
+.signin-appslabel {
+ margin: 0 0 var(--space-2);
+ font-size: var(--t-xs);
+ letter-spacing: 0.04em;
+ color: var(--ink-faint);
+}
+.signin-apps {
+ list-style: none;
+ margin: 0 0 var(--space-5);
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ justify-content: center;
+}
+.signin-apps li:nth-child(2n) {
+ transform: rotate(0.7deg);
+}
+.signin-apps li:nth-child(3n) {
+ transform: rotate(-1deg);
+}
+.signin-trouble {
+ margin: 0 0 var(--space-4);
+ padding: var(--space-2) var(--space-3);
+ border: 1px dashed var(--edge-danger);
+ border-radius: var(--r-md);
+ background: var(--stock-note);
+ color: var(--ink-danger);
+ font-size: var(--t-md);
+}
+.signin-card .signin-btn {
+ width: 100%;
+ padding: 12px var(--space-5);
+ font-size: var(--t-nav);
+}
+.signin-card .privacy {
+ margin: var(--space-4) 0 0;
+ font-size: var(--t-md);
+ color: var(--ink-soft);
+}
+.signin-card .footnote {
+ margin-top: var(--space-2);
+ line-height: 1.5;
+}
+.signin-card .colophon {
+ justify-content: center;
+ margin-top: var(--space-4);
+}
+
+/* ── drawer ───────────────────────────────────────────────────────── */
+@media (max-width: 820px) {
+ .drawer-close {
+ display: grid;
+ }
+ .scrim {
+ display: block;
+ }
+ /* Closed drawer leaves the tab order and the a11y tree, but only after it
+ has finished sliding out. */
+ .sidebar {
+ visibility: hidden;
+ transition: transform var(--dur-3) var(--ease-out),
+ visibility 0s linear var(--dur-3);
+ }
+ .sidebar.open {
+ visibility: visible;
+ transition: transform var(--dur-3) var(--ease-out), visibility 0s;
+ }
+ .nav-tabs {
+ gap: 4px;
+ }
+ .skel-composer {
+ width: calc(100% - var(--space-4) * 2);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .lapse-card {
+ animation: none;
+ }
+ .sidebar .new-chat:hover .plus-dot,
+ .sidebar .new-chat:active .plus-dot {
+ transform: none;
+ }
+}
diff --git a/app/src/server/pi.ts b/app/src/server/pi.ts
index 4f473d2..faba73d 100644
--- a/app/src/server/pi.ts
+++ b/app/src/server/pi.ts
@@ -2,7 +2,8 @@ import { createAnthropic } from "@ai-sdk/anthropic";
import { Think } from "@cloudflare/think";
import { callable, getAgentByName } from "agents";
import type { AgentMcpOAuthProvider } from "agents/mcp/do-oauth-client-provider";
-import type { UIMessage } from "ai";
+import { hasToolCall, tool, type UIMessage } from "ai";
+import { z } from "zod";
import {
GCAL_CALLBACK_PATH,
GCAL_MCP_URL,
@@ -13,6 +14,7 @@ import {
DEFAULT_ENGINE_BASE,
PI_APPS,
type AppKey,
+ type PiApp,
type PiSettings,
} from "../shared/apps";
@@ -24,6 +26,13 @@ export type PiState = {
authUrls?: Partial>;
/** Whether the desk holds Google tokens (chats can use the calendar). */
gcalReady?: boolean;
+ /**
+ * Set when a turn started without its TigerApps — the answer is running on
+ * the model alone, and the interface should say so rather than let PI look
+ * like it simply forgot the student's courses. Cleared by the next
+ * successful settings push.
+ */
+ connectError?: string;
};
const CLAUDE_MODELS = new Set(["claude-opus-5", "claude-sonnet-5"]);
@@ -31,6 +40,87 @@ const DEFAULT_CLAUDE_MODEL = "claude-opus-5";
/** Free-plan-compatible Workers AI model with solid tool calling. */
const CAMPUS_MODEL = "@cf/zai-org/glm-4.7-flash";
+/** The apps PI may ask a student to switch on. */
+const APP_KEYS = PI_APPS.map((a) => a.key) as [AppKey, ...AppKey[]];
+
+/**
+ * The two tools that hand the turn back to the student. Neither does any
+ * work: the interface draws the tool call itself — option rows, a consent
+ * card — and whatever the student taps arrives as their next message. They
+ * are merged in beforeTurn rather than declared in getTools() so they land
+ * after the MCP toolset, where no app server can shadow their names.
+ */
+const ELICITATION_TOOLS = {
+ offer_choices: tool({
+ description:
+ "Ask the student to pick from a short list of next steps or preferences. Use this instead of writing a numbered menu in your reply. The interface draws the options and their answer comes back as their next message, so end your turn right after calling this.",
+ inputSchema: z.object({
+ question: z
+ .string()
+ .describe("The question as you'd say it out loud — one short line."),
+ options: z
+ .array(
+ z.object({
+ label: z
+ .string()
+ .describe("What the student is choosing, a few words."),
+ detail: z
+ .string()
+ .optional()
+ .describe(
+ "One short line on what this option means or costs. Leave it out if the label says everything."
+ ),
+ })
+ )
+ // A bound, not just prose: an empty array would end the turn (see
+ // stopWhen) on a question the interface has nothing to draw.
+ .min(2)
+ .max(5)
+ .describe("Two to five options, in the order you'd recommend them."),
+ multi: z
+ .boolean()
+ .describe("True when more than one option can be picked."),
+ allowOther: z
+ .boolean()
+ .describe(
+ "True when a typed answer of their own makes sense alongside the options."
+ ),
+ }),
+ execute: async () =>
+ "Choices shown to the student. Their selection arrives as their next message. Do not answer for them — end your turn after any brief framing.",
+ }),
+ request_app: tool({
+ description:
+ "Ask the student to switch on an app you aren't connected to yet, with a concrete reason. Use this instead of telling them to go to the My apps page. The interface draws a card they can accept or decline, so end your turn right after calling this.",
+ inputSchema: z.object({
+ app: z.enum(APP_KEYS).describe("Which app you need."),
+ reason: z
+ .string()
+ .describe(
+ "One line on what switching it on would let you do for them right now."
+ ),
+ }),
+ execute: async ({ app }) => {
+ if (!PI_APPS.some((a) => a.key === app)) {
+ return `There's no app called "${app}". You can ask for: ${APP_KEYS.join(", ")}.`;
+ }
+ return `Consent card shown for ${app}. The student will decide; end your turn after a brief line — do not assume it was granted.`;
+ },
+ }),
+};
+
+/** "TigerJunction", "TigerJunction and TigerPath", "a, b and c". */
+function nameList(names: string[]): string {
+ if (names.length <= 1) return names[0] ?? "";
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
+}
+
+/** Tells the model an app the prompt claims it has isn't there this turn. */
+function missingAppsNote(names: string[]): string {
+ const many = names.length > 1;
+ return `${nameList(names)} ${many ? "are" : "is"} switched on but didn't connect for this turn, so none of ${many ? "their" : "its"} tools are in front of you. If the question needs ${many ? "them" : "it"}, say plainly that you can't see that right now — never answer it from memory instead.`;
+}
+
/**
* PI — a lightweight Princeton chat agent. One Durable Object instance per
* conversation. Princeton data arrives through the junction engine's scoped
@@ -77,7 +167,18 @@ export class Pi extends Think {
const today = new Date().toLocaleDateString("en-CA", {
timeZone: "America/New_York",
});
- const connected = PI_APPS.filter((a) => settings?.apps.includes(a.key));
+ const enabled = new Set(settings?.apps ?? []);
+ const connected = PI_APPS.filter((a) => enabled.has(a.key));
+ // Whether these apps actually answered isn't known yet: this prompt is
+ // assembled before beforeTurn opens the connections, so an app that turns
+ // out to be down is corrected there (see missingAppsNote).
+ // Junction's MCP scope already covers PrincetonCourses (see setup), so
+ // don't invite the student to switch on a duplicate of what they have.
+ const offerable = PI_APPS.filter(
+ (a) =>
+ !enabled.has(a.key) &&
+ !(a.key === "princetoncourses" && enabled.has("junction"))
+ );
return [
"You are PI, the TigerApps assistant for Princeton students — a quick, warm study-desk companion. Your answers should read like notes from a sharp friend: concise, concrete, no filler.",
"",
@@ -88,15 +189,31 @@ export class Pi extends Think {
"",
connected.length > 0
? `Connected TigerApps: ${connected.map((a) => `${a.name} (${a.tagline.toLowerCase()})`).join(", ")}.`
- : "No TigerApps are connected. Tell the student to enable some on the My apps page if they ask for Princeton data.",
+ : "No TigerApps are connected, so you have no live Princeton data — don't answer course facts from memory.",
+ offerable.length > 0
+ ? `Switched off: ${offerable.map((a) => `${a.name} (key "${a.key}", ${a.tagline.toLowerCase()})`).join("; ")}. When one of these would answer the question, call request_app for it instead of working around it or sending the student off to a settings page.`
+ : "",
+ "",
+ "Voice:",
+ "- Lead with the answer. No preamble, no restating the question, no summary of what you just said.",
+ "- Write like a student who knows the catalog: specific course codes, times and numbers instead of adjectives.",
+ "- Sentence case. No exclamation marks, no emoji, no bold sprinkled for emphasis.",
+ "- Never narrate your own machinery — no talk of tools, connections, servers or models. Say what you found, or what you can't see.",
+ "- When you're unsure, say what you know and what you'd have to check.",
+ "",
+ "Asking the student instead of guessing:",
+ "- offer_choices — when the next step turns on a preference you can enumerate (which term, which of three courses, mornings or afternoons), call offer_choices with the question and two to five options. Set multi when several answers can be true, allowOther when a typed answer makes sense. Write at most one line of framing, then end your turn: their pick arrives as their next message.",
+ "- request_app — when the answer needs a TigerApp that's switched off, call request_app with its key and a concrete reason (\"TigerPath holds your requirement tree\"). End your turn after a short line, and don't assume they said yes.",
+ "- Google Calendar also needs a one-time Google sign-in on the My apps page, so say that when you request it.",
+ "- One of these per turn, and never write a menu of options out in prose — that's what offer_choices is for.",
"",
"Ground rules:",
"- Princeton term codes end in 2 for Fall and 4 for Spring (e.g. 1272 = Fall 2026). When unsure which term is current, call list_terms rather than guessing.",
"- The interface renders schedule and course tool results as visual cards automatically. After a tool call, add a short takeaway (conflicts, standouts, next step) — never re-list every row the card already shows.",
- "- When a tool errors or returns nothing, say so plainly and suggest the closest thing you can do instead.",
- "- Before destructive changes (deleting a schedule, removing courses), confirm with the student first.",
- "- Keep answers tight. Prefer a short paragraph or a few bullets over headers and long lists.",
- settings?.apps.includes("gcal")
+ "- When a tool errors or comes back empty, say plainly what didn't work and offer the closest thing you can still do. Never paste raw error text, URLs or status codes at the student.",
+ "- Confirm before anything that writes: adding or dropping a course, creating or editing a schedule, subscribing to a seat alert, deleting anything. Name exactly what you're about to change, then wait for a yes.",
+ "- Keep answers tight. Prefer a short paragraph or a few bullets over headers and long lists; if a comparison really needs a table, keep it to three columns.",
+ enabled.has("gcal")
? "- Google Calendar is connected read-only: use its tools for the student's real events and free/busy when planning around their week. You cannot modify their calendar."
: "",
].join("\n");
@@ -196,7 +313,7 @@ export class Pi extends Think {
},
});
} catch (err) {
- appErrors[app.key] = err instanceof Error ? err.message : String(err);
+ appErrors[app.key] = this.connectFailure(app, err);
}
}
@@ -217,7 +334,7 @@ export class Pi extends Think {
) {
const url = await this.forceGcalConsent();
if (url) authUrls.gcal = url;
- else appErrors.gcal = "couldn't start Google sign-in — try again";
+ else appErrors.gcal = "PI couldn't start the Google sign-in. Try again.";
}
const gcalReady = enabled.has("gcal")
@@ -233,6 +350,28 @@ export class Pi extends Think {
return { ok: true as const, appErrors, authUrls, gcalReady };
}
+ /**
+ * A failed connection is read by a student on their My apps card, so say
+ * it in words they can act on. The transport's own text stays in the logs,
+ * where it's useful.
+ */
+ private connectFailure(app: PiApp, err: unknown): string {
+ const raw = err instanceof Error ? err.message : String(err);
+ console.warn(`connect ${app.key} failed:`, raw);
+ const text = raw.toLowerCase();
+ const has = (...needles: string[]) => needles.some((n) => text.includes(n));
+ if (has("timeout", "timed out", "abort")) {
+ return `${app.name} took too long to answer. Try again in a minute.`;
+ }
+ if (has("401", "403", "unauthorized", "forbidden")) {
+ return `${app.name} wouldn't let PI in. That one's on us, not you.`;
+ }
+ if (has("fetch failed", "network") || /\b5\d\d\b/.test(text)) {
+ return `${app.name} is down right now. Try again later.`;
+ }
+ return `PI couldn't reach ${app.name}. Try again in a minute.`;
+ }
+
/**
* A Durable Object holding live MCP connections never hibernates — it
* stays resident (and billed) around the clock even with zero traffic,
@@ -260,7 +399,8 @@ export class Pi extends Think {
* (which both delayed the echo of sent messages and let quick successive
* sends overtake each other). Think assembles its automatic MCP toolset
* before this hook runs — while nothing is connected — so the freshly
- * connected tools are returned here to be merged into the turn.
+ * connected tools are returned here to be merged into the turn, along
+ * with the two tools PI uses to hand the turn back to the student.
*/
override async beforeTurn(
ctx: Parameters[0]
@@ -268,15 +408,66 @@ export class Pi extends Think {
const inherited = await super.beforeTurn(ctx);
const settings = this.getConfig();
if (!settings) return inherited ?? undefined;
+ /** Apps the model was told it has, that this turn hasn't got. */
+ let missing: string[] = [];
try {
- await this.setup(settings, { connect: true });
+ const { appErrors } = await this.setup(settings, { connect: true });
+ // setup() files a dead app server under appErrors instead of throwing,
+ // so a turn can lose every course tool and still resolve here. Read what
+ // it reported rather than trusting that it came back at all.
+ missing = PI_APPS.filter((a) => appErrors[a.key]).map((a) => a.name);
+ const note =
+ missing.length > 0
+ ? `PI couldn’t use ${nameList(missing)} for this answer, so anything ${missing.length > 1 ? "they hold" : "it holds"} is missing.`
+ : undefined;
+ // A turn that did connect retires the last failure, so the interface
+ // stops warning about data this answer actually has. Only written when
+ // something changed — every setState reaches every client.
+ if (note !== this.state?.connectError) {
+ const { connectError: _prev, ...rest } = this.state;
+ this.setState(note ? { ...rest, connectError: note } : rest);
+ }
} catch (err) {
console.warn("beforeTurn connect failed", err);
+ missing = PI_APPS.filter((a) => settings.apps.includes(a.key)).map(
+ (a) => a.name
+ );
+ // The turn still runs, on the model alone. Leave a note in state so
+ // the interface can say so instead of quietly serving a thinner answer.
+ this.setState({
+ ...this.state,
+ connectError:
+ "PI couldn’t reach your TigerApps, so this answer may be missing course data.",
+ });
}
const tools = this.mcp.getAITools();
+ const inheritedStops = inherited?.stopWhen;
+ const stops = Array.isArray(inheritedStops)
+ ? inheritedStops
+ : inheritedStops
+ ? [inheritedStops]
+ : [];
return {
...(inherited ?? {}),
- tools: { ...(inherited?.tools ?? {}), ...tools },
+ // The system prompt is assembled before this hook runs, so a turn that
+ // lost an app can only correct the record here — otherwise the model
+ // reads "Connected TigerApps: TigerJunction" and answers with the
+ // confidence that implies, on no data at all.
+ ...(missing.length > 0
+ ? {
+ system: `${inherited?.system ?? ctx.system}\n\n${missingAppsNote(missing)}`,
+ }
+ : {}),
+ // The elicitation tools go on last: an app server can register any
+ // tool name it likes, and these two have to survive the merge.
+ tools: { ...(inherited?.tools ?? {}), ...tools, ...ELICITATION_TOOLS },
+ // Eliciting ends the turn — the student's answer is the next message,
+ // so don't let the model talk past its own question.
+ stopWhen: [
+ ...stops,
+ hasToolCall("offer_choices"),
+ hasToolCall("request_app"),
+ ],
};
}
diff --git a/app/src/shared/apps.ts b/app/src/shared/apps.ts
index 1725dee..d9fb711 100644
--- a/app/src/shared/apps.ts
+++ b/app/src/shared/apps.ts
@@ -21,10 +21,16 @@ export type PiApp = {
logo: string;
tagline: string;
detail: string;
- /** Highlighter color token used across the UI for this app. */
- ink: "cyan" | "orange" | "violet" | "pink";
+ /**
+ * Highlighter color for this app, as the suffix of a `--hl-*` token. One
+ * app is one color everywhere, so this must stay in step with APP_INK in
+ * client/lib/tools.ts, which paints the same apps in chat and on the agenda.
+ */
+ ink: "cyan" | "orange" | "violet" | "pink" | "mint";
/** Whether tools need to know who the user is to be useful. */
personal: boolean;
+ /** Which section of My apps this belongs under. */
+ kind: "app" | "calendar";
};
export const PI_APPS: PiApp[] = [
@@ -35,11 +41,12 @@ export const PI_APPS: PiApp[] = [
glyph: "TJ",
name: "TigerJunction",
mcpPath: "/junction/mcp",
- tagline: "Course planning & schedules",
+ tagline: "build & edit your schedule",
detail:
- "Search the catalog, build and edit ReCal schedules, check conflicts, and watch course demand.",
+ "Draft next semester before it drafts you. Search the catalog, move sections around, and catch the clashes early.",
ink: "cyan",
personal: true,
+ kind: "app",
},
{
key: "princetoncourses",
@@ -48,11 +55,12 @@ export const PI_APPS: PiApp[] = [
glyph: "PC",
name: "PrincetonCourses",
mcpPath: "/princetoncourses/mcp",
- tagline: "Ratings & evaluations",
+ tagline: "ratings & what people actually said",
detail:
- "Course evaluations, instructor history, top-rated courses, and review summaries.",
+ "What do Princeton students really think of their classes? Ratings, instructor history, and the reviews in their own words.",
ink: "orange",
personal: false,
+ kind: "app",
},
{
key: "path",
@@ -61,11 +69,12 @@ export const PI_APPS: PiApp[] = [
glyph: "TP",
name: "TigerPath",
mcpPath: "/path/mcp",
- tagline: "Degree requirements",
+ tagline: "your 4-year plan & what's left",
detail:
- "Major requirement trees, four-year plans, and what's left to satisfy your degree.",
+ "Where your degree actually stands. Requirement trees, a four-year plan, and the boxes still waiting to be ticked.",
ink: "violet",
personal: true,
+ kind: "app",
},
{
key: "snatch",
@@ -74,11 +83,12 @@ export const PI_APPS: PiApp[] = [
glyph: "TS",
name: "TigerSnatch",
mcpPath: "/snatch/mcp",
- tagline: "Seat alerts & demand",
+ tagline: "a seat when one opens",
detail:
- "Subscribe to full sections, get notified when seats open, and see trending courses.",
+ "Course registration did you dirty? Watch a full section and hear about it the moment a spot opens up.",
ink: "pink",
personal: true,
+ kind: "app",
},
{
key: "gcal",
@@ -88,25 +98,92 @@ export const PI_APPS: PiApp[] = [
name: "Google Calendar",
mcpPath: "",
mcpUrl: "https://calendarmcp.googleapis.com/mcp/v1",
- tagline: "Your real calendar, read-only",
+ tagline: "your real calendar, read-only",
detail:
- "See your events and free/busy while planning courses. PI can look at your calendar — it can never change it.",
- ink: "cyan",
+ "The rest of your week, so a class never lands on top of practice. PI can read this calendar and can never change it.",
+ ink: "mint",
personal: true,
+ kind: "calendar",
},
];
+/**
+ * Rows the app library shows but PI cannot talk to yet. Client-only on
+ * purpose: the server iterates PI_APPS to open connections, and nothing here
+ * has an endpoint to open. Keep it that way until an app ships an MCP.
+ */
+export type SoonApp = {
+ key: string;
+ name: string;
+ /** Stands in for a logo we don't have a licensed asset for yet. */
+ lettermark: string;
+ blurb: string;
+ /** Highlighter token for the lettermark tile. */
+ ink: string;
+ kind: "app" | "calendar";
+};
+
+export const COMING_SOON: SoonApp[] = [
+ {
+ key: "tigermenus",
+ name: "TigerMenus",
+ lettermark: "TM",
+ blurb: "Browse the daily offerings from Princeton's dining halls.",
+ ink: "var(--hl-mint)",
+ kind: "app",
+ },
+ {
+ key: "tigerfoodies",
+ name: "TigerFoodies",
+ lettermark: "TF",
+ blurb:
+ "Good food and better people. Meet friends of friends at Princeton and beyond.",
+ ink: "var(--hl-lemon)",
+ kind: "app",
+ },
+ {
+ key: "applecal",
+ name: "Apple Calendar (iCloud)",
+ lettermark: "AC",
+ blurb: "The same read-only look at your week, for everyone on iCloud.",
+ ink: "var(--hl-violet)",
+ kind: "calendar",
+ },
+];
+
+/** The bottom "bring your own" row. Separate: it isn't one named app. */
+export const CUSTOM_APP_SOON: SoonApp = {
+ key: "custom",
+ name: "Connect your Notion, Asana, or custom-made app",
+ lettermark: "+",
+ blurb: "Anything that speaks the same language as a TigerApp will plug in.",
+ ink: "var(--hl-cyan)",
+ kind: "app",
+};
+
export const DEFAULT_APPS: AppKey[] = ["junction"];
+/**
+ * Apps PI can genuinely read right now. Google Calendar counts only once its
+ * consent round-trip has finished, so no surface claims a connection the
+ * student hasn't granted yet.
+ */
+export function countConnected(
+ apps: AppKey[],
+ opts: { gcalReady?: boolean } = {}
+): number {
+ return apps.filter((a) => a !== "gcal" || opts.gcalReady === true).length;
+}
+
export const DEFAULT_ENGINE_BASE = "https://junction-engine.tigerapps.org";
/** Models the switcher offers. Claude models fall back to campus without a key. */
export type PiModel = "claude-opus-5" | "claude-sonnet-5" | "campus";
export const PI_MODELS: Array<{ value: PiModel; label: string }> = [
- { value: "claude-opus-5", label: "Opus 5" },
- { value: "claude-sonnet-5", label: "Sonnet 5" },
- { value: "campus", label: "Campus" },
+ { value: "claude-opus-5", label: "Opus 5, sharpest" },
+ { value: "claude-sonnet-5", label: "Sonnet 5, quicker" },
+ { value: "campus", label: "Campus, lightest" },
];
/** Settings the client pushes to a Pi agent instance. */
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 18422aa..c9ee0b0 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -17,4 +17,28 @@ export default defineConfig({
),
},
},
+ environments: {
+ // Client only. The worker build is left entirely to the Cloudflare plugin:
+ // splitting that bundle would break the Durable Object entrypoint.
+ client: {
+ build: {
+ target: "es2022",
+ rollupOptions: {
+ output: {
+ // React barely changes between deploys; keeping it in its own
+ // chunk means a route split (React.lazy in the pages) only ever
+ // invalidates the small chunk that actually changed.
+ manualChunks(id: string) {
+ if (
+ /node_modules[/\\](react|react-dom|scheduler)[/\\]/.test(id)
+ ) {
+ return "react";
+ }
+ return undefined;
+ },
+ },
+ },
+ },
+ },
+ },
});