SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%

feat(ui): official UQO wordmark + brand blue #0F6180, premium mobile pass (gradient hero, drawer grouped by date, refined bubbles, floating composer, skeletons, PWA icons/OG)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent d0aea63

31 changed files +265 −182

modified backend/app/tools/create_docx.py +1 −1
@@ -13,7 +13,7 @@ from app.llm.schemas import Artifact, ToolResult
13 13 from app.services import files as file_service
14 14 from app.tools.registry import ToolContext, registry
15 15
16 −UQO_BLUE = "00467F"
16 +UQO_BLUE = "0F6180"
17 17
18 18
19 19 class Args(BaseModel):
modified backend/app/tools/create_excel.py +3 −3
@@ -21,9 +21,9 @@ from app.tools.excel_spec import normalise_spec
21 21 from app.tools.excel_templates import TEMPLATES
22 22 from app.tools.registry import ToolContext, registry
23 23
24 −UQO_BLUE = "00467F"
25 −UQO_BLUE_DARK = "003057"
26 −UQO_BLUE_LIGHT = "E6EEF5"
24 +UQO_BLUE = "0F6180"
25 +UQO_BLUE_DARK = "0A4A63"
26 +UQO_BLUE_LIGHT = "E6F0F4"
27 27 UQO_GREEN = "78BE20"
28 28 INPUT_FILL = PatternFill("solid", fgColor="DDEBF7")
29 29 INPUT_FONT = Font(color="1F4E79", bold=False)
modified backend/app/tools/excel_ops.py +1 −1
@@ -24,7 +24,7 @@ from app.tools.create_excel import (
24 24 _readme,
25 25 )
26 26
27 −UQO_BLUE_DARK = "003057"
27 +UQO_BLUE_DARK = "0A4A63"
28 28 TOTAL_BORDER = Border(top=Side(style="medium", color=UQO_BLUE_DARK))
29 29
30 30
modified backend/app/tools/make_chart.py +2 −2
@@ -12,7 +12,7 @@ from app.services import files as file_service
12 12 from app.tools.coerce import num
13 13 from app.tools.registry import ToolContext, registry
14 14
15 −PALETTE = ["#00467F", "#78BE20", "#C6A300", "#5B6B7B", "#7FA7C9", "#C8102E", "#2E8B57", "#8B5CF6"]
15 +PALETTE = ["#0F6180", "#78BE20", "#C6A300", "#5B6B7B", "#7FA7C9", "#C8102E", "#2E8B57", "#8B5CF6"]
16 16
17 17
18 18 class Series(BaseModel):
@@ -113,7 +113,7 @@ def render(args: dict[str, Any]) -> bytes:
113 113 if n > 1 or any(s[0] for s in series):
114 114 ax.legend(frameon=False, fontsize=9)
115 115 if args.get("title"):
116 − ax.set_title(args["title"], fontsize=12, fontweight="bold", color="#003057", loc="left")
116 + ax.set_title(args["title"], fontsize=12, fontweight="bold", color="#0A4A63", loc="left")
117 117 fig.text(0.99, 0.01, "UQO-Chat · outil pédagogique", ha="right", va="bottom", fontsize=7, color="#5B6B7B")
118 118 fig.tight_layout()
119 119 buf = io.BytesIO()
modified frontend/index.html +3 −2
@@ -3,7 +3,7 @@
3 3 <head>
4 4 <meta charset="UTF-8" />
5 5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6 − <meta name="theme-color" content="#00467F" />
6 + <meta name="theme-color" content="#0F6180" />
7 7 <meta name="description" content="UQO-Chat — tuteur IA en évaluation immobilière pour les cours IMM1003 et IMM1033 de l'UQO." />
8 8 <meta name="apple-mobile-web-app-capable" content="yes" />
9 9 <meta name="apple-mobile-web-app-status-bar-style" content="default" />
@@ -12,7 +12,8 @@
12 12 <meta property="og:description" content="Comprendre, pratiquer et calculer en évaluation immobilière, 24/7." />
13 13 <meta property="og:image" content="/icons/og.png" />
14 14 <link rel="icon" href="/icons/favicon.svg" type="image/svg+xml" />
15 − <link rel="apple-touch-icon" href="/icons/icon-192.png" />
15 + <link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png" />
16 + <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
16 17 <link rel="preconnect" href="https://fonts.googleapis.com" />
17 18 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18 19 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
modified frontend/public/icons/apple-touch-icon.png +0 −0

Binary file not shown.

added frontend/public/icons/favicon-32.png +0 −0

Binary file not shown.

added frontend/public/icons/favicon-64.png +0 −0

Binary file not shown.

modified frontend/public/icons/favicon.svg +1 −1
@@ -1 +1 @@
1 −<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="1" y="1" width="30" height="30" rx="8" fill="#00467F"/><path d="M8 24V13l8-6 8 6v11h-5v-6h-6v6H8z" fill="#fff"/><rect x="21" y="8" width="3" height="4" rx=".6" fill="#78BE20"/></svg>
1 +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64"><image width="64" height="64" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAHG0lEQVR4nO1be1BUVRj/3bOLPOWhooCg4gtlFHyBCkmOL3zrTKZik+9MqzGzmqz+kKayMnXSGTNnCkxnxEpr8lG+GhU11DA1Hyj4FlIUwQUEdmF3m+9b7vWyArIiItx+zJ0997x/v/Od7ztnAeB/aBvS4zTymhFvxTMKQ2K8Q5ykxkC6NmKIxkq+pvOWattBQ7cGoQXy1fERjlRu6KiMl4DGIbSy+lXxE9UVNlaoeQpoHAIah9Ca+dvzFdA4BDQOAY1DQOMQ0DgENA4BjUNA49A/rYF0QoLZYjtrNfNwhZ93U0iShOx7BcgpKOJ8IUmw0o+16rZebi4Iau4FJ50O2YZC/JuXz/lS+dcd9m2fqAD9O7eBp4szp6/cyUP6zZwKAwf4eKJ7UCt+L7NYcPD8VZjKzEyMCAzo0g6vDI5EZMcgNPdw47Z5hcU4fiULCftSsfufDIWMTERu2ys4APOG9sNzIe3QwtOdRSkoNuJsZjbWJ5/Apj9PPdS2JpAcOQqf+Hw+glv6cHrNniN4P2kX9EIohKc93wsrp41R6oe8tRy38wt5Qp9OGobXY/tX2//65L+xcP0OWKxsBwr5BSOjsfiFIYrYlYHEm712CwpKjPxeExEMifGSQz6AOqcJ0VNkLH2onFZbLjcUlbC6NJElk2OZPIlUFUrNFkyN6YVVM8awACQs9TN3aF/ETxjCglC+DHlLEIxlZgwL64Sk+XHcTnLg2369IwLQipDpcbr8Uw0aWC5votfBUGxEZIdAvDasH4tDebmFxVi2PRkH0q7AYrEiqnMbvDM6Bv4+TZnIlOge+P1kOrYdT0OIfwt8PHEYC0djG0vLsGLHIew8lY4SUynC2vrj7VEDEBrYkttGh7TFnCF9sXpXSt0I4Cho1d4YHsVWQILl3S/GqC/WIS3rtlKH0ntOX8TuD2bC19OD6745IpoFmDUoAk46wdZBIkxamYTktCtK24xbd7HrVDp+WzQD3YP82ELmDI7Ed/v+QomprH7DoLHUjABvT0S0D+S9S6a5du9RJuys1/GK0uPipMf1nHtYvv2gYj3dAluhpac7IjoEsiAkws/HzjJ5dVtKF5aY8NHmvYp/aNvCG2FBfjWep3iSpNVOilbM19Md3u4uSl5KxnWeOJXRatEjR4ljlzKVei5N9Ojo1wLebq5Kn4cuXFWcotLWbObyszeyecU5jFqBwOZedSOAVeWE2KxVjCmpdlJUt6S0rEKeu3MTW12Vk5L7cHN2qtB3kclUwWlS/KctpRaZ+mFLcNJDpxPcjspp3DoRoLDEpKQpjhM5Mlt9+eCerrYzAsFsteJS9l1k5eYrISkuKlwRhCYqk6e8yf3Dlba594uQlnkbWbkGbkvPi3278yf5A9oStKXkA9KEft05jwQiZ3g+607dCHAtJ48nQYOO7BmCTn7NeUBS3MfdFdNieivmecdQyIJRfCaeZOpjenflmE4rS/1QPUrPHNiHQyDVobp7T1/kfskRcluzGT3a+WPF1FHsM2SnSJ/j+oTi3dExnCZBU9Kv4fLt3LqJAj+mnMbkqHCYzbb9vfvDWdiamsYCDO/RmR0QTZycU1L5yWz17hQObXT8JYIU03u2C8D8ddvQO7g1lsTFokuAb/nK6lBsKsXSrQeYzMbDpzB7UAS6traFORIqpksw/jhzCfeNJm7/fGiwIjphyS/7HKGEGp8EaUI0yNezxjEhWgH5FChDjvVHMq5j7JfrYbZY2FoGhrbHDwumsDCyQLRKi3/ayyTIKgjU/4w1m/Fr6jkl/NFZYNt70zkqyOavhjpvUdJOfLPnaIW7wxM7CVrLj6bzE7chYX/qQ+QJRH7nyXTErdrEYsg+Yv+5yxi79HtcvHVXEaF9y2ZImDsBmbkGTF6VxHUmfrWRyVMb2aQv3MzB8CUJXG5PnkB5mXcNmLV2i0PkH+suIKkuGn07BmF8RCibp04IXM7OxY4T5yu90MiTIk++9KURmNQ/zOZLrDYrItEWbtjBNzvZ0mSo38mSYsM7obO/L4tNDpbC49bUc8gvNjpM3pAY/yCoOPJ7AftJ2otEqO5KSw7vs7hYDouyCdPdgbZE0uGTnGcvQmXX5Kr6rykMjl6GZCjhTwjlVCaHJTls2YMmR+JQHbr1Df7kWzZreSt5uDjj1SGR6Bbkp2w39XjUp/2Y8jtVdZR8re8CZh7wwaBVWYQaNrO33fQoVo9ftgH9OrXhLzjO3MiucEcgER41pi1pbRjfCKmhPuFRxDhicxv1AgGNQ0DjENA4BDQOAY1DQOMQ0DhEfQ4+7+V1/GhSgHkq4vUpgoDGIepr4DUbpleaftqQ1C9a+lM5+c/nBTQOAY1D1OYfjhoq1DxFdYWNEfb8BDQOUVlmY7WCyngJRyo3ZFTFR6pJ44Z8PnjUQoon0cmziprMW3qcjp9li2ioi4X6wn8m6KGMj1O2WgAAAABJRU5ErkJggg=="/></svg>
\ No newline at end of file
modified frontend/public/icons/icon-192.png +0 −0

Binary file not shown.

modified frontend/public/icons/icon-512.png +0 −0

Binary file not shown.

modified frontend/public/icons/icon-maskable-512.png +0 −0

Binary file not shown.

modified frontend/public/icons/og.png +0 −0

Binary file not shown.

deleted frontend/public/uqo-chat-logo.svg +0 −1
@@ -1 +0,0 @@
1 −<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="1" y="1" width="30" height="30" rx="8" fill="#00467F"/><path d="M8 24V13l8-6 8 6v11h-5v-6h-6v6H8z" fill="#fff"/><rect x="21" y="8" width="3" height="4" rx=".6" fill="#78BE20"/></svg>
added frontend/public/uqo-logo-white.png +0 −0

Binary file not shown.

added frontend/public/uqo-logo.png +0 −0

Binary file not shown.

modified frontend/src/components/chat/chat-view.tsx +9 −2
@@ -61,7 +61,14 @@ export function ChatView() {
61 61 {isStreaming && <div className="progress-bar" aria-hidden="true" />}
62 62 {id && warnings[id] && <div className="bg-[#fff8dc] text-[#7a6300] text-sm px-4 py-2 text-center">{warnings[id]}</div>}
63 63 <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto scroll-thin">
64 − {!id || list.length === 0 ? (
64 + {id && !messages[id] ? (
65 + <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-6 space-y-5" aria-busy="true">
66 + <div className="flex justify-end"><div className="skeleton h-12 w-[60%]" /></div>
67 + <div className="space-y-2"><div className="skeleton h-14 w-full" /><div className="skeleton h-28 w-[92%]" /></div>
68 + <div className="flex justify-end"><div className="skeleton h-10 w-[45%]" /></div>
69 + <div className="skeleton h-36 w-[88%]" />
70 + </div>
71 + ) : !id || list.length === 0 ? (
65 72 <EmptyState course={courseObj} onPick={(t) => handleSend(t, [])} term={courseObj?.term || ''} />
66 73 ) : (
67 74 <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-4 space-y-5">
@@ -81,7 +88,7 @@ export function ChatView() {
81 88 </div>
82 89 {!atBottom && list.length > 0 && (
83 90 <button onClick={() => { setAtBottom(true); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }}
84 − className="absolute right-4 bottom-[196px] sm:bottom-[150px] h-10 w-10 rounded-full bg-white border border-neutral-line shadow-card inline-flex items-center justify-center text-uqo-blue" aria-label="Aller en bas">
91 + className="absolute right-4 bottom-[172px] sm:bottom-[150px] h-10 w-10 rounded-full bg-white border border-neutral-line shadow-float inline-flex items-center justify-center text-uqo-blue active:scale-95 transition" aria-label="Aller en bas">
85 92 <ArrowDown size={18} />
86 93 </button>
87 94 )}
modified frontend/src/components/chat/composer.tsx +9 −9
@@ -111,12 +111,12 @@ export function Composer({ conversationId, streaming, onSend, onStop, deep, onTo
111 111 };
112 112
113 113 return (
114 − <div className="px-3 sm:px-6 pb-[calc(8px+var(--safe-bottom))] pt-2 bg-gradient-to-t from-neutral-bg via-neutral-bg to-transparent">
114 + <div className="px-2.5 sm:px-6 pb-[calc(6px+var(--safe-bottom))] pt-2 bg-gradient-to-t from-[#f4f7f9] via-[#f4f7f9]/95 to-transparent">
115 115 <div
116 116 onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
117 117 onDragLeave={() => setDrag(false)}
118 118 onDrop={onDrop}
119 − className={cn('mx-auto max-w-[900px] rounded-2xl border bg-white shadow-card transition-colors', drag ? 'border-uqo-green ring-2 ring-uqo-green/30' : 'border-neutral-line')}
119 + className={cn('mx-auto max-w-[900px] rounded-[22px] border bg-white shadow-float transition-colors focus-within:border-uqo-blue/60', drag ? 'border-uqo-green ring-2 ring-uqo-green/30' : 'border-neutral-line')}
120 120 >
121 121 {(files.length > 0 || error) && (
122 122 <div className="flex flex-wrap gap-2 px-3 pt-3">
@@ -135,10 +135,10 @@ export function Composer({ conversationId, streaming, onSend, onStop, deep, onTo
135 135 onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
136 136 onKeyDown={onKey}
137 137 rows={1}
138 − placeholder={disabled ? 'Connexion requise' : 'Pose ta question… (Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne)'}
138 + placeholder={disabled ? 'Connexion requise' : window.innerWidth < 640 ? 'Pose ta question au tuteur…' : 'Pose ta question… (Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne)'}
139 139 aria-label="Message"
140 140 disabled={disabled}
141 − className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-[16px] outline-none placeholder:text-neutral-muted/70 max-h-[200px] scroll-thin"
141 + className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-[16px] leading-relaxed outline-none placeholder:text-neutral-muted/70 max-h-[200px] scroll-thin"
142 142 />
143 143 <div className="flex items-center gap-1 px-2 pb-2">
144 144 <input ref={fileRef} type="file" multiple hidden accept=".xlsx,.xls,.csv,.pdf,.png,.jpg,.jpeg,.webp,.txt,.md,.docx,.json" onChange={(e) => e.target.files && upload(e.target.files)} />
@@ -151,21 +151,21 @@ export function Composer({ conversationId, streaming, onSend, onStop, deep, onTo
151 151 </button>
152 152 )}
153 153 <select value={course} onChange={(e) => onCourseChange(e.target.value)} aria-label="Cours" disabled={!!conversationId}
154 − className="h-9 rounded-lg border border-neutral-line bg-white px-2 text-xs font-medium text-uqo-blue-dark disabled:opacity-70">
154 + className="h-9 rounded-full border border-neutral-line bg-neutral-surface px-2.5 text-xs font-semibold text-uqo-blue-dark disabled:opacity-70">
155 155 {courses.map((c) => <option key={c} value={c}>{c}</option>)}
156 156 </select>
157 − <button onClick={onToggleDeep} className={cn('h-9 px-2.5 rounded-lg text-xs font-medium inline-flex items-center gap-1 border', deep ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line text-neutral-muted hover:bg-neutral-surface')} title="Réflexion approfondie : modèle plus puissant, plus lent" aria-pressed={deep}>
157 + <button onClick={onToggleDeep} className={cn('h-9 px-2.5 rounded-full text-xs font-semibold inline-flex items-center gap-1 border transition', deep ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line bg-neutral-surface text-neutral-muted hover:bg-white')} title="Réflexion approfondie : modèle plus puissant, plus lent" aria-pressed={deep}>
158 158 <Brain size={14} /> <span className="hidden sm:inline">Réflexion approfondie</span><span className="sm:hidden">Approfondi</span>
159 159 </button>
160 160 <div className="flex-1" />
161 161 {streaming ? (
162 − <button onClick={onStop} className="h-11 w-11 inline-flex items-center justify-center rounded-xl bg-neutral-text text-white" aria-label="Arrêter"><Square size={16} /></button>
162 + <button onClick={onStop} className="h-11 w-11 inline-flex items-center justify-center rounded-full bg-neutral-text text-white shadow-soft active:scale-95 transition" aria-label="Arrêter"><Square size={16} /></button>
163 163 ) : (
164 − <button onClick={send} disabled={!text.trim() || disabled} className="h-11 w-11 inline-flex items-center justify-center rounded-xl bg-uqo-blue text-white disabled:bg-neutral-line disabled:text-neutral-muted" aria-label="Envoyer"><ArrowUp size={19} /></button>
164 + <button onClick={send} disabled={!text.trim() || disabled} className="h-11 w-11 inline-flex items-center justify-center rounded-full bg-uqo-gradient text-white shadow-soft active:scale-95 transition disabled:bg-none disabled:bg-neutral-line disabled:text-neutral-muted disabled:shadow-none" aria-label="Envoyer"><ArrowUp size={19} /></button>
165 165 )}
166 166 </div>
167 167 </div>
168 − <p className="mx-auto max-w-[900px] mt-1.5 text-center text-[11px] text-neutral-muted px-2">Outil pédagogique. UQO-Chat peut se tromper : vérifie les calculs importants. Ne constitue pas une évaluation professionnelle.</p>
168 + <p className="mx-auto max-w-[900px] mt-1.5 text-center text-[10.5px] leading-tight text-neutral-muted px-2"><span className="sm:hidden">Outil pédagogique — vérifie les calculs importants.</span><span className="hidden sm:inline">Outil pédagogique. UQO-Chat peut se tromper : vérifie les calculs importants. Ne constitue pas une évaluation professionnelle.</span></p>
169 169 </div>
170 170 );
171 171 }
modified frontend/src/components/chat/empty-state.tsx +37 −18
@@ -1,39 +1,58 @@
1 −import { Calculator, FileSpreadsheet, ListChecks, Sparkles } from 'lucide-react';
1 +import { BookOpen, Calculator, FileSpreadsheet, ListChecks, MessageSquareText, Sparkles } from 'lucide-react';
2 2 import type { Course } from '@/lib/types';
3 −import { Logo } from '@/components/ui/logo';
4 3
5 −const ICONS = [Sparkles, Calculator, ListChecks, FileSpreadsheet, Sparkles];
4 +const ICONS = [Sparkles, Calculator, ListChecks, FileSpreadsheet, MessageSquareText];
6 5
7 6 export function EmptyState({ course, onPick, term }: { course?: Course; onPick: (t: string) => void; term: string }) {
8 7 return (
9 − <div className="mx-auto max-w-[760px] px-4 pt-8 sm:pt-16 pb-6 text-center animate-fadein">
10 − <Logo className="justify-center" withText={false} />
11 − <h1 className="mt-4 text-xl sm:text-2xl font-bold text-uqo-blue-dark">Bonjour ! Sur quoi travaille-t-on aujourd'hui ?</h1>
12 − <p className="mt-2 text-sm sm:text-base text-neutral-muted">
13 − {course ? <><b className="text-uqo-blue">{course.code}</b> — {course.title}</> : 'Tuteur IA en évaluation immobilière'} · {term}
14 − </p>
8 + <div className="mx-auto max-w-[780px] px-3 sm:px-4 pt-3 sm:pt-10 pb-6 animate-fadein">
9 + <section className="relative overflow-hidden rounded-[24px] bg-uqo-gradient text-white px-5 py-6 sm:px-8 sm:py-8 shadow-float">
10 + <svg className="absolute -right-10 -top-10 h-56 w-56 opacity-15" viewBox="0 0 200 200" aria-hidden="true"><circle cx="100" cy="100" r="100" fill="white" /></svg>
11 + <svg className="absolute right-16 bottom-[-60px] h-40 w-40 opacity-10" viewBox="0 0 200 200" aria-hidden="true"><circle cx="100" cy="100" r="100" fill="#78BE20" /></svg>
12 + <img src="/uqo-logo-white.png" alt="UQO" className="h-9 sm:h-11 w-auto" />
13 + <h1 className="mt-4 text-[22px] sm:text-[28px] font-bold leading-tight tracking-tight">Bonjour ! Sur quoi travaille-t-on aujourd'hui ?</h1>
14 + <p className="mt-2 text-sm sm:text-base text-white/85">
15 + {course ? <><span className="inline-block rounded-md bg-white/15 px-1.5 py-0.5 font-semibold text-white">{course.code}</span> {course.title}</> : 'Tuteur IA en évaluation immobilière'}
16 + {term ? <span className="text-white/70"> · {term}</span> : null}
17 + </p>
18 + <ul className="mt-4 flex flex-wrap gap-1.5 text-[11.5px] text-white/85">
19 + {['Explique et cite tes notes de cours', 'Calcule en Python vérifié', 'Crée et modifie des Excel', 'Quiz et fiches Word'].map((t) => (
20 + <li key={t} className="rounded-full bg-white/12 px-2.5 py-1 backdrop-blur">{t}</li>
21 + ))}
22 + </ul>
23 + </section>
24 +
15 25 {course?.announcement && (
16 − <div className="mt-4 rounded-xl border border-uqo-gold/50 bg-[#fff8dc] text-left px-4 py-3 text-sm">
26 + <div className="mt-3 rounded-2xl border border-uqo-gold/40 bg-[#fff8dc] px-4 py-3 text-sm shadow-soft">
17 27 <b className="text-[#7a6300]">Annonce du professeur</b>
18 28 <p className="mt-0.5">{course.announcement}</p>
19 29 </div>
20 30 )}
21 − <div className="mt-6 grid gap-2 sm:grid-cols-2">
31 +
32 + <h2 className="mt-5 mb-2 px-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-muted">Pour commencer</h2>
33 + <div className="grid gap-2 sm:grid-cols-2">
22 34 {(course?.suggestions || []).map((s, i) => {
23 35 const Icon = ICONS[i % ICONS.length];
24 36 return (
25 − <button key={s} onClick={() => onPick(s)} className="flex items-center gap-3 rounded-xl border border-neutral-line bg-white px-4 py-3 text-left text-sm hover:border-uqo-blue hover:bg-uqo-blue-light/40 min-h-[56px] shadow-card">
26 − <Icon size={18} className="text-uqo-blue shrink-0" />
27 − <span>{s}</span>
37 + <button key={s} onClick={() => onPick(s)} className="group flex items-center gap-3 rounded-2xl border border-neutral-line bg-white px-4 py-3.5 text-left text-[14.5px] hover:border-uqo-blue/50 hover:shadow-soft active:scale-[0.99] transition min-h-[58px]">
38 + <span className="h-9 w-9 rounded-xl bg-uqo-blue-light text-uqo-blue inline-flex items-center justify-center shrink-0 group-hover:bg-uqo-blue group-hover:text-white transition"><Icon size={17} /></span>
39 + <span className="text-neutral-text">{s}</span>
28 40 </button>
29 41 );
30 42 })}
31 43 </div>
44 +
45 + {course?.site && (
46 + <a href={course.site} target="_blank" rel="noopener noreferrer" className="mt-3 flex items-center gap-3 rounded-2xl border border-dashed border-neutral-line px-4 py-3 text-sm text-neutral-muted hover:border-uqo-blue/50 hover:text-uqo-blue-dark">
47 + <BookOpen size={16} className="text-uqo-blue" /> Notes de cours interactives : {course.site.replace('https://', '')}
48 + </a>
49 + )}
50 +
32 51 {course?.deadlines?.length ? (
33 − <div className="mt-6 text-left">
34 − <div className="text-xs font-semibold text-neutral-muted uppercase tracking-wide">Échéances</div>
35 − <ul className="mt-1 text-sm space-y-1">
36 − {course.deadlines.map((d) => <li key={d.label} className="flex justify-between border-b border-neutral-line/70 py-1"><span>{d.label}</span><span className="text-neutral-muted">{d.date}</span></li>)}
52 + <div className="mt-5">
53 + <h2 className="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-muted">Échéances</h2>
54 + <ul className="rounded-2xl border border-neutral-line bg-white divide-y divide-neutral-line/70">
55 + {course.deadlines.map((d) => <li key={d.label} className="flex justify-between px-4 py-2.5 text-sm"><span>{d.label}</span><span className="text-neutral-muted tabular-nums">{d.date}</span></li>)}
37 56 </ul>
38 57 </div>
39 58 ) : null}
modified frontend/src/components/chat/message-bubble.tsx +22 −13
@@ -6,6 +6,14 @@ import { ToolCallTimeline } from './tool-call-timeline';
6 6 import { cn } from '@/lib/cn';
7 7 import { fmtDate } from '@/lib/format';
8 8
9 +function TutorAvatar() {
10 + return (
11 + <span className="h-8 w-8 rounded-full bg-uqo-gradient items-center justify-center shrink-0 mt-1 hidden sm:inline-flex shadow-soft" aria-hidden="true">
12 + <img src="/uqo-logo-white.png" alt="" className="h-3 w-auto" />
13 + </span>
14 + );
15 +}
16 +
9 17 export function MessageBubble({ msg, conversationId, initials, onRegenerate, onFeedback, isLast }: {
10 18 msg: Message;
11 19 conversationId: string;
@@ -24,12 +32,12 @@ export function MessageBubble({ msg, conversationId, initials, onRegenerate, onF
24 32 if (msg.role === 'user') {
25 33 return (
26 34 <div className="flex justify-end gap-2 animate-fadein">
27 − <div className="max-w-[88%] sm:max-w-[75%]">
28 − <div className="rounded-2xl rounded-br-md bg-uqo-blue text-white px-4 py-3 text-[15px] whitespace-pre-wrap break-words">{msg.content}</div>
29 − {msg.attachments && msg.attachments.length > 0 && (
30 − <div className="mt-1 text-xs text-neutral-muted inline-flex items-center gap-1 justify-end w-full"><Paperclip size={12} /> {msg.attachments.length} fichier(s)</div>
31 − )}
32 − <div className="mt-1 text-[11px] text-neutral-muted text-right">{fmtDate(msg.created_at)}</div>
35 + <div className="max-w-[86%] sm:max-w-[72%]">
36 + <div className="rounded-[20px] rounded-br-lg bg-uqo-gradient text-white px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap break-words shadow-soft">{msg.content}</div>
37 + <div className="mt-1 flex items-center justify-end gap-2 text-[11px] text-neutral-muted">
38 + {msg.attachments && msg.attachments.length > 0 && <span className="inline-flex items-center gap-1"><Paperclip size={11} /> {msg.attachments.length} fichier(s)</span>}
39 + <span>{fmtDate(msg.created_at)}</span>
40 + </div>
33 41 </div>
34 42 <span className="hidden sm:inline-flex h-8 w-8 rounded-full bg-uqo-blue-light text-uqo-blue-dark items-center justify-center text-xs font-semibold shrink-0 mt-1">{initials}</span>
35 43 </div>
@@ -39,10 +47,11 @@ export function MessageBubble({ msg, conversationId, initials, onRegenerate, onF
39 47 const empty = !msg.content && !(msg.tool_calls && msg.tool_calls.length);
40 48 return (
41 49 <div className="flex gap-2 sm:gap-3 animate-fadein group">
42 − <span className="h-8 w-8 rounded-full bg-uqo-blue text-white items-center justify-center text-[11px] font-bold shrink-0 mt-1 hidden sm:inline-flex">UQ</span>
50 + <TutorAvatar />
43 51 <div className="min-w-0 flex-1 max-w-[900px]">
52 + <div className="sm:hidden mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-uqo-blue-dark"><img src="/uqo-logo.png" alt="" className="h-2.5 w-auto" /> Tuteur</div>
44 53 {msg.tool_calls && msg.tool_calls.length > 0 && <ToolCallTimeline calls={msg.tool_calls} conversationId={conversationId} />}
45 − <div className={cn('rounded-2xl rounded-tl-md bg-white border border-neutral-line shadow-card px-4 py-3 text-[15px]', empty && msg.streaming && 'inline-block')}>
54 + <div className={cn('rounded-[20px] rounded-tl-lg bg-white border border-neutral-line/80 shadow-soft px-4 py-3.5 text-[15px]', empty && msg.streaming && 'inline-block')}>
46 55 {empty && msg.streaming ? (
47 56 <span className="inline-flex gap-1 items-center h-5" aria-label="Le tuteur réfléchit">
48 57 <i className="h-2 w-2 rounded-full bg-uqo-blue/60 animate-bounce [animation-delay:-0.2s]" />
@@ -58,17 +67,17 @@ export function MessageBubble({ msg, conversationId, initials, onRegenerate, onF
58 67 </div>
59 68 {!msg.streaming && (
60 69 <div className="mt-1 flex items-center gap-0.5 text-neutral-muted">
61 − <button onClick={copy} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface" aria-label="Copier">{copied ? <Check size={15} className="text-uqo-green" /> : <Copy size={15} />}</button>
70 + <button onClick={copy} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition" aria-label="Copier">{copied ? <Check size={15} className="text-uqo-green" /> : <Copy size={15} />}</button>
62 71 {onFeedback && (
63 72 <>
64 − <button onClick={() => onFeedback(msg.feedback === 'up' ? null : 'up')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface', msg.feedback === 'up' && 'text-uqo-green')} aria-label="Réponse utile"><ThumbsUp size={15} /></button>
65 − <button onClick={() => onFeedback(msg.feedback === 'down' ? null : 'down')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface', msg.feedback === 'down' && 'text-semantic-error')} aria-label="Réponse à améliorer"><ThumbsDown size={15} /></button>
73 + <button onClick={() => onFeedback(msg.feedback === 'up' ? null : 'up')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition', msg.feedback === 'up' && 'text-uqo-green')} aria-label="Réponse utile"><ThumbsUp size={15} /></button>
74 + <button onClick={() => onFeedback(msg.feedback === 'down' ? null : 'down')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition', msg.feedback === 'down' && 'text-semantic-error')} aria-label="Réponse à améliorer"><ThumbsDown size={15} /></button>
66 75 </>
67 76 )}
68 77 {isLast && onRegenerate && (
69 − <button onClick={onRegenerate} className="h-9 px-2 inline-flex items-center gap-1 rounded-lg hover:bg-neutral-surface text-xs" aria-label="Régénérer"><RefreshCw size={14} /> Régénérer</button>
78 + <button onClick={onRegenerate} className="h-9 px-2 inline-flex items-center gap-1 rounded-lg hover:bg-white text-xs" aria-label="Régénérer"><RefreshCw size={14} /> Régénérer</button>
70 79 )}
71 − <span className="ml-auto text-[11px]">{msg.model ? msg.model.split('/').pop() : ''} · {fmtDate(msg.created_at)}</span>
80 + <span className="ml-auto text-[11px] truncate">{msg.model ? msg.model.split('/').pop() : ''} · {fmtDate(msg.created_at)}</span>
72 81 </div>
73 82 )}
74 83 </div>
modified frontend/src/components/layout/app-shell.tsx +15 −13
@@ -28,8 +28,8 @@ export function AppShell() {
28 28 <div className="hidden lg:block shrink-0"><Sidebar /></div>
29 29 <RD.Root open={sidebarOpen} onOpenChange={setSidebar}>
30 30 <RD.Portal>
31 − <RD.Overlay className="fixed inset-0 z-40 bg-uqo-blue-dark/40 lg:hidden" />
32 − <RD.Content className="fixed inset-y-0 left-0 z-50 lg:hidden outline-none animate-fadein" aria-describedby={undefined}>
31 + <RD.Overlay className="fixed inset-0 z-40 bg-uqo-blue-dark/50 backdrop-blur-[2px] lg:hidden animate-fadein" />
32 + <RD.Content className="fixed inset-y-0 left-0 z-50 lg:hidden outline-none data-[state=open]:animate-[slidein_.22s_ease-out]" aria-describedby={undefined}>
33 33 <RD.Title className="sr-only">Menu</RD.Title>
34 34 <Sidebar onClose={() => setSidebar(false)} />
35 35 </RD.Content>
@@ -37,29 +37,31 @@ export function AppShell() {
37 37 </RD.Root>
38 38
39 39 <main className="relative flex min-w-0 flex-1 flex-col">
40 − <header className="flex items-center gap-1 border-b border-neutral-line bg-white px-2 sm:px-4 pt-[var(--safe-top)] h-[calc(56px+var(--safe-top))] shrink-0">
41 − <button onClick={() => setSidebar(true)} className="lg:hidden h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface" aria-label="Ouvrir le menu"><Menu size={22} /></button>
42 − <div className="lg:hidden"><Logo withText={false} /></div>
40 + <header className="relative z-10 flex items-center gap-1 bg-white/90 backdrop-blur border-b border-neutral-line px-2 sm:px-4 pt-[var(--safe-top)] h-[calc(58px+var(--safe-top))] shrink-0">
41 + <span className="absolute inset-x-0 top-0 h-[3px] bg-gradient-to-r from-uqo-blue via-uqo-blue-dark to-uqo-green lg:hidden" aria-hidden="true" />
42 + <button onClick={() => setSidebar(true)} className="lg:hidden h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface active:scale-95 transition" aria-label="Ouvrir le menu"><Menu size={22} /></button>
43 + <div className="lg:hidden pl-1"><Logo withText={false} size="sm" /></div>
43 44 <div className="min-w-0 flex-1 px-2">
44 − <div className="truncate text-sm sm:text-base font-semibold text-uqo-blue-dark">{conv?.title || 'Nouvelle conversation'}</div>
45 − <div className="truncate text-[11px] text-neutral-muted">{conv ? `${conv.course}` : 'Tuteur IA — IMM1003 · IMM1033'}</div>
45 + <div className="truncate text-[15px] sm:text-base font-semibold text-uqo-blue-dark tracking-tight">{conv?.title || 'Nouvelle conversation'}</div>
46 + <div className="truncate text-[11px] text-neutral-muted">{conv ? <><span className="inline-block h-1.5 w-1.5 rounded-full bg-uqo-green mr-1 align-middle" />{conv.course}</> : 'Tuteur IA · IMM1003 · IMM1033'}</div>
46 47 </div>
47 − <button onClick={() => nav('/')} className="lg:hidden h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface" aria-label="Nouvelle conversation"><Plus size={22} /></button>
48 + <button onClick={() => nav('/')} className="lg:hidden h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface active:scale-95 transition text-uqo-blue" aria-label="Nouvelle conversation"><Plus size={22} /></button>
48 49 {id && (
49 − <button onClick={() => setPanel(!panelOpen)} className={cn('h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface', panelOpen && 'bg-uqo-blue-light text-uqo-blue')} aria-label="Fichiers et sources de la conversation" aria-pressed={panelOpen}><PanelRight size={20} /></button>
50 + <button onClick={() => setPanel(!panelOpen)} className={cn('h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface active:scale-95 transition', panelOpen && 'bg-uqo-blue-light text-uqo-blue')} aria-label="Fichiers et sources de la conversation" aria-pressed={panelOpen}><PanelRight size={20} /></button>
50 51 )}
51 52 </header>
52 − <div className="flex min-h-0 flex-1">
53 + <div className="flex min-h-0 flex-1 bg-uqo-soft">
53 54 <div className="relative min-w-0 flex-1"><Outlet /></div>
54 55 {id && panelOpen && (
55 − <div className="hidden md:block w-[320px] shrink-0 border-l border-neutral-line bg-white"><ArtifactsPanel conversationId={id} /></div>
56 + <div className="hidden md:block w-[330px] shrink-0 border-l border-neutral-line bg-white"><ArtifactsPanel conversationId={id} /></div>
56 57 )}
57 58 </div>
58 59 {id && panelOpen && (
59 60 <RD.Root open onOpenChange={setPanel}>
60 61 <RD.Portal>
61 − <RD.Overlay className="fixed inset-0 z-40 bg-uqo-blue-dark/40 md:hidden" />
62 − <RD.Content className="fixed inset-x-0 bottom-0 z-50 md:hidden max-h-[80dvh] rounded-t-2xl bg-white outline-none" aria-describedby={undefined}>
62 + <RD.Overlay className="fixed inset-0 z-40 bg-uqo-blue-dark/50 backdrop-blur-[2px] md:hidden" />
63 + <RD.Content className="fixed inset-x-0 bottom-0 z-50 md:hidden max-h-[82dvh] rounded-t-3xl bg-white outline-none shadow-float" aria-describedby={undefined}>
64 + <div className="mx-auto mt-2 h-1.5 w-10 rounded-full bg-neutral-line" aria-hidden="true" />
63 65 <RD.Title className="sr-only">Fichiers et sources</RD.Title>
64 66 <ArtifactsPanel conversationId={id} onClose={() => setPanel(false)} />
65 67 </RD.Content>
modified frontend/src/components/layout/sidebar.tsx +80 −55
@@ -1,4 +1,4 @@
1 −import { useEffect, useState } from 'react';
1 +import { useEffect, useMemo, useState } from 'react';
2 2 import { Link, useNavigate, useParams } from 'react-router-dom';
3 3 import { Archive, BarChart3, LogOut, MoreHorizontal, Pencil, Pin, PinOff, Plus, Search, Settings, ShieldCheck, Trash2, X } from 'lucide-react';
4 4 import * as DM from '@radix-ui/react-dropdown-menu';
@@ -8,6 +8,19 @@ import { useUI } from '@/stores/ui';
8 8 import { Logo } from '@/components/ui/logo';
9 9 import { cn } from '@/lib/cn';
10 10 import { fmtDate } from '@/lib/format';
11 +import type { Conversation } from '@/lib/types';
12 +
13 +function groupLabel(iso: string): string {
14 + const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
15 + const now = new Date();
16 + const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
17 + const diffDays = Math.floor((startToday - new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()) / 86400000);
18 + if (diffDays <= 0) return "Aujourd'hui";
19 + if (diffDays === 1) return 'Hier';
20 + if (diffDays < 7) return '7 derniers jours';
21 + if (diffDays < 30) return '30 derniers jours';
22 + return 'Plus ancien';
23 +}
11 24
12 25 export function Sidebar({ onClose }: { onClose?: () => void }) {
13 26 const { id } = useParams();
@@ -25,79 +38,91 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
25 38 return () => clearTimeout(t);
26 39 }, [q, loadConversations]);
27 40
28 − const go = (path: string) => {
29 − nav(path);
30 − onClose?.();
31 − };
32 −
33 − const rename = async (cid: string) => {
34 − const title = draft.trim();
35 − setRenaming(null);
36 − if (title) await updateConversation(cid, { title });
37 − };
41 + const groups = useMemo(() => {
42 + const pinned = conversations.filter((c) => c.pinned);
43 + const rest = conversations.filter((c) => !c.pinned);
44 + const out: { label: string; items: Conversation[] }[] = [];
45 + if (pinned.length) out.push({ label: 'Épinglées', items: pinned });
46 + for (const c of rest) {
47 + const label = groupLabel(c.updated_at);
48 + const g = out.find((x) => x.label === label);
49 + if (g) g.items.push(c); else out.push({ label, items: [c] });
50 + }
51 + return out;
52 + }, [conversations]);
38 53
54 + const go = (path: string) => { nav(path); onClose?.(); };
55 + const rename = async (cid: string) => { const title = draft.trim(); setRenaming(null); if (title) await updateConversation(cid, { title }); };
39 56 const remove = async (cid: string) => {
40 57 if (!confirm('Supprimer définitivement cette conversation ?')) return;
41 58 await deleteConversation(cid);
42 59 if (id === cid) go('/');
43 60 };
61 + const roleLabel = user?.role === 'professor' ? 'Professeur' : user?.role === 'admin' ? 'Administrateur' : 'Étudiant·e';
44 62
45 63 return (
46 − <aside className="flex h-full w-[300px] max-w-[85vw] flex-col bg-neutral-surface border-r border-neutral-line">
47 − <div className="flex items-center justify-between px-4 pt-[calc(12px+var(--safe-top))] pb-3">
48 − <Link to="/" onClick={onClose}><Logo /></Link>
49 − {onClose && <button onClick={onClose} className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white" aria-label="Fermer le menu"><X size={20} /></button>}
50 − </div>
51 − <div className="px-3 space-y-2">
52 − <button onClick={() => go('/')} className="w-full h-11 rounded-xl bg-uqo-blue text-white font-medium inline-flex items-center justify-center gap-2 hover:bg-uqo-blue-dark">
53 − <Plus size={18} /> Nouvelle conversation <span className="text-white/70 text-xs">· {course}</span>
64 + <aside className="flex h-full w-[304px] max-w-[86vw] flex-col bg-white border-r border-neutral-line">
65 + <div className="bg-uqo-gradient text-white px-4 pt-[calc(14px+var(--safe-top))] pb-4">
66 + <div className="flex items-center justify-between">
67 + <Link to="/" onClick={onClose}><Logo inverted /></Link>
68 + {onClose && <button onClick={onClose} className="h-10 w-10 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Fermer le menu"><X size={20} /></button>}
69 + </div>
70 + <button onClick={() => go('/')} className="mt-4 w-full h-11 rounded-xl bg-white text-uqo-blue-dark font-semibold inline-flex items-center justify-center gap-2 shadow-soft hover:bg-uqo-blue-light active:scale-[0.99] transition">
71 + <Plus size={18} /> Nouvelle conversation <span className="text-neutral-muted text-xs font-medium">· {course}</span>
54 72 </button>
73 + </div>
74 + <div className="px-3 pt-3">
55 75 <label className="relative block">
56 76 <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-muted" />
57 − <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Rechercher…" className="w-full h-11 rounded-xl border border-neutral-line bg-white pl-9 pr-3 text-sm" aria-label="Rechercher dans l'historique" />
77 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Rechercher une conversation…" className="w-full h-11 rounded-xl border border-neutral-line bg-neutral-surface pl-9 pr-3 text-sm focus:bg-white" aria-label="Rechercher dans l'historique" />
58 78 </label>
59 79 </div>
60 − <nav className="mt-3 flex-1 overflow-y-auto scroll-thin px-2" aria-label="Conversations">
61 − {conversations.length === 0 && <p className="px-3 py-6 text-center text-sm text-neutral-muted">Aucune conversation pour l'instant.</p>}
62 − <ul className="space-y-0.5">
63 − {conversations.map((c) => (
64 − <li key={c.id} className={cn('group flex items-center rounded-xl', id === c.id ? 'bg-white shadow-card' : 'hover:bg-white/70')}>
65 − {renaming === c.id ? (
66 − <input autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={() => rename(c.id)} onKeyDown={(e) => e.key === 'Enter' && rename(c.id)} className="flex-1 h-11 bg-transparent px-3 text-sm outline-none" />
67 − ) : (
68 − <button onClick={() => go(`/c/${c.id}`)} className="flex-1 min-w-0 text-left px-3 py-2.5">
69 − <span className="flex items-center gap-1.5 text-sm font-medium truncate">{c.pinned && <Pin size={12} className="text-uqo-blue shrink-0" />}<span className="truncate">{c.title}</span></span>
70 − <span className="block text-[11px] text-neutral-muted">{c.course} · {fmtDate(c.updated_at)}</span>
71 − </button>
72 − )}
73 − <DM.Root>
74 − <DM.Trigger asChild>
75 − <button className="h-11 w-10 inline-flex items-center justify-center rounded-lg text-neutral-muted opacity-70 sm:opacity-0 group-hover:opacity-100 focus:opacity-100 data-[state=open]:opacity-100" aria-label="Options"><MoreHorizontal size={16} /></button>
76 − </DM.Trigger>
77 − <DM.Portal>
78 − <DM.Content align="end" className="z-50 min-w-[190px] rounded-xl border border-neutral-line bg-white p-1 shadow-card text-sm">
79 − <DM.Item onSelect={() => { setDraft(c.title); setRenaming(c.id); }} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer"><Pencil size={14} /> Renommer</DM.Item>
80 − <DM.Item onSelect={() => updateConversation(c.id, { pinned: !c.pinned })} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer">{c.pinned ? <PinOff size={14} /> : <Pin size={14} />} {c.pinned ? 'Désépingler' : 'Épingler'}</DM.Item>
81 − <DM.Item onSelect={() => updateConversation(c.id, { archived: true })} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer"><Archive size={14} /> Archiver</DM.Item>
82 − <DM.Separator className="my-1 h-px bg-neutral-line" />
83 − <DM.Item onSelect={() => remove(c.id)} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none text-semantic-error hover:bg-red-50 cursor-pointer"><Trash2 size={14} /> Supprimer</DM.Item>
84 − </DM.Content>
85 − </DM.Portal>
86 − </DM.Root>
87 − </li>
88 − ))}
89 − </ul>
80 + <nav className="mt-2 flex-1 overflow-y-auto scroll-thin px-2 pb-2" aria-label="Conversations">
81 + {conversations.length === 0 && <p className="px-3 py-8 text-center text-sm text-neutral-muted">Aucune conversation pour l'instant.<br />Pose ta première question !</p>}
82 + {groups.map((g) => (
83 + <div key={g.label} className="mt-2">
84 + <div className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-muted">{g.label}</div>
85 + <ul className="space-y-0.5">
86 + {g.items.map((c) => (
87 + <li key={c.id} className={cn('group flex items-center rounded-xl transition', id === c.id ? 'bg-uqo-blue-light/70' : 'hover:bg-neutral-surface')}>
88 + {renaming === c.id ? (
89 + <input autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={() => rename(c.id)} onKeyDown={(e) => e.key === 'Enter' && rename(c.id)} className="flex-1 h-11 bg-transparent px-3 text-sm outline-none" />
90 + ) : (
91 + <button onClick={() => go(`/c/${c.id}`)} className="flex-1 min-w-0 text-left px-3 py-2.5">
92 + <span className={cn('flex items-center gap-1.5 text-[13.5px] truncate', id === c.id ? 'font-semibold text-uqo-blue-dark' : 'font-medium')}>{c.pinned && <Pin size={12} className="text-uqo-blue shrink-0" />}<span className="truncate">{c.title}</span></span>
93 + <span className="block text-[11px] text-neutral-muted">{c.course} · {fmtDate(c.updated_at)}</span>
94 + </button>
95 + )}
96 + <DM.Root>
97 + <DM.Trigger asChild>
98 + <button className="h-11 w-10 inline-flex items-center justify-center rounded-lg text-neutral-muted opacity-70 sm:opacity-0 group-hover:opacity-100 focus:opacity-100 data-[state=open]:opacity-100" aria-label="Options"><MoreHorizontal size={16} /></button>
99 + </DM.Trigger>
100 + <DM.Portal>
101 + <DM.Content align="end" className="z-50 min-w-[190px] rounded-xl border border-neutral-line bg-white p-1 shadow-float text-sm">
102 + <DM.Item onSelect={() => { setDraft(c.title); setRenaming(c.id); }} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer"><Pencil size={14} /> Renommer</DM.Item>
103 + <DM.Item onSelect={() => updateConversation(c.id, { pinned: !c.pinned })} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer">{c.pinned ? <PinOff size={14} /> : <Pin size={14} />} {c.pinned ? 'Désépingler' : 'Épingler'}</DM.Item>
104 + <DM.Item onSelect={() => updateConversation(c.id, { archived: true })} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer"><Archive size={14} /> Archiver</DM.Item>
105 + <DM.Separator className="my-1 h-px bg-neutral-line" />
106 + <DM.Item onSelect={() => remove(c.id)} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none text-semantic-error hover:bg-red-50 cursor-pointer"><Trash2 size={14} /> Supprimer</DM.Item>
107 + </DM.Content>
108 + </DM.Portal>
109 + </DM.Root>
110 + </li>
111 + ))}
112 + </ul>
113 + </div>
114 + ))}
90 115 </nav>
91 − <div className="border-t border-neutral-line p-3 pb-[calc(12px+var(--safe-bottom))] space-y-1">
116 + <div className="border-t border-neutral-line p-3 pb-[calc(12px+var(--safe-bottom))] space-y-1 bg-neutral-surface/60">
92 117 {user && (user.role === 'professor' || user.role === 'admin') && (
93 − <button onClick={() => go('/professeur')} className="w-full h-11 rounded-xl px-3 inline-flex items-center gap-2 text-sm hover:bg-white"><BarChart3 size={16} className="text-uqo-blue" /> Tableau de bord professeur</button>
118 + <button onClick={() => go('/professeur')} className="w-full h-11 rounded-xl px-3 inline-flex items-center gap-2 text-sm font-medium hover:bg-white"><BarChart3 size={16} className="text-uqo-blue" /> Tableau de bord professeur</button>
94 119 )}
95 120 {user?.role === 'admin' && (
96 − <button onClick={() => go('/admin')} className="w-full h-11 rounded-xl px-3 inline-flex items-center gap-2 text-sm hover:bg-white"><ShieldCheck size={16} className="text-uqo-blue" /> Coûts et système</button>
121 + <button onClick={() => go('/admin')} className="w-full h-11 rounded-xl px-3 inline-flex items-center gap-2 text-sm font-medium hover:bg-white"><ShieldCheck size={16} className="text-uqo-blue" /> Coûts et système</button>
97 122 )}
98 123 <div className="flex items-center gap-2 pt-1">
99 − <span className="h-9 w-9 rounded-full bg-uqo-blue-light text-uqo-blue-dark inline-flex items-center justify-center text-xs font-semibold">{(user?.display_name || user?.email || '?').slice(0, 2).toUpperCase()}</span>
100 − <span className="min-w-0 flex-1"><span className="block text-sm font-medium truncate">{user?.display_name || user?.email}</span><span className="block text-[11px] text-neutral-muted capitalize">{user?.role === 'professor' ? 'professeur' : user?.role === 'admin' ? 'administrateur' : 'étudiant·e'}</span></span>
124 + <span className="h-9 w-9 rounded-full bg-uqo-gradient text-white inline-flex items-center justify-center text-xs font-bold">{(user?.display_name || user?.email || '?').slice(0, 2).toUpperCase()}</span>
125 + <span className="min-w-0 flex-1"><span className="block text-sm font-semibold truncate">{user?.display_name || user?.email}</span><span className="block text-[11px] text-neutral-muted">{roleLabel}</span></span>
101 126 <button onClick={() => setSettings(true)} className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white" aria-label="Préférences"><Settings size={18} /></button>
102 127 <button onClick={() => logout().then(() => go('/connexion'))} className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white" aria-label="Se déconnecter"><LogOut size={18} /></button>
103 128 </div>
modified frontend/src/components/ui/logo.tsx +11 −19
@@ -1,26 +1,18 @@
1 1 import { cn } from '@/lib/cn';
2 2
3 −const USE_OFFICIAL = import.meta.env.VITE_USE_OFFICIAL_LOGO === 'true';
4 −
5 −/** Fallback logotype (official UQO logo requires authorisation from Service des communications). */
6 −export function Logo({ className, inverted = false, withText = true }: { className?: string; inverted?: boolean; withText?: boolean }) {
7 − if (USE_OFFICIAL) {
8 − return <img src={inverted ? '/uqo-logo-white.svg' : '/uqo-logo.svg'} alt="UQO" className={cn('h-8', className)} />;
9 − }
10 − const fg = inverted ? '#FFFFFF' : '#00467F';
3 +/**
4 + * Official UQO wordmark (PNG @2x, recoloured) + product name.
5 + * `inverted` for the blue header; `withText=false` for compact spots.
6 + */
7 +export function Logo({ className, inverted = false, withText = true, size = 'md' }: { className?: string; inverted?: boolean; withText?: boolean; size?: 'sm' | 'md' | 'lg' }) {
8 + const h = size === 'lg' ? 'h-12' : size === 'sm' ? 'h-6' : 'h-8';
11 9 return (
12 − <span className={cn('inline-flex items-center gap-2', className)} aria-label="UQO-Chat">
13 − <svg width="30" height="30" viewBox="0 0 32 32" aria-hidden="true">
14 − <rect x="1" y="1" width="30" height="30" rx="8" fill={inverted ? 'rgba(255,255,255,.14)' : '#00467F'} />
15 − <path d="M8 24V13l8-6 8 6v11h-5v-6h-6v6H8z" fill={inverted ? '#FFFFFF' : '#FFFFFF'} />
16 − <rect x="21" y="8" width="3" height="4" rx=".6" fill="#78BE20" />
17 − </svg>
10 + <span className={cn('inline-flex items-center gap-2.5', className)} aria-label="UQO-Chat">
11 + <img src={inverted ? '/uqo-logo-white.png' : '/uqo-logo.png'} alt="UQO" className={cn(h, 'w-auto select-none')} draggable={false} />
18 12 {withText && (
19 − <span className="leading-none">
20 − <span className="block font-bold tracking-tight" style={{ color: fg, fontSize: 17 }}>UQO-Chat</span>
21 − <span className="block text-[10.5px] font-medium tracking-wide" style={{ color: inverted ? 'rgba(255,255,255,.8)' : '#5B6B7B' }}>
22 − Tuteur IA — IMM1003 · IMM1033
23 − </span>
13 + <span className={cn('leading-none border-l pl-2.5', inverted ? 'border-white/30' : 'border-neutral-line')}>
14 + <span className={cn('block font-bold tracking-tight', size === 'lg' ? 'text-[22px]' : 'text-[16px]', inverted ? 'text-white' : 'text-uqo-blue-dark')}>UQO-Chat</span>
15 + <span className={cn('block font-medium tracking-wide whitespace-nowrap', size === 'lg' ? 'text-[12px]' : 'text-[10.5px]', inverted ? 'text-white/75' : 'text-neutral-muted')}>Tuteur IA · IMM1003 · IMM1033</span>
24 16 </span>
25 17 )}
26 18 </span>
modified frontend/src/features/auth/login-page.tsx +9 −5
@@ -80,14 +80,18 @@ export function LoginPage() {
80 80
81 81 return (
82 82 <div className="min-h-[100dvh] flex flex-col bg-neutral-surface">
83 − <div className="bg-uqo-blue text-white px-6 pt-[calc(24px+var(--safe-top))] pb-10">
84 − <div className="mx-auto max-w-[440px]"><Logo inverted /></div>
83 + <div className="relative overflow-hidden bg-uqo-gradient text-white px-6 pt-[calc(28px+var(--safe-top))] pb-16">
84 + <svg className="absolute -right-16 -top-16 h-72 w-72 opacity-10" viewBox="0 0 200 200" aria-hidden="true"><circle cx="100" cy="100" r="100" fill="white" /></svg>
85 + <div className="mx-auto max-w-[440px]">
86 + <Logo inverted size="lg" />
87 + <p className="mt-5 text-[15px] text-white/85 max-w-[380px]">Comprendre, pratiquer et calculer en évaluation immobilière — avec un tuteur qui cite tes notes de cours.</p>
88 + </div>
85 89 </div>
86 − <div className="flex-1 px-4 -mt-6 pb-[calc(24px+var(--safe-bottom))]">
87 − <form onSubmit={submit} className="mx-auto max-w-[440px] rounded-2xl bg-white shadow-card p-6 space-y-4 animate-fadein">
90 + <div className="flex-1 px-4 -mt-9 pb-[calc(24px+var(--safe-bottom))]">
91 + <form onSubmit={submit} className="relative z-10 mx-auto max-w-[440px] rounded-[24px] bg-white shadow-float p-6 space-y-4 animate-fadein">
88 92 <div>
89 93 <h1 className="text-xl font-bold text-uqo-blue-dark">Connexion</h1>
90 − <p className="text-sm text-neutral-muted mt-1">Tuteur IA des cours IMM1003 et IMM1033 · {cfg?.term || ''}. Réservé aux adresses <b>@{cfg?.domains?.[0] || 'uqo.ca'}</b>.</p>
94 + <p className="text-sm text-neutral-muted mt-1">IMM1003 · IMM1033 · {cfg?.term || ''}. Réservé aux adresses <b>@{cfg?.domains?.[0] || 'uqo.ca'}</b> et aux comptes créés par le professeur.</p>
91 95 </div>
92 96 {token && busy && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>}
93 97 <label className="block">
modified frontend/src/features/auth/privacy-page.tsx +1 −1
@@ -4,7 +4,7 @@ import { Logo } from '@/components/ui/logo';
4 4 export function PrivacyPage() {
5 5 return (
6 6 <div className="min-h-[100dvh] bg-neutral-surface">
7 − <div className="bg-uqo-blue text-white px-6 py-5"><div className="mx-auto max-w-[760px]"><Link to="/"><Logo inverted /></Link></div></div>
7 + <div className="bg-uqo-gradient text-white px-6 py-5"><div className="mx-auto max-w-[760px]"><Link to="/"><Logo inverted /></Link></div></div>
8 8 <article className="mx-auto max-w-[760px] px-5 py-8 md bg-white my-6 rounded-2xl shadow-card text-[15px]">
9 9 <h1>Politique de confidentialité — UQO-Chat</h1>
10 10 <p><i>Dernière mise à jour : septembre 2026. Conforme à la Loi 25 (Loi modernisant des dispositions législatives en matière de protection des renseignements personnels, Québec).</i></p>
modified frontend/src/features/professor/admin-costs-page.tsx +1 −1
@@ -23,7 +23,7 @@ export function AdminCostsPage() {
23 23 const projected = daysElapsed ? (data.budget.spent_usd / daysElapsed) * 30 : 0;
24 24 return (
25 25 <div className="min-h-[100dvh] bg-neutral-surface">
26 − <header className="bg-uqo-blue text-white"><div className="mx-auto max-w-[1100px] px-4 pt-[calc(12px+var(--safe-top))] pb-3 flex items-center gap-3"><Link to="/" className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Retour"><ArrowLeft size={20} /></Link><h1 className="text-lg font-bold">Coûts OpenRouter</h1></div></header>
26 + <header className="bg-uqo-gradient text-white"><div className="mx-auto max-w-[1100px] px-4 pt-[calc(12px+var(--safe-top))] pb-3 flex items-center gap-3"><Link to="/" className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Retour"><ArrowLeft size={20} /></Link><h1 className="text-lg font-bold">Coûts OpenRouter</h1></div></header>
27 27 <main className="mx-auto max-w-[1100px] px-4 py-5 space-y-4">
28 28 <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
29 29 {[['Dépensé ce mois', `${data.budget.spent_usd.toFixed(2)} $`], ['Budget', `${data.budget.budget_usd} $`], ['Utilisation', `${Math.round(data.budget.ratio * 100)} %`], ['Projection mensuelle', `${projected.toFixed(2)} $`]].map(([l, v]) => (
modified frontend/src/features/professor/professor-page.tsx +1 −1
@@ -25,7 +25,7 @@ export function ProfessorPage() {
25 25 const [tab, setTab] = useState<(typeof TABS)[number]>('Activité');
26 26 return (
27 27 <div className="min-h-[100dvh] bg-neutral-surface">
28 − <header className="bg-uqo-blue text-white">
28 + <header className="bg-uqo-gradient text-white">
29 29 <div className="mx-auto max-w-[1100px] px-4 pt-[calc(12px+var(--safe-top))] pb-3 flex items-center gap-3">
30 30 <Link to="/" className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Retour"><ArrowLeft size={20} /></Link>
31 31 <div><h1 className="text-lg font-bold">Tableau de bord professeur</h1><p className="text-xs text-white/80">IMM1003 · IMM1033 — données anonymisées</p></div>
modified frontend/src/index.css +47 −22
@@ -7,52 +7,77 @@
7 7 --font-scale: 1;
8 8 --safe-bottom: env(safe-area-inset-bottom, 0px);
9 9 --safe-top: env(safe-area-inset-top, 0px);
10 + --uqo: #0f6180;
11 + --uqo-dark: #0a4a63;
12 + --uqo-light: #e6f0f4;
10 13 }
11 14
12 15 html, body, #root { height: 100%; }
16 +html { -webkit-text-size-adjust: 100%; }
13 17 body {
14 18 @apply bg-neutral-bg text-neutral-text font-sans antialiased;
15 19 font-size: calc(16px * var(--font-scale));
16 20 overscroll-behavior-y: none;
21 + -webkit-tap-highlight-color: transparent;
22 + text-rendering: optimizeLegibility;
17 23 }
24 +::selection { background: rgba(15, 97, 128, 0.18); }
18 25 input, textarea, select, button { font: inherit; }
19 26 /* Anti-zoom iOS: never below 16 px in inputs */
20 27 input, textarea { font-size: max(16px, 1em); }
28 +button, a { touch-action: manipulation; }
21 29
22 −*:focus-visible { outline: 2px solid theme('colors.uqo.blue'); outline-offset: 2px; border-radius: 4px; }
30 +*:focus-visible { outline: 2px solid var(--uqo); outline-offset: 2px; border-radius: 6px; }
23 31
24 −.scroll-thin { scrollbar-width: thin; scrollbar-color: #C5D0DB transparent; }
32 +.scroll-thin { scrollbar-width: thin; scrollbar-color: #c5d0db transparent; }
25 33 .scroll-thin::-webkit-scrollbar { width: 6px; height: 6px; }
26 −.scroll-thin::-webkit-scrollbar-thumb { background: #C5D0DB; border-radius: 3px; }
34 +.scroll-thin::-webkit-scrollbar-thumb { background: #c5d0db; border-radius: 3px; }
35 +.no-scrollbar { scrollbar-width: none; }
36 +.no-scrollbar::-webkit-scrollbar { display: none; }
37 +
38 +/* Brand gradients */
39 +.bg-uqo-gradient { background: linear-gradient(135deg, #0f6180 0%, #0a4a63 60%, #083a4f 100%); }
40 +.bg-uqo-soft { background: radial-gradient(1200px 400px at 20% -20%, rgba(15, 97, 128, 0.10), transparent 60%), #f4f7f9; }
41 +.shadow-soft { box-shadow: 0 1px 2px rgba(10, 74, 99, 0.05), 0 6px 20px rgba(10, 74, 99, 0.07); }
42 +.shadow-float { box-shadow: 0 2px 6px rgba(10, 74, 99, 0.08), 0 14px 34px rgba(10, 74, 99, 0.14); }
27 43
28 44 /* Markdown (prose-like, mobile first) */
29 −.md { line-height: 1.6; word-wrap: break-word; }
30 −.md > * + * { margin-top: 0.7em; }
31 −.md h1, .md h2, .md h3, .md h4 { @apply font-semibold text-uqo-blue-dark; line-height: 1.3; margin-top: 1.1em; }
32 −.md h1 { font-size: 1.35em; } .md h2 { font-size: 1.2em; } .md h3 { font-size: 1.08em; } .md h4 { font-size: 1em; }
45 +.md { line-height: 1.62; word-wrap: break-word; }
46 +.md > * + * { margin-top: 0.75em; }
47 +.md h1, .md h2, .md h3, .md h4 { @apply font-semibold text-uqo-blue-dark; line-height: 1.3; margin-top: 1.15em; letter-spacing: -0.01em; }
48 +.md h1 { font-size: 1.32em; } .md h2 { font-size: 1.18em; } .md h3 { font-size: 1.06em; } .md h4 { font-size: 1em; }
33 49 .md p { margin: 0; }
34 −.md ul { list-style: disc; padding-left: 1.4em; } .md ol { list-style: decimal; padding-left: 1.4em; }
35 −.md li + li { margin-top: 0.25em; }
36 −.md a { @apply text-uqo-blue underline underline-offset-2; }
37 −.md blockquote { @apply border-l-4 border-uqo-blue-light pl-3 text-neutral-muted; }
38 −.md code:not(pre code) { @apply font-mono text-[0.9em] bg-neutral-surface px-1 py-0.5 rounded; }
39 −.md pre { @apply font-mono text-[0.86em] rounded-lg overflow-x-auto; }
40 −.md table { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; font-size: 0.93em; }
41 −.md thead th { @apply bg-uqo-blue-light text-uqo-blue-dark font-semibold text-left; padding: 6px 10px; }
42 −.md td { padding: 6px 10px; border-top: 1px solid theme('colors.neutral.line'); white-space: nowrap; }
50 +.md ul { list-style: disc; padding-left: 1.35em; } .md ol { list-style: decimal; padding-left: 1.35em; }
51 +.md li + li { margin-top: 0.3em; }
52 +.md li::marker { color: var(--uqo); }
53 +.md a { @apply text-uqo-blue underline underline-offset-2 decoration-uqo-blue/40; }
54 +.md blockquote { @apply border-l-4 border-uqo-blue/30 pl-3 text-neutral-muted bg-uqo-blue-light/40 rounded-r-lg py-1; }
55 +.md code:not(pre code) { @apply font-mono text-[0.88em] bg-neutral-surface px-1.5 py-0.5 rounded-md text-uqo-blue-dark; }
56 +.md pre { @apply font-mono text-[0.86em] rounded-xl overflow-x-auto; }
57 +.md table { display: block; overflow-x: auto; max-width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.93em; border: 1px solid theme('colors.neutral.line'); border-radius: 10px; }
58 +.md thead th { @apply bg-uqo-blue-light text-uqo-blue-dark font-semibold text-left; padding: 7px 10px; white-space: nowrap; }
59 +.md td { padding: 7px 10px; border-top: 1px solid theme('colors.neutral.line'); white-space: nowrap; }
60 +.md tbody tr:nth-child(even) td { background: #fafbfc; }
43 61 .md hr { @apply border-neutral-line my-3; }
44 −.md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; margin: 0.5em 0; }
62 +.md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 6px 2px; margin: 0.5em 0; }
45 63 .md .katex { font-size: 1.05em; }
46 −.md img { max-width: 100%; border-radius: 8px; }
64 +.md img { max-width: 100%; border-radius: 10px; }
65 +.md strong { color: theme('colors.uqo.blue-dark'); }
47 66
48 −.shiki { padding: 12px 14px; margin: 0; background: #0f1b2a !important; color: #e6edf3; }
67 +.shiki { padding: 12px 14px; margin: 0; background: #0e1f2b !important; color: #e6edf3; }
49 68 .shiki code { counter-reset: line; }
50 69
51 70 .streaming-cursor::after { content: '▍'; @apply text-uqo-blue; animation: blink 1s steps(2) infinite; margin-left: 1px; }
52 71 @keyframes blink { 50% { opacity: 0; } }
53 72
54 −.progress-bar { position: relative; height: 2px; overflow: hidden; background: theme('colors.uqo.blue-light'); }
55 −.progress-bar::after { content: ''; position: absolute; left: -40%; width: 40%; height: 100%; background: theme('colors.uqo.blue'); animation: slide 1.2s ease-in-out infinite; }
73 +.progress-bar { position: relative; height: 3px; overflow: hidden; background: var(--uqo-light); }
74 +.progress-bar::after { content: ''; position: absolute; left: -40%; width: 40%; height: 100%; background: linear-gradient(90deg, transparent, var(--uqo), #78be20); animation: slide 1.2s ease-in-out infinite; }
56 75 @keyframes slide { to { left: 100%; } }
57 76
58 −.skeleton { @apply bg-neutral-surface rounded animate-pulse; }
77 +.skeleton { @apply bg-neutral-surface rounded-xl; position: relative; overflow: hidden; }
78 +.skeleton::after { content: ''; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,.7), transparent); animation: shimmer 1.4s infinite; }
79 +@keyframes shimmer { to { transform: translateX(100%); } }
80 +
81 +@media (prefers-reduced-motion: reduce) {
82 + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
83 +}
modified frontend/src/theme/uqo.ts +8 −8
@@ -1,17 +1,17 @@
1 −/** UQO design tokens — verify against the official graphic standards before launch. */
1 +/** UQO design tokens — aligned on the official wordmark blue (#0F6180). */
2 2 export const uqo = {
3 − blue: '#00467F',
4 − blueDark: '#003057',
5 − blueLight: '#E6EEF5',
3 + blue: '#0F6180',
4 + blueDark: '#0A4A63',
5 + blueLight: '#E6F0F4',
6 6 green: '#78BE20',
7 7 gold: '#C6A300',
8 8 bg: '#FFFFFF',
9 − surface: '#F5F7FA',
10 − text: '#1A2B3C',
9 + surface: '#F4F7F9',
10 + text: '#16232D',
11 11 muted: '#5B6B7B',
12 − line: '#D9E1EA',
12 + line: '#DCE4EA',
13 13 warning: '#F2A900',
14 14 error: '#C8102E',
15 15 } as const;
16 16
17 −export const chartPalette = ['#00467F', '#78BE20', '#C6A300', '#5B6B7B', '#7FA7C9', '#C8102E'];
17 +export const chartPalette = ['#0F6180', '#78BE20', '#C6A300', '#5B6B7B', '#7FB0C4', '#C8102E'];
modified frontend/tailwind.config.ts +2 −2
@@ -24,8 +24,8 @@ export default {
24 24 xs: ['12px', '1.4'], sm: ['14px', '1.55'], base: ['16px', '1.55'], lg: ['18px', '1.5'],
25 25 xl: ['22px', '1.35'], '2xl': ['28px', '1.25'],
26 26 },
27 − boxShadow: { card: '0 1px 2px rgba(0,48,87,.06), 0 4px 16px rgba(0,48,87,.06)' },
28 − keyframes: { fadein: { from: { opacity: '0', transform: 'translateY(4px)' }, to: { opacity: '1', transform: 'none' } } },
27 + boxShadow: { card: '0 1px 2px rgba(10,74,99,.05), 0 4px 16px rgba(10,74,99,.06)' },
28 + keyframes: { fadein: { from: { opacity: '0', transform: 'translateY(4px)' }, to: { opacity: '1', transform: 'none' } }, slidein: { from: { transform: 'translateX(-100%)' }, to: { transform: 'none' } } },
29 29 animation: { fadein: 'fadein 150ms ease-out' },
30 30 },
31 31 },
modified frontend/vite.config.ts +2 −2
@@ -9,7 +9,7 @@ export default defineConfig({
9 9 react(),
10 10 VitePWA({
11 11 registerType: 'autoUpdate',
12 − includeAssets: ['icons/*.png', 'icons/*.svg', 'uqo-chat-logo.svg'],
12 + includeAssets: ['icons/*.png', 'icons/*.svg', 'uqo-logo.png', 'uqo-logo-white.png'],
13 13 manifest: {
14 14 name: 'UQO-Chat',
15 15 short_name: 'UQO-Chat',
@@ -18,7 +18,7 @@ export default defineConfig({
18 18 display: 'standalone',
19 19 start_url: '/',
20 20 scope: '/',
21 − theme_color: '#00467F',
21 + theme_color: '#0F6180',
22 22 background_color: '#FFFFFF',
23 23 icons: [
24 24 { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
25 25