From 4e6d4a4cea9562949ff903745dbf8ff6d03d830e Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 18:05:23 +0200 Subject: [PATCH] feat(frontend): restyle ThothII to the Omics Portal (GSD) design system Adopt the portal's design language so the app embeds seamlessly into its header/sidebar chrome later: - Token layer rewritten as raw OKLCH triplets (the previous oklch()-wrapped vars produced invalid colors; the UI was effectively unstyled). Values mirror GSD: Manrope + Fraunces fonts, #cb333b red brand, warm off-white surfaces, pill buttons, brand focus rings. Dark tokens follow .dark or the portal's [data-bs-theme=dark]. - Layout: no left sidebar, no header (the portal supplies them). Conversation column flush-left with a slim F1-F8 phase strip; session rail moved to the RIGHT, mirroring the portal's left sidebar (red section label, red-tint active item). Editorial empty state. - Transcript component now actually renders the streamed assistant turns (the store accumulated them but nothing displayed them). Toaster mounted for errors (matchMedia polyfill added to the jsdom test setup). Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 6 + frontend/src/components/ui/button.tsx | 20 +- frontend/src/components/ui/sonner.tsx | 6 +- frontend/src/index.css | 235 ++++++++++++++++-------- frontend/src/shell/AppShell.tsx | 125 ++++++++----- frontend/src/shell/NavSessions.tsx | 68 +++++-- frontend/src/shell/NewSessionDialog.tsx | 4 +- frontend/src/shell/SettingsDialog.tsx | 20 +- frontend/src/shell/SteerInput.tsx | 17 +- frontend/src/shell/Transcript.tsx | 28 +++ frontend/src/shell/WorkflowBar.tsx | 60 ++++-- frontend/src/test/setup.ts | 15 ++ 12 files changed, 424 insertions(+), 180 deletions(-) create mode 100644 frontend/src/shell/Transcript.tsx diff --git a/frontend/index.html b/frontend/index.html index 93cd5782..4efb5f19 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,12 @@ ThothII + + +
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index b0336017..480b14c7 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -4,15 +4,15 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-clip-padding text-sm font-semibold tracking-[0.005em] whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 focus-visible:ring-ring/25 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", + default: "bg-primary text-primary-foreground hover:bg-[oklch(var(--primary-hover))]", outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,oklch(var(--secondary)),oklch(var(--foreground))_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: @@ -21,15 +21,13 @@ const buttonVariants = cva( }, size: { default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + "h-8 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", + xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 px-3 text-[0.8rem] has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-10 gap-1.5 px-5 text-[0.9rem] has-data-[icon=inline-end]:pr-3.5 has-data-[icon=inline-start]:pl-3.5", icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-7", "icon-lg": "size-9", }, }, diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 9772eb20..d69d13d5 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -28,9 +28,9 @@ const Toaster = ({ ...props }: ToasterProps) => { }} style={ { - "--normal-bg": "var(--popover)", - "--normal-text": "var(--popover-foreground)", - "--normal-border": "var(--border)", + "--normal-bg": "oklch(var(--popover))", + "--normal-text": "oklch(var(--popover-foreground))", + "--normal-border": "oklch(var(--border))", "--border-radius": "var(--radius)", } as React.CSSProperties } diff --git a/frontend/src/index.css b/frontend/src/index.css index 40c6263b..7265c079 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,89 +1,178 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; -@import "@fontsource-variable/geist"; @tailwind base; @tailwind components; @tailwind utilities; +/* + * GSD theme — mirrors the Omics Portal design system (Manrope + Fraunces, warm + * off-white surfaces, #cb333b red). Tokens are RAW OKLCH triplets (L C H) so the + * Tailwind v3 config can wrap them as `oklch(var(--token) / )` and + * opacity utilities (bg-primary/80, …) keep working. Dark tokens follow either a + * `.dark` class or the portal's `[data-bs-theme="dark"]`, ready for embedding. + */ @layer base { - .theme { - --font-heading: var(--font-sans); - --font-sans: 'Geist Variable', sans-serif - } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0) + --font-sans: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Arial, sans-serif; + --font-heading: "Fraunces", "Source Serif Pro", Georgia, "Times New Roman", serif; + + --background: 0.9918 0.0011 17.2; /* surface */ + --foreground: 0.2678 0.0097 355.6; /* text */ + --card: 0.9985 0.0006 17.2; + --card-foreground: 0.2678 0.0097 355.6; + --popover: 0.9985 0.0006 17.2; + --popover-foreground: 0.2678 0.0097 355.6; + --primary: 0.5587 0.1881 23.2; /* gsd red */ + --primary-foreground: 0.9918 0.0011 17.2; + --primary-hover: 0.5095 0.1812 24.1; + --secondary: 0.9709 0.0011 17.2; /* surface-alt */ + --secondary-foreground: 0.2678 0.0097 355.6; + --muted: 0.9709 0.0011 17.2; + --muted-foreground: 0.5133 0.0088 345.6; + --accent: 0.9408 0.0011 17.2; /* sunken */ + --accent-foreground: 0.2678 0.0097 355.6; + --destructive: 0.5587 0.1881 23.2; + --destructive-foreground: 0.9918 0.0011 17.2; + --border: 0.9093 0.0035 354.7; + --input: 0.9093 0.0035 354.7; + --ring: 0.5587 0.1881 23.2; + --success: 0.7577 0.1581 165.0; + --warning: 0.8523 0.1386 78.9; + --info: 0.7035 0.1128 221.3; + --radius: 0.5rem; + --sidebar: 0.9709 0.0011 17.2; + --sidebar-foreground: 0.2678 0.0097 355.6; + --sidebar-primary: 0.5587 0.1881 23.2; + --sidebar-primary-foreground: 0.9918 0.0011 17.2; + --sidebar-accent: 0.9408 0.0011 17.2; + --sidebar-accent-foreground: 0.2678 0.0097 355.6; + --sidebar-border: 0.9093 0.0035 354.7; + --sidebar-ring: 0.5587 0.1881 23.2; } - .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0) + + .dark, + [data-bs-theme="dark"] { + --background: 0.2178 0 90; + --foreground: 0.9310 0 90; + --card: 0.2350 0 90; + --card-foreground: 0.9310 0 90; + --popover: 0.2350 0 90; + --popover-foreground: 0.9310 0 90; + --primary: 0.6023 0.1848 20.3; + --primary-foreground: 0.9310 0 90; + --primary-hover: 0.6897 0.1779 16.9; + --secondary: 0.2972 0 90; + --secondary-foreground: 0.9310 0 90; + --muted: 0.2972 0 90; + --muted-foreground: 0.7826 0 90; + --accent: 0.3485 0 90; + --accent-foreground: 0.9310 0 90; + --destructive: 0.6023 0.1848 20.3; + --destructive-foreground: 0.9310 0 90; + --border: 0.3715 0 90; + --input: 0.3715 0 90; + --ring: 0.6897 0.1779 16.9; + --sidebar: 0.2350 0 90; + --sidebar-foreground: 0.9310 0 90; + --sidebar-primary: 0.6023 0.1848 20.3; + --sidebar-primary-foreground: 0.9310 0 90; + --sidebar-accent: 0.3485 0 90; + --sidebar-accent-foreground: 0.9310 0 90; + --sidebar-border: 0.3715 0 90; + --sidebar-ring: 0.6897 0.1779 16.9; } + * { @apply border-border outline-ring/50; } - body { - @apply bg-background text-foreground; - } + html { @apply font-sans; } -} \ No newline at end of file + + body { + @apply bg-background text-foreground antialiased; + font-feature-settings: "ss01", "cv11"; + } + + h1, h2, h3, h4 { + font-family: var(--font-heading); + font-optical-sizing: auto; + letter-spacing: -0.01em; + } + + ::selection { + background: oklch(var(--primary) / 0.16); + } + + /* Thin, brand-tinted scrollbars */ + * { + scrollbar-width: thin; + scrollbar-color: oklch(var(--border)) transparent; + } + *::-webkit-scrollbar { + width: 10px; + height: 10px; + } + *::-webkit-scrollbar-thumb { + background: oklch(var(--border)); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; + } + *::-webkit-scrollbar-thumb:hover { + background: oklch(var(--muted-foreground) / 0.5); + } +} + +/* + * Minimal markdown prose for assistant turns (no typography plugin installed). + * Scoped so it never leaks into widgets. + */ +@layer components { + .thot-prose { + color: oklch(var(--foreground)); + font-size: 0.9375rem; + line-height: 1.65; + } + .thot-prose > :first-child { margin-top: 0; } + .thot-prose > :last-child { margin-bottom: 0; } + .thot-prose p { margin: 0.6em 0; } + .thot-prose h1, .thot-prose h2, .thot-prose h3 { + font-family: var(--font-heading); + font-weight: 600; + line-height: 1.25; + margin: 1.1em 0 0.5em; + letter-spacing: -0.01em; + } + .thot-prose h1 { font-size: 1.4rem; } + .thot-prose h2 { font-size: 1.2rem; } + .thot-prose h3 { font-size: 1.05rem; } + .thot-prose ul, .thot-prose ol { margin: 0.6em 0; padding-left: 1.3em; } + .thot-prose li { margin: 0.25em 0; } + .thot-prose a { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 2px; } + .thot-prose code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; + background: oklch(var(--muted)); + padding: 0.12em 0.4em; + border-radius: 0.3rem; + } + .thot-prose pre { + background: oklch(var(--muted)); + padding: 0.9rem 1rem; + border-radius: var(--radius); + overflow-x: auto; + border: 1px solid oklch(var(--border)); + } + .thot-prose pre code { background: transparent; padding: 0; } + .thot-prose blockquote { + border-left: 2px solid oklch(var(--border)); + padding-left: 0.9em; + color: oklch(var(--muted-foreground)); + margin: 0.7em 0; + } + .thot-prose table { border-collapse: collapse; width: 100%; margin: 0.7em 0; font-size: 0.875rem; } + .thot-prose th, .thot-prose td { border: 1px solid oklch(var(--border)); padding: 0.4em 0.6em; text-align: left; } + .thot-prose th { background: oklch(var(--muted)); font-weight: 600; } +} diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 66a27705..67754427 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -1,64 +1,103 @@ -import { useState } from "react"; import { useSessionStream } from "../stream/useSessionStream"; import { WidgetHost } from "./WidgetHost"; +import { Transcript } from "./Transcript"; import { NavSessions } from "./NavSessions"; import { NewSessionDialog } from "./NewSessionDialog"; import { SettingsDialog } from "./SettingsDialog"; import { SteerInput } from "./SteerInput"; import { WorkflowBar } from "./WorkflowBar"; +import { Toaster } from "../components/ui/sonner"; +import { useState } from "react"; +/** + * The page is designed to live INSIDE the Omics Portal chrome (its left sidebar + * + topbar) once embedded, so this shell intentionally has no left rail and no + * top header of its own. The session rail sits on the RIGHT, mirroring the + * portal's left sidebar, and the workflow phases ride a slim strip above the chat. + */ export function AppShell() { const [activeSessionId, setActiveSessionId] = useState(null); - const [sidebarOpen, setSidebarOpen] = useState(true); useSessionStream(activeSessionId); - function handleSessionSelect(id: string) { - setActiveSessionId(id); - } - - function handleCreated(id: string) { - setActiveSessionId(id); - } - return ( -
- {/* Left nav: new session button + sessions list */} - +
+ {/* Conversation column */} +
+ {activeSessionId && ( +
+
+ +
+
+ )} -
- {/* Top workflow bar */} -
- - -
- - {/* Center: chat + steer input */} -
- - {activeSessionId && } +
+
+ {activeSessionId ? ( + <> + + + + ) : ( + + )} +
+ + {activeSessionId && ( +
+
+ +
+
+ )}
- {/* Right artifact sidebar — collapsible */} - {sidebarOpen && ( - - )} + {/* Right session rail — symmetric to the portal's left sidebar */} + + + +
+ ); +} + +function EmptyState() { + return ( +
+ + ThothII + +

+ Interroga il tuo data warehouse in linguaggio naturale. Crea una nuova + domanda dal pannello a destra per avviare una sessione guidata. +

); } diff --git a/frontend/src/shell/NavSessions.tsx b/frontend/src/shell/NavSessions.tsx index ac413d91..0512ae4b 100644 --- a/frontend/src/shell/NavSessions.tsx +++ b/frontend/src/shell/NavSessions.tsx @@ -23,24 +23,58 @@ export function NavSessions({ activeSessionId, onSessionSelect }: Props) { onSessionSelect(id); } + if (sessions.length === 0) { + return ( +

+ Nessuna sessione. Inizia con Nuova domanda. +

+ ); + } + return ( -
    - {sessions.map((s) => ( -
  • - -
  • - ))} +
      + {sessions.map((s) => { + const active = s.id === activeSessionId; + return ( +
    • + +
    • + ); + })}
    ); } diff --git a/frontend/src/shell/NewSessionDialog.tsx b/frontend/src/shell/NewSessionDialog.tsx index fd26e834..a21752f5 100644 --- a/frontend/src/shell/NewSessionDialog.tsx +++ b/frontend/src/shell/NewSessionDialog.tsx @@ -55,7 +55,7 @@ export function NewSessionDialog({ onCreated }: Props) {
    -