Franskaman110 commited on
Commit
1e6fa10
·
1 Parent(s): 68c07e1
README.md CHANGED
@@ -27,7 +27,7 @@ tags:
27
 
28
  # Pay Equity for EU
29
 
30
- A grounded, multilingual chat assistant that answers two questions Danish
31
  graduates ask every job offer: **"What pay should I expect?"** and **"What does
32
  the law actually let me ask for?"**
33
 
@@ -49,9 +49,11 @@ This app fixes both, in one chat.
49
  (salary statistics from the engineers' union), broken down by sector,
50
  experience, and role. No hallucinations: numbers come straight from the
51
  union's published tables.
52
- - **Your rights** — RAG over the EU Pay Transparency Directive in EN / DA / FR,
53
- with article-level citations on every claim (e.g. *"You can request the
54
- average pay for your role broken down by gender (Art. 7(1))"*).
 
 
55
  - **Private document review** — session-only upload of a contract or payslip,
56
  never persisted, never sent to a third-party API.
57
 
@@ -72,8 +74,9 @@ for a real circle of people (Danish graduates job-hunting right now).
72
  | Swap-in LLM | OpenBMB MiniCPM3-4B | One-line swap via `LLMClient` interface |
73
  | Embeddings | **NVIDIA Nemotron-Embed 1B v2** | Strong multilingual retrieval, ungated |
74
  | Retrieval | FAISS IndexFlatIP + per-source top-k + Reciprocal Rank Fusion | Keeps salary numbers and legal text from drowning each other |
75
- | Runtime | Gradio Server + ZeroGPU on HF Spaces | Single `main.py`, FastAPI-style endpoints exposed alongside the chat UI |
76
- | Theme | Custom Scandinavian sage palette via `gr.themes.Soft` + Bricolage Grotesque / Inter | Goes well beyond default Gradio look |
 
77
 
78
  All model revisions are pinned by SHA in `configs/models.yaml`. No `latest`.
79
 
 
27
 
28
  # Pay Equity for EU
29
 
30
+ A grounded, citation-first assistant that answers two questions Danish
31
  graduates ask every job offer: **"What pay should I expect?"** and **"What does
32
  the law actually let me ask for?"**
33
 
 
49
  (salary statistics from the engineers' union), broken down by sector,
50
  experience, and role. No hallucinations: numbers come straight from the
51
  union's published tables.
52
+ - **Your rights** — RAG over the EU Pay Transparency Directive with
53
+ article-level citations on every claim (e.g. *"You can request the
54
+ average pay for your role broken down by gender (Art. 7(1))"*). Answers are
55
+ in **English** in v1; the directive text is already parsed in DA / FR and the
56
+ generation model is multilingual, so localized answers are a fast follow.
57
  - **Private document review** — session-only upload of a contract or payslip,
58
  never persisted, never sent to a third-party API.
59
 
 
74
  | Swap-in LLM | OpenBMB MiniCPM3-4B | One-line swap via `LLMClient` interface |
75
  | Embeddings | **NVIDIA Nemotron-Embed 1B v2** | Strong multilingual retrieval, ungated |
76
  | Retrieval | FAISS IndexFlatIP + per-source top-k + Reciprocal Rank Fusion | Keeps salary numbers and legal text from drowning each other |
77
+ | Runtime | Gradio Server + ZeroGPU on HF Spaces | FastAPI-style `@app.api` endpoints behind a custom JS frontend |
78
+ | Frontend | **Hand-authored HTML / CSS / JS SPA** (no Gradio Blocks at the root) | Onboarding wizard → profile-driven dashboard with hand-drawn SVG charts (bell curve, gauge, comparison bars) → contextual rights chat with seeded questions, served on top of `gradio.Server` via the JS client |
79
+ | Type / palette | Bricolage Grotesque + Inter on a sage / ink Scandinavian palette | Goes well beyond default Gradio look |
80
 
81
  All model revisions are pinned by SHA in `configs/models.yaml`. No `latest`.
82
 
src/backend/README.md CHANGED
@@ -4,7 +4,9 @@ A retrieval-augmented chat assistant over two sources — **IDA salary
4
  statistics** (engineers) and the **EU Pay Transparency Directive 2023/970** —
5
  served through a [Gradio Server](https://huggingface.co/blog/introducing-gradio-server).
6
  `build_server()` (in `server.py`) creates the `gradio.Server`, registers the
7
- backend API endpoints, and mounts the Gradio-native chat [UI](ui.py) at `/`.
 
 
8
 
9
  ## Start it
10
 
@@ -14,9 +16,9 @@ From the repo root:
14
  uv run python main.py
15
  ```
16
 
17
- `main.py` builds `demo = build_blocks()` and calls `demo.launch(...)`. The
18
- server prints a local URL (default `http://127.0.0.1:7860`). Open it for the
19
- chat UI.
20
 
21
  Before first run, build the vector index (see **RAG pipeline** below).
22
 
@@ -53,13 +55,13 @@ selected by `llm.<role>.backend` (`tiny_aya` | `minicpm` | `llama_cpp` |
53
 
54
  ## Endpoints
55
 
56
- Registered in `api/endpoints.py` via `@app.api(name=...)`; the structured chat
57
- API is also exposed inside the Blocks via `gr.api(api_name="chat")` so it stays
58
- reachable at `/` for `gradio_client`.
59
 
60
  | `api_name` | Input | Returns | Status |
61
  |---|---|---|---|
62
  | `/chat` | `messages: list[dict]`, `lang: str` | `ChatResponse` (reply + citations) | RAG-grounded |
 
63
  | `/parse_directive` | `lang: str` | `list[Chunk]` | real (article chunks) |
64
  | `/parse_lonstatistik` | `file: FileData`, `source: str` | `ParseResult` | placeholder |
65
  | `/index_documents` | `source: str` | `IndexStatus` | reports loaded index |
@@ -80,8 +82,7 @@ print(r["reply"], r["citations"])
80
  ## Layout
81
 
82
  ```
83
- server.py build_server() — Server + endpoints + mounts the chat UI at /
84
- ui.py build_blocks(): sage-themed gr.Blocks ChatInterface + gr.api chat
85
  rag.py answer() + single @spaces.GPU retrieve→generate function
86
  config.py load_models_config() (configs/models.yaml) + get_secret()
87
  schemas.py pydantic models (Chunk, ChatMessage/Request/Response, …)
 
4
  statistics** (engineers) and the **EU Pay Transparency Directive 2023/970** —
5
  served through a [Gradio Server](https://huggingface.co/blog/introducing-gradio-server).
6
  `build_server()` (in `server.py`) creates the `gradio.Server`, registers the
7
+ backend API endpoints, and serves the **custom HTML/CSS/JS SPA** from
8
+ [`../frontend/`](../frontend/) at `/`. The SPA calls the registered
9
+ `@app.api` endpoints via the Gradio JS client.
10
 
11
  ## Start it
12
 
 
16
  uv run python main.py
17
  ```
18
 
19
+ `main.py` calls `build_server().launch(...)`. The server prints a local URL
20
+ (default `http://127.0.0.1:7860`). Open it to use the SPA (wizard → dashboard
21
+ → rights chat).
22
 
23
  Before first run, build the vector index (see **RAG pipeline** below).
24
 
 
55
 
56
  ## Endpoints
57
 
58
+ Registered in `api/endpoints.py` via `@app.api(name=...)` and consumed by the
59
+ custom SPA in `../frontend/static/app.js` through the Gradio JS client.
 
60
 
61
  | `api_name` | Input | Returns | Status |
62
  |---|---|---|---|
63
  | `/chat` | `messages: list[dict]`, `lang: str` | `ChatResponse` (reply + citations) | RAG-grounded |
64
+ | `/dashboard` | `profile: Profile` | `Dashboard` (KPIs + cells + projection) | profile-driven IDA lookup |
65
  | `/parse_directive` | `lang: str` | `list[Chunk]` | real (article chunks) |
66
  | `/parse_lonstatistik` | `file: FileData`, `source: str` | `ParseResult` | placeholder |
67
  | `/index_documents` | `source: str` | `IndexStatus` | reports loaded index |
 
82
  ## Layout
83
 
84
  ```
85
+ server.py build_server() — Server + endpoints + serves the custom SPA at /
 
86
  rag.py answer() + single @spaces.GPU retrieve→generate function
87
  config.py load_models_config() (configs/models.yaml) + get_secret()
88
  schemas.py pydantic models (Chunk, ChatMessage/Request/Response, …)
src/backend/ui.py DELETED
@@ -1,149 +0,0 @@
1
- """Gradio-native chat UI (replaces the old custom HTML/JS frontend).
2
-
3
- A free-text, grounded Q&A chat styled to match the project's sage/Scandinavian
4
- look — implemented through Gradio's own theming + CSS rather than custom HTML.
5
- The chat function calls the same shared ``rag.answer()`` as the ``/chat`` API
6
- endpoint, so there is one grounding code path.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import gradio as gr
12
-
13
- from .rag import answer, answer_stream, build_index
14
- from .schemas import ChatMessage, ChatResponse
15
-
16
- # The disclaimer is owned by ChatResponse; reuse its default as the UI footer.
17
- _DISCLAIMER = ChatResponse.model_fields["disclaimer"].default
18
-
19
- # --- palette (ported from the old static/styles.css :root tokens) ----------
20
- SAGE = "#C4D5D3"
21
- SAGE_LIGHT = "#E7EFEE"
22
- INK = "#15211D"
23
- ACCENT = "#1F4A3F"
24
-
25
- SAGE_THEME = gr.themes.Soft(
26
- # Editorial serif to match the Djøf lønstatistik look (Lora ≈ Djøf's warm,
27
- # moderate-contrast Nordic serif, with proper ø/å glyphs).
28
- font=[gr.themes.GoogleFont("Lora"), "Georgia", "serif"],
29
- font_mono=[gr.themes.GoogleFont("Inter"), "monospace"],
30
- ).set(
31
- body_background_fill="#FFFFFF",
32
- body_text_color=INK,
33
- block_title_text_color=INK,
34
- button_primary_background_fill=INK,
35
- button_primary_background_fill_hover=ACCENT,
36
- button_primary_text_color="#FFFFFF",
37
- input_background_fill="#FFFFFF",
38
- block_border_color="#D7E0DD",
39
- )
40
-
41
- CUSTOM_CSS = """
42
- @import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..600&display=swap');
43
- .gradio-container { font-family: 'Lora', Georgia, 'Times New Roman', serif; }
44
- h1, h2, h3, .prose h1, .prose h2 {
45
- font-family: 'Lora', Georgia, serif;
46
- font-weight: 600;
47
- letter-spacing: -0.01em;
48
- }
49
- #hero { background: #C4D5D3; border-radius: 16px; padding: 28px 32px; margin-bottom: 8px; }
50
- #hero h1 { margin: 0 0 6px; font-size: 2.2rem; font-weight: 700; color: #15211D !important; }
51
- #hero p { margin: 0; font-size: 1.05rem; color: #34423D !important; }
52
-
53
- /* user bubble: white text on ink */
54
- .message.user, .user .message-bubble-border, div[data-testid="user"] {
55
- background: #15211D !important; color: #FFFFFF !important;
56
- }
57
- /* assistant bubble: dark ink text on sage-light — the readability fix.
58
- The background was set but the text color was not, so it inherited the
59
- dark theme's light text → light-on-light. Force ink on the bubble and all
60
- its markdown children (paragraphs, bold "Sources", italic disclaimer). */
61
- .message.bot, div[data-testid="bot"] {
62
- background: #E7EFEE !important; color: #15211D !important;
63
- }
64
- .message.bot *, div[data-testid="bot"] * { color: #15211D !important; }
65
- .message.bot a, div[data-testid="bot"] a {
66
- color: #1F4A3F !important; text-decoration: underline;
67
- }
68
-
69
- .disclaimer { color: #6E7B77; font-size: 0.85rem; text-align: center; margin-top: 8px; }
70
- footer { display: none !important; }
71
- """
72
-
73
- _HERO = (
74
- "<div id='hero'><h1>Fair pay, made clear.</h1>"
75
- "<p>Ask about expected pay for engineers in Denmark, or your rights under "
76
- "the EU Pay Transparency Directive (2023/970). Answers are grounded in "
77
- "IDA salary statistics and the Directive, with citations.</p></div>"
78
- )
79
-
80
- # Pin the page to light mode so the sage/white theme renders as designed —
81
- # Gradio otherwise follows the visitor's system dark mode. Runs once on load;
82
- # only redirects if not already light, so no reload loop.
83
- # NOTE: passed to ``launch(js=...)`` (Gradio 6 moved `js` off the Blocks ctor).
84
- FORCE_LIGHT_JS = """
85
- () => {
86
- const u = new URL(window.location.href);
87
- if (u.searchParams.get('__theme') !== 'light') {
88
- u.searchParams.set('__theme', 'light');
89
- window.location.replace(u.href);
90
- }
91
- }
92
- """
93
-
94
-
95
- def _chat_fn(message: str, history: list[dict], lang: str):
96
- """Streaming ChatInterface callback → grounded answer, then sources + disclaimer.
97
-
98
- Yields the reply incrementally as tokens arrive (smooth UX), then a final
99
- frame with the citation footer and disclaimer appended.
100
- """
101
- msgs = [ChatMessage(role=h["role"], content=h["content"]) for h in history]
102
- msgs.append(ChatMessage(role="user", content=message))
103
- resp = ChatResponse(reply="", citations=[])
104
- for resp in answer_stream(msgs, lang=lang):
105
- yield resp.reply
106
- footer = ""
107
- if resp.citations:
108
- footer += "\n\n**Sources:** " + " · ".join(resp.citations)
109
- footer += f"\n\n_{resp.disclaimer}_"
110
- yield resp.reply + footer
111
-
112
-
113
- def _chat_api(messages: list[dict], lang: str = "en") -> dict:
114
- """Structured chat endpoint (api_name='chat') for gradio_client.
115
-
116
- Same contract as before: ``{messages, lang}`` → ``{reply, citations,
117
- disclaimer}``. Delegates to the shared ``answer()`` so the programmatic API
118
- and the UI share one grounding path.
119
- """
120
- msgs = [ChatMessage(role=m["role"], content=m["content"]) for m in messages]
121
- return answer(msgs, lang=lang).model_dump()
122
-
123
-
124
- def build_blocks() -> gr.Blocks:
125
- """Build the sage-themed chat UI (mounted at the site root)."""
126
- with gr.Blocks(title="Pay Equity for EU") as demo:
127
- gr.HTML(_HERO)
128
- lang = gr.Dropdown(
129
- choices=[("English", "en"), ("Dansk", "da"), ("Français", "fr")],
130
- value="en",
131
- label="Language",
132
- )
133
- gr.ChatInterface(
134
- fn=_chat_fn,
135
- additional_inputs=[lang],
136
- api_name=False, # the structured gr.api below is the public chat API
137
- examples=[
138
- ["What monthly salary should an experienced engineer expect?"],
139
- ["What information am I entitled to about pay at my workplace?"],
140
- ["Can my employer ask my salary history during hiring?"],
141
- ],
142
- )
143
- gr.Markdown(f"<p class='disclaimer'>{_DISCLAIMER}</p>")
144
- with gr.Accordion("Admin", open=False):
145
- build_btn = gr.Button("Build Index", variant="secondary")
146
- build_status = gr.Textbox(label="Status", interactive=False)
147
- build_btn.click(fn=build_index, inputs=[], outputs=[build_status])
148
- gr.api(_chat_api, api_name="chat")
149
- return demo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/frontend/README.md CHANGED
@@ -1,34 +1,72 @@
1
  # Frontend
2
 
3
- The user-facing UI is now a **Gradio-native** chat (`gr.Blocks` +
4
- `gr.ChatInterface`) defined in [`../backend/ui.py`](../backend/ui.py) and mounted
5
- at `/` by `build_server()`. It is a free-text, grounded Q&A chat with a language
6
- selector (en/da/fr) and inline source citations.
 
7
 
8
- The Scandinavian/sage style is implemented through Gradio's own theming engine
9
- (`gr.themes.Soft(...).set(...)` + a small CSS block for the Bricolage Grotesque
10
- / Inter fonts and message-bubble colors) — **not** custom HTML. Theme and CSS
11
- are passed to `demo.launch(...)` in `main.py` (Gradio 6 moved them off the
12
- `Blocks` constructor).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  ## Start it
15
 
16
- No build step. Start the backend and the UI is served at `/`:
17
 
18
  ```bash
19
- uv run python main.py # from the repo root
20
  ```
21
 
22
- ## Legacy static files
 
 
 
 
 
 
 
 
 
23
 
24
- `index.html` and `static/{app.js,i18n.js,styles.css}` are the previous custom
25
- HTML/CSS/JS frontend. They are **no longer served** (the Gradio UI replaced
26
- them); `styles.css` is kept only as the reference for the palette/fonts ported
27
- into `ui.py`. These can be deleted in a follow-up cleanup.
28
 
29
  ## Editing
30
 
31
- - **Layout / behavior:** edit `build_blocks()` in `../backend/ui.py`.
32
- - **Style:** the `SAGE_THEME` and `CUSTOM_CSS` constants in `../backend/ui.py`.
33
- - **Chat logic:** `_chat_fn` (UI) and `_chat_api` (structured `/chat`) both call
34
- the shared `rag.answer()`.
 
 
 
1
  # Frontend
2
 
3
+ A **hand-authored single-page app** in `index.html` + `static/` — no Gradio
4
+ Blocks at the root, no `gr.ChatInterface`. The page is served by
5
+ `build_server()` in `../backend/server.py`, which mounts `static/` and returns
6
+ `index.html` from `/`. The page connects back to the same origin via the Gradio
7
+ JS client (`@gradio/client`) and calls the registered `@app.api` endpoints.
8
 
9
+ ## What's on the page
10
+
11
+ - **Hero** with a two-branch entry point: *I'm employed — benchmark my pay* /
12
+ *I'm job-seeking — prep for an offer*.
13
+ - **Onboarding wizard** (`#wizard`) — 9 questions, branch-aware, with a
14
+ per-step help text written in plain language. Salary entry supports either
15
+ a known total range or `base × (1 + perks%)`.
16
+ - **Dashboard** (`#dashboard`) — KPI row, comparison cards from real IDA
17
+ cells, and a "make it visual" toggle that swaps in hand-drawn SVG charts:
18
+ - Bell curve of your peer group with your pay marked and the percentile
19
+ computed via a JS-side normal CDF
20
+ - Circular gauge of your standing
21
+ - Horizontal comparison bars across all matched IDA cells
22
+ - **Rights chat** (`#rights`) — seeded with profile-aware suggested questions
23
+ (e.g. mode-, sector-, headcount-, gender-driven), session-only document
24
+ upload, inline citations and disclaimer rendered under each reply.
25
+ - **Language switcher** (EN / DA / FR) wired through `static/i18n.js`.
26
+
27
+ All charts are hand-rolled SVG (no charting library) and degrade gracefully
28
+ down to a single matched cell.
29
+
30
+ ## Style system
31
+
32
+ `static/styles.css` defines a Scandinavian / Djøf-inspired token system:
33
+
34
+ - Sage hero (`#C4D5D3`), ink near-black (`#15211D`), deep-teal accent
35
+ (`#1F4A3F`), sage-light alt sections (`#E7EFEE`).
36
+ - Display: **Bricolage Grotesque**. Body: **Inter**. Both via Google Fonts.
37
+ - Pill, rounded, and editorial radii (`--radius-md/lg/xl/pill`) plus a custom
38
+ shadow scale and motion tokens.
39
+
40
+ This is the project's Off-Brand UI; Gradio's own theming engine is **not** used
41
+ on the served page.
42
 
43
  ## Start it
44
 
45
+ From the repo root:
46
 
47
  ```bash
48
+ uv run python main.py
49
  ```
50
 
51
+ `main.py` calls `build_server().launch(...)`, which serves `index.html` at `/`
52
+ and the API endpoints alongside it.
53
+
54
+ ## API contract used by the SPA
55
+
56
+ | `api_name` | Used by |
57
+ |---|---|
58
+ | `/dashboard` | `submitProfile()` after the wizard |
59
+ | `/chat` | `sendMessage()` (rights chat) |
60
+ | `/parse_lonstatistik` | `uploadDocument()` (file upload) |
61
 
62
+ See `../backend/api/endpoints.py` for the registered endpoints and
63
+ `../backend/schemas.py` for the request/response models.
 
 
64
 
65
  ## Editing
66
 
67
+ - **Layout / wizard / chat behavior:** `static/app.js`.
68
+ - **Translations:** `static/i18n.js` (`en` populated; `da`/`fr` keys exist with
69
+ empty values, falling back to EN).
70
+ - **Style:** `static/styles.css` (token system at the top, sectioned by
71
+ component below).
72
+ - **Page shell:** `index.html`.
src/frontend/static/app.js CHANGED
@@ -803,13 +803,21 @@ function appendMessage(role, content, citations = []) {
803
  return el;
804
  }
805
 
 
 
806
  async function sendMessage() {
807
  const text = chatInput.value.trim();
808
  if (!text) return;
809
  chatInput.value = "";
810
  appendMessage("user", text);
811
  messages.push({ role: "user", content: text });
812
- const pending = appendMessage("assistant", "…");
 
 
 
 
 
 
813
  try {
814
  const c = await getClient();
815
  const result = await c.predict("/chat", {
@@ -817,6 +825,8 @@ async function sendMessage() {
817
  lang: window.i18n.lang,
818
  });
819
  const data = Array.isArray(result.data) ? result.data[0] : result.data;
 
 
820
  pending.textContent = data.reply ?? String(data);
821
  if (data.citations?.length) {
822
  const cit = document.createElement("span");
@@ -832,7 +842,10 @@ async function sendMessage() {
832
  }
833
  messages.push({ role: "assistant", content: data.reply ?? "" });
834
  } catch (err) {
835
- pending.textContent = "Error: " + (err?.message ?? err);
 
 
 
836
  }
837
  }
838
 
 
803
  return el;
804
  }
805
 
806
+ let chatWarmed = false; // first reply loads the model on GPU — warn once.
807
+
808
  async function sendMessage() {
809
  const text = chatInput.value.trim();
810
  if (!text) return;
811
  chatInput.value = "";
812
  appendMessage("user", text);
813
  messages.push({ role: "user", content: text });
814
+ const pending = appendMessage(
815
+ "assistant",
816
+ chatWarmed
817
+ ? "…"
818
+ : "Cold-starting the assistant — the first reply can take up to a minute while the model loads. Thanks for your patience…"
819
+ );
820
+ if (!chatWarmed) pending.classList.add("msg-warming");
821
  try {
822
  const c = await getClient();
823
  const result = await c.predict("/chat", {
 
825
  lang: window.i18n.lang,
826
  });
827
  const data = Array.isArray(result.data) ? result.data[0] : result.data;
828
+ chatWarmed = true;
829
+ pending.classList.remove("msg-warming");
830
  pending.textContent = data.reply ?? String(data);
831
  if (data.citations?.length) {
832
  const cit = document.createElement("span");
 
842
  }
843
  messages.push({ role: "assistant", content: data.reply ?? "" });
844
  } catch (err) {
845
+ pending.classList.remove("msg-warming");
846
+ pending.textContent =
847
+ "The assistant is still waking up or busy right now — please try again in a moment.";
848
+ console.error("chat error:", err);
849
  }
850
  }
851
 
src/frontend/static/styles.css CHANGED
@@ -307,6 +307,8 @@ section { padding: var(--section-padding-y) 0; }
307
  .msg.user { align-self: flex-end; background: var(--color-ink); color: #fff; border-bottom-right-radius: var(--radius-sm); }
308
  .msg.assistant { align-self: flex-start; background: var(--color-sage-light); color: var(--color-text-primary); border-bottom-left-radius: var(--radius-sm); }
309
  .msg.assistant .citations { display:block; margin-top: var(--space-2); font-size: var(--text-xs); color: var(--color-text-muted); }
 
 
310
  .chat-input {
311
  display: flex;
312
  gap: var(--space-3);
 
307
  .msg.user { align-self: flex-end; background: var(--color-ink); color: #fff; border-bottom-right-radius: var(--radius-sm); }
308
  .msg.assistant { align-self: flex-start; background: var(--color-sage-light); color: var(--color-text-primary); border-bottom-left-radius: var(--radius-sm); }
309
  .msg.assistant .citations { display:block; margin-top: var(--space-2); font-size: var(--text-xs); color: var(--color-text-muted); }
310
+ .msg.assistant.msg-warming { font-style: italic; color: var(--color-text-secondary); animation: warm-pulse 1.6s ease-in-out infinite; }
311
+ @keyframes warm-pulse { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }
312
  .chat-input {
313
  display: flex;
314
  gap: var(--space-3);