From 2749132e9ac32f5493df4cf72b97621b7d69b06e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=27=C3=A9lectron=20rare?=
<108685187+electron-rare@users.noreply.github.com>
Date: Tue, 19 May 2026 12:17:33 +0200
Subject: [PATCH] feat: restyle chat playground to design system
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The playground was built with stock Tailwind utilities (slate/emerald/
amber boxes) and looked like a different app dropped into the site's
editorial paper/ink design. styles.css already carried a purpose-built
chat design (.chat-banner, .chat-body, .msg, .chat-input) that the
components never used.
Rewire all four components to those classes:
- MessageBubble: editorial .msg grid — mono speaker label, serif user
text, sans assistant text, ink code blocks.
- ChatPlayground: ink banner, editorial AI-disclaimer footnote, the
empty state and seed prompts restyled.
- PromptInput: paper input dock; the send button becomes a stop button
while streaming. min-width:0 on the textarea keeps mobile intact.
- ParamsPanel: mono labels, rule borders, accent-blue controls.
User-facing copy is now French, consistent with the rest of the site.
---
.../ChatPlayground/ChatPlayground.tsx | 69 ++---
.../ChatPlayground/MessageBubble.tsx | 41 ++-
.../components/ChatPlayground/ParamsPanel.tsx | 29 +-
.../components/ChatPlayground/PromptInput.tsx | 106 ++++----
apps/cockpit-public/src/styles.css | 254 +++++++++++++++++-
.../tests/components/ChatPlayground.test.tsx | 2 +-
.../tests/components/PromptInput.test.tsx | 10 +-
7 files changed, 369 insertions(+), 142 deletions(-)
diff --git a/apps/cockpit-public/src/components/ChatPlayground/ChatPlayground.tsx b/apps/cockpit-public/src/components/ChatPlayground/ChatPlayground.tsx
index d480161..a1ec547 100644
--- a/apps/cockpit-public/src/components/ChatPlayground/ChatPlayground.tsx
+++ b/apps/cockpit-public/src/components/ChatPlayground/ChatPlayground.tsx
@@ -1,5 +1,4 @@
import { type ChatMessage, useChatStream } from '@/hooks/useChatStream';
-import { Square } from 'lucide-react';
import { useEffect, useState } from 'react';
import { MessageBubble } from './MessageBubble';
import { type ChatParams, ParamsPanel } from './ParamsPanel';
@@ -74,41 +73,35 @@ export function ChatPlayground({ modelId, modelDisplayName }: Props) {
}
};
+ const isEmpty = messages.length === 0 && !isStreaming && !error;
+
return (
-
-
- Chat — {modelDisplayName}
- {modelId}
-
- ⚠️
- You are interacting with an AI. Replies may be inaccurate, biased, or fabricated and must
- not be treated as professional advice. See the{' '}
-
- transparency page
- {' '}
- for model provenance and limitations.
-
-
+ <>
+
+
+
+ {modelDisplayName}
+
+ SSE streaming
+
+
+
+ ⚠
+ Réponses générées par IA — potentiellement inexactes, biaisées ou fabriquées, à ne pas
+ traiter comme un avis professionnel. Voir la démarche qualité .
+
-
- {messages.length === 0 && !isStreaming && !error && (
-
-
- Posez votre première question à {modelDisplayName}.
+
+ {isEmpty && (
+
+
+ Posez votre première question à {modelDisplayName} .
-
+
{EXAMPLE_PROMPTS.map((p) => (
-
setInputText(p)}
- className="rounded border border-slate-200 px-3 py-2 text-left text-sm text-slate-600 hover:border-slate-400 hover:bg-slate-50"
- >
+ setInputText(p)}>
{p}
))}
@@ -124,24 +117,16 @@ export function ChatPlayground({ modelId, modelDisplayName }: Props) {
/>
))}
{isStreaming && }
- {error && Error: {error}
}
+ {error && Erreur : {error}
}
- {isStreaming && (
-
- Stop
-
- )}
-
+ >
);
}
diff --git a/apps/cockpit-public/src/components/ChatPlayground/MessageBubble.tsx b/apps/cockpit-public/src/components/ChatPlayground/MessageBubble.tsx
index 02632ed..822918c 100644
--- a/apps/cockpit-public/src/components/ChatPlayground/MessageBubble.tsx
+++ b/apps/cockpit-public/src/components/ChatPlayground/MessageBubble.tsx
@@ -7,30 +7,27 @@ interface Props {
}
export function MessageBubble({ speaker, content, streaming }: Props) {
- const align =
- speaker === 'user'
- ? 'ml-auto bg-slate-100 text-slate-900'
- : 'mr-auto bg-emerald-50 text-slate-900';
- // During the gap between request and first token (Cloudflare can buffer SSE
- // for several seconds before any chunk reaches the client) show three
- // pulsing dots so the user gets immediate feedback.
+ // Between the request and the first token, Cloudflare can buffer the SSE
+ // stream for several seconds — show pulsing dots so the user gets
+ // immediate feedback that something is happening.
const isThinking = streaming && !content;
return (
-
- {isThinking ? (
-
-
-
-
-
- ) : (
- <>
-
{content}
- {streaming && (
-
- )}
- >
- )}
+
+
{speaker === 'user' ? 'vous' : 'ailiance'}
+
+ {isThinking ? (
+
+
+
+
+
+ ) : (
+ <>
+ {content}
+ {streaming && }
+ >
+ )}
+
);
}
diff --git a/apps/cockpit-public/src/components/ChatPlayground/ParamsPanel.tsx b/apps/cockpit-public/src/components/ChatPlayground/ParamsPanel.tsx
index 34640d0..122c9a6 100644
--- a/apps/cockpit-public/src/components/ChatPlayground/ParamsPanel.tsx
+++ b/apps/cockpit-public/src/components/ChatPlayground/ParamsPanel.tsx
@@ -1,4 +1,3 @@
-import { ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';
export interface ChatParams {
@@ -16,19 +15,22 @@ export function ParamsPanel({ value, onChange }: Props) {
const [open, setOpen] = useState(false);
return (
-
+
setOpen(!open)}
- className="w-full flex items-center gap-2 p-3 text-sm font-medium"
+ aria-expanded={open}
>
- {open ? : }
- Parameters
+ {open ? '−' : '+'}
+ Paramètres
{open && (
-
-
- Temperature: {value.temperature.toFixed(2)}
+
+
+
+ Température {value.temperature.toFixed(2)}
+
onChange({ ...value, temperature: Number(e.target.value) })}
- className="block w-full"
/>
-
- Max tokens
+
+ Max tokens
onChange({ ...value, max_tokens: Number(e.target.value) })}
- className="block w-full rounded border border-slate-300 p-1 mt-1"
/>
-
- System prompt
+
+ Prompt système
diff --git a/apps/cockpit-public/src/components/ChatPlayground/PromptInput.tsx b/apps/cockpit-public/src/components/ChatPlayground/PromptInput.tsx
index cd2ea4c..04cc4b9 100644
--- a/apps/cockpit-public/src/components/ChatPlayground/PromptInput.tsx
+++ b/apps/cockpit-public/src/components/ChatPlayground/PromptInput.tsx
@@ -1,11 +1,13 @@
-import { Paperclip, Send, X } from 'lucide-react';
+import { Paperclip, Send, Square, X } from 'lucide-react';
import { type ChangeEvent, type KeyboardEvent, useRef, useState } from 'react';
interface Props {
value: string;
onChange: (v: string) => void;
onSubmit: (text: string) => void;
- disabled?: boolean;
+ /** A reply is currently streaming — the send button becomes a stop button. */
+ streaming?: boolean;
+ onStop?: () => void;
}
// Mirrors the gateway's /v1/files/extract supported list. Kept in sync
@@ -56,7 +58,7 @@ interface ExtractedAttachment {
truncated?: boolean;
}
-export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
+export function PromptInput({ value, onChange, onSubmit, streaming, onStop }: Props) {
const [attachment, setAttachment] = useState(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
@@ -70,15 +72,15 @@ export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
const userText = value.trim();
const intro = userText
? userText
- : `Please analyze the attached ${attachment.format.toUpperCase()} file.`;
+ : `Analyse le fichier ${attachment.format.toUpperCase()} ci-joint.`;
const truncNote = attachment.truncated
- ? '\n\n[note: file was truncated by the server to fit prompt limits]'
+ ? '\n\n[note : le fichier a été tronqué par le serveur pour tenir dans les limites du prompt]'
: '';
- return `Attached file: ${attachment.filename}\n\n\`\`\`markdown\n${attachment.markdown}\n\`\`\`${truncNote}\n\n${intro}`;
+ return `Fichier joint : ${attachment.filename}\n\n\`\`\`markdown\n${attachment.markdown}\n\`\`\`${truncNote}\n\n${intro}`;
};
const handleSubmit = () => {
- if (disabled) return;
+ if (streaming || uploading) return;
const payload = composePayload();
if (!payload) return;
onSubmit(payload);
@@ -107,7 +109,8 @@ export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
const resp = await fetch(EXTRACT_URL, { method: 'POST', body: form });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
- const msg = body?.detail?.message ?? body?.detail ?? `Upload failed (HTTP ${resp.status})`;
+ const msg =
+ body?.detail?.message ?? body?.detail ?? `Échec de l'envoi (HTTP ${resp.status})`;
setUploadError(String(msg));
return;
}
@@ -119,60 +122,48 @@ export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
truncated: body.metadata?.truncated === true,
});
} catch (err) {
- setUploadError(err instanceof Error ? err.message : 'Upload failed');
+ setUploadError(err instanceof Error ? err.message : "Échec de l'envoi");
} finally {
setUploading(false);
}
};
- const canSend = !disabled && !uploading && (value.trim().length > 0 || attachment !== null);
+ const canSend = !streaming && !uploading && (value.trim().length > 0 || attachment !== null);
return (
-
+
{attachment && (
-
-
{attachment.filename}
-
- {attachment.format}
+
+ {attachment.filename}
+ {attachment.format}
+
+ {attachment.markdown.length.toLocaleString()} car.
-
- {attachment.markdown.length.toLocaleString()} chars
-
- {attachment.truncated && (
-
- truncated
-
- )}
+ {attachment.truncated && tronqué }
setAttachment(null)}
- className="ml-auto rounded p-0.5 hover:bg-emerald-200"
- aria-label="Remove attachment"
+ className="chat-attach-x"
+ aria-label="Retirer la pièce jointe"
>
)}
{uploadError && (
-
+
{uploadError}
)}
-
-
+
+
fileInputRef.current?.click()}
- disabled={disabled || uploading}
- aria-label="Attach a file"
- title="Attach a file (PDF/DOCX/XLSX/PPTX/TXT/MD/HTML) or image (PNG/JPG/etc., OCR'd server-side)"
- className="rounded border border-slate-300 bg-white px-3 text-slate-700 disabled:opacity-50 hover:bg-slate-100"
+ disabled={streaming || uploading}
+ className="chat-icon-btn"
+ aria-label="Joindre un fichier"
+ title="Joindre un fichier (PDF/DOCX/XLSX/PPTX/TXT/MD/HTML) ou une image (PNG/JPG/etc., OCR côté serveur)"
>
@@ -182,23 +173,34 @@ export function PromptInput({ value, onChange, onSubmit, disabled }: Props) {
onKeyDown={handleKeyDown}
placeholder={
uploading
- ? 'Extracting file…'
+ ? 'Extraction du fichier…'
: attachment
- ? 'Ask a question about the attached file…'
- : 'Type a message…'
+ ? 'Posez une question sur le fichier joint…'
+ : 'Écrivez un message…'
}
- rows={3}
- disabled={disabled || uploading}
- className="flex-1 min-w-0 rounded border border-slate-300 bg-white text-slate-900 placeholder:text-slate-400 p-2 resize-none focus:outline-none focus:ring-2 focus:ring-emerald-500"
+ rows={1}
+ disabled={streaming || uploading}
/>
-
-
-
+ {streaming ? (
+
+
+
+ ) : (
+
+
+
+ )}
);
diff --git a/apps/cockpit-public/src/styles.css b/apps/cockpit-public/src/styles.css
index bcae6eb..923e976 100644
--- a/apps/cockpit-public/src/styles.css
+++ b/apps/cockpit-public/src/styles.css
@@ -983,30 +983,274 @@ section.block {
}
}
-.chat-input {
- border-top: 1px solid var(--rule);
- padding: 14px 16px;
+/* AI disclaimer — editorial footnote, not a boxed alert. */
+.chat-note {
+ margin: 0;
+ padding: 9px 18px 9px 15px;
+ font-size: 11.5px;
+ line-height: 1.5;
+ color: var(--ink-3);
+ background: var(--paper);
+ border-bottom: 1px solid var(--rule);
+ border-left: 3px solid var(--warn);
+}
+.chat-note a {
+ color: var(--accent);
+ text-decoration: underline;
+}
+
+/* Collapsible inference parameters. */
+.chat-params {
+ border-bottom: 1px solid var(--rule);
+}
+.chat-params-toggle {
+ width: 100%;
display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 18px;
+ background: none;
+ border: 0;
+ cursor: pointer;
+ font-family: var(--mono);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--ink-3);
+}
+.chat-params-toggle:hover {
+ color: var(--ink);
+}
+.chat-params-caret {
+ color: var(--accent);
+}
+.chat-params-body {
+ padding: 2px 18px 14px;
+ display: flex;
+ flex-direction: column;
gap: 12px;
+}
+.chat-params-body label {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ font-family: var(--mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--ink-4);
+}
+.chat-params-body label b {
+ color: var(--accent);
+ font-weight: 400;
+}
+.chat-params-body input[type="number"],
+.chat-params-body textarea {
+ font-family: var(--mono);
+ font-size: 12px;
+ background: var(--paper);
+ border: 1px solid var(--rule);
+ padding: 6px 8px;
+ color: var(--ink);
+ text-transform: none;
+ letter-spacing: 0;
+ resize: none;
+}
+.chat-params-body input[type="range"] {
+ accent-color: var(--accent);
+}
+.chat-params-body input:focus,
+.chat-params-body textarea:focus {
+ outline: none;
+ border-color: var(--ink);
+}
+
+/* Empty-state welcome + seed prompts. */
+.chat-empty {
+ margin: auto;
+ max-width: 440px;
+ text-align: center;
+}
+.chat-empty > p {
+ font-family: var(--serif);
+ font-size: 20px;
+ line-height: 1.4;
+ color: var(--ink-3);
+ margin: 0 0 18px;
+}
+.chat-empty > p em {
+ color: var(--accent);
+ font-style: italic;
+}
+.chat-empty-prompts {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.chat-empty-prompts button {
+ text-align: left;
+ padding: 10px 14px;
+ background: var(--paper);
+ border: 1px solid var(--rule);
+ cursor: pointer;
+ font-family: var(--sans);
+ font-size: 13px;
+ line-height: 1.4;
+ color: var(--ink-2);
+}
+.chat-empty-prompts button:hover {
+ border-color: var(--ink);
+ color: var(--ink);
+}
+
+/* Pre-first-token feedback dots. */
+.chat-thinking {
+ display: inline-flex;
+ gap: 5px;
align-items: center;
+}
+.chat-thinking span {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--accent);
+ animation: blink 1.2s ease-in-out infinite;
+}
+.chat-thinking span:nth-child(2) {
+ animation-delay: 0.2s;
+}
+.chat-thinking span:nth-child(3) {
+ animation-delay: 0.4s;
+}
+.chat-error {
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--bad);
+ border-left: 3px solid var(--bad);
+ padding: 8px 16px;
+ margin: 0;
+}
+
+/* Input dock — attachment pill above, prompt row below. */
+.chat-input-wrap {
+ border-top: 1px solid var(--rule);
background: var(--paper-2);
}
+.chat-input {
+ padding: 14px 16px;
+ display: flex;
+ gap: 10px;
+ align-items: flex-end;
+}
.chat-input textarea {
flex: 1;
+ min-width: 0;
font-family: var(--sans);
font-size: 14px;
background: var(--paper);
border: 1px solid var(--rule);
- padding: 10px 14px;
+ padding: 11px 12px;
resize: none;
color: var(--ink);
- height: 42px;
+ height: 44px;
line-height: 1.4;
}
.chat-input textarea:focus {
outline: none;
border-color: var(--ink);
}
+.chat-input textarea:disabled {
+ opacity: 0.55;
+}
+.chat-icon-btn,
+.chat-send,
+.chat-stop {
+ height: 44px;
+ display: grid;
+ place-items: center;
+ border: 1px solid;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.chat-icon-btn {
+ width: 44px;
+ background: var(--paper);
+ border-color: var(--rule);
+ color: var(--ink-3);
+}
+.chat-icon-btn:hover:not(:disabled) {
+ border-color: var(--ink);
+ color: var(--ink);
+}
+.chat-send {
+ width: 48px;
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--paper);
+}
+.chat-send:hover:not(:disabled) {
+ background: var(--accent-deep);
+ border-color: var(--accent-deep);
+}
+.chat-stop {
+ width: 48px;
+ background: var(--bad);
+ border-color: var(--bad);
+ color: var(--paper);
+}
+.chat-send:disabled,
+.chat-icon-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Attachment pill. */
+.chat-attach {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 9px 16px 0;
+ font-family: var(--mono);
+ font-size: 11px;
+}
+.chat-attach-name {
+ color: var(--ink);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.chat-attach-tag {
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ background: var(--accent-soft);
+ color: var(--accent-deep);
+ padding: 1px 6px;
+ flex-shrink: 0;
+}
+.chat-attach-size {
+ color: var(--ink-4);
+ flex-shrink: 0;
+}
+.chat-attach-warn {
+ text-transform: uppercase;
+ background: var(--warn-soft);
+ color: var(--warn);
+ padding: 1px 6px;
+ flex-shrink: 0;
+}
+.chat-attach-x {
+ margin-left: auto;
+ background: none;
+ border: 0;
+ cursor: pointer;
+ color: var(--ink-4);
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+}
+.chat-attach-x:hover {
+ color: var(--ink);
+}
.chain-trace {
font-family: var(--mono);
diff --git a/apps/cockpit-public/tests/components/ChatPlayground.test.tsx b/apps/cockpit-public/tests/components/ChatPlayground.test.tsx
index d5d23a2..b556c43 100644
--- a/apps/cockpit-public/tests/components/ChatPlayground.test.tsx
+++ b/apps/cockpit-public/tests/components/ChatPlayground.test.tsx
@@ -16,7 +16,7 @@ vi.mock('@/hooks/useChatStream', () => ({
function readMaxTokensInput(): HTMLInputElement {
// ParamsPanel is collapsed by default — open it first.
- const toggle = screen.getByRole('button', { name: /parameters/i });
+ const toggle = screen.getByRole('button', { name: /paramètres/i });
fireEvent.click(toggle);
// The only number input in ParamsPanel is max_tokens (temperature is a
// range, system_prompt is a textarea).
diff --git a/apps/cockpit-public/tests/components/PromptInput.test.tsx b/apps/cockpit-public/tests/components/PromptInput.test.tsx
index 32e60ea..c17de6e 100644
--- a/apps/cockpit-public/tests/components/PromptInput.test.tsx
+++ b/apps/cockpit-public/tests/components/PromptInput.test.tsx
@@ -21,7 +21,7 @@ describe('PromptInput', () => {
it('submits plain text on Enter', () => {
const onSubmit = vi.fn();
render( );
- const textarea = screen.getByPlaceholderText(/Type a message/i);
+ const textarea = screen.getByPlaceholderText(/Écrivez un message/i);
fireEvent.change(textarea, { target: { value: 'hello' } });
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(onSubmit).toHaveBeenCalledWith('hello');
@@ -30,7 +30,7 @@ describe('PromptInput', () => {
it("doesn't submit on Shift+Enter (newline)", () => {
const onSubmit = vi.fn();
render( );
- const textarea = screen.getByPlaceholderText(/Type a message/i);
+ const textarea = screen.getByPlaceholderText(/Écrivez un message/i);
fireEvent.change(textarea, { target: { value: 'hello' } });
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();
@@ -87,14 +87,14 @@ describe('PromptInput', () => {
target: { files: [new File(['x'], 'q1.pdf', { type: 'application/pdf' })] },
});
await screen.findByText('q1.pdf');
- fireEvent.change(screen.getByPlaceholderText(/Ask a question/i), {
+ fireEvent.change(screen.getByPlaceholderText(/question sur le fichier/i), {
target: { value: 'summarize please' },
});
- fireEvent.keyDown(screen.getByPlaceholderText(/Ask a question/i), { key: 'Enter' });
+ fireEvent.keyDown(screen.getByPlaceholderText(/question sur le fichier/i), { key: 'Enter' });
expect(onSubmit).toHaveBeenCalled();
const payload = onSubmit.mock.calls[0]?.[0] as string | undefined;
if (!payload) throw new Error('onSubmit was not called with a payload');
- expect(payload).toContain('Attached file: q1.pdf');
+ expect(payload).toContain('Fichier joint : q1.pdf');
expect(payload).toContain('EXTRACTED');
expect(payload).toContain('summarize please');
});