Berkkirik commited on
Commit
a737f01
·
1 Parent(s): 48eafa7

redesign: Risograph Folio — printed-publication aesthetic

Browse files

Trade dark-mode AI dashboard for a magazine: warm parchment paper,
halftone dot field, riso-style rust accent, Fraunces italic display
with SOFT/WONK axes, IBM Plex Sans body, JetBrains Mono folio
chrome. Editorial moves throughout:

- Top: thin issue strip + masthead (Vol. I · Issue 02 · Etiya BSS)
- Sidebar: catalog with numbered conversation entries (№ 01 …)
- Empty state: huge italic display "Pose a question." with lede +
margin notes column
- Chat: transcript layout (margin folio | content column), assistant
answers carry rust drop-cap, bibliography for sources, colophon
for spec sheet
- Documents: library catalog with 4-digit folio numbers
- System: colophon page with halftone-textured pull-quote panels and
inverted ink Eval issue review
- Decoration: register marks (✚) at page corners, hairline rules,
italic-on-hover links

app/documents/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { api, ApiError } from "@/lib/api";
4
  import type { DocumentMeta, ReindexResponse } from "@/lib/types";
5
  import {
6
  useMutation,
@@ -54,58 +54,70 @@ export default function DocumentsPage() {
54
 
55
  return (
56
  <div className="flex-1 overflow-y-auto">
57
- <div className="container-app py-10 space-y-8">
58
- {/* Hero strip */}
59
- <header className="flex items-end justify-between flex-wrap gap-4 animate-fade-in">
60
- <div>
61
- <h1 className="text-display-lg text-ink">
62
- Documents <span className="serif-italic text-amber">corpus.</span>
63
- </h1>
64
- <p className="text-body text-ink-50 mt-2">
65
- {data ? (
66
- <>
67
- <span className="font-mono text-ink">
68
- {data.count.toLocaleString()}
69
- </span>{" "}
70
- indexed · persistent at{" "}
71
- <span className="font-mono">/data/docs/</span>
72
- </>
73
- ) : (
74
- "Loading…"
75
- )}
76
- </p>
77
  </div>
78
- <div className="flex items-center gap-3">
79
- <button
80
- onClick={() => reindex.mutate()}
81
- disabled={reindex.isPending}
82
- className="btn-secondary"
83
- >
84
- {reindex.isPending ? "Re-indexing…" : "Re-index now"}
85
- </button>
86
- <button
87
- onClick={() => setShowAdd((v) => !v)}
88
- className="btn-primary"
 
 
 
 
 
 
 
 
 
 
 
89
  >
90
- {showAdd ? "Close" : "Add document"}
91
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  </div>
93
  </header>
94
 
95
  {/* Reindex banner */}
96
  {reindexResult && (
97
  <div
98
- className="rounded-md p-4 flex items-center justify-between gap-3 animate-fade-up"
99
- style={{
100
- background: "rgba(93, 214, 168, 0.08)",
101
- boxShadow: "0 0 0 1px rgba(93, 214, 168, 0.28)",
102
- }}
103
  >
104
- <div className="text-caption">
105
- <span className="text-status-ok font-mono uppercase tracking-[0.12em] mr-3">
106
- re-index ok
107
  </span>
108
- <span className="font-mono text-ink-70">
109
  mode={reindexResult.mode ?? "none"} · added=
110
  {reindexResult.added} · removed={reindexResult.removed} ·
111
  indexed={reindexResult.indexed_count} ·{" "}
@@ -114,62 +126,67 @@ export default function DocumentsPage() {
114
  </div>
115
  <button
116
  onClick={() => setReindexResult(null)}
117
- className="text-caption text-ink-50 hover:text-ink"
118
  >
119
- dismiss
120
  </button>
121
  </div>
122
  )}
123
 
124
- {/* Add document panel */}
125
  {showAdd && (
126
  <AddDocumentForm
127
- onCreated={() => {
128
- qc.invalidateQueries({ queryKey: ["documents"] });
129
- }}
130
  />
131
  )}
132
 
133
- {/* Search + list */}
134
- <section className="card animate-fade-up-slow">
135
- <div className="flex items-center justify-between gap-4 mb-5 flex-wrap">
136
- <div className="relative flex-1 min-w-[260px] max-w-[480px]">
137
- <svg
138
- className="absolute left-3.5 top-1/2 -translate-y-1/2 text-ink-50"
139
- width="14"
140
- height="14"
141
- viewBox="0 0 14 14"
142
- fill="none"
143
- >
144
- <circle cx="6" cy="6" r="4" stroke="currentColor" strokeWidth="1.4" />
145
- <path d="m9.5 9.5 3 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
146
- </svg>
147
- <input
148
- type="text"
149
- placeholder="Search by name or doc_id…"
150
- value={filter}
151
- onChange={(e) => {
152
- setFilter(e.target.value);
153
- setPage(0);
154
- }}
155
- className="input-glass !pl-10"
156
- />
157
- </div>
158
- <span className="text-caption text-ink-50 font-mono shrink-0 uppercase tracking-[0.12em]">
159
  {filtered.length.toLocaleString()} match
160
  {filtered.length === 1 ? "" : "es"}
161
  </span>
162
  </div>
163
 
164
- {error && (
165
- <div
166
- className="p-4 rounded-md mb-4"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  style={{
168
- background: "rgba(255, 122, 122, 0.06)",
169
- boxShadow: "0 0 0 1px rgba(255, 122, 122, 0.28)",
 
170
  }}
171
- >
172
- <p className="text-status-err text-body-strong">
 
 
 
 
 
173
  Failed to load documents
174
  </p>
175
  <p className="text-caption text-ink-70 mt-1">
@@ -179,54 +196,59 @@ export default function DocumentsPage() {
179
  )}
180
 
181
  {isLoading && (
182
- <div className="text-ink-50 text-body py-12 text-center text-shimmer">
183
- Loading documents
184
  </div>
185
  )}
186
 
187
  {visible.length > 0 && (
188
- <ul className="divide-y divide-glass-border">
189
- {visible.map((d) => (
190
- <li
191
- key={d.doc_id}
192
- className="flex items-center justify-between gap-4 py-3.5 group"
193
- >
194
- <div className="min-w-0 flex-1">
195
- <div className="text-body-strong text-ink truncate">
196
- {d.name}
 
197
  </div>
198
- <div className="flex items-center gap-2.5 mt-0.5 text-micro text-ink-50 font-mono uppercase tracking-[0.10em]">
199
- <span className="truncate">{d.doc_id}</span>
200
- <Sep />
201
- <span>{d.length_chars.toLocaleString()} chars</span>
202
- <Sep />
203
- <span>
 
 
 
204
  {new Date(d.created_at * 1000).toLocaleDateString()}
205
- </span>
206
  </div>
207
- </div>
208
- <button
209
- onClick={() => setConfirmDelete(d)}
210
- className="opacity-0 group-hover:opacity-100 transition-opacity text-caption text-status-err hover:bg-status-err-glow px-2.5 py-1 rounded-md"
211
- >
212
- Delete
213
- </button>
214
- </li>
215
- ))}
216
  </ul>
217
  )}
218
 
219
  {totalPages > 1 && (
220
- <div className="flex items-center justify-between mt-5">
221
  <button
222
  onClick={() => setPage((p) => Math.max(0, p - 1))}
223
  disabled={page === 0}
224
  className="btn-ghost disabled:opacity-30"
225
  >
226
- Prev
227
  </button>
228
- <span className="text-caption text-ink-50 font-mono uppercase tracking-[0.12em]">
229
- page {page + 1} of {totalPages}
 
230
  </span>
231
  <button
232
  onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
@@ -256,10 +278,7 @@ function AddDocumentForm({ onCreated }: { onCreated: () => void }) {
256
  const [name, setName] = useState("");
257
  const [text, setText] = useState("");
258
  const [doReindex, setDoReindex] = useState(true);
259
- const [feedback, setFeedback] = useState<{
260
- kind: "ok" | "err";
261
- msg: string;
262
- } | null>(null);
263
 
264
  const create = useMutation({
265
  mutationFn: async (payload: { text: string; name?: string }) => {
@@ -270,7 +289,7 @@ function AddDocumentForm({ onCreated }: { onCreated: () => void }) {
270
  onSuccess: (created) => {
271
  setFeedback({
272
  kind: "ok",
273
- msg: `Added: ${created.name} (${created.doc_id.slice(0, 12)}…)`,
274
  });
275
  setText("");
276
  setName("");
@@ -284,11 +303,23 @@ function AddDocumentForm({ onCreated }: { onCreated: () => void }) {
284
 
285
  return (
286
  <section className="card animate-fade-up">
287
- <h2 className="text-display-sm text-ink">
288
- Add <span className="serif-italic text-amber">document</span>
 
 
 
 
 
 
 
 
 
 
 
289
  </h2>
290
- <p className="text-caption text-ink-50 mt-1">
291
- Documents persist immediately; re-indexing syncs them into RAG retrieval.
 
292
  </p>
293
 
294
  <form
@@ -297,25 +328,25 @@ function AddDocumentForm({ onCreated }: { onCreated: () => void }) {
297
  if (!text.trim()) return;
298
  create.mutate({ text: text.trim(), name: name.trim() || undefined });
299
  }}
300
- className="mt-5 space-y-4"
301
  >
302
  <div>
303
- <label className="text-caption-strong text-ink block mb-1.5">
304
- Name <span className="text-ink-50 font-normal">(optional)</span>
305
  </label>
306
  <input
307
  type="text"
308
  value={name}
309
  onChange={(e) => setName(e.target.value)}
310
  placeholder="e.g. Customer Onboarding Flow v2"
311
- className="input-glass"
312
  disabled={create.isPending}
313
  />
314
  </div>
315
 
316
  <div>
317
- <label className="text-caption-strong text-ink block mb-1.5">
318
- Content
319
  </label>
320
  <textarea
321
  value={text}
@@ -323,40 +354,41 @@ function AddDocumentForm({ onCreated }: { onCreated: () => void }) {
323
  required
324
  rows={8}
325
  placeholder="Paste markdown or plain text…"
326
- className="input-glass resize-y"
327
  disabled={create.isPending}
 
328
  />
329
- <p className="text-micro text-ink-50 mt-1 font-mono uppercase tracking-[0.10em]">
330
  {text.length.toLocaleString()} chars
331
  </p>
332
  </div>
333
 
334
- <label className="flex items-center gap-2.5 text-caption text-ink cursor-pointer">
335
  <input
336
  type="checkbox"
337
  checked={doReindex}
338
  onChange={(e) => setDoReindex(e.target.checked)}
339
- className="w-4 h-4 accent-amber"
340
  />
341
- <span>Auto re-index after add</span>
342
- <span className="text-ink-50 text-micro font-mono">
343
- (~350ms)
344
  </span>
 
345
  </label>
346
 
347
  <div className="flex items-center gap-4 pt-2">
348
  <button
349
  type="submit"
350
  disabled={create.isPending || !text.trim()}
351
- className="btn-primary"
352
  >
353
- {create.isPending ? "Saving…" : "Save document"}
354
  </button>
355
  {feedback && (
356
  <span
357
  className={clsx(
358
  "text-caption",
359
- feedback.kind === "ok" ? "text-status-ok" : "text-status-err"
360
  )}
361
  >
362
  {feedback.msg}
@@ -381,23 +413,29 @@ function ConfirmDelete({
381
  }) {
382
  return (
383
  <div
384
- className="fixed inset-0 z-50 bg-canvas-deep/70 backdrop-blur-sm flex items-center justify-center p-6 animate-fade-in"
 
385
  onClick={onCancel}
386
  >
387
  <div
388
  onClick={(e) => e.stopPropagation()}
389
- className="glass-strong rounded-lg max-w-[460px] w-full p-7 animate-fade-up"
 
390
  >
391
- <h3 className="text-display-sm">Delete document?</h3>
392
- <p className="text-caption text-ink-50 mt-1">This is permanent.</p>
393
- <div className="mt-4 p-3 rounded-md bg-glass">
394
- <div className="text-body-strong text-ink truncate">{doc.name}</div>
395
- <div className="text-micro text-ink-50 font-mono mt-0.5 truncate">
396
- {doc.doc_id}
397
- </div>
398
  </div>
399
- <p className="text-caption text-ink-50 mt-4">
400
- Re-indexing will remove it from /ask_smart retrieval.
 
 
 
 
 
 
 
 
 
401
  </p>
402
  <div className="mt-6 flex items-center justify-end gap-3">
403
  <button onClick={onCancel} className="btn-secondary">
@@ -406,16 +444,12 @@ function ConfirmDelete({
406
  <button
407
  onClick={onConfirm}
408
  disabled={loading}
409
- className="btn-danger"
410
  >
411
- {loading ? "Deleting…" : "Delete"}
412
  </button>
413
  </div>
414
  </div>
415
  </div>
416
  );
417
  }
418
-
419
- function Sep() {
420
- return <span className="text-ink-30">·</span>;
421
- }
 
1
  "use client";
2
 
3
+ import { api } from "@/lib/api";
4
  import type { DocumentMeta, ReindexResponse } from "@/lib/types";
5
  import {
6
  useMutation,
 
54
 
55
  return (
56
  <div className="flex-1 overflow-y-auto">
57
+ <div className="container-app py-10 space-y-10">
58
+ {/* Editorial masthead */}
59
+ <header className="animate-fade-in">
60
+ <div className="flex items-baseline justify-between mb-3">
61
+ <span className="folio-chrome">Folio II · Library</span>
62
+ <span className="folio-chrome">
63
+ {data ? `${data.count.toLocaleString()} entries` : "Loading…"}
64
+ </span>
 
 
 
 
 
 
 
 
 
 
 
 
65
  </div>
66
+ <h1
67
+ className="text-ink"
68
+ style={{
69
+ fontSize: "clamp(56px, 9vw, 110px)",
70
+ lineHeight: 0.92,
71
+ fontWeight: 300,
72
+ letterSpacing: "-0.04em",
73
+ fontFamily: "var(--font-fraunces)",
74
+ }}
75
+ >
76
+ The <span className="italic-display text-rust">corpus.</span>
77
+ </h1>
78
+ <div className="hairline--double mt-6 animate-rule" />
79
+ <div className="grid md:grid-cols-[1fr_auto] gap-6 items-end mt-6 animate-stagger-2">
80
+ <p
81
+ className="text-ink max-w-[60ch]"
82
+ style={{
83
+ fontFamily: "var(--font-fraunces)",
84
+ fontSize: "20px",
85
+ lineHeight: 1.45,
86
+ fontWeight: 300,
87
+ }}
88
  >
89
+ The standing collection of Etiya BSS documents, persisted at{" "}
90
+ <span className="font-mono text-ink-70">/data/docs/</span>. Use
91
+ the search to query the catalog, or contribute a new entry.
92
+ </p>
93
+ <div className="flex items-center gap-3">
94
+ <button
95
+ onClick={() => reindex.mutate()}
96
+ disabled={reindex.isPending}
97
+ className="btn-secondary"
98
+ >
99
+ {reindex.isPending ? "Re-indexing…" : "Re-index"}
100
+ </button>
101
+ <button
102
+ onClick={() => setShowAdd((v) => !v)}
103
+ className="btn-rust"
104
+ >
105
+ {showAdd ? "Close" : "Add entry"}
106
+ </button>
107
+ </div>
108
  </div>
109
  </header>
110
 
111
  {/* Reindex banner */}
112
  {reindexResult && (
113
  <div
114
+ className="card-rust flex items-center justify-between gap-3 animate-fade-up"
 
 
 
 
115
  >
116
+ <div>
117
+ <span className="folio-chrome--rust folio-chrome mr-3">
118
+ Re-index complete
119
  </span>
120
+ <span className="font-mono text-caption text-ink-70">
121
  mode={reindexResult.mode ?? "none"} · added=
122
  {reindexResult.added} · removed={reindexResult.removed} ·
123
  indexed={reindexResult.indexed_count} ·{" "}
 
126
  </div>
127
  <button
128
  onClick={() => setReindexResult(null)}
129
+ className="folio-chrome hover:text-rust"
130
  >
131
+ Dismiss
132
  </button>
133
  </div>
134
  )}
135
 
136
+ {/* Add entry form */}
137
  {showAdd && (
138
  <AddDocumentForm
139
+ onCreated={() => qc.invalidateQueries({ queryKey: ["documents"] })}
 
 
140
  />
141
  )}
142
 
143
+ {/* Search */}
144
+ <section className="animate-fade-up-slow">
145
+ <div className="flex items-baseline justify-between mb-3">
146
+ <span className="folio-chrome">Index</span>
147
+ <span className="folio-chrome">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  {filtered.length.toLocaleString()} match
149
  {filtered.length === 1 ? "" : "es"}
150
  </span>
151
  </div>
152
 
153
+ <div className="relative max-w-[640px] mb-6">
154
+ <svg
155
+ className="absolute left-4 top-1/2 -translate-y-1/2 text-ink-50"
156
+ width="14"
157
+ height="14"
158
+ viewBox="0 0 14 14"
159
+ fill="none"
160
+ >
161
+ <circle cx="6" cy="6" r="4" stroke="currentColor" strokeWidth="1.4" />
162
+ <path
163
+ d="m9.5 9.5 3 3"
164
+ stroke="currentColor"
165
+ strokeWidth="1.4"
166
+ strokeLinecap="round"
167
+ />
168
+ </svg>
169
+ <input
170
+ type="text"
171
+ placeholder="Query the catalog…"
172
+ value={filter}
173
+ onChange={(e) => {
174
+ setFilter(e.target.value);
175
+ setPage(0);
176
+ }}
177
+ className="input-print !pl-11"
178
  style={{
179
+ fontFamily: "var(--font-fraunces)",
180
+ fontSize: "17px",
181
+ fontWeight: 400,
182
  }}
183
+ />
184
+ </div>
185
+
186
+ {error && (
187
+ <div className="card-rust mb-4">
188
+ <div className="folio-chrome--rust folio-chrome mb-1">Errata</div>
189
+ <p className="text-body-strong text-ink">
190
  Failed to load documents
191
  </p>
192
  <p className="text-caption text-ink-70 mt-1">
 
196
  )}
197
 
198
  {isLoading && (
199
+ <div className="text-ink-50 italic-display py-12 text-center" style={{ fontSize: "22px" }}>
200
+ Loading the catalog
201
  </div>
202
  )}
203
 
204
  {visible.length > 0 && (
205
+ <ul className="border-t-2 border-ink/30">
206
+ {visible.map((d, i) => {
207
+ const folio = String(page * PAGE_SIZE + i + 1).padStart(4, "0");
208
+ return (
209
+ <li
210
+ key={d.doc_id}
211
+ className="grid grid-cols-[80px_1fr_auto] gap-4 sm:gap-6 py-4 border-b border-ink/12 group"
212
+ >
213
+ <div className="folio-chrome--ink folio-chrome pt-0.5">
214
+ № {folio}
215
  </div>
216
+ <div className="min-w-0">
217
+ <div
218
+ className="italic-display text-ink truncate group-hover:text-rust transition-colors"
219
+ style={{ fontSize: "19px", lineHeight: 1.3 }}
220
+ >
221
+ {d.name}
222
+ </div>
223
+ <div className="folio-chrome mt-1 truncate">
224
+ {d.doc_id} · {d.length_chars.toLocaleString()} chars ·{" "}
225
  {new Date(d.created_at * 1000).toLocaleDateString()}
226
+ </div>
227
  </div>
228
+ <button
229
+ onClick={() => setConfirmDelete(d)}
230
+ className="opacity-0 group-hover:opacity-100 transition-opacity folio-chrome hover:text-rust"
231
+ >
232
+ Withdraw
233
+ </button>
234
+ </li>
235
+ );
236
+ })}
237
  </ul>
238
  )}
239
 
240
  {totalPages > 1 && (
241
+ <div className="flex items-center justify-between mt-6">
242
  <button
243
  onClick={() => setPage((p) => Math.max(0, p - 1))}
244
  disabled={page === 0}
245
  className="btn-ghost disabled:opacity-30"
246
  >
247
+ Previous
248
  </button>
249
+ <span className="folio-chrome">
250
+ Folio {String(page + 1).padStart(2, "0")} ·{" "}
251
+ {String(totalPages).padStart(2, "0")}
252
  </span>
253
  <button
254
  onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
 
278
  const [name, setName] = useState("");
279
  const [text, setText] = useState("");
280
  const [doReindex, setDoReindex] = useState(true);
281
+ const [feedback, setFeedback] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
 
 
 
282
 
283
  const create = useMutation({
284
  mutationFn: async (payload: { text: string; name?: string }) => {
 
289
  onSuccess: (created) => {
290
  setFeedback({
291
  kind: "ok",
292
+ msg: `Filed: ${created.name} (${created.doc_id.slice(0, 12)}…)`,
293
  });
294
  setText("");
295
  setName("");
 
303
 
304
  return (
305
  <section className="card animate-fade-up">
306
+ <div className="flex items-baseline justify-between mb-2">
307
+ <span className="folio-chrome">Submission · New entry</span>
308
+ </div>
309
+ <h2
310
+ className="text-ink"
311
+ style={{
312
+ fontFamily: "var(--font-fraunces)",
313
+ fontSize: "32px",
314
+ fontWeight: 500,
315
+ letterSpacing: "-0.018em",
316
+ }}
317
+ >
318
+ File a new <span className="italic-display text-rust">entry.</span>
319
  </h2>
320
+ <p className="text-caption text-ink-70 mt-1.5 max-w-[60ch]">
321
+ Documents persist immediately; re-indexing syncs them into the
322
+ retrieval atelier.
323
  </p>
324
 
325
  <form
 
328
  if (!text.trim()) return;
329
  create.mutate({ text: text.trim(), name: name.trim() || undefined });
330
  }}
331
+ className="mt-6 space-y-4"
332
  >
333
  <div>
334
+ <label className="folio-chrome--ink folio-chrome block mb-1.5">
335
+ Title <span className="text-ink-50 normal-case tracking-normal">(optional)</span>
336
  </label>
337
  <input
338
  type="text"
339
  value={name}
340
  onChange={(e) => setName(e.target.value)}
341
  placeholder="e.g. Customer Onboarding Flow v2"
342
+ className="input-print"
343
  disabled={create.isPending}
344
  />
345
  </div>
346
 
347
  <div>
348
+ <label className="folio-chrome--ink folio-chrome block mb-1.5">
349
+ Body
350
  </label>
351
  <textarea
352
  value={text}
 
354
  required
355
  rows={8}
356
  placeholder="Paste markdown or plain text…"
357
+ className="input-print resize-y"
358
  disabled={create.isPending}
359
+ style={{ fontFamily: "var(--font-mono)", fontSize: "13px" }}
360
  />
361
+ <p className="folio-chrome mt-1">
362
  {text.length.toLocaleString()} chars
363
  </p>
364
  </div>
365
 
366
+ <label className="flex items-center gap-2.5 cursor-pointer">
367
  <input
368
  type="checkbox"
369
  checked={doReindex}
370
  onChange={(e) => setDoReindex(e.target.checked)}
371
+ className="w-4 h-4 accent-rust"
372
  />
373
+ <span className="text-caption text-ink">
374
+ Auto re-index after filing
 
375
  </span>
376
+ <span className="folio-chrome">~350ms</span>
377
  </label>
378
 
379
  <div className="flex items-center gap-4 pt-2">
380
  <button
381
  type="submit"
382
  disabled={create.isPending || !text.trim()}
383
+ className="btn-rust"
384
  >
385
+ {create.isPending ? "Filing…" : "File entry"}
386
  </button>
387
  {feedback && (
388
  <span
389
  className={clsx(
390
  "text-caption",
391
+ feedback.kind === "ok" ? "text-status-ok" : "text-rust"
392
  )}
393
  >
394
  {feedback.msg}
 
413
  }) {
414
  return (
415
  <div
416
+ className="fixed inset-0 z-50 flex items-center justify-center p-6 animate-fade-in"
417
+ style={{ background: "rgba(25,23,19,0.32)" }}
418
  onClick={onCancel}
419
  >
420
  <div
421
  onClick={(e) => e.stopPropagation()}
422
+ className="card max-w-[460px] w-full animate-fade-up"
423
+ style={{ background: "var(--paper)" }}
424
  >
425
+ <div className="folio-chrome--rust folio-chrome mb-2">
426
+ Permanent withdrawal
 
 
 
 
 
427
  </div>
428
+ <h3
429
+ className="italic-display text-ink"
430
+ style={{ fontSize: "28px", lineHeight: 1.2 }}
431
+ >
432
+ Withdraw entry?
433
+ </h3>
434
+ <div className="hairline mt-3 mb-3" />
435
+ <p className="text-body-strong text-ink truncate">{doc.name}</p>
436
+ <p className="folio-chrome mt-1 truncate">{doc.doc_id}</p>
437
+ <p className="text-caption text-ink-70 mt-4">
438
+ Re-indexing will remove it from retrieval.
439
  </p>
440
  <div className="mt-6 flex items-center justify-end gap-3">
441
  <button onClick={onCancel} className="btn-secondary">
 
444
  <button
445
  onClick={onConfirm}
446
  disabled={loading}
447
+ className="btn-rust"
448
  >
449
+ {loading ? "Withdrawing…" : "Withdraw"}
450
  </button>
451
  </div>
452
  </div>
453
  </div>
454
  );
455
  }
 
 
 
 
app/globals.css CHANGED
@@ -3,151 +3,176 @@
3
  @tailwind utilities;
4
 
5
  /* ───────────────────────────────────────────────────────────────
6
- Twilight Atelier — base canvas
7
  ─────────────────────────────────────────────────────────────── */
8
  @layer base {
9
  :root {
10
- color-scheme: dark;
 
 
 
 
11
  }
12
 
13
  html,
14
  body {
15
- background: #06070a;
16
- color: #f5f3ec;
17
- font-family: var(--font-manrope), system-ui, -apple-system, sans-serif;
18
  font-size: 15px;
19
- line-height: 1.6;
20
  font-weight: 400;
21
- font-feature-settings: "ss01", "cv11", "calt";
22
  -webkit-font-smoothing: antialiased;
23
  -moz-osx-font-smoothing: grayscale;
24
  text-rendering: optimizeLegibility;
25
  }
26
 
27
  body {
28
- /* Layered: deep canvas + warm aurora + subtle grain */
 
 
 
 
 
29
  background:
30
- radial-gradient(1100px 700px at 78% -10%, rgba(255, 181, 69, 0.12), transparent 65%),
31
- radial-gradient(900px 800px at 8% 110%, rgba(98, 70, 255, 0.10), transparent 60%),
32
- radial-gradient(1400px 900px at 50% 50%, rgba(20, 24, 40, 0.6), transparent 70%),
33
- #06070a;
34
  background-attachment: fixed;
35
  min-height: 100vh;
36
  overflow-x: hidden;
 
37
  }
38
 
39
- /* Subtle grain overlay (doesn't capture pointer events) */
40
  body::before {
41
  content: "";
42
  position: fixed;
43
  inset: 0;
44
  pointer-events: none;
45
  z-index: 0;
46
- background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1, 0 0 0 0 1, 0 0 0 0 1, 0 0 0 0.06 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
47
- opacity: 0.65;
48
- mix-blend-mode: overlay;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
50
 
51
  ::selection {
52
- background: rgba(255, 181, 69, 0.32);
53
- color: #fff8e7;
54
  }
55
 
56
- /* Custom scrollbar barely there */
57
  ::-webkit-scrollbar {
58
- width: 10px;
59
- height: 10px;
60
  }
61
  ::-webkit-scrollbar-thumb {
62
- background-color: rgba(255, 255, 255, 0.08);
63
  border-radius: 9999px;
64
- border: 2px solid transparent;
65
  background-clip: content-box;
66
  }
67
  ::-webkit-scrollbar-thumb:hover {
68
- background-color: rgba(255, 255, 255, 0.16);
69
  }
70
  ::-webkit-scrollbar-track {
71
  background: transparent;
72
  }
73
 
74
- /* Focus ring — luminous amber */
75
  :focus-visible {
76
- outline: 2px solid rgba(255, 181, 69, 0.75);
77
- outline-offset: 2px;
78
- border-radius: 4px;
79
- }
80
- button:focus-visible,
81
- a:focus-visible,
82
- input:focus-visible,
83
- textarea:focus-visible {
84
  outline-offset: 3px;
85
  }
86
 
87
- /* Default kerning for headlines */
88
  h1,
89
  h2,
90
  h3,
91
  h4 {
 
 
92
  letter-spacing: -0.02em;
 
 
 
 
 
 
93
  }
94
  }
95
 
96
  /* ───────────────────────────────────────────────────────────────
97
- Component grammars
98
  ─────────────────────────────────────────────────────────────── */
99
  @layer components {
100
- /* ── Glass surfaces ─────────────────────────────────────────── */
101
- .glass {
102
- background: rgba(255, 255, 255, 0.04);
103
- box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset,
104
- 0 0 0 1px rgba(255, 255, 255, 0.08),
105
- 0 16px 40px -16px rgba(0, 0, 0, 0.6);
106
- backdrop-filter: blur(16px) saturate(140%);
107
- -webkit-backdrop-filter: blur(16px) saturate(140%);
108
- }
109
-
110
- .glass-strong {
111
- background: rgba(255, 255, 255, 0.06);
112
- box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) inset,
113
- 0 0 0 1px rgba(255, 255, 255, 0.14),
114
- 0 28px 80px -24px rgba(0, 0, 0, 0.7);
115
- backdrop-filter: blur(20px) saturate(160%);
116
- -webkit-backdrop-filter: blur(20px) saturate(160%);
117
  }
118
 
119
- .glass-elev {
120
- background: linear-gradient(
121
- 180deg,
122
- rgba(255, 255, 255, 0.06) 0%,
123
- rgba(255, 255, 255, 0.02) 100%
124
- );
125
- box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) inset,
126
- 0 0 0 1px rgba(255, 255, 255, 0.10),
127
- 0 32px 80px -24px rgba(0, 0, 0, 0.7);
 
128
  }
129
 
130
- /* ── Buttons ────────────────────────────────────────────────── */
131
- .btn-primary {
132
  @apply inline-flex items-center justify-center
133
  px-5 py-2.5 rounded-pill
134
- text-body-strong text-canvas
135
- bg-amber
136
  transition-all duration-200 ease-atelier
137
  active:scale-[0.97]
138
- disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100
139
- hover:bg-amber-soft hover:shadow-amber;
140
  }
141
 
142
  .btn-secondary {
143
  @apply inline-flex items-center justify-center
144
  px-5 py-2.5 rounded-pill
145
  text-body text-ink
146
- bg-glass
147
  transition-all duration-200 ease-atelier
148
  active:scale-[0.97]
149
- hover:bg-glass-stronger;
150
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10);
151
  }
152
 
153
  .btn-ghost {
@@ -155,50 +180,96 @@
155
  px-3 py-1.5 rounded-md
156
  text-caption text-ink-70
157
  transition-colors duration-200
158
- hover:text-ink hover:bg-glass;
159
  }
160
 
161
- .btn-danger {
162
- @apply inline-flex items-center justify-center
163
- px-5 py-2.5 rounded-pill
164
- text-body-strong text-status-err
165
- transition-colors duration-200
166
- hover:bg-status-err-glow;
167
- box-shadow: 0 0 0 1px rgba(255, 122, 122, 0.4);
168
- }
169
-
170
- /* ── Inputs ─────────────────────────────────────────────────── */
171
- .input-glass {
172
- @apply w-full bg-glass text-ink
173
  rounded-md px-4 py-3
174
  text-body
175
  outline-none
176
  transition-all duration-200;
177
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10);
 
178
  }
179
- .input-glass:focus {
180
- background: rgba(255, 255, 255, 0.07);
181
- box-shadow: 0 0 0 1px rgba(255, 181, 69, 0.55),
182
- 0 0 24px -6px rgba(255, 181, 69, 0.35);
183
  }
184
- .input-glass::placeholder {
185
- color: rgba(245, 243, 236, 0.36);
186
  }
187
 
188
- /* ── Cards ──────────────────────────────────────────────────── */
189
  .card {
190
- @apply rounded-lg p-6 glass;
 
 
 
191
  }
192
 
193
- /* ── Editorial italic moments ───────────────────────────────── */
194
- .serif-italic {
195
- font-family: var(--font-instrument), ui-serif, Georgia, serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  font-style: italic;
197
  font-weight: 400;
198
- letter-spacing: -0.01em;
 
 
 
 
 
 
199
  }
200
 
201
- /* ── Containers ─────────────────────────────────────────────── */
202
  .container-app {
203
  @apply max-w-[1480px] mx-auto px-6 md:px-10;
204
  }
@@ -209,49 +280,35 @@
209
  @apply max-w-[760px] mx-auto px-6 md:px-10;
210
  }
211
 
212
- /* ── Chat-specific helpers ──────────────────────────────────── */
213
- .chat-bubble-user {
214
- @apply rounded-2xl rounded-tr-sm px-5 py-3 text-body text-ink;
215
- background: linear-gradient(
216
- 135deg,
217
- rgba(255, 181, 69, 0.16) 0%,
218
- rgba(255, 181, 69, 0.08) 100%
219
- );
220
- box-shadow: 0 0 0 1px rgba(255, 181, 69, 0.22),
221
- 0 8px 24px -8px rgba(255, 181, 69, 0.18);
222
  }
223
-
224
- .chat-bubble-assistant {
225
- @apply rounded-2xl rounded-tl-sm px-5 py-4 text-body text-ink;
226
- background: linear-gradient(
227
- 180deg,
228
- rgba(255, 255, 255, 0.05) 0%,
229
- rgba(255, 255, 255, 0.025) 100%
230
- );
231
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10),
232
- 0 16px 48px -16px rgba(0, 0, 0, 0.5);
233
- backdrop-filter: blur(16px);
234
- -webkit-backdrop-filter: blur(16px);
235
  }
236
 
237
- /* Hairline divider — for sectioning inside cards */
238
- .hairline {
 
 
 
 
 
 
 
 
 
239
  height: 1px;
240
- background: linear-gradient(
241
- 90deg,
242
- transparent,
243
- rgba(255, 255, 255, 0.12),
244
- transparent
245
- );
246
  }
247
- .hairline-vert {
248
- width: 1px;
249
- background: linear-gradient(
250
- 180deg,
251
- transparent,
252
- rgba(255, 255, 255, 0.10),
253
- transparent
254
- );
255
  }
256
  }
257
 
@@ -259,65 +316,54 @@
259
  Utilities
260
  ─────────────────────────────────────────────────────────────── */
261
  @layer utilities {
262
- .text-shimmer {
263
- background: linear-gradient(
264
- 110deg,
265
- rgba(245, 243, 236, 0.4) 30%,
266
- rgba(245, 243, 236, 1) 50%,
267
- rgba(245, 243, 236, 0.4) 70%
268
- );
269
- background-size: 200% 100%;
270
- -webkit-background-clip: text;
271
- background-clip: text;
272
- color: transparent;
273
- animation: shimmerBg 2.4s linear infinite;
274
  }
275
-
276
- @keyframes shimmerBg {
277
- 0% {
278
- background-position: 200% 0;
279
- }
280
- 100% {
281
- background-position: -200% 0;
282
- }
283
  }
284
 
285
- /* Aurora gradient orbs (used decoratively behind hero) */
286
- .aurora-amber {
287
- position: absolute;
288
- border-radius: 9999px;
289
- background: radial-gradient(
290
- circle,
291
- rgba(255, 181, 69, 0.32) 0%,
292
- rgba(255, 181, 69, 0) 65%
293
  );
294
- filter: blur(80px);
295
- pointer-events: none;
296
  }
297
- .aurora-violet {
298
- position: absolute;
299
- border-radius: 9999px;
300
- background: radial-gradient(
301
- circle,
302
- rgba(120, 90, 240, 0.22) 0%,
303
- rgba(120, 90, 240, 0) 65%
304
  );
305
- filter: blur(90px);
306
- pointer-events: none;
 
 
 
 
307
  }
308
- .aurora-teal {
 
 
309
  position: absolute;
310
- border-radius: 9999px;
311
- background: radial-gradient(
312
- circle,
313
- rgba(80, 200, 200, 0.18) 0%,
314
- rgba(80, 200, 200, 0) 65%
315
- );
316
- filter: blur(80px);
317
- pointer-events: none;
 
 
 
 
318
  }
319
 
320
- /* No-scrollbar utility */
321
  .scrollbar-none {
322
  scrollbar-width: none;
323
  }
@@ -325,7 +371,8 @@
325
  display: none;
326
  }
327
 
328
- .text-balance {
329
- text-wrap: balance;
 
330
  }
331
  }
 
3
  @tailwind utilities;
4
 
5
  /* ───────────────────────────────────────────────────────────────
6
+ Risograph Folio — base
7
  ─────────────────────────────────────────────────────────────── */
8
  @layer base {
9
  :root {
10
+ color-scheme: light;
11
+ --paper: #f4ede0;
12
+ --paper-deep: #ebe2d0;
13
+ --ink: #191713;
14
+ --rust: #c84d2c;
15
  }
16
 
17
  html,
18
  body {
19
+ background: var(--paper);
20
+ color: var(--ink);
21
+ font-family: var(--font-plex), ui-sans-serif, system-ui, sans-serif;
22
  font-size: 15px;
23
+ line-height: 1.65;
24
  font-weight: 400;
25
+ font-feature-settings: "ss01", "ss03", "calt";
26
  -webkit-font-smoothing: antialiased;
27
  -moz-osx-font-smoothing: grayscale;
28
  text-rendering: optimizeLegibility;
29
  }
30
 
31
  body {
32
+ /* Layered paper:
33
+ 1. base parchment color
34
+ 2. halftone dot pattern (very low opacity)
35
+ 3. warm sun-bleached gradient at top-right
36
+ 4. inked-edge vignette toward bottom-left
37
+ 5. fine paper grain (svg noise) */
38
  background:
39
+ radial-gradient(900px 600px at 92% 0%, rgba(200, 77, 44, 0.07), transparent 60%),
40
+ radial-gradient(800px 700px at 0% 100%, rgba(25, 23, 19, 0.05), transparent 60%),
41
+ var(--paper);
 
42
  background-attachment: fixed;
43
  min-height: 100vh;
44
  overflow-x: hidden;
45
+ position: relative;
46
  }
47
 
48
+ /* Halftone dot field (decorative; below all content) */
49
  body::before {
50
  content: "";
51
  position: fixed;
52
  inset: 0;
53
  pointer-events: none;
54
  z-index: 0;
55
+ background-image: radial-gradient(
56
+ circle at center,
57
+ rgba(25, 23, 19, 0.08) 0.7px,
58
+ transparent 1.4px
59
+ );
60
+ background-size: 6px 6px;
61
+ background-position: 0 0;
62
+ opacity: 0.55;
63
+ mask-image: radial-gradient(
64
+ ellipse 100% 70% at 50% 30%,
65
+ transparent 0%,
66
+ black 70%
67
+ );
68
+ -webkit-mask-image: radial-gradient(
69
+ ellipse 100% 70% at 50% 30%,
70
+ transparent 0%,
71
+ black 70%
72
+ );
73
+ }
74
+
75
+ /* Paper grain — very subtle */
76
+ body::after {
77
+ content: "";
78
+ position: fixed;
79
+ inset: 0;
80
+ pointer-events: none;
81
+ z-index: 0;
82
+ background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 240 240' xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch' seed='3'/><feColorMatrix values='0 0 0 0 0.10, 0 0 0 0 0.09, 0 0 0 0 0.08, 0 0 0 0.10 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
83
+ opacity: 0.5;
84
+ mix-blend-mode: multiply;
85
  }
86
 
87
  ::selection {
88
+ background: var(--rust);
89
+ color: var(--paper);
90
  }
91
 
92
+ /* Hand-set scrollbar (warm, paper-tinted) */
93
  ::-webkit-scrollbar {
94
+ width: 12px;
95
+ height: 12px;
96
  }
97
  ::-webkit-scrollbar-thumb {
98
+ background-color: rgba(25, 23, 19, 0.18);
99
  border-radius: 9999px;
100
+ border: 3px solid var(--paper);
101
  background-clip: content-box;
102
  }
103
  ::-webkit-scrollbar-thumb:hover {
104
+ background-color: rgba(25, 23, 19, 0.30);
105
  }
106
  ::-webkit-scrollbar-track {
107
  background: transparent;
108
  }
109
 
 
110
  :focus-visible {
111
+ outline: 2px solid var(--rust);
 
 
 
 
 
 
 
112
  outline-offset: 3px;
113
  }
114
 
115
+ /* Headlines Fraunces by default for h1/h2/h3 */
116
  h1,
117
  h2,
118
  h3,
119
  h4 {
120
+ font-family: var(--font-fraunces), ui-serif, Georgia, serif;
121
+ font-weight: 500;
122
  letter-spacing: -0.02em;
123
+ color: var(--ink);
124
+ }
125
+
126
+ /* Italic links: slip into italic on hover */
127
+ a {
128
+ transition: color 0.2s ease, font-style 0s;
129
  }
130
  }
131
 
132
  /* ───────────────────────────────────────────────────────────────
133
+ Components
134
  ─────────────────────────────────────────────────────────────── */
135
  @layer components {
136
+ /* ── Italic display moments ─────────────────────────────────── */
137
+ .italic-display {
138
+ font-family: var(--font-fraunces), ui-serif, Georgia, serif;
139
+ font-style: italic;
140
+ font-weight: 400;
141
+ font-variation-settings: "SOFT" 30, "WONK" 1;
 
 
 
 
 
 
 
 
 
 
 
142
  }
143
 
144
+ /* ── Buttons — flat printed ─────────────────────────────────── */
145
+ .btn-primary {
146
+ @apply inline-flex items-center justify-center
147
+ px-5 py-2.5 rounded-pill
148
+ text-body-strong text-paper
149
+ bg-ink
150
+ transition-all duration-200 ease-atelier
151
+ active:scale-[0.97]
152
+ disabled:opacity-30 disabled:cursor-not-allowed
153
+ hover:bg-rust;
154
  }
155
 
156
+ .btn-rust {
 
157
  @apply inline-flex items-center justify-center
158
  px-5 py-2.5 rounded-pill
159
+ text-body-strong text-paper
160
+ bg-rust
161
  transition-all duration-200 ease-atelier
162
  active:scale-[0.97]
163
+ hover:bg-rust-deep
164
+ disabled:opacity-30 disabled:cursor-not-allowed;
165
  }
166
 
167
  .btn-secondary {
168
  @apply inline-flex items-center justify-center
169
  px-5 py-2.5 rounded-pill
170
  text-body text-ink
171
+ bg-transparent
172
  transition-all duration-200 ease-atelier
173
  active:scale-[0.97]
174
+ hover:bg-ink-08;
175
+ box-shadow: 0 0 0 1px rgba(25, 23, 19, 0.20);
176
  }
177
 
178
  .btn-ghost {
 
180
  px-3 py-1.5 rounded-md
181
  text-caption text-ink-70
182
  transition-colors duration-200
183
+ hover:text-ink hover:bg-ink-08;
184
  }
185
 
186
+ /* ── Inputs — flat printed input fields ────────────────────── */
187
+ .input-print {
188
+ @apply w-full bg-paper-deep text-ink
 
 
 
 
 
 
 
 
 
189
  rounded-md px-4 py-3
190
  text-body
191
  outline-none
192
  transition-all duration-200;
193
+ box-shadow: 0 0 0 1px rgba(25, 23, 19, 0.18),
194
+ inset 0 1px 0 rgba(25, 23, 19, 0.04);
195
  }
196
+ .input-print:focus {
197
+ box-shadow: 0 0 0 2px var(--rust),
198
+ inset 0 1px 0 rgba(25, 23, 19, 0.04);
 
199
  }
200
+ .input-print::placeholder {
201
+ color: rgba(25, 23, 19, 0.36);
202
  }
203
 
204
+ /* ── Cards — flat printed paper, hairline ruled ────────────── */
205
  .card {
206
+ @apply rounded-md p-6;
207
+ background: var(--paper-deep);
208
+ box-shadow: 0 0 0 1px rgba(25, 23, 19, 0.12),
209
+ inset 0 1px 0 rgba(255, 255, 255, 0.45);
210
  }
211
 
212
+ .card-rust {
213
+ @apply rounded-md p-6;
214
+ background: linear-gradient(
215
+ 180deg,
216
+ rgba(245, 216, 200, 0.6) 0%,
217
+ var(--paper-deep) 100%
218
+ );
219
+ box-shadow: 0 0 0 1px rgba(200, 77, 44, 0.35),
220
+ inset 0 1px 0 rgba(255, 255, 255, 0.45);
221
+ }
222
+
223
+ /* ── Folio chrome (the magazine masthead vibe) ─────────────── */
224
+ .folio-chrome {
225
+ @apply font-mono uppercase tracking-[0.22em] text-ink-50;
226
+ font-size: 10px;
227
+ line-height: 1.4;
228
+ font-weight: 600;
229
+ }
230
+ .folio-chrome--ink {
231
+ @apply text-ink;
232
+ }
233
+ .folio-chrome--rust {
234
+ @apply text-rust;
235
+ }
236
+
237
+ /* Hairline rule — animates in (use animate-rule) */
238
+ .hairline {
239
+ height: 1px;
240
+ background: rgba(25, 23, 19, 0.16);
241
+ }
242
+ .hairline--rust {
243
+ background: var(--rust);
244
+ }
245
+ .hairline--double {
246
+ height: 4px;
247
+ background: linear-gradient(
248
+ to bottom,
249
+ rgba(25, 23, 19, 0.22),
250
+ rgba(25, 23, 19, 0.22) 1px,
251
+ transparent 1px,
252
+ transparent 3px,
253
+ rgba(25, 23, 19, 0.22) 3px,
254
+ rgba(25, 23, 19, 0.22) 4px
255
+ );
256
+ }
257
+
258
+ /* Drop-cap for assistant answers (the editorial feature) */
259
+ .drop-cap::first-letter {
260
+ font-family: var(--font-fraunces), ui-serif, Georgia, serif;
261
  font-style: italic;
262
  font-weight: 400;
263
+ font-variation-settings: "SOFT" 30, "WONK" 1;
264
+ color: var(--rust);
265
+ float: left;
266
+ font-size: 4.4em;
267
+ line-height: 0.85;
268
+ margin: 0.08em 0.12em -0.05em -0.04em;
269
+ padding: 0;
270
  }
271
 
272
+ /* Containers */
273
  .container-app {
274
  @apply max-w-[1480px] mx-auto px-6 md:px-10;
275
  }
 
280
  @apply max-w-[760px] mx-auto px-6 md:px-10;
281
  }
282
 
283
+ /* Italic-on-hover (editorial micro-interaction) */
284
+ .hover-italic {
285
+ transition: color 0.2s ease, font-style 0.001s;
 
 
 
 
 
 
 
286
  }
287
+ .hover-italic:hover {
288
+ font-style: italic;
289
+ color: var(--rust);
 
 
 
 
 
 
 
 
 
290
  }
291
 
292
+ /* Underline-from-left link */
293
+ .link-rule {
294
+ position: relative;
295
+ color: inherit;
296
+ }
297
+ .link-rule::after {
298
+ content: "";
299
+ position: absolute;
300
+ bottom: -2px;
301
+ left: 0;
302
+ width: 100%;
303
  height: 1px;
304
+ background: currentColor;
305
+ transform: scaleX(0);
306
+ transform-origin: right;
307
+ transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
 
 
308
  }
309
+ .link-rule:hover::after {
310
+ transform-origin: left;
311
+ transform: scaleX(1);
 
 
 
 
 
312
  }
313
  }
314
 
 
316
  Utilities
317
  ─────────────────────────────────────────────────────────────── */
318
  @layer utilities {
319
+ .text-balance {
320
+ text-wrap: balance;
 
 
 
 
 
 
 
 
 
 
321
  }
322
+ .text-pretty {
323
+ text-wrap: pretty;
 
 
 
 
 
 
324
  }
325
 
326
+ /* Halftone backdrop for hero/feature panels */
327
+ .halftone-bg {
328
+ background-image: radial-gradient(
329
+ circle at center,
330
+ rgba(25, 23, 19, 0.18) 0.8px,
331
+ transparent 1.6px
 
 
332
  );
333
+ background-size: 5px 5px;
 
334
  }
335
+ .halftone-rust {
336
+ background-image: radial-gradient(
337
+ circle at center,
338
+ rgba(200, 77, 44, 0.4) 0.8px,
339
+ transparent 1.6px
 
 
340
  );
341
+ background-size: 5px 5px;
342
+ }
343
+
344
+ /* Register marks (corner crosshairs) */
345
+ .register-corners {
346
+ position: relative;
347
  }
348
+ .register-corners::before,
349
+ .register-corners::after {
350
+ content: "✚";
351
  position: absolute;
352
+ font-family: var(--font-mono);
353
+ font-size: 11px;
354
+ color: rgba(25, 23, 19, 0.30);
355
+ line-height: 1;
356
+ }
357
+ .register-corners::before {
358
+ top: 8px;
359
+ left: 8px;
360
+ }
361
+ .register-corners::after {
362
+ bottom: 8px;
363
+ right: 8px;
364
  }
365
 
366
+ /* No-scrollbar */
367
  .scrollbar-none {
368
  scrollbar-width: none;
369
  }
 
371
  display: none;
372
  }
373
 
374
+ /* Riso-style "ink offset" effect — used for emphasis text */
375
+ .ink-offset {
376
+ text-shadow: 1px 0 0 rgba(200, 77, 44, 0.5);
377
  }
378
  }
app/layout.tsx CHANGED
@@ -1,35 +1,35 @@
1
  import type { Metadata } from "next";
2
- import { Instrument_Serif, Manrope, JetBrains_Mono } from "next/font/google";
3
  import "./globals.css";
4
  import { Providers } from "./providers";
5
  import { AppShell } from "@/components/chat/AppShell";
6
 
7
- const manrope = Manrope({
8
  subsets: ["latin"],
9
- variable: "--font-manrope",
10
  display: "swap",
11
- weight: ["300", "400", "500", "600", "700"],
 
12
  });
13
 
14
- const instrument = Instrument_Serif({
15
  subsets: ["latin"],
16
- variable: "--font-instrument",
17
  display: "swap",
18
- weight: ["400"],
19
- style: ["normal", "italic"],
20
  });
21
 
22
  const mono = JetBrains_Mono({
23
  subsets: ["latin"],
24
  variable: "--font-mono",
25
  display: "swap",
26
- weight: ["400", "500"],
27
  });
28
 
29
  export const metadata: Metadata = {
30
- title: "doc-to-lora · Etiya BSS Atelier",
31
  description:
32
- "An atelier for stateless retrieval-augmented inference over Etiya BSS documents built on the doc-to-lora hypernetwork.",
33
  };
34
 
35
  export default function RootLayout({
@@ -40,7 +40,7 @@ export default function RootLayout({
40
  return (
41
  <html
42
  lang="en"
43
- className={`${manrope.variable} ${instrument.variable} ${mono.variable}`}
44
  >
45
  <body className="min-h-screen">
46
  <Providers>
 
1
  import type { Metadata } from "next";
2
+ import { Fraunces, IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
3
  import "./globals.css";
4
  import { Providers } from "./providers";
5
  import { AppShell } from "@/components/chat/AppShell";
6
 
7
+ const fraunces = Fraunces({
8
  subsets: ["latin"],
9
+ variable: "--font-fraunces",
10
  display: "swap",
11
+ style: ["normal", "italic"],
12
+ axes: ["SOFT", "WONK", "opsz"],
13
  });
14
 
15
+ const plex = IBM_Plex_Sans({
16
  subsets: ["latin"],
17
+ variable: "--font-plex",
18
  display: "swap",
19
+ weight: ["300", "400", "500", "600", "700"],
 
20
  });
21
 
22
  const mono = JetBrains_Mono({
23
  subsets: ["latin"],
24
  variable: "--font-mono",
25
  display: "swap",
26
+ weight: ["400", "500", "600"],
27
  });
28
 
29
  export const metadata: Metadata = {
30
+ title: "doc·to·lora Etiya BSS Folio",
31
  description:
32
+ "A printed-publication interrogation surface for the Etiya BSS corpus, built on the doc-to-lora hypernetwork.",
33
  };
34
 
35
  export default function RootLayout({
 
40
  return (
41
  <html
42
  lang="en"
43
+ className={`${fraunces.variable} ${plex.variable} ${mono.variable}`}
44
  >
45
  <body className="min-h-screen">
46
  <Providers>
app/system/page.tsx CHANGED
@@ -6,6 +6,10 @@ import { useMutation, useQuery } from "@tanstack/react-query";
6
  import { useEffect, useState } from "react";
7
  import clsx from "clsx";
8
 
 
 
 
 
9
  export default function SystemPage() {
10
  const { data, error, refetch, isFetching } = useQuery({
11
  queryKey: ["health"],
@@ -26,125 +30,195 @@ export default function SystemPage() {
26
 
27
  return (
28
  <div className="flex-1 overflow-y-auto">
29
- <div className="container-app py-10 space-y-10">
30
- {/* Header */}
31
- <header className="flex items-end justify-between flex-wrap gap-4 animate-fade-in">
32
- <div>
33
- <h1 className="text-display-lg text-ink">
34
- System <span className="serif-italic text-amber">readout.</span>
35
- </h1>
36
- <p className="text-body text-ink-50 mt-2">
37
- Live telemetry — auto-refresh every 30s.
38
- </p>
 
 
39
  </div>
40
- <button
41
- onClick={() => refetch()}
42
- disabled={isFetching}
43
- className="btn-secondary"
 
 
 
 
 
44
  >
45
- {isFetching ? "Refreshing…" : "Refresh now"}
46
- </button>
47
- </header>
48
-
49
- {error && (
50
- <div
51
- className="p-4 rounded-md"
52
  style={{
53
- background: "rgba(255, 122, 122, 0.06)",
54
- boxShadow: "0 0 0 1px rgba(255, 122, 122, 0.28)",
 
 
55
  }}
56
  >
57
- <p className="text-status-err text-body-strong">
58
- Failed to read /health
59
- </p>
 
 
 
 
 
 
60
  <p className="text-caption text-ink-70 mt-1">
61
  {error instanceof Error ? error.message : String(error)}
62
  </p>
63
  </div>
64
  )}
65
 
66
- {/* Hero metrics */}
67
- <section className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 animate-fade-up">
68
- <BigMetric
69
- label="Stage"
70
- value={
71
- data === undefined
72
- ? "—"
73
- : data.model_loaded
74
- ? "Running"
75
- : "Loading"
76
- }
77
- tone={
78
- data === undefined ? "neutral" : data.model_loaded ? "ok" : "warn"
79
- }
80
- />
81
- <BigMetric
82
- label="Documents indexed"
83
- value={data?.doc_count?.toLocaleString() ?? "—"}
84
- />
85
- <BigMetric
86
- label="GPU memory"
87
- value={data ? data.gpu_memory_gb.toFixed(2) : "—"}
88
- unit="GB"
89
- />
90
- <BigMetric label="Hardware" value="A100" unit="80GB" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  </section>
92
 
93
  {/* Latencies + corpus */}
94
- <section className="grid lg:grid-cols-2 gap-4 animate-fade-up-slow">
95
  <Latencies />
96
  <CorpusBreakdown data={data} />
97
  </section>
98
 
99
- {/* Eval suite featured panel */}
100
- <section
101
- className="relative rounded-xl p-8 md:p-10 overflow-hidden"
102
- style={{
103
- background:
104
- "linear-gradient(135deg, rgba(255,181,69,0.08) 0%, rgba(255,255,255,0.02) 100%)",
105
- boxShadow:
106
- "0 0 0 1px rgba(255,181,69,0.20), 0 32px 80px -32px rgba(0,0,0,0.6)",
107
- }}
108
- >
109
- <div className="aurora-amber" style={{ width: 480, height: 480, top: -200, right: -200 }} />
110
- <div className="relative">
111
- <div className="text-micro uppercase tracking-[0.18em] text-amber font-mono">
112
- Eval suite
113
- </div>
114
- <h2 className="text-display-md text-ink mt-3">
115
- 100-question <span className="serif-italic">benchmark.</span>
116
- </h2>
117
- <div className="mt-8 grid grid-cols-2 md:grid-cols-4 gap-6">
118
- <BenchMetric label="Hallucination defense" value="87.5" unit="%" />
119
- <BenchMetric label="Concept queries" value="84" unit="%" />
120
- <BenchMetric label="Cross-domain" value="55" unit="%" />
121
- <BenchMetric label="Fact recall" value="59" unit="%" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  </div>
123
- <p className="text-caption text-ink-70 mt-8 max-w-[640px]">
124
- Pure numerical signals (anchor / dense / rerank) with zero hardcoded
125
- reference text. The eval set lives at{" "}
126
- <span className="font-mono text-ink">eval/eval_set.jsonl</span>{" "}
127
- and can be replayed any time.
128
- </p>
129
  </div>
130
  </section>
131
 
132
  {/* Index management */}
133
  <section>
134
  <div className="mb-5">
135
- <h2 className="text-display-md text-ink">
136
- Index <span className="serif-italic text-amber">management.</span>
 
 
 
 
 
 
 
 
 
137
  </h2>
138
- <p className="text-caption text-ink-50 mt-1.5 max-w-[680px]">
139
- The RAG index needs to be synchronized after document changes.
140
- Incremental is the default it only embeds new/removed docs.
141
  </p>
142
  </div>
143
 
144
- <div className="grid md:grid-cols-2 gap-4">
145
  <ReindexCard
146
  title="Incremental"
147
- description="Sync new and removed docs only."
148
  cost="≈ $0.0001"
149
  latency="~350 ms"
150
  loading={
@@ -153,13 +227,13 @@ export default function SystemPage() {
153
  onClick={() =>
154
  reindex.mutate({ force_full: false, rebuild_anchors: false })
155
  }
 
156
  />
157
  <ReindexCard
158
  title="Full rebuild"
159
- description="Re-embed all docs + rebuild K-means anchors."
160
  cost="≈ $0.16"
161
  latency="~30 s"
162
- danger
163
  loading={reindex.isPending && !!reindex.variables?.force_full}
164
  onClick={() =>
165
  reindex.mutate({ force_full: true, rebuild_anchors: true })
@@ -168,25 +242,22 @@ export default function SystemPage() {
168
  </div>
169
 
170
  {reindex.data && (
171
- <div className="mt-4 card animate-fade-up">
172
- <div className="text-micro uppercase tracking-[0.15em] text-status-ok font-mono mb-2">
173
- re-index complete
174
  </div>
175
- <pre className="font-mono text-caption text-ink whitespace-pre-wrap break-all">
 
 
 
176
  {JSON.stringify(reindex.data, null, 2)}
177
  </pre>
178
  </div>
179
  )}
180
  {reindex.error && (
181
- <div
182
- className="mt-4 p-4 rounded-md"
183
- style={{
184
- background: "rgba(255, 122, 122, 0.06)",
185
- boxShadow: "0 0 0 1px rgba(255, 122, 122, 0.28)",
186
- }}
187
- >
188
- <div className="text-micro uppercase tracking-[0.15em] text-status-err font-mono mb-1">
189
- re-index failed
190
  </div>
191
  <p className="text-caption text-ink">
192
  {reindex.error instanceof Error
@@ -201,44 +272,40 @@ export default function SystemPage() {
201
  );
202
  }
203
 
204
- function BigMetric({
205
  label,
206
  value,
207
  unit,
208
- tone = "neutral",
209
  }: {
210
  label: string;
211
  value: string | number;
212
  unit?: string;
213
- tone?: "ok" | "warn" | "err" | "neutral";
214
  }) {
215
- const dotCls = {
216
- ok: "bg-status-ok shadow-[0_0_8px_rgba(93,214,168,0.7)]",
217
- warn: "bg-amber shadow-[0_0_8px_rgba(255,181,69,0.7)]",
218
- err: "bg-status-err",
219
- neutral: "bg-ink-30",
220
- }[tone];
221
  return (
222
- <div className="card">
223
- <div className="flex items-center gap-2 text-micro uppercase tracking-[0.14em] text-ink-50 font-mono">
224
- <span
225
- className={clsx(
226
- "inline-block w-1.5 h-1.5 rounded-full",
227
- dotCls,
228
- tone !== "neutral" && "animate-pulse-soft"
229
- )}
230
- />
231
- {label}
232
- </div>
233
- <div className="text-display-md font-mono text-ink mt-2">
234
  {value}
235
- {unit && <span className="text-ink-50 ml-1.5 text-lead">{unit}</span>}
 
 
 
 
236
  </div>
237
  </div>
238
  );
239
  }
240
 
241
- function BenchMetric({
242
  label,
243
  value,
244
  unit,
@@ -249,12 +316,28 @@ function BenchMetric({
249
  }) {
250
  return (
251
  <div>
252
- <div className="text-micro uppercase tracking-[0.14em] text-ink-50 font-mono">
 
 
 
253
  {label}
254
  </div>
255
- <div className="text-display-lg font-mono text-ink mt-2">
 
 
 
 
 
 
 
 
 
256
  {value}
257
- {unit && <span className="text-amber ml-1 text-display-sm">{unit}</span>}
 
 
 
 
258
  </div>
259
  </div>
260
  );
@@ -284,11 +367,23 @@ function Latencies() {
284
 
285
  return (
286
  <div className="card">
287
- <h3 className="text-body-strong text-ink mb-4">Round-trip latencies</h3>
288
- <div className="space-y-2.5 text-caption">
 
 
 
 
 
 
 
 
 
 
 
 
289
  <Row label="browser → ping" value={pingMs ? `${pingMs.toFixed(0)} ms` : "—"} />
290
  <Row label="ask_smart (reject)" value="≈ 400 ms" />
291
- <Row label="ask_smart (inference)" value="≈ 1.3-2.0 s" />
292
  <Row label="reindex (incremental)" value="≈ 350 ms" />
293
  <Row label="reindex (full)" value="≈ 30 s" />
294
  </div>
@@ -299,8 +394,20 @@ function Latencies() {
299
  function CorpusBreakdown({ data }: { data: HealthResponse | undefined }) {
300
  return (
301
  <div className="card">
302
- <h3 className="text-body-strong text-ink mb-4">Corpus &amp; index</h3>
303
- <div className="space-y-2.5 text-caption">
 
 
 
 
 
 
 
 
 
 
 
 
304
  <Row
305
  label="documents on disk"
306
  value={data ? data.doc_count.toLocaleString() : "—"}
@@ -321,7 +428,7 @@ function ReindexCard({
321
  cost,
322
  latency,
323
  loading,
324
- danger,
325
  onClick,
326
  }: {
327
  title: string;
@@ -329,30 +436,32 @@ function ReindexCard({
329
  cost: string;
330
  latency: string;
331
  loading: boolean;
332
- danger?: boolean;
333
  onClick: () => void;
334
  }) {
335
  return (
336
- <div className="card flex flex-col gap-4">
337
  <div>
338
- <div className="flex items-center justify-between">
339
- <h3 className="text-display-sm text-ink">{title}</h3>
340
- <span className="text-micro font-mono uppercase tracking-[0.12em] text-amber">
341
- {latency}
342
- </span>
343
  </div>
344
- <p className="text-caption text-ink-70 mt-1">{description}</p>
345
- <p className="text-micro text-ink-50 mt-2 font-mono uppercase tracking-[0.10em]">
346
- openai cost {cost}
347
- </p>
 
 
 
 
 
 
 
348
  </div>
349
  <button
350
  onClick={onClick}
351
  disabled={loading}
352
- className={clsx(
353
- danger ? "btn-secondary" : "btn-primary",
354
- "self-start"
355
- )}
356
  >
357
  {loading ? "Running…" : `Run ${title.toLowerCase()}`}
358
  </button>
@@ -362,11 +471,9 @@ function ReindexCard({
362
 
363
  function Row({ label, value }: { label: string; value: string }) {
364
  return (
365
- <div className="flex items-center justify-between gap-4">
366
- <span className="text-ink-50 font-mono uppercase tracking-[0.10em] text-micro">
367
- {label}
368
- </span>
369
- <span className="font-mono text-ink text-caption truncate">{value}</span>
370
  </div>
371
  );
372
  }
 
6
  import { useEffect, useState } from "react";
7
  import clsx from "clsx";
8
 
9
+ /**
10
+ * Colophon. The publication's printer's marks: typesetters, presses,
11
+ * paper, ink. Here: model, hardware, latencies, eval suite, indexer.
12
+ */
13
  export default function SystemPage() {
14
  const { data, error, refetch, isFetching } = useQuery({
15
  queryKey: ["health"],
 
30
 
31
  return (
32
  <div className="flex-1 overflow-y-auto">
33
+ <div className="container-app py-10 space-y-12">
34
+ {/* Editorial masthead */}
35
+ <header className="animate-fade-in">
36
+ <div className="flex items-baseline justify-between mb-3">
37
+ <span className="folio-chrome">Folio III · Colophon</span>
38
+ <button
39
+ onClick={() => refetch()}
40
+ disabled={isFetching}
41
+ className="folio-chrome hover:text-rust transition-colors"
42
+ >
43
+ {isFetching ? "Refreshing…" : "Refresh ↻"}
44
+ </button>
45
  </div>
46
+ <h1
47
+ className="text-ink"
48
+ style={{
49
+ fontSize: "clamp(56px, 9vw, 110px)",
50
+ lineHeight: 0.92,
51
+ fontWeight: 300,
52
+ letterSpacing: "-0.04em",
53
+ fontFamily: "var(--font-fraunces)",
54
+ }}
55
  >
56
+ The <span className="italic-display text-rust">colophon.</span>
57
+ </h1>
58
+ <div className="hairline--double mt-6 animate-rule" />
59
+ <p
60
+ className="text-ink mt-6 max-w-[64ch] animate-stagger-1"
 
 
61
  style={{
62
+ fontFamily: "var(--font-fraunces)",
63
+ fontSize: "20px",
64
+ lineHeight: 1.45,
65
+ fontWeight: 300,
66
  }}
67
  >
68
+ A printer's mark for the atelier — the presses, the type, the paper.
69
+ Live readouts auto-refresh every thirty seconds.
70
+ </p>
71
+ </header>
72
+
73
+ {error && (
74
+ <div className="card-rust animate-fade-in">
75
+ <div className="folio-chrome--rust folio-chrome mb-1">Errata</div>
76
+ <p className="text-body-strong text-ink">Failed to read /health</p>
77
  <p className="text-caption text-ink-70 mt-1">
78
  {error instanceof Error ? error.message : String(error)}
79
  </p>
80
  </div>
81
  )}
82
 
83
+ {/* Hero metric panel — magazine pull-quote of the GPU */}
84
+ <section className="animate-fade-up">
85
+ <div
86
+ className="rounded-lg p-8 md:p-12 register-corners halftone-bg"
87
+ style={{
88
+ background:
89
+ "linear-gradient(180deg, var(--paper-deep) 0%, var(--paper) 100%)",
90
+ boxShadow: "0 0 0 1.5px rgba(25,23,19,0.30), 0 1px 0 rgba(255,255,255,0.5) inset",
91
+ }}
92
+ >
93
+ <div className="folio-chrome mb-3">Press status</div>
94
+ <div className="flex items-baseline gap-4 flex-wrap">
95
+ <div
96
+ className="italic-display text-ink"
97
+ style={{ fontSize: "clamp(64px, 10vw, 120px)", lineHeight: 0.95, fontWeight: 300 }}
98
+ >
99
+ {data === undefined ? "—" : data.model_loaded ? "Running" : "Loading"}
100
+ </div>
101
+ <span
102
+ className={clsx(
103
+ "inline-block w-3 h-3 rounded-full mb-3 ml-2 transition-colors",
104
+ data === undefined
105
+ ? "bg-ink-30"
106
+ : data.model_loaded
107
+ ? "bg-status-ok animate-ink-pulse"
108
+ : "bg-status-warn animate-ink-pulse"
109
+ )}
110
+ />
111
+ </div>
112
+
113
+ <div className="hairline mt-8 mb-6" />
114
+
115
+ <div className="grid sm:grid-cols-3 gap-x-8 gap-y-6">
116
+ <Pull
117
+ label="Documents indexed"
118
+ value={data?.doc_count?.toLocaleString() ?? "—"}
119
+ />
120
+ <Pull
121
+ label="GPU memory"
122
+ value={data ? data.gpu_memory_gb.toFixed(2) : "—"}
123
+ unit="GB"
124
+ />
125
+ <Pull label="Hardware" value="A100" unit="80GB" />
126
+ </div>
127
+ </div>
128
  </section>
129
 
130
  {/* Latencies + corpus */}
131
+ <section className="grid lg:grid-cols-2 gap-6 animate-fade-up-slow">
132
  <Latencies />
133
  <CorpusBreakdown data={data} />
134
  </section>
135
 
136
+ {/* Eval — pull-quote panel with halftone */}
137
+ <section className="animate-fade-up">
138
+ <div
139
+ className="rounded-lg p-8 md:p-10 relative overflow-hidden"
140
+ style={{
141
+ background: "var(--ink)",
142
+ color: "var(--paper)",
143
+ }}
144
+ >
145
+ <div
146
+ aria-hidden
147
+ className="absolute inset-0 opacity-25"
148
+ style={{
149
+ backgroundImage:
150
+ "radial-gradient(circle at center, rgba(244, 237, 224, 0.5) 0.8px, transparent 1.6px)",
151
+ backgroundSize: "5px 5px",
152
+ }}
153
+ />
154
+ <div className="relative">
155
+ <div className="folio-chrome" style={{ color: "rgba(244,237,224,0.5)" }}>
156
+ Issue review · Eval suite · 100 questions
157
+ </div>
158
+ <h2
159
+ className="mt-3"
160
+ style={{
161
+ fontFamily: "var(--font-fraunces)",
162
+ fontSize: "44px",
163
+ lineHeight: 1.05,
164
+ fontWeight: 400,
165
+ }}
166
+ >
167
+ The <span className="italic-display" style={{ color: "var(--rust)" }}>numbers.</span>
168
+ </h2>
169
+ <div className="hairline--double mt-6 mb-8" style={{ filter: "invert(1)", opacity: 0.6 }} />
170
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-x-8 gap-y-6">
171
+ <Bench label="Hallucination defense" value="87.5" unit="%" />
172
+ <Bench label="Concept queries" value="84" unit="%" />
173
+ <Bench label="Cross-domain" value="55" unit="%" />
174
+ <Bench label="Fact recall" value="59" unit="%" />
175
+ </div>
176
+ <p
177
+ className="mt-8 max-w-[60ch]"
178
+ style={{
179
+ fontFamily: "var(--font-fraunces)",
180
+ fontSize: "16px",
181
+ lineHeight: 1.55,
182
+ fontWeight: 300,
183
+ color: "rgba(244,237,224,0.75)",
184
+ }}
185
+ >
186
+ Pure numerical signals — anchor, dense, rerank — with zero
187
+ hardcoded reference text. The eval set lives at{" "}
188
+ <span className="font-mono" style={{ color: "var(--paper)" }}>
189
+ eval/eval_set.jsonl
190
+ </span>{" "}
191
+ and is replayed on demand.
192
+ </p>
193
  </div>
 
 
 
 
 
 
194
  </div>
195
  </section>
196
 
197
  {/* Index management */}
198
  <section>
199
  <div className="mb-5">
200
+ <span className="folio-chrome">Press · Index management</span>
201
+ <h2
202
+ className="text-ink mt-2"
203
+ style={{
204
+ fontFamily: "var(--font-fraunces)",
205
+ fontSize: "36px",
206
+ fontWeight: 500,
207
+ letterSpacing: "-0.02em",
208
+ }}
209
+ >
210
+ Run the <span className="italic-display text-rust">indexer.</span>
211
  </h2>
212
+ <p className="text-caption text-ink-70 mt-1.5 max-w-[64ch]">
213
+ The retrieval index needs syncing after documents change.
214
+ Incremental embeds only deltas; full rebuild re-anchors topics.
215
  </p>
216
  </div>
217
 
218
+ <div className="grid md:grid-cols-2 gap-6">
219
  <ReindexCard
220
  title="Incremental"
221
+ description="Sync new and removed entries only."
222
  cost="≈ $0.0001"
223
  latency="~350 ms"
224
  loading={
 
227
  onClick={() =>
228
  reindex.mutate({ force_full: false, rebuild_anchors: false })
229
  }
230
+ accent
231
  />
232
  <ReindexCard
233
  title="Full rebuild"
234
+ description="Re-embed all entries + rebuild K-means topic anchors."
235
  cost="≈ $0.16"
236
  latency="~30 s"
 
237
  loading={reindex.isPending && !!reindex.variables?.force_full}
238
  onClick={() =>
239
  reindex.mutate({ force_full: true, rebuild_anchors: true })
 
242
  </div>
243
 
244
  {reindex.data && (
245
+ <div className="mt-6 card animate-fade-up">
246
+ <div className="folio-chrome--ink folio-chrome mb-2">
247
+ Re-index complete
248
  </div>
249
+ <pre
250
+ className="font-mono text-caption text-ink whitespace-pre-wrap break-all"
251
+ style={{ fontSize: "12px", lineHeight: 1.6 }}
252
+ >
253
  {JSON.stringify(reindex.data, null, 2)}
254
  </pre>
255
  </div>
256
  )}
257
  {reindex.error && (
258
+ <div className="mt-6 card-rust">
259
+ <div className="folio-chrome--rust folio-chrome mb-1">
260
+ Re-index failed
 
 
 
 
 
 
261
  </div>
262
  <p className="text-caption text-ink">
263
  {reindex.error instanceof Error
 
272
  );
273
  }
274
 
275
+ function Pull({
276
  label,
277
  value,
278
  unit,
 
279
  }: {
280
  label: string;
281
  value: string | number;
282
  unit?: string;
 
283
  }) {
 
 
 
 
 
 
284
  return (
285
+ <div>
286
+ <div className="folio-chrome mb-1.5">{label}</div>
287
+ <div
288
+ className="text-ink"
289
+ style={{
290
+ fontFamily: "var(--font-fraunces)",
291
+ fontSize: "44px",
292
+ lineHeight: 1,
293
+ fontWeight: 400,
294
+ letterSpacing: "-0.02em",
295
+ }}
296
+ >
297
  {value}
298
+ {unit && (
299
+ <span className="text-rust ml-1.5" style={{ fontSize: "22px", fontWeight: 400 }}>
300
+ {unit}
301
+ </span>
302
+ )}
303
  </div>
304
  </div>
305
  );
306
  }
307
 
308
+ function Bench({
309
  label,
310
  value,
311
  unit,
 
316
  }) {
317
  return (
318
  <div>
319
+ <div
320
+ className="folio-chrome"
321
+ style={{ color: "rgba(244,237,224,0.5)" }}
322
+ >
323
  {label}
324
  </div>
325
+ <div
326
+ className="mt-1.5"
327
+ style={{
328
+ fontFamily: "var(--font-fraunces)",
329
+ fontSize: "56px",
330
+ lineHeight: 1,
331
+ fontWeight: 300,
332
+ letterSpacing: "-0.025em",
333
+ }}
334
+ >
335
  {value}
336
+ {unit && (
337
+ <span style={{ color: "var(--rust)", fontSize: "26px", marginLeft: "2px" }}>
338
+ {unit}
339
+ </span>
340
+ )}
341
  </div>
342
  </div>
343
  );
 
367
 
368
  return (
369
  <div className="card">
370
+ <div className="flex items-baseline justify-between mb-3">
371
+ <span className="folio-chrome">Press timings</span>
372
+ </div>
373
+ <h3
374
+ className="text-ink mb-4"
375
+ style={{
376
+ fontFamily: "var(--font-fraunces)",
377
+ fontSize: "24px",
378
+ fontWeight: 500,
379
+ }}
380
+ >
381
+ Round-trip <span className="italic-display text-rust">latencies.</span>
382
+ </h3>
383
+ <div className="space-y-2.5">
384
  <Row label="browser → ping" value={pingMs ? `${pingMs.toFixed(0)} ms` : "—"} />
385
  <Row label="ask_smart (reject)" value="≈ 400 ms" />
386
+ <Row label="ask_smart (inference)" value="≈ 1.32.0 s" />
387
  <Row label="reindex (incremental)" value="≈ 350 ms" />
388
  <Row label="reindex (full)" value="≈ 30 s" />
389
  </div>
 
394
  function CorpusBreakdown({ data }: { data: HealthResponse | undefined }) {
395
  return (
396
  <div className="card">
397
+ <div className="flex items-baseline justify-between mb-3">
398
+ <span className="folio-chrome">Plates &amp; type</span>
399
+ </div>
400
+ <h3
401
+ className="text-ink mb-4"
402
+ style={{
403
+ fontFamily: "var(--font-fraunces)",
404
+ fontSize: "24px",
405
+ fontWeight: 500,
406
+ }}
407
+ >
408
+ Corpus &amp; <span className="italic-display text-rust">index.</span>
409
+ </h3>
410
+ <div className="space-y-2.5">
411
  <Row
412
  label="documents on disk"
413
  value={data ? data.doc_count.toLocaleString() : "—"}
 
428
  cost,
429
  latency,
430
  loading,
431
+ accent,
432
  onClick,
433
  }: {
434
  title: string;
 
436
  cost: string;
437
  latency: string;
438
  loading: boolean;
439
+ accent?: boolean;
440
  onClick: () => void;
441
  }) {
442
  return (
443
+ <div className={accent ? "card-rust flex flex-col gap-4" : "card flex flex-col gap-4"}>
444
  <div>
445
+ <div className="flex items-baseline justify-between">
446
+ <span className="folio-chrome--ink folio-chrome">{latency}</span>
447
+ <span className="folio-chrome">openai cost {cost}</span>
 
 
448
  </div>
449
+ <h3
450
+ className="text-ink mt-2"
451
+ style={{
452
+ fontFamily: "var(--font-fraunces)",
453
+ fontSize: "26px",
454
+ fontWeight: 500,
455
+ }}
456
+ >
457
+ {title}<span className="italic-display text-rust">.</span>
458
+ </h3>
459
+ <p className="text-caption text-ink-70 mt-1.5">{description}</p>
460
  </div>
461
  <button
462
  onClick={onClick}
463
  disabled={loading}
464
+ className={accent ? "btn-rust self-start" : "btn-primary self-start"}
 
 
 
465
  >
466
  {loading ? "Running…" : `Run ${title.toLowerCase()}`}
467
  </button>
 
471
 
472
  function Row({ label, value }: { label: string; value: string }) {
473
  return (
474
+ <div className="grid grid-cols-[1fr_auto] gap-4 items-baseline border-b border-ink/10 pb-2 last:border-0 last:pb-0">
475
+ <span className="folio-chrome">{label}</span>
476
+ <span className="font-mono text-caption text-ink truncate">{value}</span>
 
 
477
  </div>
478
  );
479
  }
components/chat/AppShell.tsx CHANGED
@@ -4,12 +4,10 @@ import { Sidebar } from "./Sidebar";
4
  import { TopBar } from "./TopBar";
5
  import { Aurora } from "./Aurora";
6
  import { useChatStore } from "@/lib/chatStore";
7
- import clsx from "clsx";
8
  import { usePathname } from "next/navigation";
9
  import { useEffect } from "react";
10
 
11
  export function AppShell({ children }: { children: React.ReactNode }) {
12
- const sidebarOpen = useChatStore((s) => s.sidebarOpen);
13
  const pathname = usePathname();
14
  const conversations = useChatStore((s) => s.conversations);
15
  const activeId = useChatStore((s) => s.activeId);
@@ -25,21 +23,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
25
 
26
  return (
27
  <div className="relative min-h-screen flex flex-col">
28
- {/* Atmospheric background — fixed, behind everything */}
29
  <Aurora />
30
-
31
  <TopBar />
32
 
33
- <div className="relative z-10 flex-1 flex overflow-hidden">
34
  <Sidebar />
35
- <main
36
- className={clsx(
37
- "flex-1 flex flex-col min-w-0 transition-[margin] duration-300 ease-atelier",
38
- sidebarOpen ? "md:ml-0" : "md:ml-0"
39
- )}
40
- >
41
- {children}
42
- </main>
43
  </div>
44
  </div>
45
  );
 
4
  import { TopBar } from "./TopBar";
5
  import { Aurora } from "./Aurora";
6
  import { useChatStore } from "@/lib/chatStore";
 
7
  import { usePathname } from "next/navigation";
8
  import { useEffect } from "react";
9
 
10
  export function AppShell({ children }: { children: React.ReactNode }) {
 
11
  const pathname = usePathname();
12
  const conversations = useChatStore((s) => s.conversations);
13
  const activeId = useChatStore((s) => s.activeId);
 
23
 
24
  return (
25
  <div className="relative min-h-screen flex flex-col">
 
26
  <Aurora />
 
27
  <TopBar />
28
 
29
+ <div className="relative z-10 flex-1 flex">
30
  <Sidebar />
31
+ <main className="flex-1 flex flex-col min-w-0">{children}</main>
 
 
 
 
 
 
 
32
  </div>
33
  </div>
34
  );
components/chat/Aurora.tsx CHANGED
@@ -1,44 +1,34 @@
1
  "use client";
2
 
3
  /**
4
- * Atmospheric backdrop — three slow-drifting aurora orbs.
5
- * Pure CSS, no JS animation. Pinned behind everything via fixed inset.
 
6
  */
7
  export function Aurora() {
8
  return (
9
- <div
10
- aria-hidden
11
- className="fixed inset-0 z-0 overflow-hidden pointer-events-none"
12
- >
13
- <div
14
- className="aurora-amber animate-aurora-1"
15
- style={{
16
- width: "70vmax",
17
- height: "70vmax",
18
- top: "-25vmax",
19
- right: "-20vmax",
20
- }}
21
- />
22
  <div
23
- className="aurora-violet animate-aurora-2"
24
- style={{
25
- width: "60vmax",
26
- height: "60vmax",
27
- bottom: "-20vmax",
28
- left: "-15vmax",
29
- }}
30
- />
31
- <div
32
- className="aurora-teal animate-aurora-1"
33
- style={{
34
- width: "40vmax",
35
- height: "40vmax",
36
- top: "40vh",
37
- left: "30vw",
38
- opacity: 0.6,
39
- animationDelay: "-8s",
40
- }}
41
- />
42
- </div>
43
  );
44
  }
 
1
  "use client";
2
 
3
  /**
4
+ * Halftone Folio backdrop.
5
+ * A printer's plate: corner register marks + bleed lines + halftone field.
6
+ * Pure decoration. Pinned behind the page.
7
  */
8
  export function Aurora() {
9
  return (
10
+ <>
11
+ {/* Page-edge register marks (printer's bleed lines) */}
 
 
 
 
 
 
 
 
 
 
 
12
  <div
13
+ aria-hidden
14
+ className="fixed inset-0 z-0 pointer-events-none hidden md:block"
15
+ >
16
+ <Cross className="top-3 left-3" />
17
+ <Cross className="top-3 right-3" />
18
+ <Cross className="bottom-3 left-3" />
19
+ <Cross className="bottom-3 right-3" />
20
+ </div>
21
+ </>
22
+ );
23
+ }
24
+
25
+ function Cross({ className }: { className?: string }) {
26
+ return (
27
+ <span
28
+ className={`absolute w-[14px] h-[14px] text-ink-30 font-mono leading-none ${className ?? ""}`}
29
+ style={{ fontSize: "13px" }}
30
+ >
31
+
32
+ </span>
33
  );
34
  }
components/chat/Composer.tsx CHANGED
@@ -4,6 +4,10 @@ import { useEffect, useRef, useState } from "react";
4
  import clsx from "clsx";
5
  import { useChatStore } from "@/lib/chatStore";
6
 
 
 
 
 
7
  export function Composer({
8
  onSubmit,
9
  disabled,
@@ -19,7 +23,6 @@ export function Composer({
19
  const ref = useRef<HTMLTextAreaElement | null>(null);
20
  const settings = useChatStore((s) => s.settings);
21
 
22
- // Autosize textarea
23
  useEffect(() => {
24
  const el = ref.current;
25
  if (!el) return;
@@ -41,71 +44,99 @@ export function Composer({
41
  }
42
  };
43
 
 
 
44
  return (
45
- <div className="sticky bottom-0 z-20 px-4 sm:px-6 pt-4 pb-5 bg-gradient-to-t from-canvas-deep via-canvas-deep/85 to-transparent">
46
- <div className="max-w-[920px] mx-auto">
47
- <div
48
- className="relative rounded-2xl glass-strong overflow-hidden transition-all duration-200"
49
- style={{ padding: "10px 12px" }}
50
- >
51
- <textarea
52
- ref={ref}
53
- value={value}
54
- onChange={(e) => setValue(e.target.value)}
55
- onKeyDown={onKey}
56
- disabled={disabled}
57
- rows={1}
58
- placeholder="Ask anything about Etiya BSS…"
59
- className="w-full bg-transparent outline-none resize-none px-3 py-2.5 text-body text-ink placeholder:text-ink-30 leading-relaxed scrollbar-none"
60
- style={{ maxHeight: 200 }}
61
- aria-label="Question"
62
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
- {/* Action row */}
65
- <div className="flex items-center justify-between mt-1.5 px-1">
66
- <div className="flex items-center gap-1">
67
- <ToolButton
68
- onClick={onOpenSettings}
69
- label="Tune retrieval & inference"
70
- icon={
71
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
72
- <circle cx="7" cy="7" r="2" stroke="currentColor" strokeWidth="1.4" />
73
- <path
74
- d="M7 1v2M7 11v2M1 7h2M11 7h2M2.8 2.8 4.2 4.2M9.8 9.8l1.4 1.4M2.8 11.2 4.2 9.8M9.8 4.2l1.4-1.4"
75
- stroke="currentColor"
76
- strokeWidth="1.3"
77
- strokeLinecap="round"
78
- />
79
- </svg>
80
- }
81
- >
82
- <span className="text-caption hidden sm:inline">Settings</span>
83
- {!isDefaults(settings) && (
84
- <span className="ml-1 inline-block w-1.5 h-1.5 rounded-full bg-amber animate-pulse-soft" />
85
- )}
86
- </ToolButton>
87
- <ToolButton
88
- onClick={onClear}
89
- label="Clear conversation"
90
- icon={
91
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
92
- <path
93
- d="M3 4h8M5.5 4V2.5h3V4M4 4l.5 8h5L10 4M6 6.5v3M8 6.5v3"
94
- stroke="currentColor"
95
- strokeWidth="1.3"
96
- strokeLinecap="round"
97
- strokeLinejoin="round"
98
- />
99
- </svg>
100
- }
101
- >
102
- <span className="text-caption hidden sm:inline">Clear</span>
103
- </ToolButton>
104
- <span className="text-micro font-mono text-ink-30 ml-2 hidden md:inline">
105
- ⏎ to send · ⇧⏎ for newline
106
- </span>
 
 
 
 
 
 
 
 
107
  </div>
108
- <SendButton onClick={submit} disabled={!value.trim() || disabled} />
109
  </div>
110
  </div>
111
  </div>
@@ -117,21 +148,29 @@ function ToolButton({
117
  onClick,
118
  icon,
119
  label,
 
120
  children,
121
  }: {
122
  onClick: () => void;
123
  icon: React.ReactNode;
124
  label: string;
 
125
  children?: React.ReactNode;
126
  }) {
127
  return (
128
  <button
129
  onClick={onClick}
130
  title={label}
131
- className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-ink-50 hover:text-ink hover:bg-glass transition-colors"
132
  >
133
  {icon}
134
- {children}
 
 
 
 
 
 
135
  </button>
136
  );
137
  }
@@ -147,18 +186,21 @@ function SendButton({
147
  <button
148
  onClick={onClick}
149
  disabled={disabled}
150
- aria-label="Send"
151
  className={clsx(
152
- "shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-full transition-all",
153
- "active:scale-[0.95]",
154
  disabled
155
- ? "bg-glass text-ink-30 cursor-not-allowed"
156
- : "bg-amber text-canvas hover:bg-amber-soft hover:shadow-amber"
157
  )}
158
  >
159
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
 
 
 
160
  <path
161
- d="M2.5 7h9M7 2.5l4.5 4.5L7 11.5"
162
  stroke="currentColor"
163
  strokeWidth="1.6"
164
  strokeLinecap="round"
 
4
  import clsx from "clsx";
5
  import { useChatStore } from "@/lib/chatStore";
6
 
7
+ /**
8
+ * Composer — the printer's submission box.
9
+ * Heavy hairline frame, italic placeholder, ink-on-paper buttons.
10
+ */
11
  export function Composer({
12
  onSubmit,
13
  disabled,
 
23
  const ref = useRef<HTMLTextAreaElement | null>(null);
24
  const settings = useChatStore((s) => s.settings);
25
 
 
26
  useEffect(() => {
27
  const el = ref.current;
28
  if (!el) return;
 
44
  }
45
  };
46
 
47
+ const tunedDot = !isDefaults(settings);
48
+
49
  return (
50
+ <div className="sticky bottom-0 z-20 px-4 sm:px-6 pt-3 pb-5 bg-gradient-to-t from-paper via-paper to-transparent">
51
+ <div className="max-w-[1040px] mx-auto">
52
+ <div className="grid grid-cols-[80px_1fr] gap-6">
53
+ {/* Margin folio */}
54
+ <div className="text-right pr-2 pt-3 hidden sm:block">
55
+ <div className="folio-chrome">Submit</div>
56
+ </div>
57
+
58
+ {/* Composer body */}
59
+ <div
60
+ className="rounded-md transition-shadow duration-200"
61
+ style={{
62
+ background: "var(--paper)",
63
+ boxShadow:
64
+ "0 0 0 1.5px rgba(25,23,19,0.30), 0 6px 24px -8px rgba(25,23,19,0.18)",
65
+ }}
66
+ >
67
+ <textarea
68
+ ref={ref}
69
+ value={value}
70
+ onChange={(e) => setValue(e.target.value)}
71
+ onKeyDown={onKey}
72
+ disabled={disabled}
73
+ rows={1}
74
+ placeholder="Pose your question…"
75
+ className={clsx(
76
+ "w-full bg-transparent outline-none resize-none px-5 pt-4 pb-2 leading-relaxed scrollbar-none",
77
+ "text-ink placeholder:italic placeholder:text-ink-50",
78
+ "text-[18px]"
79
+ )}
80
+ style={{
81
+ fontFamily: "var(--font-fraunces), ui-serif, Georgia, serif",
82
+ fontWeight: 400,
83
+ maxHeight: 200,
84
+ }}
85
+ aria-label="Question"
86
+ />
87
 
88
+ <div className="flex items-center justify-between px-3 pb-2.5 pt-1 border-t border-ink/10">
89
+ <div className="flex items-center gap-1">
90
+ <ToolButton
91
+ onClick={onOpenSettings}
92
+ label="Tune retrieval & generation"
93
+ hasDot={tunedDot}
94
+ icon={
95
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
96
+ <circle
97
+ cx="6.5"
98
+ cy="6.5"
99
+ r="2"
100
+ stroke="currentColor"
101
+ strokeWidth="1.4"
102
+ />
103
+ <path
104
+ d="M6.5 1v2M6.5 10v2M1 6.5h2M10 6.5h2M2.6 2.6 4 4M9 9l1.4 1.4M2.6 10.4 4 9M9 4l1.4-1.4"
105
+ stroke="currentColor"
106
+ strokeWidth="1.3"
107
+ strokeLinecap="round"
108
+ />
109
+ </svg>
110
+ }
111
+ >
112
+ Edit settings
113
+ </ToolButton>
114
+ <ToolButton
115
+ onClick={onClear}
116
+ label="Clear transcript"
117
+ icon={
118
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
119
+ <path
120
+ d="M2.5 3.5h8M5 3.5V2h3v1.5M3.5 3.5l.5 7.5h5l.5-7.5"
121
+ stroke="currentColor"
122
+ strokeWidth="1.3"
123
+ strokeLinecap="round"
124
+ strokeLinejoin="round"
125
+ />
126
+ </svg>
127
+ }
128
+ >
129
+ Clear
130
+ </ToolButton>
131
+ <span className="folio-chrome ml-3 hidden md:inline">
132
+ ⏎ submit · ⇧⏎ newline
133
+ </span>
134
+ </div>
135
+ <SendButton
136
+ onClick={submit}
137
+ disabled={!value.trim() || disabled}
138
+ />
139
  </div>
 
140
  </div>
141
  </div>
142
  </div>
 
148
  onClick,
149
  icon,
150
  label,
151
+ hasDot,
152
  children,
153
  }: {
154
  onClick: () => void;
155
  icon: React.ReactNode;
156
  label: string;
157
+ hasDot?: boolean;
158
  children?: React.ReactNode;
159
  }) {
160
  return (
161
  <button
162
  onClick={onClick}
163
  title={label}
164
+ className="relative inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-ink-70 hover:text-ink hover:bg-ink-08 transition-colors"
165
  >
166
  {icon}
167
+ <span className="folio-chrome hidden sm:inline">{children}</span>
168
+ {hasDot && (
169
+ <span
170
+ className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-rust animate-ink-pulse"
171
+ aria-hidden
172
+ />
173
+ )}
174
  </button>
175
  );
176
  }
 
186
  <button
187
  onClick={onClick}
188
  disabled={disabled}
189
+ aria-label="Submit"
190
  className={clsx(
191
+ "shrink-0 inline-flex items-center gap-2 px-4 py-2 rounded-pill transition-all",
192
+ "active:scale-[0.97]",
193
  disabled
194
+ ? "bg-paper-deep text-ink-30 cursor-not-allowed"
195
+ : "bg-ink text-paper hover:bg-rust hover:shadow-rust"
196
  )}
197
  >
198
+ <span className="folio-chrome--ink folio-chrome" style={{ color: "inherit" }}>
199
+ Submit
200
+ </span>
201
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
202
  <path
203
+ d="M2 5.5h7M5.5 2 9 5.5 5.5 9"
204
  stroke="currentColor"
205
  strokeWidth="1.6"
206
  strokeLinecap="round"
components/chat/Empty.tsx CHANGED
@@ -7,85 +7,115 @@ const SAMPLES = [
7
  "Can a category be localized in multiple languages?",
8
  ];
9
 
 
 
 
10
  export function ChatEmpty({ onAsk }: { onAsk: (q: string) => void }) {
11
  return (
12
  <div className="relative flex-1 flex items-center justify-center px-6 py-12">
13
- <div className="max-w-[760px] w-full text-center">
14
- {/* Decorative orbital mark */}
15
- <div className="flex justify-center mb-8 animate-fade-in">
16
- <div
17
- className="relative w-16 h-16 rounded-2xl flex items-center justify-center"
 
 
 
 
 
 
18
  style={{
19
- background:
20
- "linear-gradient(135deg, rgba(255,181,69,0.28), rgba(255,181,69,0.04))",
21
- boxShadow:
22
- "0 0 0 1px rgba(255,181,69,0.40), 0 0 64px -16px rgba(255,181,69,0.55)",
23
  }}
24
  >
25
- <svg width="28" height="28" viewBox="0 0 28 28" fill="none">
26
- <path
27
- d="M14 3v4M14 21v4M3 14h4M21 14h4M6 6l3 3M19 19l3 3M6 22l3-3M19 9l3-3"
28
- stroke="#ffb545"
29
- strokeWidth="1.6"
30
- strokeLinecap="round"
31
- />
32
- <circle cx="14" cy="14" r="3" fill="#ffb545" />
33
- </svg>
34
- </div>
35
- </div>
36
-
37
- {/* Editorial headline — sans + serif italic mix */}
38
- <h1 className="text-display-xl text-ink animate-stagger-1 text-balance">
39
- Ask <span className="serif-italic text-amber">anything.</span>
40
  </h1>
41
- <p className="text-lead text-ink-70 mt-5 animate-stagger-2 text-balance">
42
- A stateless retrieval-augmented atelier over{" "}
43
- <span className="font-mono text-ink">1,166</span> Etiya BSS documents,
44
- re-tokenizing every turn — no cache, no drift.
45
- </p>
46
 
47
- {/* Sample chips */}
48
- <div className="mt-12 grid sm:grid-cols-2 gap-2.5 animate-stagger-3">
49
- {SAMPLES.map((q) => (
50
- <button
51
- key={q}
52
- onClick={() => onAsk(q)}
53
- className="group text-left rounded-md p-4 transition-all
54
- bg-glass hover:bg-glass-stronger
55
- hover:shadow-amber/30"
56
- style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.08)" }}
57
- >
58
- <div className="flex items-start gap-3">
59
- <span className="shrink-0 mt-1 w-1 h-1 rounded-full bg-amber" />
60
- <span className="text-body text-ink-70 group-hover:text-ink transition-colors">
61
- {q}
62
- </span>
63
- </div>
64
- </button>
65
- ))}
 
 
 
 
 
 
 
 
 
66
  </div>
67
 
68
- {/* Capability strip */}
69
- <div className="mt-14 flex flex-wrap items-center justify-center gap-x-8 gap-y-3 text-micro uppercase tracking-[0.18em] text-ink-50 font-mono animate-stagger-4">
70
- <Cap label="triple-gate retrieval" />
71
- <Dot />
72
- <Cap label="bge rerank" />
73
- <Dot />
74
- <Cap label="kmeans anchors" />
75
- <Dot />
76
- <Cap label="zero hardcoded text" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  </div>
78
  </div>
79
  </div>
80
  );
81
  }
82
-
83
- function Cap({ label }: { label: string }) {
84
- return <span>{label}</span>;
85
- }
86
-
87
- function Dot() {
88
- return (
89
- <span className="inline-block w-1 h-1 rounded-full bg-ink-30" aria-hidden />
90
- );
91
- }
 
7
  "Can a category be localized in multiple languages?",
8
  ];
9
 
10
+ /**
11
+ * Empty state — magazine cover for a new transcript.
12
+ */
13
  export function ChatEmpty({ onAsk }: { onAsk: (q: string) => void }) {
14
  return (
15
  <div className="relative flex-1 flex items-center justify-center px-6 py-12">
16
+ <div className="max-w-[920px] w-full">
17
+ {/* Top folio bar */}
18
+ <div className="flex items-baseline justify-between mb-10 animate-fade-in">
19
+ <span className="folio-chrome">Folio I · Transcript</span>
20
+ <span className="folio-chrome">№ 01</span>
21
+ </div>
22
+
23
+ {/* Headline — heavy editorial */}
24
+ <h1 className="animate-stagger-1">
25
+ <span
26
+ className="block font-display text-ink"
27
  style={{
28
+ fontSize: "clamp(56px, 11vw, 132px)",
29
+ lineHeight: 0.92,
30
+ fontWeight: 300,
31
+ letterSpacing: "-0.04em",
32
  }}
33
  >
34
+ Pose a
35
+ </span>
36
+ <span
37
+ className="italic-display block text-rust"
38
+ style={{
39
+ fontSize: "clamp(64px, 13vw, 156px)",
40
+ lineHeight: 0.88,
41
+ fontWeight: 300,
42
+ letterSpacing: "-0.045em",
43
+ fontVariationSettings: '"SOFT" 100, "WONK" 1, "opsz" 144',
44
+ }}
45
+ >
46
+ question.
47
+ </span>
 
48
  </h1>
 
 
 
 
 
49
 
50
+ <div className="hairline mt-10 mb-6 animate-rule" />
51
+
52
+ {/* Lede paragraph (magazine deck style) */}
53
+ <div className="grid md:grid-cols-[2fr_1fr] gap-10 items-start animate-stagger-2">
54
+ <p
55
+ className="text-ink"
56
+ style={{
57
+ fontFamily: "var(--font-fraunces)",
58
+ fontSize: "22px",
59
+ lineHeight: 1.45,
60
+ fontWeight: 300,
61
+ letterSpacing: "-0.012em",
62
+ }}
63
+ >
64
+ A stateless retrieval-augmented atelier over{" "}
65
+ <span className="italic-display text-rust">1,166</span>{" "}
66
+ Etiya BSS documents. The hypernetwork re-tokenizes its source on
67
+ every turn — no LoRA cache, no drift, only fresh thinking.
68
+ </p>
69
+ <aside className="folio-chrome--ink folio-chrome border-l border-ink/30 pl-4">
70
+ <div className="mb-2">Methods</div>
71
+ <ul className="space-y-1.5 list-none normal-case tracking-normal text-caption text-ink-70">
72
+ <li>· K-means topic anchors</li>
73
+ <li>· BM25 + dense (text-3-large)</li>
74
+ <li>· BGE rerank · RRF fusion</li>
75
+ <li>· Local refusal classifier</li>
76
+ </ul>
77
+ </aside>
78
  </div>
79
 
80
+ <div className="hairline--double mt-10 mb-8 animate-rule-2" />
81
+
82
+ {/* Sample interrogations */}
83
+ <div className="animate-stagger-3">
84
+ <div className="folio-chrome mb-4">Suggested entries</div>
85
+ <ul className="grid sm:grid-cols-2 gap-x-8 gap-y-1">
86
+ {SAMPLES.map((q, i) => (
87
+ <li key={q}>
88
+ <button
89
+ onClick={() => onAsk(q)}
90
+ className="group w-full text-left py-3 flex items-baseline gap-3
91
+ border-b border-ink/12 hover:border-rust transition-colors"
92
+ >
93
+ <span className="folio-chrome shrink-0 w-7 group-hover:text-rust transition-colors">
94
+ {String(i + 1).padStart(2, "0")}
95
+ </span>
96
+ <span
97
+ className="flex-1 text-ink group-hover:italic transition-all"
98
+ style={{
99
+ fontFamily: "var(--font-fraunces)",
100
+ fontSize: "17px",
101
+ lineHeight: 1.4,
102
+ fontWeight: 400,
103
+ }}
104
+ >
105
+ {q}
106
+ </span>
107
+ <span
108
+ className="text-rust opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0 transition-all"
109
+ aria-hidden
110
+ >
111
+
112
+ </span>
113
+ </button>
114
+ </li>
115
+ ))}
116
+ </ul>
117
  </div>
118
  </div>
119
  </div>
120
  );
121
  }
 
 
 
 
 
 
 
 
 
 
components/chat/GroundingPill.tsx CHANGED
@@ -20,7 +20,7 @@ const META: Record<
20
  ungrounded: {
21
  label: "Ungrounded",
22
  tone: "warn",
23
- hint: "Answer was generated but doesn't strongly match the source. Treat with caution.",
24
  },
25
  rejected_low_similarity: {
26
  label: "No source",
@@ -40,27 +40,21 @@ export function GroundingPill({ status }: { status: GroundingStatus }) {
40
  tone: "neutral",
41
  hint: "",
42
  };
43
- const toneCls = {
44
- ok: "bg-status-ok-glow text-status-ok",
45
- warn: "bg-status-warn-glow text-amber",
46
- err: "bg-status-err-glow text-status-err",
47
- neutral: "bg-glass text-ink-70",
48
- }[meta.tone];
49
-
50
- const ringCls = {
51
- ok: "ring-1 ring-status-ok/30",
52
- warn: "ring-1 ring-amber/35",
53
- err: "ring-1 ring-status-err/30",
54
- neutral: "ring-1 ring-glass-border",
55
  }[meta.tone];
56
 
57
  return (
58
  <span
59
  title={meta.hint}
60
  className={clsx(
61
- "inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-micro uppercase tracking-[0.12em] font-mono",
62
- toneCls,
63
- ringCls
 
64
  )}
65
  >
66
  <Dot tone={meta.tone} />
@@ -72,8 +66,8 @@ export function GroundingPill({ status }: { status: GroundingStatus }) {
72
  function Dot({ tone }: { tone: "ok" | "warn" | "err" | "neutral" }) {
73
  const cls = {
74
  ok: "bg-status-ok",
75
- warn: "bg-amber",
76
- err: "bg-status-err",
77
  neutral: "bg-ink-50",
78
  }[tone];
79
  return <span className={clsx("inline-block w-1.5 h-1.5 rounded-full", cls)} />;
 
20
  ungrounded: {
21
  label: "Ungrounded",
22
  tone: "warn",
23
+ hint: "Answer was generated but doesn't strongly match the source.",
24
  },
25
  rejected_low_similarity: {
26
  label: "No source",
 
40
  tone: "neutral",
41
  hint: "",
42
  };
43
+ const cls = {
44
+ ok: "text-status-ok ring-status-ok/40 bg-status-ok-glow",
45
+ warn: "text-status-warn ring-status-warn/40 bg-status-warn-glow",
46
+ err: "text-status-err ring-status-err/40 bg-status-err-glow",
47
+ neutral: "text-ink-70 ring-ink/24 bg-paper-deep",
 
 
 
 
 
 
 
48
  }[meta.tone];
49
 
50
  return (
51
  <span
52
  title={meta.hint}
53
  className={clsx(
54
+ "inline-flex items-center gap-1.5 rounded-pill px-2.5 py-0.5",
55
+ "folio-chrome",
56
+ "ring-1",
57
+ cls
58
  )}
59
  >
60
  <Dot tone={meta.tone} />
 
66
  function Dot({ tone }: { tone: "ok" | "warn" | "err" | "neutral" }) {
67
  const cls = {
68
  ok: "bg-status-ok",
69
+ warn: "bg-status-warn",
70
+ err: "bg-rust",
71
  neutral: "bg-ink-50",
72
  }[tone];
73
  return <span className={clsx("inline-block w-1.5 h-1.5 rounded-full", cls)} />;
components/chat/Message.tsx CHANGED
@@ -6,25 +6,50 @@ import { SourceChips } from "./SourceChips";
6
  import { useState } from "react";
7
  import clsx from "clsx";
8
 
9
- export function UserMessage({ text }: { text: string }) {
 
 
 
 
 
 
 
 
 
10
  return (
11
- <div className="flex items-start gap-3 justify-end animate-fade-up">
12
- <div className="max-w-[80%] chat-bubble-user">
13
- <span className="whitespace-pre-wrap">{text}</span>
14
  </div>
15
- <div className="shrink-0 w-7 h-7 rounded-md bg-glass flex items-center justify-center text-micro font-mono text-ink-70 mt-1"
16
- style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.10)" }}
17
- >
18
- U
 
 
 
 
 
 
 
 
 
 
19
  </div>
20
- </div>
21
  );
22
  }
23
 
24
- export function AssistantMessage({ turn }: { turn: ChatTurn }) {
 
 
 
 
 
 
25
  const r = turn.response;
26
- if (!r) return null;
27
  const [copied, setCopied] = useState(false);
 
28
 
29
  const copy = async () => {
30
  try {
@@ -35,40 +60,82 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
35
  };
36
 
37
  return (
38
- <div className="flex items-start gap-3 animate-fade-up">
39
- <Avatar />
40
- <article className="chat-bubble-assistant flex-1 min-w-0 max-w-[88%]">
41
- {/* Header: grounding + actions */}
42
- <header className="flex items-center justify-between gap-3 mb-3">
43
- <GroundingPill status={r._grounding_status} />
44
- <div className="flex items-center gap-1">
45
- <button
46
- onClick={copy}
47
- className="text-micro text-ink-50 hover:text-ink hover:bg-glass rounded-md px-2 py-1 transition-colors flex items-center gap-1"
48
- title="Copy answer"
49
- >
50
- {copied ? (
51
- <>
52
- <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
53
- <path d="M2 6 4.5 8.5 9 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
54
- </svg>
55
- copied
56
- </>
57
- ) : (
58
- <>
59
- <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
60
- <rect x="2" y="2" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.2"/>
61
- <rect x="3.5" y="3.5" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.2"/>
62
- </svg>
63
- copy
64
- </>
65
- )}
66
- </button>
67
  </div>
68
- </header>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
- {/* Answer */}
71
- <div className="text-ink leading-[1.65] whitespace-pre-wrap">
 
 
 
 
 
 
 
 
 
 
72
  {r.answer}
73
  </div>
74
 
@@ -76,98 +143,85 @@ export function AssistantMessage({ turn }: { turn: ChatTurn }) {
76
  {r.source_docs?.length > 0 && <SourceChips docs={r.source_docs} />}
77
 
78
  {/* Spec sheet */}
79
- <footer className="mt-4 pt-3 border-t border-glass-border flex flex-wrap items-center gap-x-5 gap-y-1.5 text-micro text-ink-50 font-mono uppercase tracking-[0.10em]">
80
- <Spec label="top·sim" value={r._top_similarity?.toFixed(3) ?? "—"} />
81
- <Spec label="rerank" value={r._top_rerank_score?.toFixed(2) ?? "—"} />
82
- <Spec label="anchor" value={r._anchor_score?.toFixed(3) ?? "—"} />
83
- <Spec
84
- label="retrieve"
85
- value={`${r.retrieve_seconds.toFixed(2)}s`}
86
- />
87
- <Spec
88
- label="inference"
89
- value={`${r.inference_seconds.toFixed(2)}s`}
90
- />
91
- <Spec
92
- label="total"
93
- value={`${r.total_seconds.toFixed(2)}s`}
94
- highlight
95
- />
96
  </footer>
97
- </article>
98
- </div>
99
  );
100
  }
101
 
102
- export function ErrorMessage({ turn }: { turn: ChatTurn }) {
 
 
 
 
 
 
103
  const e = turn.error;
104
  if (!e) return null;
105
  return (
106
- <div className="flex items-start gap-3 animate-fade-up">
107
- <Avatar tone="err" />
108
- <article
109
- className="flex-1 min-w-0 max-w-[88%] rounded-2xl rounded-tl-sm px-5 py-4"
110
- style={{
111
- background: "rgba(255, 122, 122, 0.06)",
112
- boxShadow: "0 0 0 1px rgba(255, 122, 122, 0.28)",
113
- }}
114
- >
115
- <div className="text-micro uppercase tracking-[0.12em] text-status-err font-mono">
116
- error · http {e.status || "—"}
117
- </div>
118
- <div className="text-body-strong text-ink mt-1.5">{e.message}</div>
 
 
119
  {e.status === 502 && (
120
- <div className="text-caption text-ink-70 mt-2">
121
- Backend not reachable. Check the HF Space stage.
122
- </div>
 
123
  )}
124
- </article>
125
- </div>
126
  );
127
  }
128
 
129
  function Spec({
130
  label,
131
  value,
132
- highlight,
133
  }: {
134
  label: string;
135
  value: string;
136
- highlight?: boolean;
137
  }) {
138
  return (
139
- <span className="inline-flex items-baseline gap-1">
140
- <span>{label}</span>
141
- <span className={clsx(highlight ? "text-amber" : "text-ink-70")}>
 
 
 
 
 
 
142
  {value}
143
- </span>
144
- </span>
145
- );
146
- }
147
-
148
- function Avatar({ tone = "amber" }: { tone?: "amber" | "err" }) {
149
- const bg =
150
- tone === "err"
151
- ? "linear-gradient(135deg, rgba(255,122,122,0.95), rgba(195,70,70,1))"
152
- : "linear-gradient(135deg, rgba(255,181,69,0.95), rgba(214,138,31,1))";
153
- const ring =
154
- tone === "err"
155
- ? "0 0 0 1px rgba(255,122,122,0.45), 0 0 14px -2px rgba(255,122,122,0.4)"
156
- : "0 0 0 1px rgba(255,181,69,0.45), 0 0 14px -2px rgba(255,181,69,0.45)";
157
- return (
158
- <div
159
- className="shrink-0 w-7 h-7 rounded-md flex items-center justify-center mt-1"
160
- style={{ background: bg, boxShadow: ring }}
161
- >
162
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
163
- <path
164
- d="M2.5 7.5 4.8 9.8l6-6.6"
165
- stroke="#0a0b10"
166
- strokeWidth="1.8"
167
- strokeLinecap="round"
168
- strokeLinejoin="round"
169
- />
170
- </svg>
171
  </div>
172
  );
173
  }
 
6
  import { useState } from "react";
7
  import clsx from "clsx";
8
 
9
+ /**
10
+ * Editorial transcript layout:
11
+ * [margin folio] | [content column]
12
+ *
13
+ * User questions: italic Fraunces ledes, hairline rule beneath.
14
+ * Assistant answers: drop-cap first letter, IBM Plex body, bibliography below,
15
+ * spec sheet typeset like a magazine colophon.
16
+ */
17
+
18
+ export function UserMessage({ text, index }: { text: string; index: number }) {
19
  return (
20
+ <article className="grid grid-cols-[80px_1fr] gap-6 animate-fade-up">
21
+ <div className="text-right pr-2 pt-1">
22
+ <div className="folio-chrome">{String(index + 1).padStart(2, "0")} · Q</div>
23
  </div>
24
+ <div className="border-t border-ink/16 pt-3">
25
+ <span className="folio-chrome--ink folio-chrome">Question</span>
26
+ <p
27
+ className="italic-display text-ink mt-1.5"
28
+ style={{
29
+ fontSize: "clamp(22px, 2.6vw, 30px)",
30
+ lineHeight: 1.25,
31
+ letterSpacing: "-0.018em",
32
+ fontWeight: 400,
33
+ fontVariationSettings: '"SOFT" 30, "WONK" 1',
34
+ }}
35
+ >
36
+ {text}
37
+ </p>
38
  </div>
39
+ </article>
40
  );
41
  }
42
 
43
+ export function AssistantMessage({
44
+ turn,
45
+ index,
46
+ }: {
47
+ turn: ChatTurn;
48
+ index: number;
49
+ }) {
50
  const r = turn.response;
 
51
  const [copied, setCopied] = useState(false);
52
+ if (!r) return null;
53
 
54
  const copy = async () => {
55
  try {
 
60
  };
61
 
62
  return (
63
+ <article className="grid grid-cols-[80px_1fr] gap-6 animate-fade-up">
64
+ {/* Margin folio */}
65
+ <div className="text-right pr-2 pt-1">
66
+ <div className="folio-chrome">
67
+ {String(index + 1).padStart(2, "0")} · A
68
+ </div>
69
+ <div className="mt-3 folio-chrome text-rust">
70
+ {r.total_seconds.toFixed(2)}s
71
+ </div>
72
+ </div>
73
+
74
+ {/* Body column */}
75
+ <div className="border-t border-ink/16 pt-3">
76
+ {/* Banner */}
77
+ <div className="flex items-baseline justify-between mb-3 flex-wrap gap-2">
78
+ <div className="flex items-baseline gap-3">
79
+ <span className="folio-chrome--ink folio-chrome">Answer</span>
80
+ <GroundingPill status={r._grounding_status} />
 
 
 
 
 
 
 
 
 
 
 
81
  </div>
82
+ <button
83
+ onClick={copy}
84
+ className="folio-chrome hover:text-rust transition-colors flex items-center gap-1.5"
85
+ title="Copy answer"
86
+ >
87
+ {copied ? (
88
+ <>
89
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
90
+ <path
91
+ d="M2 6 4.5 8.5 9 3"
92
+ stroke="currentColor"
93
+ strokeWidth="1.6"
94
+ strokeLinecap="round"
95
+ />
96
+ </svg>
97
+ Copied
98
+ </>
99
+ ) : (
100
+ <>
101
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
102
+ <rect
103
+ x="2"
104
+ y="2"
105
+ width="6"
106
+ height="6"
107
+ rx="1"
108
+ stroke="currentColor"
109
+ strokeWidth="1.2"
110
+ />
111
+ <rect
112
+ x="3.5"
113
+ y="3.5"
114
+ width="6"
115
+ height="6"
116
+ rx="1"
117
+ stroke="currentColor"
118
+ strokeWidth="1.2"
119
+ />
120
+ </svg>
121
+ Copy
122
+ </>
123
+ )}
124
+ </button>
125
+ </div>
126
 
127
+ {/* Answer body — drop-cap, generous reading width */}
128
+ <div
129
+ className="drop-cap text-ink"
130
+ style={{
131
+ fontFamily: "var(--font-fraunces)",
132
+ fontSize: "20px",
133
+ lineHeight: 1.55,
134
+ fontWeight: 400,
135
+ maxWidth: "70ch",
136
+ whiteSpace: "pre-wrap",
137
+ }}
138
+ >
139
  {r.answer}
140
  </div>
141
 
 
143
  {r.source_docs?.length > 0 && <SourceChips docs={r.source_docs} />}
144
 
145
  {/* Spec sheet */}
146
+ <footer className="mt-6 pt-3 border-t border-ink/16">
147
+ <div className="folio-chrome mb-2">Colophon</div>
148
+ <div className="grid grid-cols-3 sm:grid-cols-6 gap-x-6 gap-y-3">
149
+ <Spec label="top·sim" value={r._top_similarity?.toFixed(3) ?? "—"} />
150
+ <Spec label="rerank" value={r._top_rerank_score?.toFixed(2) ?? "—"} />
151
+ <Spec label="anchor" value={r._anchor_score?.toFixed(3) ?? "—"} />
152
+ <Spec
153
+ label="retrieve"
154
+ value={`${r.retrieve_seconds.toFixed(2)}s`}
155
+ />
156
+ <Spec
157
+ label="inference"
158
+ value={`${r.inference_seconds.toFixed(2)}s`}
159
+ />
160
+ <Spec label="total" value={`${r.total_seconds.toFixed(2)}s`} accent />
161
+ </div>
 
162
  </footer>
163
+ </div>
164
+ </article>
165
  );
166
  }
167
 
168
+ export function ErrorMessage({
169
+ turn,
170
+ index,
171
+ }: {
172
+ turn: ChatTurn;
173
+ index: number;
174
+ }) {
175
  const e = turn.error;
176
  if (!e) return null;
177
  return (
178
+ <article className="grid grid-cols-[80px_1fr] gap-6 animate-fade-up">
179
+ <div className="text-right pr-2 pt-1">
180
+ <div className="folio-chrome text-rust">Errata</div>
181
+ <div className="mt-2 folio-chrome">№ {String(index + 1).padStart(2, "0")}</div>
182
+ </div>
183
+ <div className="border-t border-rust pt-3">
184
+ <span className="folio-chrome--rust folio-chrome">
185
+ HTTP {e.status || "—"}
186
+ </span>
187
+ <p
188
+ className="italic-display text-ink mt-1.5"
189
+ style={{ fontSize: "20px", lineHeight: 1.3 }}
190
+ >
191
+ {e.message}
192
+ </p>
193
  {e.status === 502 && (
194
+ <p className="text-caption text-ink-70 mt-3 max-w-prose">
195
+ The upstream Space appears to be sleeping or building. Try again in
196
+ a moment.
197
+ </p>
198
  )}
199
+ </div>
200
+ </article>
201
  );
202
  }
203
 
204
  function Spec({
205
  label,
206
  value,
207
+ accent,
208
  }: {
209
  label: string;
210
  value: string;
211
+ accent?: boolean;
212
  }) {
213
  return (
214
+ <div>
215
+ <div className="folio-chrome">{label}</div>
216
+ <div
217
+ className={clsx(
218
+ "font-mono mt-0.5",
219
+ accent ? "text-rust" : "text-ink"
220
+ )}
221
+ style={{ fontSize: "15px", fontWeight: 500 }}
222
+ >
223
  {value}
224
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  </div>
226
  );
227
  }
components/chat/SettingsDrawer.tsx CHANGED
@@ -14,78 +14,15 @@ type ParamMeta = {
14
  };
15
 
16
  const PARAMS: ParamMeta[] = [
17
- {
18
- key: "top_k",
19
- label: "Top-K retrieval",
20
- desc: "How many docs surface as context. 1 = single best; 3 = blend.",
21
- min: 1,
22
- max: 3,
23
- step: 1,
24
- },
25
- {
26
- key: "max_new_tokens",
27
- label: "Max new tokens",
28
- desc: "Generation cap. >200 risks repetition under greedy decoding.",
29
- min: 32,
30
- max: 512,
31
- step: 8,
32
- },
33
- {
34
- key: "similarity_threshold",
35
- label: "Dense similarity gate",
36
- desc: "Reject if dense cosine top-1 below this. Lower = more permissive.",
37
- min: 0,
38
- max: 1,
39
- step: 0.01,
40
- },
41
- {
42
- key: "rerank_threshold",
43
- label: "BGE rerank gate",
44
- desc: "Reject if BGE cross-encoder score below this. Higher = stricter.",
45
- min: -2,
46
- max: 5,
47
- step: 0.05,
48
- },
49
- {
50
- key: "anchor_threshold",
51
- label: "Anchor (topic) gate",
52
- desc: "Reject if question is far from every K-means topic centroid.",
53
- min: 0,
54
- max: 1,
55
- step: 0.01,
56
- },
57
- {
58
- key: "scaler",
59
- label: "doc-to-lora scaler",
60
- desc: "LoRA intensity. 0=ignore doc · 1=normal · >1 amplify · <0 invert.",
61
- min: -2,
62
- max: 2,
63
- step: 0.05,
64
- },
65
- {
66
- key: "bias_scaler",
67
- label: "Bias scaler",
68
- desc: "Bias-side LoRA intensity. Usually 1.0.",
69
- min: -2,
70
- max: 2,
71
- step: 0.05,
72
- },
73
- {
74
- key: "repetition_penalty",
75
- label: "Repetition penalty",
76
- desc: "1.0 = off. 1.1–1.2 cuts loops without hurting fluency.",
77
- min: 1,
78
- max: 2,
79
- step: 0.05,
80
- },
81
- {
82
- key: "no_repeat_ngram_size",
83
- label: "No-repeat n-gram",
84
- desc: "Forbid same n-gram twice. 0=off. 4=cuts most loops.",
85
- min: 0,
86
- max: 8,
87
- step: 1,
88
- },
89
  ];
90
 
91
  export function SettingsDrawer({
@@ -99,7 +36,6 @@ export function SettingsDrawer({
99
  const setSettings = useChatStore((s) => s.setSettings);
100
  const reset = useChatStore((s) => s.resetSettings);
101
 
102
- // ESC to close
103
  useEffect(() => {
104
  if (!open) return;
105
  const onKey = (e: KeyboardEvent) => {
@@ -111,52 +47,65 @@ export function SettingsDrawer({
111
 
112
  return (
113
  <>
114
- {/* Scrim */}
115
  <div
116
  className={clsx(
117
- "fixed inset-0 z-40 bg-canvas-deep/60 backdrop-blur-sm transition-opacity duration-300",
118
  open ? "opacity-100" : "opacity-0 pointer-events-none"
119
  )}
 
120
  onClick={onClose}
121
  aria-hidden
122
  />
123
- {/* Panel */}
124
  <aside
125
  className={clsx(
126
- "fixed top-0 right-0 z-50 h-screen w-full sm:w-[460px] glass-strong",
127
  "flex flex-col transition-transform duration-300 ease-atelier",
128
  open ? "translate-x-0" : "translate-x-full"
129
  )}
 
 
 
 
130
  role="dialog"
131
  aria-modal="true"
132
  aria-label="Inference settings"
133
  >
134
- <header className="px-6 py-5 flex items-center justify-between border-b border-glass-border">
135
- <div>
136
- <div className="text-display-sm">Inference settings</div>
137
- <div className="text-caption text-ink-50 mt-0.5">
138
- Tune retrieval gates and generation in real time.
139
- </div>
 
 
 
 
 
 
 
 
 
 
 
140
  </div>
141
- <button
142
- onClick={onClose}
143
- className="p-2 rounded-md text-ink-50 hover:text-ink hover:bg-glass transition-colors"
144
- aria-label="Close settings"
145
  >
146
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
147
- <path
148
- d="m3 3 8 8M11 3l-8 8"
149
- stroke="currentColor"
150
- strokeWidth="1.5"
151
- strokeLinecap="round"
152
- />
153
- </svg>
154
- </button>
155
  </header>
156
 
157
- <div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
158
- {/* use_grounding toggle */}
159
- <label className="flex items-start gap-3 p-3 rounded-md bg-glass cursor-pointer hover:bg-glass-stronger transition-colors">
 
 
 
160
  <Toggle
161
  checked={settings.use_grounding}
162
  onChange={(v) => setSettings({ ...settings, use_grounding: v })}
@@ -165,7 +114,7 @@ export function SettingsDrawer({
165
  <div className="text-body-strong text-ink">
166
  Wrap with grounding instruction
167
  </div>
168
- <div className="text-caption text-ink-50 mt-0.5">
169
  Forces the model to refuse if context is insufficient.
170
  </div>
171
  </div>
@@ -183,10 +132,10 @@ export function SettingsDrawer({
183
  ))}
184
  </div>
185
 
186
- <footer className="px-6 py-4 border-t border-glass-border flex items-center justify-between">
187
  <button
188
  onClick={reset}
189
- className="text-caption text-ink-70 hover:text-ink transition-colors flex items-center gap-1.5"
190
  >
191
  <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
192
  <path
@@ -199,7 +148,7 @@ export function SettingsDrawer({
199
  </svg>
200
  Reset to defaults
201
  </button>
202
- <button onClick={onClose} className="btn-primary text-caption">
203
  Done
204
  </button>
205
  </footer>
@@ -217,22 +166,21 @@ function ParamSlider({
217
  value: number;
218
  onChange: (v: number) => void;
219
  }) {
220
- const isDefault =
221
- (DEFAULT_SETTINGS[meta.key] as number) === value;
222
- const displayValue =
223
  meta.step < 1 ? value.toFixed(2) : Math.round(value).toString();
224
 
225
  return (
226
  <div>
227
- <div className="flex items-center justify-between mb-1.5">
228
  <label className="text-body-strong text-ink">{meta.label}</label>
229
  <span
230
  className={clsx(
231
- "text-caption font-mono",
232
- isDefault ? "text-ink-50" : "text-amber"
233
  )}
234
  >
235
- {displayValue}
236
  </span>
237
  </div>
238
  <input
@@ -242,10 +190,10 @@ function ParamSlider({
242
  step={meta.step}
243
  value={value}
244
  onChange={(e) => onChange(parseFloat(e.target.value))}
245
- className="w-full accent-amber"
246
  aria-label={meta.label}
247
  />
248
- <p className="text-micro text-ink-50 mt-1.5">{meta.desc}</p>
249
  </div>
250
  );
251
  }
@@ -265,15 +213,16 @@ function Toggle({
265
  onClick={() => onChange(!checked)}
266
  className={clsx(
267
  "relative w-9 h-5 rounded-pill transition-colors duration-200 shrink-0 mt-0.5",
268
- checked ? "bg-amber" : "bg-glass-stronger"
269
  )}
270
- style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.10)" }}
271
  >
272
  <span
273
  className={clsx(
274
- "absolute top-0.5 w-4 h-4 rounded-full bg-canvas-deep transition-transform duration-200",
275
  checked ? "translate-x-[18px]" : "translate-x-0.5"
276
  )}
 
277
  />
278
  </button>
279
  );
 
14
  };
15
 
16
  const PARAMS: ParamMeta[] = [
17
+ { key: "top_k", label: "Top-K retrieval", desc: "How many documents surface as context. 1 = single best; 3 = blend.", min: 1, max: 3, step: 1 },
18
+ { key: "max_new_tokens", label: "Max new tokens", desc: "Generation cap. >200 risks repetition under greedy decoding.", min: 32, max: 512, step: 8 },
19
+ { key: "similarity_threshold", label: "Dense similarity gate", desc: "Reject if dense cosine top-1 below this. Lower = more permissive.", min: 0, max: 1, step: 0.01 },
20
+ { key: "rerank_threshold", label: "BGE rerank gate", desc: "Reject if cross-encoder score below this. Higher = stricter.", min: -2, max: 5, step: 0.05 },
21
+ { key: "anchor_threshold", label: "Anchor (topic) gate", desc: "Reject if question is far from every K-means topic centroid.", min: 0, max: 1, step: 0.01 },
22
+ { key: "scaler", label: "doc-to-lora scaler", desc: "LoRA intensity. 0=ignore doc · 1=normal · >1 amplify · <0 invert.", min: -2, max: 2, step: 0.05 },
23
+ { key: "bias_scaler", label: "Bias scaler", desc: "Bias-side LoRA intensity. Usually 1.0.", min: -2, max: 2, step: 0.05 },
24
+ { key: "repetition_penalty", label: "Repetition penalty", desc: "1.0 = off. 1.1–1.2 cuts loops without hurting fluency.", min: 1, max: 2, step: 0.05 },
25
+ { key: "no_repeat_ngram_size", label: "No-repeat n-gram", desc: "Forbid same n-gram twice. 0=off. 4=cuts most loops.", min: 0, max: 8, step: 1 },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  ];
27
 
28
  export function SettingsDrawer({
 
36
  const setSettings = useChatStore((s) => s.setSettings);
37
  const reset = useChatStore((s) => s.resetSettings);
38
 
 
39
  useEffect(() => {
40
  if (!open) return;
41
  const onKey = (e: KeyboardEvent) => {
 
47
 
48
  return (
49
  <>
 
50
  <div
51
  className={clsx(
52
+ "fixed inset-0 z-40 transition-opacity duration-300",
53
  open ? "opacity-100" : "opacity-0 pointer-events-none"
54
  )}
55
+ style={{ background: "rgba(25,23,19,0.20)" }}
56
  onClick={onClose}
57
  aria-hidden
58
  />
 
59
  <aside
60
  className={clsx(
61
+ "fixed top-0 right-0 z-50 h-screen w-full sm:w-[480px]",
62
  "flex flex-col transition-transform duration-300 ease-atelier",
63
  open ? "translate-x-0" : "translate-x-full"
64
  )}
65
+ style={{
66
+ background: "var(--paper-deep)",
67
+ boxShadow: "-12px 0 48px -12px rgba(25,23,19,0.20), -1px 0 0 rgba(25,23,19,0.20)",
68
+ }}
69
  role="dialog"
70
  aria-modal="true"
71
  aria-label="Inference settings"
72
  >
73
+ <header className="px-7 pt-7 pb-5 border-b border-ink/16">
74
+ <div className="flex items-baseline justify-between mb-2">
75
+ <span className="folio-chrome">Workshop · Settings</span>
76
+ <button
77
+ onClick={onClose}
78
+ className="p-1.5 -mr-1.5 rounded-md text-ink-50 hover:text-ink hover:bg-ink-08 transition-colors"
79
+ aria-label="Close"
80
+ >
81
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
82
+ <path
83
+ d="m3 3 7 7M10 3l-7 7"
84
+ stroke="currentColor"
85
+ strokeWidth="1.5"
86
+ strokeLinecap="round"
87
+ />
88
+ </svg>
89
+ </button>
90
  </div>
91
+ <h2
92
+ className="italic-display text-ink"
93
+ style={{ fontSize: "32px", lineHeight: 1.15 }}
 
94
  >
95
+ Tune the <span className="text-rust">inference.</span>
96
+ </h2>
97
+ <p className="text-caption text-ink-70 mt-2 max-w-[36ch]">
98
+ Adjust retrieval gates and generation behaviour. Changes take
99
+ effect on the next submitted question.
100
+ </p>
 
 
 
101
  </header>
102
 
103
+ <div className="flex-1 overflow-y-auto px-7 py-6 space-y-6">
104
+ {/* Grounding toggle */}
105
+ <label
106
+ className="flex items-start gap-3 p-3 -mx-1 rounded-md cursor-pointer hover:bg-ink-08 transition-colors"
107
+ style={{ boxShadow: "0 0 0 1px rgba(25,23,19,0.12)" }}
108
+ >
109
  <Toggle
110
  checked={settings.use_grounding}
111
  onChange={(v) => setSettings({ ...settings, use_grounding: v })}
 
114
  <div className="text-body-strong text-ink">
115
  Wrap with grounding instruction
116
  </div>
117
+ <div className="text-caption text-ink-70 mt-0.5">
118
  Forces the model to refuse if context is insufficient.
119
  </div>
120
  </div>
 
132
  ))}
133
  </div>
134
 
135
+ <footer className="px-7 py-4 border-t border-ink/16 flex items-center justify-between">
136
  <button
137
  onClick={reset}
138
+ className="folio-chrome flex items-center gap-1.5 hover:text-rust transition-colors"
139
  >
140
  <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
141
  <path
 
148
  </svg>
149
  Reset to defaults
150
  </button>
151
+ <button onClick={onClose} className="btn-primary">
152
  Done
153
  </button>
154
  </footer>
 
166
  value: number;
167
  onChange: (v: number) => void;
168
  }) {
169
+ const isDefault = (DEFAULT_SETTINGS[meta.key] as number) === value;
170
+ const display =
 
171
  meta.step < 1 ? value.toFixed(2) : Math.round(value).toString();
172
 
173
  return (
174
  <div>
175
+ <div className="flex items-baseline justify-between mb-1.5">
176
  <label className="text-body-strong text-ink">{meta.label}</label>
177
  <span
178
  className={clsx(
179
+ "font-mono text-caption",
180
+ isDefault ? "text-ink-50" : "text-rust"
181
  )}
182
  >
183
+ {display}
184
  </span>
185
  </div>
186
  <input
 
190
  step={meta.step}
191
  value={value}
192
  onChange={(e) => onChange(parseFloat(e.target.value))}
193
+ className="w-full accent-rust"
194
  aria-label={meta.label}
195
  />
196
+ <p className="text-caption text-ink-70 mt-1.5">{meta.desc}</p>
197
  </div>
198
  );
199
  }
 
213
  onClick={() => onChange(!checked)}
214
  className={clsx(
215
  "relative w-9 h-5 rounded-pill transition-colors duration-200 shrink-0 mt-0.5",
216
+ checked ? "bg-rust" : "bg-paper-edge"
217
  )}
218
+ style={{ boxShadow: "inset 0 0 0 1px rgba(25,23,19,0.18)" }}
219
  >
220
  <span
221
  className={clsx(
222
+ "absolute top-0.5 w-4 h-4 rounded-full bg-paper transition-transform duration-200",
223
  checked ? "translate-x-[18px]" : "translate-x-0.5"
224
  )}
225
+ style={{ boxShadow: "0 1px 2px rgba(25,23,19,0.25)" }}
226
  />
227
  </button>
228
  );
components/chat/Sidebar.tsx CHANGED
@@ -5,6 +5,10 @@ import { usePathname } from "next/navigation";
5
  import { useChatStore } from "@/lib/chatStore";
6
  import clsx from "clsx";
7
 
 
 
 
 
8
  export function Sidebar() {
9
  const pathname = usePathname();
10
  const sidebarOpen = useChatStore((s) => s.sidebarOpen);
@@ -18,16 +22,39 @@ export function Sidebar() {
18
  return (
19
  <aside
20
  className={clsx(
21
- "shrink-0 h-[calc(100vh-3.5rem)] sticky top-14 z-20",
22
  "transition-[width,opacity] duration-300 ease-atelier",
23
  sidebarOpen
24
- ? "w-[280px] opacity-100"
25
  : "w-0 opacity-0 pointer-events-none"
26
  )}
 
 
 
 
27
  >
28
- <div className="h-full overflow-hidden border-r border-glass-border bg-canvas-deep/40 backdrop-blur-glass flex flex-col">
29
- {/* New chat */}
30
- <div className="p-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  <button
32
  onClick={() => {
33
  newConversation();
@@ -36,129 +63,161 @@ export function Sidebar() {
36
  window.dispatchEvent(new PopStateEvent("popstate"));
37
  }
38
  }}
39
- className="w-full flex items-center gap-2.5 px-4 py-3 rounded-md
40
- bg-amber/10 text-amber
41
- hover:bg-amber/15 transition-colors
42
- text-body-strong"
43
- style={{ boxShadow: "0 0 0 1px rgba(255,181,69,0.28)" }}
44
  >
45
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
46
- <path
47
- d="M7 2v10M2 7h10"
48
- stroke="currentColor"
49
- strokeWidth="1.6"
50
- strokeLinecap="round"
51
- />
52
- </svg>
53
- <span>New conversation</span>
 
 
 
54
  </button>
55
  </div>
56
 
57
- {/* Sections */}
58
- <nav className="px-3 pb-3 flex flex-col gap-0.5">
59
- <NavLink
60
- href="/"
61
- active={pathname === "/"}
62
- label="Chat"
63
- icon={<IconSpark />}
64
- />
65
  <NavLink
66
  href="/documents"
67
  active={pathname.startsWith("/documents")}
68
- label="Documents"
69
- icon={<IconDoc />}
70
  />
71
  <NavLink
72
  href="/system"
73
  active={pathname.startsWith("/system")}
74
- label="System"
75
- icon={<IconPulse />}
76
  />
77
  </nav>
78
 
79
- <div className="hairline mx-4 my-2" />
80
 
81
- {/* Conversation history */}
82
- <div className="px-3 flex-1 overflow-y-auto">
83
- <div className="px-3 py-2 text-micro uppercase tracking-[0.15em] text-ink-50">
84
- Recent
85
- </div>
86
- {list.length === 0 && (
87
- <div className="px-3 py-4 text-caption text-ink-50">
88
- No conversations yet.
89
  </div>
90
- )}
91
- <ul className="flex flex-col gap-0.5">
92
- {list.map((c) => (
93
- <li key={c.id} className="group relative">
94
- <button
95
- onClick={() => {
96
- openConversation(c.id);
97
- if (pathname !== "/") {
98
- window.history.pushState(null, "", "/");
99
- window.dispatchEvent(new PopStateEvent("popstate"));
100
- }
101
- if (window.innerWidth < 768) setSidebarOpen(false);
102
- }}
103
- className={clsx(
104
- "w-full text-left px-3 py-2 rounded-md transition-colors",
105
- "flex items-start gap-2 min-w-0",
106
- activeId === c.id && pathname === "/"
107
- ? "bg-glass-stronger text-ink"
108
- : "text-ink-70 hover:text-ink hover:bg-glass"
109
- )}
110
- >
111
- <span className="text-caption truncate flex-1 min-w-0">
112
- {c.title}
113
- </span>
114
- <span className="text-micro text-ink-30 font-mono shrink-0 mt-0.5">
115
- {formatRelative(c.updatedAt)}
116
- </span>
117
- </button>
118
- <button
119
- onClick={(e) => {
120
- e.stopPropagation();
121
- if (window.confirm(`Delete "${c.title}"?`)) {
122
- deleteConversation(c.id);
123
- }
124
- }}
125
- className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100
126
- p-1 rounded text-ink-50 hover:text-status-err hover:bg-status-err-glow
127
- transition-all"
128
- aria-label="Delete conversation"
129
- title="Delete"
130
- >
131
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
132
- <path
133
- d="M3 3l6 6M9 3l-6 6"
134
- stroke="currentColor"
135
- strokeWidth="1.4"
136
- strokeLinecap="round"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  />
138
- </svg>
139
- </button>
140
- </li>
141
- ))}
142
- </ul>
143
  </div>
144
 
145
- {/* Footer */}
146
- <div className="px-4 py-3 border-t border-glass-border">
 
 
 
147
  <a
148
  href="https://huggingface.co/spaces/Etiya/d2l-api"
149
  target="_blank"
150
  rel="noopener noreferrer"
151
- className="flex items-center justify-between text-micro uppercase tracking-[0.12em] text-ink-50 hover:text-ink-70 transition-colors"
152
  >
153
- <span>HF Space · backend</span>
154
- <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
155
- <path
156
- d="M3 3h4v4M7 3 3 7"
157
- stroke="currentColor"
158
- strokeWidth="1.3"
159
- strokeLinecap="round"
160
- />
161
- </svg>
162
  </a>
163
  </div>
164
  </div>
@@ -169,26 +228,52 @@ export function Sidebar() {
169
  function NavLink({
170
  href,
171
  active,
 
172
  label,
173
- icon,
174
  }: {
175
  href: string;
176
  active: boolean;
 
177
  label: string;
178
- icon: React.ReactNode;
179
  }) {
180
  return (
181
  <Link
182
  href={href}
183
  className={clsx(
184
- "flex items-center gap-3 px-3 py-2 rounded-md transition-all",
185
- active
186
- ? "bg-glass-stronger text-ink"
187
- : "text-ink-70 hover:text-ink hover:bg-glass"
188
  )}
189
  >
190
- <span className={clsx("w-4 h-4", active && "text-amber")}>{icon}</span>
191
- <span className="text-body">{label}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  </Link>
193
  );
194
  }
@@ -196,51 +281,14 @@ function NavLink({
196
  function formatRelative(ts: number): string {
197
  const diff = Date.now() - ts;
198
  const min = Math.floor(diff / 60_000);
199
- if (min < 1) return "now";
200
- if (min < 60) return `${min}m`;
201
  const hr = Math.floor(min / 60);
202
- if (hr < 24) return `${hr}h`;
203
  const d = Math.floor(hr / 24);
204
- if (d < 7) return `${d}d`;
205
- return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" });
206
- }
207
-
208
- function IconSpark() {
209
- return (
210
- <svg viewBox="0 0 16 16" fill="none" className="w-full h-full">
211
- <path
212
- d="M8 1.5v3M8 11.5v3M1.5 8h3M11.5 8h3M3.5 3.5l2 2M10.5 10.5l2 2M3.5 12.5l2-2M10.5 5.5l2-2"
213
- stroke="currentColor"
214
- strokeWidth="1.4"
215
- strokeLinecap="round"
216
- />
217
- </svg>
218
- );
219
- }
220
-
221
- function IconDoc() {
222
- return (
223
- <svg viewBox="0 0 16 16" fill="none" className="w-full h-full">
224
- <path
225
- d="M3.5 1.5h6l3.5 3.5v9a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5v-12a.5.5 0 0 1 .5-.5z"
226
- stroke="currentColor"
227
- strokeWidth="1.4"
228
- />
229
- <path d="M9.5 1.5V5h3.5" stroke="currentColor" strokeWidth="1.4" />
230
- </svg>
231
- );
232
- }
233
-
234
- function IconPulse() {
235
- return (
236
- <svg viewBox="0 0 16 16" fill="none" className="w-full h-full">
237
- <path
238
- d="M1.5 8h3l1.5-4 3 8L10.5 8h4"
239
- stroke="currentColor"
240
- strokeWidth="1.4"
241
- strokeLinecap="round"
242
- strokeLinejoin="round"
243
- />
244
- </svg>
245
- );
246
  }
 
5
  import { useChatStore } from "@/lib/chatStore";
6
  import clsx from "clsx";
7
 
8
+ /**
9
+ * Catalog sidebar — magazine table-of-contents vibe.
10
+ * Each conversation is a folio entry, numbered, with a hairline rule below.
11
+ */
12
  export function Sidebar() {
13
  const pathname = usePathname();
14
  const sidebarOpen = useChatStore((s) => s.sidebarOpen);
 
22
  return (
23
  <aside
24
  className={clsx(
25
+ "shrink-0 sticky z-20 self-start",
26
  "transition-[width,opacity] duration-300 ease-atelier",
27
  sidebarOpen
28
+ ? "w-[300px] opacity-100"
29
  : "w-0 opacity-0 pointer-events-none"
30
  )}
31
+ style={{
32
+ top: "calc(var(--topbar-h, 88px))",
33
+ height: "calc(100vh - var(--topbar-h, 88px))",
34
+ }}
35
  >
36
+ <div
37
+ className="h-full overflow-hidden flex flex-col"
38
+ style={{
39
+ background: "var(--paper-deep)",
40
+ boxShadow: "1px 0 0 rgba(25,23,19,0.12)",
41
+ }}
42
+ >
43
+ {/* Header: Catalog */}
44
+ <div className="px-5 pt-5 pb-3">
45
+ <div className="folio-chrome">Catalog</div>
46
+ <h2
47
+ className="italic-display text-ink mt-1"
48
+ style={{ fontSize: "26px", lineHeight: 1.05 }}
49
+ >
50
+ Recent <span className="not-italic font-display font-medium not-italic-fix">interrogations.</span>
51
+ </h2>
52
+ </div>
53
+
54
+ <div className="hairline mx-5" />
55
+
56
+ {/* New conversation */}
57
+ <div className="px-5 py-4">
58
  <button
59
  onClick={() => {
60
  newConversation();
 
63
  window.dispatchEvent(new PopStateEvent("popstate"));
64
  }
65
  }}
66
+ className="group w-full flex items-center justify-between
67
+ py-2.5 px-3 rounded-md
68
+ text-ink hover:text-paper hover:bg-ink
69
+ transition-all"
70
+ style={{ boxShadow: "0 0 0 1px rgba(25,23,19,0.18)" }}
71
  >
72
+ <span className="flex items-center gap-2.5">
73
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
74
+ <path
75
+ d="M6.5 1.5v10M1.5 6.5h10"
76
+ stroke="currentColor"
77
+ strokeWidth="1.4"
78
+ strokeLinecap="round"
79
+ />
80
+ </svg>
81
+ <span className="text-body-strong">Begin new transcript</span>
82
+ </span>
83
+ <span className="folio-chrome group-hover:text-paper transition-colors">⌘N</span>
84
  </button>
85
  </div>
86
 
87
+ {/* Sections (folio nav) */}
88
+ <nav className="px-3 pb-2 flex flex-col">
89
+ <NavLink href="/" active={pathname === "/"} folio="I" label="Transcript" />
 
 
 
 
 
90
  <NavLink
91
  href="/documents"
92
  active={pathname.startsWith("/documents")}
93
+ folio="II"
94
+ label="Library"
95
  />
96
  <NavLink
97
  href="/system"
98
  active={pathname.startsWith("/system")}
99
+ folio="III"
100
+ label="Colophon"
101
  />
102
  </nav>
103
 
104
+ <div className="hairline mx-5 my-2" />
105
 
106
+ {/* Recent transcripts */}
107
+ <div className="flex-1 overflow-y-auto px-3 pt-2 pb-4">
108
+ <div className="px-2 pb-2 folio-chrome">Index of transcripts</div>
109
+ {list.length === 0 ? (
110
+ <div className="px-3 py-3 text-caption text-ink-50 italic">
111
+ <span className="italic-display">No interrogations yet.</span>
 
 
112
  </div>
113
+ ) : (
114
+ <ul>
115
+ {list.map((c, idx) => {
116
+ const folio = String(idx + 1).padStart(2, "0");
117
+ const active = activeId === c.id && pathname === "/";
118
+ return (
119
+ <li key={c.id} className="relative group">
120
+ <button
121
+ onClick={() => {
122
+ openConversation(c.id);
123
+ if (pathname !== "/") {
124
+ window.history.pushState(null, "", "/");
125
+ window.dispatchEvent(new PopStateEvent("popstate"));
126
+ }
127
+ if (window.innerWidth < 768) setSidebarOpen(false);
128
+ }}
129
+ className={clsx(
130
+ "w-full text-left px-2 py-3 transition-colors flex items-start gap-3 rounded-sm",
131
+ active
132
+ ? "bg-ink text-paper"
133
+ : "text-ink hover:bg-ink-08"
134
+ )}
135
+ >
136
+ <span
137
+ className={clsx(
138
+ "shrink-0 mt-0.5 font-mono",
139
+ active ? "text-paper/70" : "text-rust"
140
+ )}
141
+ style={{
142
+ fontSize: "10px",
143
+ letterSpacing: "0.18em",
144
+ fontWeight: 600,
145
+ }}
146
+ >
147
+ № {folio}
148
+ </span>
149
+ <span className="min-w-0 flex-1">
150
+ <span
151
+ className={clsx(
152
+ "block truncate text-body",
153
+ active ? "text-paper" : "text-ink"
154
+ )}
155
+ style={{ fontWeight: active ? 500 : 400 }}
156
+ >
157
+ {c.title}
158
+ </span>
159
+ <span
160
+ className={clsx(
161
+ "block mt-0.5 folio-chrome",
162
+ active && "text-paper/60"
163
+ )}
164
+ >
165
+ {formatRelative(c.updatedAt)} · {c.turns.length}{" "}
166
+ turn{c.turns.length === 1 ? "" : "s"}
167
+ </span>
168
+ </span>
169
+ </button>
170
+ <button
171
+ onClick={(e) => {
172
+ e.stopPropagation();
173
+ if (window.confirm(`Delete "${c.title}"?`)) {
174
+ deleteConversation(c.id);
175
+ }
176
+ }}
177
+ className={clsx(
178
+ "absolute right-2 top-3 opacity-0 group-hover:opacity-100",
179
+ "p-1 rounded text-ink-50 hover:text-rust hover:bg-rust-glow",
180
+ "transition-all",
181
+ active && "text-paper/60 hover:text-paper hover:bg-rust"
182
+ )}
183
+ aria-label="Delete transcript"
184
+ title="Delete"
185
+ >
186
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
187
+ <path
188
+ d="M2.5 2.5l6 6M8.5 2.5l-6 6"
189
+ stroke="currentColor"
190
+ strokeWidth="1.4"
191
+ strokeLinecap="round"
192
+ />
193
+ </svg>
194
+ </button>
195
+ <div
196
+ className={clsx(
197
+ "h-px mx-2",
198
+ active ? "bg-transparent" : "bg-ink/12"
199
+ )}
200
  />
201
+ </li>
202
+ );
203
+ })}
204
+ </ul>
205
+ )}
206
  </div>
207
 
208
+ {/* Footer colophon */}
209
+ <div
210
+ className="px-5 py-4 border-t border-ink/12"
211
+ style={{ background: "rgba(0,0,0,0.02)" }}
212
+ >
213
  <a
214
  href="https://huggingface.co/spaces/Etiya/d2l-api"
215
  target="_blank"
216
  rel="noopener noreferrer"
217
+ className="folio-chrome flex items-center justify-between hover:text-rust transition-colors"
218
  >
219
+ <span>Imprint · HF Space</span>
220
+ <span aria-hidden>↗</span>
 
 
 
 
 
 
 
221
  </a>
222
  </div>
223
  </div>
 
228
  function NavLink({
229
  href,
230
  active,
231
+ folio,
232
  label,
 
233
  }: {
234
  href: string;
235
  active: boolean;
236
+ folio: string;
237
  label: string;
 
238
  }) {
239
  return (
240
  <Link
241
  href={href}
242
  className={clsx(
243
+ "group flex items-baseline gap-3 px-3 py-2 rounded-sm transition-colors",
244
+ active ? "text-rust" : "text-ink hover:text-rust"
 
 
245
  )}
246
  >
247
+ <span
248
+ className="folio-chrome shrink-0 w-7"
249
+ style={{ color: active ? "var(--rust)" : undefined }}
250
+ >
251
+ {folio}
252
+ </span>
253
+ <span
254
+ className={clsx(
255
+ "flex-1 transition-all",
256
+ active ? "italic" : "group-hover:italic"
257
+ )}
258
+ style={{
259
+ fontFamily: active ? "var(--font-fraunces)" : "var(--font-plex)",
260
+ fontSize: active ? "20px" : "16px",
261
+ fontWeight: active ? 400 : 500,
262
+ lineHeight: 1.1,
263
+ fontVariationSettings: active ? '"SOFT" 30, "WONK" 1' : undefined,
264
+ }}
265
+ >
266
+ {label}{active && "."}
267
+ </span>
268
+ <span
269
+ className={clsx(
270
+ "transition-transform shrink-0 text-rust",
271
+ active ? "translate-x-0 opacity-100" : "-translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100"
272
+ )}
273
+ aria-hidden
274
+ >
275
+
276
+ </span>
277
  </Link>
278
  );
279
  }
 
281
  function formatRelative(ts: number): string {
282
  const diff = Date.now() - ts;
283
  const min = Math.floor(diff / 60_000);
284
+ if (min < 1) return "just now";
285
+ if (min < 60) return `${min}m ago`;
286
  const hr = Math.floor(min / 60);
287
+ if (hr < 24) return `${hr}h ago`;
288
  const d = Math.floor(hr / 24);
289
+ if (d < 7) return `${d}d ago`;
290
+ return new Date(ts).toLocaleDateString(undefined, {
291
+ month: "short",
292
+ day: "numeric",
293
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  }
components/chat/SourceChips.tsx CHANGED
@@ -4,46 +4,51 @@ import type { SourceDoc } from "@/lib/types";
4
  import { useState } from "react";
5
  import clsx from "clsx";
6
 
 
 
 
 
7
  export function SourceChips({ docs }: { docs: SourceDoc[] }) {
8
  const [expanded, setExpanded] = useState<string | null>(null);
9
  if (!docs?.length) return null;
10
 
11
  return (
12
- <div className="mt-4 flex flex-col gap-2">
13
- <div className="text-micro uppercase tracking-[0.12em] text-ink-50 font-mono">
14
- Sources · {docs.length}
 
15
  </div>
16
- <ul className="flex flex-col gap-1.5">
17
- {docs.map((d) => {
18
  const isOpen = expanded === d.doc_id;
19
  const sim = d.similarity ?? d.dense_similarity ?? 0;
20
  return (
21
- <li key={d.doc_id}>
22
  <button
23
  onClick={() => setExpanded(isOpen ? null : d.doc_id)}
24
- className={clsx(
25
- "w-full text-left rounded-md px-3 py-2 transition-all",
26
- "flex items-center gap-3",
27
- isOpen
28
- ? "bg-glass-stronger"
29
- : "bg-glass hover:bg-glass-stronger"
30
- )}
31
- style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.07)" }}
32
  >
33
- <SimilarityRing value={sim} />
34
- <div className="min-w-0 flex-1">
35
- <div className="text-caption-strong text-ink truncate">
 
 
 
 
 
36
  {d.name}
37
- </div>
38
- <div className="text-micro font-mono text-ink-50 truncate">
39
  {d.doc_id}
40
- </div>
41
- </div>
 
42
  <span
43
  className={clsx(
44
  "text-ink-50 transition-transform shrink-0",
45
  isOpen && "rotate-180"
46
  )}
 
47
  >
48
  <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
49
  <path
@@ -56,10 +61,7 @@ export function SourceChips({ docs }: { docs: SourceDoc[] }) {
56
  </span>
57
  </button>
58
  {isOpen && (
59
- <div
60
- className="mt-1.5 px-3 py-3 rounded-md bg-canvas-deep/70 grid grid-cols-3 gap-3 animate-fade-in"
61
- style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.06)" }}
62
- >
63
  <Metric label="Cosine" value={fmt(d.similarity)} />
64
  <Metric label="Dense" value={fmt(d.dense_similarity)} />
65
  <Metric label="Rerank" value={fmt(d.rerank_score, 2)} />
@@ -69,17 +71,20 @@ export function SourceChips({ docs }: { docs: SourceDoc[] }) {
69
  );
70
  })}
71
  </ul>
72
- </div>
73
  );
74
  }
75
 
76
  function Metric({ label, value }: { label: string; value: string }) {
77
  return (
78
  <div>
79
- <div className="text-micro uppercase tracking-[0.12em] text-ink-50">
80
- {label}
 
 
 
 
81
  </div>
82
- <div className="text-body-strong font-mono text-ink mt-0.5">{value}</div>
83
  </div>
84
  );
85
  }
@@ -93,41 +98,41 @@ function SimilarityRing({ value }: { value: number }) {
93
  const v = Math.max(0, Math.min(1, value || 0));
94
  const tone =
95
  v >= 0.7
96
- ? "rgb(93, 214, 168)"
97
  : v >= 0.45
98
- ? "rgb(255, 181, 69)"
99
- : "rgba(245, 243, 236, 0.4)";
100
- const r = 9;
101
  const C = 2 * Math.PI * r;
102
  const dash = C * v;
103
  return (
104
- <svg width="22" height="22" viewBox="0 0 22 22" className="shrink-0">
105
  <circle
106
- cx="11"
107
- cy="11"
108
  r={r}
109
- stroke="rgba(255,255,255,0.10)"
110
- strokeWidth="2"
111
  fill="none"
112
  />
113
  <circle
114
- cx="11"
115
- cy="11"
116
  r={r}
117
  stroke={tone}
118
- strokeWidth="2"
119
  fill="none"
120
  strokeLinecap="round"
121
  strokeDasharray={`${dash} ${C - dash}`}
122
- transform="rotate(-90 11 11)"
123
  style={{ transition: "stroke-dasharray 0.4s cubic-bezier(0.16,1,0.3,1)" }}
124
  />
125
  <text
126
- x="11"
127
- y="13.5"
128
  textAnchor="middle"
129
- fontSize="7"
130
- fill="rgba(245, 243, 236, 0.85)"
131
  fontFamily="var(--font-mono), monospace"
132
  fontWeight="600"
133
  >
 
4
  import { useState } from "react";
5
  import clsx from "clsx";
6
 
7
+ /**
8
+ * Source list — bibliography style.
9
+ * Numbered entries, italic title, hairline below, expandable scores.
10
+ */
11
  export function SourceChips({ docs }: { docs: SourceDoc[] }) {
12
  const [expanded, setExpanded] = useState<string | null>(null);
13
  if (!docs?.length) return null;
14
 
15
  return (
16
+ <section className="mt-6">
17
+ <div className="flex items-baseline justify-between mb-2">
18
+ <span className="folio-chrome">Bibliography</span>
19
+ <span className="folio-chrome">{docs.length} entr{docs.length === 1 ? "y" : "ies"}</span>
20
  </div>
21
+ <ul className="border-t border-ink/16">
22
+ {docs.map((d, i) => {
23
  const isOpen = expanded === d.doc_id;
24
  const sim = d.similarity ?? d.dense_similarity ?? 0;
25
  return (
26
+ <li key={d.doc_id} className="border-b border-ink/12">
27
  <button
28
  onClick={() => setExpanded(isOpen ? null : d.doc_id)}
29
+ className="w-full text-left py-3 flex items-baseline gap-4 group"
 
 
 
 
 
 
 
30
  >
31
+ <span className="folio-chrome--ink folio-chrome shrink-0 w-7">
32
+ {String(i + 1).padStart(2, "0")}
33
+ </span>
34
+ <span className="min-w-0 flex-1">
35
+ <span
36
+ className="block italic-display text-ink truncate group-hover:text-rust transition-colors"
37
+ style={{ fontSize: "16px", lineHeight: 1.3 }}
38
+ >
39
  {d.name}
40
+ </span>
41
+ <span className="block mt-0.5 folio-chrome">
42
  {d.doc_id}
43
+ </span>
44
+ </span>
45
+ <SimilarityRing value={sim} />
46
  <span
47
  className={clsx(
48
  "text-ink-50 transition-transform shrink-0",
49
  isOpen && "rotate-180"
50
  )}
51
+ aria-hidden
52
  >
53
  <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
54
  <path
 
61
  </span>
62
  </button>
63
  {isOpen && (
64
+ <div className="pb-4 grid grid-cols-3 gap-6 animate-fade-in">
 
 
 
65
  <Metric label="Cosine" value={fmt(d.similarity)} />
66
  <Metric label="Dense" value={fmt(d.dense_similarity)} />
67
  <Metric label="Rerank" value={fmt(d.rerank_score, 2)} />
 
71
  );
72
  })}
73
  </ul>
74
+ </section>
75
  );
76
  }
77
 
78
  function Metric({ label, value }: { label: string; value: string }) {
79
  return (
80
  <div>
81
+ <div className="folio-chrome">{label}</div>
82
+ <div
83
+ className="font-mono text-ink mt-0.5"
84
+ style={{ fontSize: "16px", fontWeight: 500 }}
85
+ >
86
+ {value}
87
  </div>
 
88
  </div>
89
  );
90
  }
 
98
  const v = Math.max(0, Math.min(1, value || 0));
99
  const tone =
100
  v >= 0.7
101
+ ? "var(--rust)"
102
  : v >= 0.45
103
+ ? "rgba(25, 23, 19, 0.85)"
104
+ : "rgba(25, 23, 19, 0.35)";
105
+ const r = 11;
106
  const C = 2 * Math.PI * r;
107
  const dash = C * v;
108
  return (
109
+ <svg width="28" height="28" viewBox="0 0 28 28" className="shrink-0">
110
  <circle
111
+ cx="14"
112
+ cy="14"
113
  r={r}
114
+ stroke="rgba(25,23,19,0.18)"
115
+ strokeWidth="1.5"
116
  fill="none"
117
  />
118
  <circle
119
+ cx="14"
120
+ cy="14"
121
  r={r}
122
  stroke={tone}
123
+ strokeWidth="1.8"
124
  fill="none"
125
  strokeLinecap="round"
126
  strokeDasharray={`${dash} ${C - dash}`}
127
+ transform="rotate(-90 14 14)"
128
  style={{ transition: "stroke-dasharray 0.4s cubic-bezier(0.16,1,0.3,1)" }}
129
  />
130
  <text
131
+ x="14"
132
+ y="16.5"
133
  textAnchor="middle"
134
+ fontSize="8"
135
+ fill="rgba(25, 23, 19, 0.85)"
136
  fontFamily="var(--font-mono), monospace"
137
  fontWeight="600"
138
  >
components/chat/Thinking.tsx CHANGED
@@ -1,17 +1,30 @@
1
  "use client";
2
 
3
- const STAGES = ["retrieving", "reranking", "grounding", "generating"];
4
 
 
 
 
 
5
  export function Thinking() {
6
  return (
7
- <div className="flex items-start gap-3 animate-fade-in">
8
- <Avatar />
9
- <div className="chat-bubble-assistant flex-1 min-w-0">
10
- <div className="flex items-center gap-2.5">
11
- <Dots />
12
- <span className="text-caption text-ink-70 text-shimmer">
 
 
 
 
 
 
 
 
13
  {STAGES.join(" · ")}
14
  </span>
 
15
  </div>
16
  </div>
17
  </div>
@@ -22,41 +35,17 @@ function Dots() {
22
  return (
23
  <span className="inline-flex items-center gap-1">
24
  <span
25
- className="w-1.5 h-1.5 rounded-full bg-amber animate-thinking-dot"
26
  style={{ animationDelay: "0s" }}
27
  />
28
  <span
29
- className="w-1.5 h-1.5 rounded-full bg-amber animate-thinking-dot"
30
- style={{ animationDelay: "0.16s" }}
31
  />
32
  <span
33
- className="w-1.5 h-1.5 rounded-full bg-amber animate-thinking-dot"
34
- style={{ animationDelay: "0.32s" }}
35
  />
36
  </span>
37
  );
38
  }
39
-
40
- function Avatar() {
41
- return (
42
- <div
43
- className="shrink-0 w-7 h-7 rounded-md flex items-center justify-center"
44
- style={{
45
- background:
46
- "linear-gradient(135deg, rgba(255,181,69,0.95), rgba(214,138,31,1))",
47
- boxShadow:
48
- "0 0 0 1px rgba(255,181,69,0.45), 0 0 14px -2px rgba(255,181,69,0.45)",
49
- }}
50
- >
51
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
52
- <path
53
- d="M2.5 7.5 4.8 9.8l6-6.6"
54
- stroke="#0a0b10"
55
- strokeWidth="1.8"
56
- strokeLinecap="round"
57
- strokeLinejoin="round"
58
- />
59
- </svg>
60
- </div>
61
- );
62
- }
 
1
  "use client";
2
 
3
+ const STAGES = ["Retrieving", "Reranking", "Grounding", "Generating"];
4
 
5
+ /**
6
+ * Thinking placeholder. Editorial: a folio mark, italic running header,
7
+ * three pulsing dots in mono.
8
+ */
9
  export function Thinking() {
10
  return (
11
+ <div className="grid grid-cols-[80px_1fr] gap-6 animate-fade-in">
12
+ <div className="text-right pr-2 pt-1">
13
+ <div className="folio-chrome">Pending</div>
14
+ </div>
15
+ <div className="border-t border-ink/16 pt-3">
16
+ <div className="flex items-baseline gap-3">
17
+ <span
18
+ className="italic-display text-ink-50"
19
+ style={{
20
+ fontSize: "20px",
21
+ lineHeight: 1.2,
22
+ fontVariationSettings: '"SOFT" 30, "WONK" 1',
23
+ }}
24
+ >
25
  {STAGES.join(" · ")}
26
  </span>
27
+ <Dots />
28
  </div>
29
  </div>
30
  </div>
 
35
  return (
36
  <span className="inline-flex items-center gap-1">
37
  <span
38
+ className="w-1.5 h-1.5 rounded-full bg-rust animate-thinking-dot"
39
  style={{ animationDelay: "0s" }}
40
  />
41
  <span
42
+ className="w-1.5 h-1.5 rounded-full bg-rust animate-thinking-dot"
43
+ style={{ animationDelay: "0.18s" }}
44
  />
45
  <span
46
+ className="w-1.5 h-1.5 rounded-full bg-rust animate-thinking-dot"
47
+ style={{ animationDelay: "0.36s" }}
48
  />
49
  </span>
50
  );
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
components/chat/Thread.tsx CHANGED
@@ -6,7 +6,9 @@ import { AssistantMessage, ErrorMessage, UserMessage } from "./Message";
6
  import { Thinking } from "./Thinking";
7
 
8
  export function Thread() {
9
- const conv = useChatStore((s) => (s.activeId ? s.conversations[s.activeId] : null));
 
 
10
  const ref = useRef<HTMLDivElement | null>(null);
11
 
12
  // Auto-scroll to bottom on new turns
@@ -20,15 +22,36 @@ export function Thread() {
20
 
21
  return (
22
  <div ref={ref} className="flex-1 overflow-y-auto">
23
- <div className="max-w-[920px] mx-auto px-4 sm:px-6 py-8 space-y-6">
24
- {conv.turns.map((t) => (
25
- <div key={t.id} className="space-y-4">
26
- <UserMessage text={t.question} />
27
- {t.pending && <Thinking />}
28
- {t.error && <ErrorMessage turn={t} />}
29
- {t.response && <AssistantMessage turn={t} />}
 
 
 
 
 
 
 
 
 
 
30
  </div>
31
- ))}
 
 
 
 
 
 
 
 
 
 
 
32
  </div>
33
  </div>
34
  );
 
6
  import { Thinking } from "./Thinking";
7
 
8
  export function Thread() {
9
+ const conv = useChatStore((s) =>
10
+ s.activeId ? s.conversations[s.activeId] : null
11
+ );
12
  const ref = useRef<HTMLDivElement | null>(null);
13
 
14
  // Auto-scroll to bottom on new turns
 
22
 
23
  return (
24
  <div ref={ref} className="flex-1 overflow-y-auto">
25
+ <div className="max-w-[1040px] mx-auto px-6 md:px-10 py-10">
26
+ {/* Transcript header */}
27
+ <header className="grid grid-cols-[80px_1fr] gap-6 mb-8 animate-fade-in">
28
+ <div className="text-right pr-2">
29
+ <div className="folio-chrome">Transcript</div>
30
+ </div>
31
+ <div className="border-b-2 border-ink/30 pb-3 flex items-baseline justify-between flex-wrap gap-2">
32
+ <h1
33
+ className="italic-display text-ink"
34
+ style={{ fontSize: "32px", lineHeight: 1.1 }}
35
+ >
36
+ {conv.title}
37
+ </h1>
38
+ <span className="folio-chrome">
39
+ {conv.turns.length} turn{conv.turns.length === 1 ? "" : "s"} ·{" "}
40
+ {new Date(conv.createdAt).toLocaleDateString()}
41
+ </span>
42
  </div>
43
+ </header>
44
+
45
+ <div className="space-y-10">
46
+ {conv.turns.map((t, i) => (
47
+ <div key={t.id} className="space-y-8">
48
+ <UserMessage text={t.question} index={i} />
49
+ {t.pending && <Thinking />}
50
+ {t.error && <ErrorMessage turn={t} index={i} />}
51
+ {t.response && <AssistantMessage turn={t} index={i} />}
52
+ </div>
53
+ ))}
54
+ </div>
55
  </div>
56
  </div>
57
  );
components/chat/TopBar.tsx CHANGED
@@ -6,6 +6,10 @@ import { api } from "@/lib/api";
6
  import { useChatStore } from "@/lib/chatStore";
7
  import clsx from "clsx";
8
 
 
 
 
 
9
  export function TopBar() {
10
  const [pulse, setPulse] = useState<"alive" | "down" | "loading">("loading");
11
  const [pingMs, setPingMs] = useState<number | null>(null);
@@ -37,64 +41,84 @@ export function TopBar() {
37
  };
38
  }, []);
39
 
 
 
 
 
 
 
40
  return (
41
- <header className="sticky top-0 z-30 h-14 flex items-center backdrop-blur-nav bg-canvas-deep/60 border-b border-glass-border">
42
- <div className="container-app flex items-center justify-between w-full">
43
- {/* Left: brand + sidebar toggle */}
44
- <div className="flex items-center gap-3">
45
- <button
46
- onClick={toggleSidebar}
47
- className="md:flex hidden p-2 rounded-md text-ink-50 hover:text-ink hover:bg-glass transition-colors"
48
- aria-label="Toggle sidebar"
49
- >
50
- <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
51
- <path
52
- d="M3 5h12M3 9h12M3 13h12"
53
- stroke="currentColor"
54
- strokeWidth="1.5"
55
- strokeLinecap="round"
56
- />
57
- </svg>
58
- </button>
59
- <Link href="/" className="flex items-center gap-2.5 group">
60
- <Mark />
61
- <div className="leading-none">
62
- <div className="text-body-strong text-ink">
63
- doc<span className="text-amber">·</span>to
64
- <span className="text-amber">·</span>lora
65
- </div>
66
- <div className="text-micro text-ink-50 mt-0.5 uppercase tracking-[0.18em]">
67
- Etiya BSS Atelier
68
- </div>
69
- </div>
70
- </Link>
71
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
- {/* Right: status pulse */}
74
- <div className="flex items-center gap-3">
75
- <span
76
- className="hidden sm:flex items-center gap-2 rounded-pill px-3 py-1.5 bg-glass"
77
- style={{ boxShadow: "0 0 0 1px rgba(255,255,255,0.08)" }}
78
- aria-live="polite"
79
- >
80
  <span
81
- className={clsx(
82
- "inline-block w-1.5 h-1.5 rounded-full transition-colors",
83
- pulse === "alive"
84
- ? "bg-status-ok shadow-[0_0_8px_rgba(93,214,168,0.8)] animate-pulse-soft"
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  : pulse === "down"
86
- ? "bg-status-err"
87
- : "bg-ink-50"
88
- )}
89
- />
90
- <span className="text-micro text-ink-70 font-mono uppercase tracking-[0.15em]">
91
- {pulse === "alive"
92
- ? `live · ${pingMs?.toFixed(0) ?? "—"}ms`
93
- : pulse === "down"
94
- ? "down"
95
- : "···"}
96
  </span>
97
- </span>
98
  </div>
99
  </div>
100
  </header>
@@ -104,23 +128,23 @@ export function TopBar() {
104
  function Mark() {
105
  return (
106
  <span
107
- className="relative inline-flex items-center justify-center w-8 h-8 rounded-md"
108
  style={{
109
- background:
110
- "linear-gradient(135deg, rgba(255,181,69,0.95) 0%, rgba(214,138,31,1) 100%)",
111
- boxShadow:
112
- "0 0 0 1px rgba(255,181,69,0.4), 0 0 20px -2px rgba(255,181,69,0.5)",
113
  }}
114
  >
115
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
116
- <path
117
- d="M2.5 8.5 5 11l6-7"
118
- stroke="#0a0b10"
119
- strokeWidth="2"
120
- strokeLinecap="round"
121
- strokeLinejoin="round"
122
- />
123
- </svg>
 
 
124
  </span>
125
  );
126
  }
 
6
  import { useChatStore } from "@/lib/chatStore";
7
  import clsx from "clsx";
8
 
9
+ /**
10
+ * Magazine masthead. Vol/Issue/folio chrome on the left, brand/title centered,
11
+ * status pulse on the right.
12
+ */
13
  export function TopBar() {
14
  const [pulse, setPulse] = useState<"alive" | "down" | "loading">("loading");
15
  const [pingMs, setPingMs] = useState<number | null>(null);
 
41
  };
42
  }, []);
43
 
44
+ const today = new Date().toLocaleDateString("en-US", {
45
+ year: "numeric",
46
+ month: "short",
47
+ day: "2-digit",
48
+ });
49
+
50
  return (
51
+ <header className="relative z-30 sticky top-0 bg-paper/85 backdrop-blur-sm">
52
+ {/* Top thin issue-strip */}
53
+ <div className="border-b border-ink/12">
54
+ <div className="container-app py-1.5 flex items-center justify-between text-[10px] folio-chrome">
55
+ <span>Vol. I · Issue 02 · Etiya BSS Atelier · {today}</span>
56
+ <span className="hidden md:inline">EN · UTC+3 · ISTANBUL</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  </div>
58
+ </div>
59
+
60
+ {/* Masthead */}
61
+ <div className="border-b border-ink/16">
62
+ <div className="container-app py-3 flex items-center justify-between gap-4">
63
+ {/* Left: sidebar toggle + brand */}
64
+ <div className="flex items-center gap-3">
65
+ <button
66
+ onClick={toggleSidebar}
67
+ className="hidden md:inline-flex p-2 rounded-md text-ink-50 hover:text-ink hover:bg-ink-08 transition-colors"
68
+ aria-label="Toggle catalog"
69
+ >
70
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
71
+ <path
72
+ d="M2 4h12M2 8h12M2 12h12"
73
+ stroke="currentColor"
74
+ strokeWidth="1.5"
75
+ strokeLinecap="round"
76
+ />
77
+ </svg>
78
+ </button>
79
+ <Link href="/" className="flex items-center gap-2.5 group">
80
+ <Mark />
81
+ <div className="leading-tight">
82
+ <div
83
+ className="italic-display text-ink"
84
+ style={{ fontSize: "26px", lineHeight: 1, letterSpacing: "-0.02em" }}
85
+ >
86
+ doc<span className="text-rust">·</span>to
87
+ <span className="text-rust">·</span>lora
88
+ </div>
89
+ <div className="folio-chrome mt-1">
90
+ An interrogation atelier
91
+ </div>
92
+ </div>
93
+ </Link>
94
+ </div>
95
 
96
+ {/* Right: status */}
97
+ <div className="flex items-center gap-3">
 
 
 
 
 
98
  <span
99
+ className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-pill bg-paper-deep"
100
+ style={{ boxShadow: "0 0 0 1px rgba(25,23,19,0.18)" }}
101
+ aria-live="polite"
102
+ >
103
+ <span
104
+ className={clsx(
105
+ "inline-block w-1.5 h-1.5 rounded-full transition-colors",
106
+ pulse === "alive"
107
+ ? "bg-status-ok animate-ink-pulse"
108
+ : pulse === "down"
109
+ ? "bg-rust"
110
+ : "bg-ink-50"
111
+ )}
112
+ />
113
+ <span className="folio-chrome--ink folio-chrome">
114
+ {pulse === "alive"
115
+ ? `Live · ${pingMs?.toFixed(0) ?? "—"}ms`
116
  : pulse === "down"
117
+ ? "Down"
118
+ : "···"}
119
+ </span>
 
 
 
 
 
 
 
120
  </span>
121
+ </div>
122
  </div>
123
  </div>
124
  </header>
 
128
  function Mark() {
129
  return (
130
  <span
131
+ className="relative inline-flex items-center justify-center w-9 h-9 rounded-md"
132
  style={{
133
+ background: "var(--rust)",
134
+ boxShadow: "0 0 0 1px rgba(200,77,44,0.35), 0 1px 0 rgba(255,255,255,0.4) inset",
 
 
135
  }}
136
  >
137
+ <span
138
+ className="italic-display"
139
+ style={{
140
+ fontSize: "22px",
141
+ lineHeight: 1,
142
+ color: "#f4ede0",
143
+ fontWeight: 400,
144
+ }}
145
+ >
146
+ d
147
+ </span>
148
  </span>
149
  );
150
  }
tailwind.config.ts CHANGED
@@ -1,12 +1,13 @@
1
  import type { Config } from "tailwindcss";
2
 
3
  /**
4
- * Etiya doc-to-lora — Twilight Atelier design tokens.
5
  *
6
- * Aesthetic: A research notebook at dusk. Deep indigo canvas warmed by a
7
- * single luminous amber, atmospheric aurora behind glass surfaces, editorial
8
- * serif italics carrying display moments. Sober enough for enterprise BSS,
9
- * soulful enough to belong in an AI atelier.
 
10
  */
11
  const config: Config = {
12
  content: [
@@ -17,105 +18,97 @@ const config: Config = {
17
  theme: {
18
  extend: {
19
  colors: {
20
- // ── Canvas (deep indigo-charcoal with warmth)
21
- canvas: {
22
- DEFAULT: "#0a0b10",
23
- deep: "#06070a",
24
- 1: "#0e0f17",
25
- 2: "#14151f",
26
- 3: "#1c1d29",
27
- 4: "#262735",
28
  },
29
- // ── Glass / overlay
30
- glass: {
31
- DEFAULT: "rgba(255, 255, 255, 0.04)",
32
- stronger: "rgba(255, 255, 255, 0.07)",
33
- border: "rgba(255, 255, 255, 0.10)",
34
- "border-strong": "rgba(255, 255, 255, 0.16)",
35
- },
36
- // ── Ink (warm off-white text — never pure white)
37
  ink: {
38
- DEFAULT: "#f5f3ec",
39
- 90: "rgba(245, 243, 236, 0.90)",
40
- 70: "rgba(245, 243, 236, 0.65)",
41
- 50: "rgba(245, 243, 236, 0.46)",
42
- 30: "rgba(245, 243, 236, 0.28)",
43
- 15: "rgba(245, 243, 236, 0.14)",
 
 
 
 
 
 
 
 
 
 
44
  },
45
- // ── Single luminous accent (warm amber)
46
- amber: {
47
- DEFAULT: "#ffb545",
48
- soft: "#ffd599",
49
- deep: "#d68a1f",
50
- glow: "rgba(255, 181, 69, 0.18)",
51
- ring: "rgba(255, 181, 69, 0.45)",
52
  },
53
- // ── Status (warm-tuned, never primary brand colors)
54
  status: {
55
- ok: "#5dd6a8",
56
- "ok-glow": "rgba(93, 214, 168, 0.16)",
57
- warn: "#ffb545",
58
- "warn-glow": "rgba(255, 181, 69, 0.16)",
59
- err: "#ff7a7a",
60
- "err-glow": "rgba(255, 122, 122, 0.16)",
61
  },
62
  },
63
  fontFamily: {
64
- // Editorial italic display (Google Font: Instrument Serif)
65
- serif: ["var(--font-instrument)", "ui-serif", "Georgia", "serif"],
66
- // UI / body (Manropemodern, geometric, warmer than Inter)
67
- sans: ["var(--font-manrope)", "system-ui", "-apple-system", "sans-serif"],
68
- // Spec-sheet readouts
69
  mono: ["var(--font-mono)", "ui-monospace", "SF Mono", "Menlo", "monospace"],
 
 
70
  },
71
  fontSize: {
72
- // Atelier display ladder — generous, with -0.02em tracking
73
- "display-2xl": ["88px", { lineHeight: "0.95", letterSpacing: "-0.04em", fontWeight: "500" }],
74
- "display-xl": ["64px", { lineHeight: "1.0", letterSpacing: "-0.035em", fontWeight: "500" }],
75
- "display-lg": ["44px", { lineHeight: "1.05", letterSpacing: "-0.025em", fontWeight: "600" }],
76
- "display-md": ["32px", { lineHeight: "1.12", letterSpacing: "-0.02em", fontWeight: "600" }],
77
- "display-sm": ["24px", { lineHeight: "1.2", letterSpacing: "-0.015em", fontWeight: "600" }],
78
- // Body
79
- lead: ["20px", { lineHeight: "1.5", letterSpacing: "-0.005em", fontWeight: "400" }],
80
- body: ["15px", { lineHeight: "1.6", letterSpacing: "0", fontWeight: "400" }],
 
81
  "body-strong": ["15px", { lineHeight: "1.6", letterSpacing: "0", fontWeight: "600" }],
82
  caption: ["13px", { lineHeight: "1.5", letterSpacing: "0", fontWeight: "400" }],
83
  "caption-strong": ["13px", { lineHeight: "1.4", letterSpacing: "0.005em", fontWeight: "600" }],
84
- micro: ["11px", { lineHeight: "1.4", letterSpacing: "0.05em", fontWeight: "500" }],
85
- },
86
- spacing: {
87
- section: "96px",
88
  },
89
  borderRadius: {
90
  none: "0",
91
- xs: "6px",
92
- sm: "10px",
93
- DEFAULT: "12px",
94
- md: "14px",
95
- lg: "20px",
96
- xl: "28px",
97
- "2xl": "36px",
98
  pill: "9999px",
99
  },
100
  boxShadow: {
101
- // The single sanctioned card glow for elevated surfaces
102
- glass:
103
- "0 1px 0 rgba(255,255,255,0.05) inset, 0 0 0 1px rgba(255,255,255,0.08), 0 16px 40px -16px rgba(0,0,0,0.6)",
104
- "glass-lg":
105
- "0 1px 0 rgba(255,255,255,0.06) inset, 0 0 0 1px rgba(255,255,255,0.10), 0 28px 80px -24px rgba(0,0,0,0.7)",
106
- // Amber accent glow for active CTAs
107
- amber:
108
- "0 0 0 1px rgba(255,181,69,0.42), 0 0 32px -8px rgba(255,181,69,0.45)",
109
- // Hairline ring (replaces border-1 for crisp edges on glass)
110
- hairline: "0 0 0 1px rgba(255,255,255,0.08)",
111
- "hairline-strong": "0 0 0 1px rgba(255,255,255,0.16)",
112
- },
113
- backdropBlur: {
114
- glass: "16px",
115
- nav: "24px",
116
  },
117
  transitionTimingFunction: {
118
- // Smooth deceleration — feels like Apple's spring
119
  atelier: "cubic-bezier(0.32, 0.72, 0, 1)",
120
  "out-expo": "cubic-bezier(0.16, 1, 0.3, 1)",
121
  },
@@ -127,10 +120,10 @@ const config: Config = {
127
  "stagger-2": "fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.16s both",
128
  "stagger-3": "fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.24s both",
129
  "stagger-4": "fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.32s both",
130
- shimmer: "shimmer 2.4s ease-in-out infinite",
131
- "pulse-soft": "pulseSoft 2.4s ease-in-out infinite",
132
- "aurora-1": "aurora1 22s ease-in-out infinite alternate",
133
- "aurora-2": "aurora2 28s ease-in-out infinite alternate",
134
  "thinking-dot": "thinkingDot 1.4s ease-in-out infinite",
135
  },
136
  keyframes: {
@@ -139,28 +132,20 @@ const config: Config = {
139
  "100%": { opacity: "1" },
140
  },
141
  fadeUp: {
142
- "0%": { opacity: "0", transform: "translateY(12px)" },
143
  "100%": { opacity: "1", transform: "translateY(0)" },
144
  },
145
- shimmer: {
146
- "0%, 100%": { opacity: "0.4" },
147
- "50%": { opacity: "0.85" },
148
- },
149
- pulseSoft: {
150
- "0%, 100%": { opacity: "0.7", transform: "scale(1)" },
151
- "50%": { opacity: "1", transform: "scale(1.04)" },
152
- },
153
- aurora1: {
154
- "0%": { transform: "translate(-10%, -8%) scale(1)" },
155
- "100%": { transform: "translate(8%, 10%) scale(1.15)" },
156
  },
157
- aurora2: {
158
- "0%": { transform: "translate(8%, 10%) scale(1.1)" },
159
- "100%": { transform: "translate(-6%, -4%) scale(1)" },
160
  },
161
  thinkingDot: {
162
- "0%, 80%, 100%": { opacity: "0.25", transform: "translateY(0)" },
163
- "40%": { opacity: "1", transform: "translateY(-3px)" },
164
  },
165
  },
166
  },
 
1
  import type { Config } from "tailwindcss";
2
 
3
  /**
4
+ * Etiya doc-to-lora — Risograph Folio.
5
  *
6
+ * A printed-publication aesthetic for an AI corpus. Two-color riso ink on
7
+ * warm parchment, halftone textures instead of gradients, Fraunces italic
8
+ * carrying display moments, magazine-folio chrome (volume, issue, folio
9
+ * numbers, register marks). The interrogation is a transcript; the doc list
10
+ * is a catalog; the system page is a colophon.
11
  */
12
  const config: Config = {
13
  content: [
 
18
  theme: {
19
  extend: {
20
  colors: {
21
+ // ── Paper (warm parchment, never pure white)
22
+ paper: {
23
+ DEFAULT: "#f4ede0",
24
+ deep: "#ebe2d0",
25
+ shade: "#e2d6bd",
26
+ edge: "#d3c6a8",
 
 
27
  },
28
+ // ── Ink (warm near-black, espresso)
 
 
 
 
 
 
 
29
  ink: {
30
+ DEFAULT: "#191713",
31
+ 90: "rgba(25, 23, 19, 0.92)",
32
+ 70: "rgba(25, 23, 19, 0.68)",
33
+ 50: "rgba(25, 23, 19, 0.46)",
34
+ 30: "rgba(25, 23, 19, 0.26)",
35
+ 15: "rgba(25, 23, 19, 0.14)",
36
+ "08": "rgba(25, 23, 19, 0.08)",
37
+ },
38
+ // ── Single saturated accent (Riso warm red / rust)
39
+ rust: {
40
+ DEFAULT: "#c84d2c",
41
+ deep: "#a43d20",
42
+ soft: "#e89878",
43
+ glow: "rgba(200, 77, 44, 0.14)",
44
+ ring: "rgba(200, 77, 44, 0.45)",
45
+ paper: "#f5d8c8",
46
  },
47
+ // ── Cool counterpoint (used sparingly: status-ok, technical chips)
48
+ slate: {
49
+ DEFAULT: "#3e5260",
50
+ soft: "#7a8c98",
51
+ paper: "#d6dde2",
 
 
52
  },
53
+ // ── Status (riso-tuned, not generic)
54
  status: {
55
+ ok: "#496c2a",
56
+ "ok-glow": "rgba(73, 108, 42, 0.12)",
57
+ warn: "#c2873a",
58
+ "warn-glow": "rgba(194, 135, 58, 0.14)",
59
+ err: "#c84d2c",
60
+ "err-glow": "rgba(200, 77, 44, 0.14)",
61
  },
62
  },
63
  fontFamily: {
64
+ // Display: Fraunces (variable italic, optical sizes — wonderful)
65
+ display: ["var(--font-fraunces)", "ui-serif", "Georgia", "serif"],
66
+ // UI / body: IBM Plex Sans humanist, characterful, never Inter
67
+ sans: ["var(--font-plex)", "ui-sans-serif", "system-ui", "sans-serif"],
68
+ // Folio numerals & technical readouts
69
  mono: ["var(--font-mono)", "ui-monospace", "SF Mono", "Menlo", "monospace"],
70
+ // Editorial italic shortcut
71
+ serif: ["var(--font-fraunces)", "ui-serif", "Georgia", "serif"],
72
  },
73
  fontSize: {
74
+ // Folio display ladder — Fraunces with wide tracking range
75
+ folio: ["120px", { lineHeight: "0.92", letterSpacing: "-0.04em", fontWeight: "300" }],
76
+ "display-2xl": ["88px", { lineHeight: "0.96", letterSpacing: "-0.035em", fontWeight: "300" }],
77
+ "display-xl": ["64px", { lineHeight: "1.0", letterSpacing: "-0.03em", fontWeight: "400" }],
78
+ "display-lg": ["44px", { lineHeight: "1.05", letterSpacing: "-0.02em", fontWeight: "500" }],
79
+ "display-md": ["32px", { lineHeight: "1.15", letterSpacing: "-0.018em", fontWeight: "500" }],
80
+ "display-sm": ["22px", { lineHeight: "1.25", letterSpacing: "-0.012em", fontWeight: "500" }],
81
+ // Body (Plex)
82
+ lead: ["19px", { lineHeight: "1.55", letterSpacing: "0", fontWeight: "400" }],
83
+ body: ["15px", { lineHeight: "1.65", letterSpacing: "0", fontWeight: "400" }],
84
  "body-strong": ["15px", { lineHeight: "1.6", letterSpacing: "0", fontWeight: "600" }],
85
  caption: ["13px", { lineHeight: "1.5", letterSpacing: "0", fontWeight: "400" }],
86
  "caption-strong": ["13px", { lineHeight: "1.4", letterSpacing: "0.005em", fontWeight: "600" }],
87
+ // Folio chrome heavy uppercase tracking
88
+ folio_chrome: ["10px", { lineHeight: "1.4", letterSpacing: "0.22em", fontWeight: "600" }],
89
+ micro: ["11px", { lineHeight: "1.4", letterSpacing: "0.16em", fontWeight: "500" }],
 
90
  },
91
  borderRadius: {
92
  none: "0",
93
+ xs: "2px",
94
+ sm: "4px",
95
+ DEFAULT: "6px",
96
+ md: "8px",
97
+ lg: "12px",
 
 
98
  pill: "9999px",
99
  },
100
  boxShadow: {
101
+ // No glassy floatsflat printed surfaces with hairline rules
102
+ rule: "0 1px 0 rgba(25, 23, 19, 0.14)",
103
+ "rule-strong": "0 1px 0 rgba(25, 23, 19, 0.26)",
104
+ "rule-up": "0 -1px 0 rgba(25, 23, 19, 0.14)",
105
+ // A faint press shadow for cards (almost imperceptible)
106
+ press: "0 0 0 1px rgba(25,23,19,0.08), 0 1px 0 rgba(25,23,19,0.06)",
107
+ // Rust ring for accent buttons
108
+ rust:
109
+ "0 0 0 1px rgba(200,77,44,0.45), 0 6px 18px -6px rgba(200,77,44,0.35)",
 
 
 
 
 
 
110
  },
111
  transitionTimingFunction: {
 
112
  atelier: "cubic-bezier(0.32, 0.72, 0, 1)",
113
  "out-expo": "cubic-bezier(0.16, 1, 0.3, 1)",
114
  },
 
120
  "stagger-2": "fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.16s both",
121
  "stagger-3": "fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.24s both",
122
  "stagger-4": "fadeUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.32s both",
123
+ rule: "rule 0.5s cubic-bezier(0.16, 1, 0.3, 1) both",
124
+ "rule-2": "rule 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both",
125
+ "rule-3": "rule 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both",
126
+ "ink-pulse": "inkPulse 2.4s ease-in-out infinite",
127
  "thinking-dot": "thinkingDot 1.4s ease-in-out infinite",
128
  },
129
  keyframes: {
 
132
  "100%": { opacity: "1" },
133
  },
134
  fadeUp: {
135
+ "0%": { opacity: "0", transform: "translateY(10px)" },
136
  "100%": { opacity: "1", transform: "translateY(0)" },
137
  },
138
+ rule: {
139
+ "0%": { transform: "scaleX(0)", transformOrigin: "left" },
140
+ "100%": { transform: "scaleX(1)", transformOrigin: "left" },
 
 
 
 
 
 
 
 
141
  },
142
+ inkPulse: {
143
+ "0%, 100%": { opacity: "0.65" },
144
+ "50%": { opacity: "1" },
145
  },
146
  thinkingDot: {
147
+ "0%, 80%, 100%": { opacity: "0.18" },
148
+ "40%": { opacity: "1" },
149
  },
150
  },
151
  },