Merge visual review into full shell and preserve bilingual layout
This commit is contained in:
@@ -11,50 +11,50 @@ colors:
|
||||
warm-graphite: "oklch(26.78% 0.0097 355.6)"
|
||||
muted-graphite: "oklch(51.33% 0.0088 345.6)"
|
||||
quiet-border: "oklch(90.93% 0.0035 354.7)"
|
||||
success-mint: "oklch(75.77% 0.1581 165)"
|
||||
success-mint: "oklch(46% 0.095 160)"
|
||||
navigation-active: "oklch(92.5% 0.052 23.2)"
|
||||
navigation-active-hover: "oklch(89.5% 0.071 23.2)"
|
||||
navigation-active-foreground: "oklch(36.5% 0.11 23.2)"
|
||||
navigation-active-border: "oklch(60% 0.135 23.2)"
|
||||
warning-amber: "oklch(85.23% 0.1386 78.9)"
|
||||
information-blue: "oklch(70.35% 0.1128 221.3)"
|
||||
warning-amber: "oklch(48% 0.09 70)"
|
||||
information-neutral: "oklch(51.33% 0.0088 345.6)"
|
||||
typography:
|
||||
display:
|
||||
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
|
||||
fontSize: "3rem"
|
||||
fontFamily: "Manrope Variable, Manrope, system-ui, sans-serif"
|
||||
fontSize: "1.5rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.03
|
||||
letterSpacing: "-0.025em"
|
||||
headline:
|
||||
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
|
||||
fontSize: "1.875rem"
|
||||
fontFamily: "Manrope Variable, Manrope, system-ui, sans-serif"
|
||||
fontSize: "1.5rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.15
|
||||
letterSpacing: "-0.015em"
|
||||
title:
|
||||
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
|
||||
fontSize: "1.2rem"
|
||||
fontFamily: "Manrope Variable, Manrope, system-ui, sans-serif"
|
||||
fontSize: "1.25rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "-0.01em"
|
||||
body:
|
||||
fontFamily: "Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
|
||||
fontSize: "0.9375rem"
|
||||
fontFamily: "Manrope Variable, Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
|
||||
fontSize: "1rem"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.65
|
||||
letterSpacing: "normal"
|
||||
control:
|
||||
fontFamily: "Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
|
||||
fontFamily: "Manrope Variable, Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
|
||||
fontSize: "0.875rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "0.005em"
|
||||
label:
|
||||
fontFamily: "ui-monospace, SF Mono, Cascadia Code, Menlo, Consolas, monospace"
|
||||
fontSize: "0.6875rem"
|
||||
fontFamily: "Manrope Variable, Manrope, system-ui, sans-serif"
|
||||
fontSize: "0.75rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "0.06em"
|
||||
letterSpacing: "normal"
|
||||
rounded:
|
||||
xs: "4px"
|
||||
sm: "6px"
|
||||
@@ -113,6 +113,16 @@ components:
|
||||
|
||||
# Design System: ThothII
|
||||
|
||||
## Visual review branch, September 2026
|
||||
|
||||
The revision on `codex/ui-visual-review` is approved for implementation and Docker visual review,
|
||||
not yet for adoption on `main`. The previous look remains recoverable from the base commit and
|
||||
the preserved Docker image. Historical prototypes must remain untouched.
|
||||
|
||||
This revision follows Impeccable's product register: one locally bundled Manrope family for the
|
||||
whole UI, five fixed size roles, red as the sole brand accent and additional color only for meaningful
|
||||
state. The primary scene remains an analyst reading data and SQL in a well-lit office.
|
||||
|
||||
## Overview
|
||||
|
||||
**Creative North Star: "The Clinical Workbench"**
|
||||
@@ -134,7 +144,7 @@ disciplined and tactile, never playful, sluggish, or visually unstable.
|
||||
**Key Characteristics:**
|
||||
|
||||
- Warm, restrained surfaces with one scarce red accent.
|
||||
- Editorial headings paired with highly legible operational body text.
|
||||
- One sans-serif family, with hierarchy expressed through size, weight and spacing.
|
||||
- Dense information organized through hierarchy, rhythm, and progressive disclosure.
|
||||
- Persisted artifacts and reviewer decisions presented as the visual source of truth.
|
||||
- Fast state feedback with reduced-motion parity.
|
||||
@@ -178,7 +188,8 @@ frontend uses OKLCH tokens directly.
|
||||
foreground. It shares Instrument Red's hue but uses a lighter, lower-chroma fill, so location is
|
||||
visible without carrying the full weight of a primary action.
|
||||
- **Warning Amber** (`warning-amber`): waiting, attention, and in-progress states.
|
||||
- **Information Blue** (`information-blue`): informational state when red would imply action.
|
||||
- **Information**: neutral text and indicators for dates, protocols and ordinary status. The legacy
|
||||
`--info` token resolves to muted foreground, not an additional blue accent.
|
||||
|
||||
The dark theme keeps the same semantic mapping with neutral near-black surfaces and a slightly
|
||||
lighter red accent. Do not introduce a second visual identity for dark mode.
|
||||
@@ -191,30 +202,33 @@ for their named states. Color is never the only state indicator.
|
||||
|
||||
## Typography
|
||||
|
||||
**Display Font:** Fraunces, with Source Serif Pro, Georgia, and Times New Roman fallbacks
|
||||
**Body Font:** Manrope, with native system sans-serif fallbacks
|
||||
**Label/Mono Font:** SF Mono or Cascadia Code, with Menlo and Consolas fallbacks
|
||||
**UI Font:** locally bundled Manrope Variable, with Manrope and native sans-serif fallbacks.
|
||||
**Technical Font:** SF Mono or Cascadia Code, with Menlo and Consolas fallbacks.
|
||||
|
||||
**Character:** Fraunces gives persisted artifacts and key headings editorial authority. Manrope
|
||||
keeps dense controls and prose calm and readable. The mono register separates machine identity,
|
||||
metadata, SQL, identifiers, and micro-labels from natural-language content.
|
||||
Manrope covers headings, labels, controls, navigation and document reading. Monospace is reserved
|
||||
for SQL, code, paths and machine identifiers, never for ordinary UI labels or status headings.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
- **Display** (600, `3rem`, `1.03`): authentication and exceptional page-level statements only.
|
||||
- **Headline** (600, `1.875rem`, `1.15`): major page or artifact titles.
|
||||
- **Title** (600, `1.2rem`, `1.25`): panel and document section hierarchy.
|
||||
- **Body** (400, `0.9375rem`, `1.65`): operational prose, with a target line length of 65 to 75
|
||||
- **Headline** (600, `1.5rem`, `1.3`): page or artifact titles, `--text-page`.
|
||||
- **Title** (600, `1.25rem`, `1.4`): section hierarchy, `--text-section`.
|
||||
- **Body** (400, `1rem`, `1.6`): operational prose, `--text-body`, with a target line length of 65 to 75
|
||||
characters where the surface controls width.
|
||||
- **Control** (600, `0.875rem`, `1.25`): buttons, inputs, tabs, and compact actions.
|
||||
- **Label** (600, `0.6875rem`, `0.06em` tracking): uppercase micro-labels, state metadata, and panel
|
||||
headers. Labels use the mono family.
|
||||
- **Control** (400–600, `0.875rem`, `1.5`): buttons, inputs, tables, tabs and compact subheadings,
|
||||
`--text-control`.
|
||||
- **Metadata** (400–600, `0.75rem`, `1.5`): secondary status, counts and timestamps, `--text-meta`.
|
||||
Labels use sentence case and normal tracking. Ordinary operational text never falls below 12px.
|
||||
|
||||
Typography uses fixed sizes. Responsive changes happen at structural breakpoints, not through fluid
|
||||
type scaling. Numeric data and identifiers use tabular numerals where comparison matters.
|
||||
|
||||
**The Three Registers Rule.** Serif means authority, sans means interaction and reading, mono means
|
||||
machine identity. Do not exchange these roles for novelty.
|
||||
**Application wordmark:** ThothII is a brand mark, not a page title: use Manrope semibold at
|
||||
48px (`3rem`) in the Core welcome area and 32px (`2rem`) in the session sidebar, with the
|
||||
`II` suffix in brand red. Preserve these sizes across responsive layouts.
|
||||
|
||||
**The One Family Rule.** The UI and document readers use sans-serif throughout. The legacy
|
||||
`--font-heading` alias resolves to `--font-sans`. Preserve technical monospace without turning it
|
||||
into a second decorative hierarchy. Do not shrink text to solve layout constraints.
|
||||
|
||||
**The Read Once Rule.** A heading, label, and body must be distinguishable on first glance through
|
||||
size and weight. Do not repeat headings in explanatory copy.
|
||||
@@ -279,25 +293,26 @@ default, hover, focus, active, disabled, loading, and error behavior where those
|
||||
- **Focus:** three-pixel Instrument Red ring with a clear border shift.
|
||||
- **Error / Disabled:** errors combine destructive color with explanatory text; disabled controls
|
||||
retain readable contrast and use 50 percent opacity.
|
||||
- **Metadata catalog model:** Database Management keeps one compact, installation-level
|
||||
metadata-generation LLM selector in the application header. The selection persists across
|
||||
database, table, column, and relationship views; when no usable profile is configured, the
|
||||
disabled control explains: “No metadata-generation LLM model is configured for this installation.”
|
||||
- **Global context:** the collapsible top shelf is the sole workspace/model selector for Core and
|
||||
Admin. Preserve independent remembered choices, installation defaults, operation locks and unsaved
|
||||
edit guards. Never introduce a separate metadata-generation default or selector.
|
||||
|
||||
### Navigation
|
||||
|
||||
- **Style:** compact session rows use `8px` corners and restrained vertical padding.
|
||||
- **Default / Hover / Active:** porcelain at rest, Sunken Surface on hover, and a muted Navigation
|
||||
Active red with a defined border when current. Exactly one top-level navigation control is current.
|
||||
- **Administrative controls:** the admin-only Administration accordion groups Database management,
|
||||
Memory management, Evidence management, a structural divider, Workspace management, and Pi
|
||||
management in that order. Its trigger exposes
|
||||
- **Administrative controls:** the admin-only Administration accordion groups Database,
|
||||
Memory, Evidence, a structural divider, Workspace, and Pi configuration in that order. Its trigger exposes
|
||||
expanded state and starts collapsed by default, while non-admin users do not receive the accordion
|
||||
or its navigation actions.
|
||||
- **Responsive:** collapse navigation structurally at the application breakpoint. Do not shrink
|
||||
labels into illegibility. Below 768px, Memory and Evidence management use the full content
|
||||
width; a Navigation button opens the shared accessible dialog. Selecting another archive
|
||||
page or pressing Escape closes it. Desktop retains the session sidebar.
|
||||
page or pressing Escape closes it. Desktop retains the right session sidebar and its My sessions /
|
||||
All sessions tabs. Core retains question/answer, eight phases, reviewer gates and the left log.
|
||||
The portal owns the red header and left sidebar; ThothII must not duplicate them. Size to the
|
||||
actual application container. Narrow session document panels may use the available width.
|
||||
|
||||
### Tabs
|
||||
|
||||
@@ -347,7 +362,8 @@ paths with copy controls, never browser file links to container-only locations.
|
||||
- **Do** preserve information density with headings, rhythm, and progressive disclosure.
|
||||
- **Do** keep keyboard focus explicit and pair color with text, shape, icon, or position.
|
||||
- **Do** respect `prefers-reduced-motion` while preserving immediate non-kinetic feedback.
|
||||
- **Do** use English for interface chrome and the workspace language for persisted document content.
|
||||
- **Do** use the selected interface language (English by default) for chrome and preserve the
|
||||
workspace language for persisted domain content. Session interaction language remains pinned.
|
||||
- **Do** render curated metadata and scope as Markdown prose or lists, never as a frontmatter table.
|
||||
- **Do** break long curated rules into paragraphs, labelled subsections, and lists at existing
|
||||
punctuation boundaries while preserving the exact canonical text for machines.
|
||||
|
||||
+28
-4
@@ -26,13 +26,37 @@ independent reviews, local browser checks and rollback details.
|
||||
|
||||
## Current product shape
|
||||
|
||||
### Visual review integrated with the full/embedded shell
|
||||
|
||||
At the owner's request, the seven commits through `a59624a6` from
|
||||
`codex/ui-visual-review` are integrated with the shell/i18n work in this checkout,
|
||||
`codex/prototype-administration-pages`. Both branches started at `2d1b714e`; the
|
||||
first full-shell build omitted that lateral branch and regressed the installed UI.
|
||||
The integrated source retains bundled Manrope, shared type roles, catalog/context
|
||||
alignment, wordmark sizes and composer autosizing alongside full/embedded and EN/IT.
|
||||
|
||||
Local Docker was updated at 12:54 UTC on 2026-09-13: frontend image `605a6e6bba68`,
|
||||
healthy; Core and all data-service containers were unchanged. Mac remains full/en.
|
||||
The immediate pre-merge frontend is retained as
|
||||
`thothii-frontend:before-visual-shell-merge-20260913`.
|
||||
|
||||
Verification: 755 frontend tests, 11 Playwright visual/interaction scenarios at five
|
||||
widths, translation-catalog checks, typecheck and production build. See
|
||||
`docs/reports/2026-09-13-visual-shell-integration.md` for delivery, provenance and
|
||||
rollback details. The separate visual-review worktree and its rollback images are
|
||||
retained. No merge to `main`, push or production deployment is part of this delivery.
|
||||
|
||||
Gitea #28–#31 follow-up is implemented in this checkout: Workspace's four tabs,
|
||||
Database list-first entry without the preparation footer, full-height Pi instructions
|
||||
with installation-host OS selection, and short Admin navigation labels. Core and
|
||||
session behavior are retained. These follow-up changes are **not yet rebuilt into
|
||||
local Docker**; see `docs/reports/2026-09-12-admin-issues-28-31.md` for test/review results.
|
||||
The host CLI must regenerate its Compose projection on the destination installation
|
||||
to identify the physical host OS correctly when the backend runs in Docker.
|
||||
session behavior are retained. At the owner's request, these changes were rebuilt into
|
||||
local Docker on 2026-09-12 at 18:31 UTC. Core/frontend are healthy and the UI at
|
||||
`http://127.0.0.1:8080` serves the updated bundle. The host projection now supplies
|
||||
`THT_HOST_PLATFORM=darwin`, so Pi selects macOS rather than the container's Linux OS.
|
||||
Persistent dependency containers and volumes were unchanged. Rollback images are tagged
|
||||
`thothii-core:before-admin-28-31-20260912` and
|
||||
`thothii-frontend:before-admin-28-31-20260912`. See
|
||||
`docs/reports/2026-09-12-admin-issues-28-31.md` for verification and deployment details.
|
||||
|
||||
The latest context-shelf A and five Administration pages are implemented locally.
|
||||
At the owner's request, local Docker project `thothii-18998cca7b0a` was rebuilt and
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# The image is tagged from the running frontend before the visual review is published.
|
||||
services:
|
||||
frontend:
|
||||
image: thothii-frontend:before-visual-review-20260912
|
||||
@@ -0,0 +1,8 @@
|
||||
# Local-only review override. Apply after the existing local installation profiles.
|
||||
# Changes only the frontend image; Core, configuration and volumes remain unchanged.
|
||||
services:
|
||||
frontend:
|
||||
image: thothii-frontend:visual-review-20260912
|
||||
build:
|
||||
context: /Users/mp/projects/ThothII-visual-review
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
@@ -0,0 +1,5 @@
|
||||
# Local-only rollback to the full-shell frontend before visual integration.
|
||||
# Apply after local installation profiles, with --no-build and --no-deps.
|
||||
services:
|
||||
frontend:
|
||||
image: thothii-frontend:before-visual-shell-merge-20260913
|
||||
@@ -52,7 +52,7 @@ projecting the host platform, and shortening navigation labels.
|
||||
initially exposed horizontal page overflow at 390 pixels; constrained grid children
|
||||
and narrow definition lists corrected it. Database QA used an empty synthetic catalog;
|
||||
the unit suites exercise populated tables, columns, edits and operation guards.
|
||||
- Docker and real-stack Playwright acceptance were not run for this follow-up.
|
||||
- Docker and real-stack Playwright acceptance were not run during initial implementation.
|
||||
The preview is synthetic and read-only, not a deployment or provider smoke test.
|
||||
|
||||
## Standards
|
||||
@@ -73,5 +73,24 @@ The independent follow-up review confirmed both findings resolved, with no new r
|
||||
Review summary: Standards 2 findings resolved, 0 outstanding; Spec 2 findings resolved,
|
||||
0 outstanding. Both reviews were read-only, relative to the user-approved baseline.
|
||||
|
||||
Docker has not been rebuilt for this follow-up; the read-only QA preview uses the changed source.
|
||||
## Local Docker deployment
|
||||
|
||||
At the owner's request, core and frontend were rebuilt from this checkout and recreated
|
||||
on 2026-09-12 at 18:31 UTC using `/private/tmp/thothii-memory-preview.sh`, with
|
||||
`--no-deps --force-recreate --wait`. Both are healthy. Database, Qdrant and embedding
|
||||
containers retained their original IDs; no persistent volumes were deleted or recreated.
|
||||
|
||||
The normal model projection generator was run on this Mac before recreation; the running
|
||||
core now receives `THT_HOST_PLATFORM=darwin`. The authored installation configuration and
|
||||
credentials were unchanged. Before the update, images were retained as
|
||||
`thothii-core:before-admin-28-31-20260912` and
|
||||
`thothii-frontend:before-admin-28-31-20260912`; the prior projection directory was copied to
|
||||
`/private/tmp/thoth-admin-28-31-docker.5ebAo8/generated`.
|
||||
|
||||
HTTP checks for the real UI (`http://127.0.0.1:8080/`), its `/api/health` proxy, and
|
||||
the core `/health` returned 200. Served bundle `/index-Bsz7otBx.js` contains Workspace
|
||||
areas and Pi configuration, and no longer contains the preparation footer or persisted
|
||||
Database child-screen key. Authenticated end-to-end operator workflows were not rerun.
|
||||
|
||||
The Vite preview remains separate from Docker. Remote server deployment was not performed.
|
||||
No Gitea issue state or remote branch was changed.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# ThothII: survey e piano di revisione grafica
|
||||
|
||||
Data: 12 settembre 2026. Stato: **proposta, da approvare; nessuna modifica all'interfaccia**.
|
||||
|
||||
## Sintesi e direzione consigliata
|
||||
|
||||
L'incoerenza segnalata è confermata. Non dipende soltanto da dettagli sfuggiti nelle singole pagine: il design system attuale prescrive tre registri tipografici, ai quali alcuni componenti aggiungono un secondo stack serif. Titoli, etichette operative, documenti e istruzioni finiscono così per sembrare parti di prodotti diversi.
|
||||
|
||||
Propongo una direzione unica: **interfaccia operativa interamente sans-serif, rosso istituzionale come accento, colori aggiuntivi soltanto quando comunicano uno stato, maggiore coerenza senza impoverire le informazioni**. Non serve una nuova impaginazione del Core né una nuova collocazione delle sessioni.
|
||||
|
||||
Ho usato **Impeccable**, nel registro product, per orientare la valutazione di gerarchia, tipografia, densità, colore e microcopy. La varietà visiva dovrà venire da spaziatura, pesi e organizzazione, non dall'alternanza di famiglie tipografiche. Le grazie non sono intrinsecamente incompatibili con un prodotto tecnologico, ma qui contrastano con la direzione richiesta e non svolgono una funzione necessaria: le eliminerei dalla UI, compresi i lettori di documenti.
|
||||
|
||||
## 1. Metodo, copertura e limiti
|
||||
|
||||
Verifica tramite Playwright nel browser dell'app, con screenshot, navigazione e lettura degli stili CSS calcolati. La survey principale riguarda la **versione Docker reale su `http://127.0.0.1:8080/`**, autenticata con l'account autorizzato. Una precedente esplorazione dell'anteprima sintetica non è usata come prova dei dati o del funzionamento del Docker.
|
||||
|
||||
| Area | Verifica sulla versione reale |
|
||||
| --- | --- |
|
||||
| Workspace | Preparation, Specific actions, About, Authentication |
|
||||
| Database | Elenco database, navigazione alle tabelle e alle colonne |
|
||||
| Memory | Elenco vuoto e apertura del form New card, chiuso senza modifiche |
|
||||
| Evidence | Fonti/importazione, elenco, dettaglio di un documento |
|
||||
| Pi | Runtime e Host maintenance, scheda macOS |
|
||||
| Core | Stato iniziale, compositore e controlli presenti senza avviare una domanda |
|
||||
| Sessioni | My sessions, All sessions, archivio e lettura di una sessione archiviata |
|
||||
| Navigazione | Drawer responsive, accessi Admin e contesto globale |
|
||||
|
||||
Non ho salvato form, lanciato elaborazioni, ripreso sessioni, importato documenti o modificato configurazioni. Il controllo del Core attivo, delle fasi, dei gate e del log è limitato alla lettura del codice: non è stato eseguito un ciclo LLM per una survey grafica.
|
||||
|
||||
Il viewport misurato nel pannello browser era **649 × 1091 CSS px**. Questo offre evidenza concreta del comportamento a larghezza ridotta, ma non sostituisce una matrice desktop/mobile. Tema scuro, contrasto numerico, navigazione completa da tastiera, zoom e integrazione effettiva in Omics Portal restano da verificare. Non è una certificazione WCAG né un audit automatizzato completo. Le famiglie riportate sono gli stack CSS calcolati, non una verifica del font effettivamente caricato dal sistema.
|
||||
|
||||
## 2. Problemi riscontrati
|
||||
|
||||
### Tipografia: quattro stack e gerarchie variabili
|
||||
|
||||
| Esempio osservato | Trattamento attuale | Problema |
|
||||
| --- | --- | --- |
|
||||
| Titoli delle pagine Admin | Fraunces/serif, 30 px | Registro editoriale molto più marcato del resto dell'app |
|
||||
| Sources and imports; titolo del form Memory | Altro stack `ui-serif`, 18–20 px | Nemmeno i titoli serif usano tutti la stessa famiglia |
|
||||
| Evidence library; Original question; Revised question | Monospace, 11 px | Etichette ordinarie trattate come identificatori tecnici |
|
||||
| Working context | Monospace maiuscolo, 10 px | Informazione globale importante resa molto piccola |
|
||||
| Guida Pi Host maintenance | Testo 12 px, interlinea 16 px; titoletti serif 12 px | Istruzioni lunghe troppo dense e gerarchia debole |
|
||||
| Sessione archiviata | Titolo sessione serif 14 px; titolo nel documento serif 23,2 px | Il contenuto sovrasta il contesto del pannello |
|
||||
|
||||
La causa è documentata in `DESIGN.md`, nella regola dei tre registri. Nel codice, `frontend/src/index.css` assegna globalmente il font heading a `h1`–`h6` e ai titoli della prosa; Memory ed Evidence aggiungono classi `font-serif`. Il problema ricompare quindi anche quando un componente non richiede esplicitamente un titolo decorativo.
|
||||
|
||||
### Colore: troppi significati impliciti
|
||||
|
||||
- In Database il tipo di connessione REST e la data di aggiornamento ricevono accenti blu pur essendo informazioni ordinarie.
|
||||
- La guida ordinaria di manutenzione Pi è racchiusa in una cornice ambra, visivamente simile a un avviso. Il colore non distingue un pericolo concreto.
|
||||
- Gli stati Ready in Pi usano anche testo verde molto chiaro: è un rischio di leggibilità da misurare, non una violazione numericamente accertata in questa survey.
|
||||
- Stato operativo, azione primaria, contenitore informativo e semplice categoria non seguono una grammatica cromatica sufficientemente riconoscibile.
|
||||
|
||||
Non eliminerei invece indiscriminatamente verde e ambra: successo, errore, attesa di revisione e avvertimento devono restare distinguibili, sempre anche tramite testo o icona.
|
||||
|
||||
### Spazio e linguaggio
|
||||
|
||||
| Area | Evidenza | Intervento proposto |
|
||||
| --- | --- | --- |
|
||||
| Workspace | Identità e descrizioni ripetute fra contesto, selezione e intestazioni; molte gerarchie prima delle azioni | Mantenere le quattro schede, ridurre ripetizioni e uniformare sezioni e azioni |
|
||||
| Database | Intestazione, stato, cronologia, Fleet summary, breadcrumb e toolbar precedono i dati; a 649 px il riepilogo occupa più righe | Compattare il riepilogo, dare priorità alla griglia; sostituire il gergo Fleet con un nome corrispondente al suo ambito |
|
||||
| Memory | Stato vuoto formulato come assenza di risultati filtrati anche senza carte; ampie aree vuote e paginazione disabilitata | Distinguere libreria vuota da ricerca senza risultati; stesso linguaggio visivo di toolbar e form del resto dell'Admin |
|
||||
| Evidence | Percorso tecnico e istruzioni di importazione dominano la parte superiore; ricerca compressa rispetto agli altri filtri | Istruzioni brevi con dettagli espandibili; percorso secondario ma copiabile; filtri che si dispongono su più righe prima di diventare illeggibili |
|
||||
| Pi | Guida molto lunga e piccola; spiegazioni dell'architettura mescolate con istruzioni all'operatore | Separare stato, azioni e guida; rendere la guida leggibile e consultabile per attività |
|
||||
| Sessioni | Molti registri tipografici; a 649 px il pannello documenti è stretto e tronca il titolo | Coerenza dei titoli e dei documenti; adeguare il pannello alla larghezza disponibile senza cambiare la gestione delle sessioni |
|
||||
| Core e navigazione | Branding ripetuto, microetichette e controlli di taglie diverse | Riallineare la gerarchia e i componenti condivisi, conservando struttura e comportamento |
|
||||
|
||||
Gli stili comunicativi oscillano fra introduzione promozionale nel Core, manuale tecnico in Pi/Evidence e terminologia da dashboard infrastrutturale in Database. La soluzione è un tono operativo unico, non la rimozione delle informazioni tecniche necessarie.
|
||||
|
||||
## 3. Regole del sistema visivo proposto
|
||||
|
||||
### Una famiglia UI, cinque ruoli dimensionali
|
||||
|
||||
Consiglio di **mantenere Manrope**, già adottato per l'interazione, e usarlo per tutta la UI. Non aggiungerei una nuova famiglia per il solo gusto di cambiare. Pesi ordinari 400, 500 e 600; monospace soltanto per SQL, codice, percorsi e identificatori quando serve riconoscerli o copiarli.
|
||||
|
||||
| Ruolo | Dimensione proposta | Uso |
|
||||
| --- | --- | --- |
|
||||
| Titolo pagina | 24 px / 1,5 rem, peso 600 | Core/Admin, senza effetto copertina |
|
||||
| Titolo sezione | 20 px / 1,25 rem, peso 600 | Sezioni principali e pannelli di lettura |
|
||||
| Lettura | 16 px / 1 rem, peso 400 | Risposte, documenti, istruzioni articolate; interlinea circa 1,5 |
|
||||
| Interazione e dati | 14 px / 0,875 rem, peso 400–600 | Form, tab, pulsanti, griglie; sottosezioni compatte in peso 600 |
|
||||
| Metadati secondari | 12 px / 0,75 rem, peso 400–500 | Timestamp, conteggi e indicazioni accessorie |
|
||||
|
||||
Sono ruoli, non l'obbligo di rendere ogni titolo di sezione grande: una sottosezione compatta può usare il ruolo da 14 px in semibold. Niente ridimensionamento fluido che faccia oscillare continuamente i caratteri con la larghezza. Prima si riorganizza il layout, poi si valutano eccezioni documentate.
|
||||
|
||||
Ulteriori regole:
|
||||
|
||||
- Eliminare Fraunces e le classi serif dall'interfaccia e dalla resa dei documenti. Non riscrivere il contenuto dei documenti.
|
||||
- Usare etichette sans-serif in sentence case; limitare maiuscole e spaziatura espansa.
|
||||
- Nessun testo operativo ordinario sotto 12 px; non far stare più contenuto riducendolo a 8–11 px.
|
||||
- Numeri tabulari nelle colonne numeriche; testo libero proporzionale; codice monospace leggibile.
|
||||
- Distribuire il font con l'app, evitando che Google Fonts sia necessario alla coerenza della UI in un ambiente intranet. Verificare fallback e caricamento, senza presumere che oggi il font remoto non funzioni.
|
||||
|
||||
### Palette: neutri e rosso, con eccezioni semantiche
|
||||
|
||||
| Ruolo | Regola |
|
||||
| --- | --- |
|
||||
| Superfici | Bianco e pochi grigi neutri, bordi leggeri; niente colorazione per ogni sezione |
|
||||
| Testo | Grafite per testo principale, grigio leggibile per secondario |
|
||||
| Rosso istituzionale | Azione primaria, selezione e focus; tonalità coerente con il tema Omics, da verificare nell'integrazione |
|
||||
| Successo | Indicatore verde sobrio con testo sufficientemente scuro; non interi paragrafi verde pallido |
|
||||
| Avvertimento/attesa umana | Ambra soltanto quando occorre attenzione, con motivazione esplicita |
|
||||
| Errore/distruzione | Trattamento distinto tramite icona, etichetta e conferma dove prevista; non affidarsi al solo rosso condiviso con il brand |
|
||||
| Informazioni ordinarie | Neutre: timestamp, protocollo, conteggi, guide e configurazione non sono automaticamente stati colorati |
|
||||
|
||||
SQL, grafici e visualizzazioni possono mantenere colori utili alla comprensione dei dati. Questa revisione non impone un monocromo indiscriminato. Gli stati del workflow conserveranno il loro significato; ogni modifica cromatica dovrà avere una mappatura esplicita.
|
||||
|
||||
### Componenti e testi condivisi
|
||||
|
||||
- Un'unica famiglia di intestazioni Admin, toolbar, tab, campi, pulsanti, badge e messaggi.
|
||||
- Scala di spaziatura comune basata su multipli di 4 px, con distinzione chiara fra spazio dentro un gruppo e spazio fra gruppi.
|
||||
- Una primaria per il compito locale; azioni secondarie neutre, pericolose riconoscibili senza rendere tutto rosso.
|
||||
- Tab allo stesso livello con lo stesso trattamento. Le schede OS di una guida possono essere più compatte, ma non appartenere a un altro sistema grafico.
|
||||
- Istruzione essenziale accanto all'azione; procedure e dettagli di implementazione in una sezione di aiuto espandibile.
|
||||
- Testi UI in inglese; documenti e dati conservano la propria lingua. I titoli italiani di Evidence e sessioni non sono un errore di uniformità.
|
||||
|
||||
Esempi di microcopy da affinare nell'implementazione:
|
||||
|
||||
| Attuale | Proposta |
|
||||
| --- | --- |
|
||||
| Fleet summary | Catalog summary, se confermato l'ambito del riepilogo |
|
||||
| No cards match these filters… in una libreria vuota | No memory cards yet. Create a card to add reusable knowledge. |
|
||||
| Session models in Pi | Available models, con indicazione dell'effettiva idoneità Core/Admin |
|
||||
| Spiegazione che la pagina “never creates a second model default” | Installation default, con breve indicazione di dove modificarlo e dettagli tecnici separati |
|
||||
|
||||
Il pulsante che prova il default d'installazione deve continuare a dichiarare **quale modello testa**: non va confuso con il modello selezionato nel contesto globale. Anche la distinzione fra importazione, revisione e consolidamento Evidence deve rimanere esplicita.
|
||||
|
||||
## 4. Vincoli funzionali e integrazione
|
||||
|
||||
La revisione deve conservare:
|
||||
|
||||
1. Core originale: domanda/risposta, otto fasi in alto, gate di revisione, log sul lato sinistro e controlli esistenti.
|
||||
2. Sessioni nella navigazione destra con **My sessions / All sessions**, archivio e azioni attuali. Nessuna sezione sostitutiva in fondo alla pagina.
|
||||
3. Contesto globale A espandibile in alto: workspace e modello unici per tutta l'app; scelte ricordate indipendentemente e default esistenti.
|
||||
4. Blocco delle attività senza contesto valido e del cambio contesto durante un'elaborazione; protezione delle modifiche Admin non salvate.
|
||||
5. Cinque pagine Admin, preprocessing in Workspace Preparation e gestione database/tabelle/campi/relazioni in Database.
|
||||
6. Unificazione già realizzata della configurazione LLM, senza introdurre selettori o default alternativi.
|
||||
7. Prototipi precedenti conservati e recuperabili.
|
||||
|
||||
**Omics Portal possiede header rosso e sidebar sinistra.** ThothII non deve duplicarli. Le regole globali attuali su `:root`, `body` e titoli richiedono una verifica di isolamento: se l'integrazione avviene nello stesso DOM, gli stili vanno circoscritti alla radice ThothII; se avviene in iframe, vanno verificati dimensionamento, tema e scrolling. È un rischio di integrazione da verificare, non una sovrapposizione già osservata sul portale.
|
||||
|
||||
A larghezze ridotte pannelli e filtri devono adattarsi alla larghezza del contenitore reale, non soltanto della finestra. Nel lettore della sessione il testo non dovrebbe restare confinato in una colonna di circa 195 px come nello screenshot a 649 px: prevedere un pannello più ampio o sovrapposto nella modalità compatta, mantenendo accessi e funzione originali.
|
||||
|
||||
## 5. Sequenza di lavoro proposta
|
||||
|
||||
### Fase 1: regole e campione rappresentativo
|
||||
|
||||
Dopo approvazione, aggiornare `DESIGN.md` eliminando la regola dei tre registri e allineando gli eventuali riferimenti superati ai modelli e alla navigazione. Definire token tipografici, colori semantici e componenti comuni.
|
||||
|
||||
Applicare un primo campione a **Workspace e Pi**: insieme verificano form, stato operativo, selezioni, istruzioni lunghe e codice. Mostrare il confronto prima/dopo mantenendo stessi contenuti e viewport. Non servono tre nuovi design alternativi: serve validare un sistema coerente.
|
||||
|
||||
### Fase 2: shell e tutte le pagine Admin
|
||||
|
||||
Uniformare contesto globale, navigazione, titoli, tab e controlli; estendere il sistema a Database, Memory ed Evidence. Compattare riepiloghi e istruzioni senza eliminare funzionalità. Correggere stati vuoti, larghezze dei filtri e formattazione dei blocchi di codice nella guida Pi.
|
||||
|
||||
### Fase 3: Core, documenti e sessioni
|
||||
|
||||
Applicare gli stessi token a compositore, fasi, log, widget di revisione, documenti e navigazione sessioni. Nessuna nuova struttura del workflow. Controllare separatamente liste, documento aperto, sessione attiva e pannelli nella modalità compatta.
|
||||
|
||||
### Fase 4: verifica e consegna
|
||||
|
||||
Eseguire typecheck, test dei componenti interessati e regressione Playwright con dati di test, senza consumare chiamate LLM reali soltanto per verificare gli stili. Includere prove di flusso per navigazione con modifiche non salvate e blocco del contesto durante un'operazione.
|
||||
|
||||
Preparare screenshot comparabili e verifiche a 390, 649, 768, 1280 e 1600 px, oltre alla larghezza effettiva assegnata da Omics con sidebar presente. Verificare tema chiaro/scuro, zoom 200%, tastiera, focus, testo lungo, stato vuoto/errore/caricamento e griglie larghe. L'integrazione finale nel server resta una fase distinta, secondo le decisioni già prese.
|
||||
|
||||
## 6. Criteri di accettazione
|
||||
|
||||
- Una sola famiglia sans per la UI; monospace solo per contenuto tecnico. Nessuna classe serif residua nei componenti operativi o nei lettori.
|
||||
- Dimensioni e pesi derivati dai ruoli condivisi; ogni eccezione ha un motivo documentato.
|
||||
- Colori dei componenti da token semantici; nessun blu decorativo per date o protocolli e nessuna cornice di warning intorno a una guida ordinaria.
|
||||
- Contrasto del testo almeno 4,5:1, salvo le eccezioni previste per testo grande e altri casi specifici; non assumere che ogni semibold sia testo grande. Riferimento: [W3C, Contrast Minimum](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html).
|
||||
- Contrasto almeno 3:1 per informazioni visive necessarie a identificare controlli e stati, nei casi applicabili; non è un obbligo per ogni bordo decorativo. Riferimento: [W3C, Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html).
|
||||
- Stato e azione distinguibili anche senza colore; focus visibile e controlli utilizzabili da tastiera.
|
||||
- Nessuna sovrapposizione o scroll orizzontale dell'intera pagina ai viewport concordati; scroll locale consentito per tabelle e codice quando necessario.
|
||||
- Nessuna regressione funzionale di Core, sessioni, Admin, selezione globale o salvataggio; test dei flussi critici invariati o rafforzati.
|
||||
- Nessuna modifica del contenuto scientifico, dei default LLM, delle autorizzazioni o delle procedure di manutenzione mascherata da intervento grafico.
|
||||
|
||||
## 7. Punti principali del codice interessati in una futura implementazione
|
||||
|
||||
- `DESIGN.md`: regole tipografiche e cromatiche.
|
||||
- `frontend/index.html`: caricamento delle famiglie di caratteri.
|
||||
- `frontend/src/index.css`: token, titoli globali, etichette e prosa.
|
||||
- `frontend/src/shell/WorkingContextShelf.css`: contesto globale e microtipografia.
|
||||
- `frontend/src/shell/administration/AdministrationPage.css`: intestazioni comuni.
|
||||
- `frontend/src/shell/WorkspaceManager.css`: gerarchie e densità Workspace.
|
||||
- `frontend/src/shell/database-management/FleetLedgerShell.css`: riepiloghi, toolbar e griglie.
|
||||
- Componenti Memory/Evidence: override serif di titoli e form.
|
||||
- Componenti Pi: guida, stati, tab OS e microcopy operativo.
|
||||
- Componenti Core/sessioni: fasi, lettura documenti, log, navigazione e pannelli responsive.
|
||||
|
||||
**Decisione richiesta:** approvare questa direzione visiva e il campione Workspace/Pi prima di estenderla. Questa survey non crea ticket, non aggiorna il design normativo e non ricostruisce Docker.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Revisione grafica: consegna su worktree e Docker
|
||||
|
||||
Stato: **pubblicata per revisione visiva, non integrata in main**.
|
||||
|
||||
Aggiornamento 2026-09-13: i sette commit di questa revisione sono ora integrati con
|
||||
full/embedded e i18n nel ramo `codex/prototype-administration-pages`, su richiesta
|
||||
del proprietario. Per lo stato corrente e il rollback usare il
|
||||
[rapporto di integrazione](2026-09-13-visual-shell-integration.md).
|
||||
Il seguito conserva la cronologia della consegna isolata originale.
|
||||
|
||||
## Dove provarla
|
||||
|
||||
- Docker locale: <http://127.0.0.1:8080/>.
|
||||
- Worktree: `/Users/mp/projects/ThothII-visual-review`.
|
||||
- Ramo: `codex/ui-visual-review`.
|
||||
- Base preservata: `2d1b714ebe31419d712e9c3324a5e171d5f0317d`.
|
||||
- Checkout originale: `/Users/mp/projects/ThothII`, ramo `codex/prototype-administration-pages`, lasciato intatto, incluse le note non ancora committate e i prototipi.
|
||||
|
||||
Non è stato eseguito alcun merge, push o aggiornamento di Gitea. Questa consegna richiede la revisione ottica del proprietario prima dell'adozione.
|
||||
|
||||
## Interventi
|
||||
|
||||
Impeccable, registro product, ha guidato le scelte: Manrope sans-serif locale per tutta la UI; niente serif nei componenti o nei documenti; cinque ruoli tipografici condivisi; monospace conservato per contenuto tecnico. I font non dipendono più da Google Fonts.
|
||||
|
||||
Le etichette ordinarie non usano più monospace maiuscolo. Le piccole dimensioni arbitrarie dei componenti sono state ricondotte a 12, 14 o 16 px. Titoli Admin a 24 px; intestazioni, controlli e prosa sono coerenti fra superfici.
|
||||
|
||||
Il rosso resta l'accento principale. Date e protocolli sono neutri. Verde e ambra usano token semantici leggibili anche in tema scuro. La guida Pi non è più presentata come un grande avviso ambra. Il tema delle utility segue il tema dell'app/portale, non autonomamente quello del sistema operativo. Placeholder e focus sono più leggibili.
|
||||
|
||||
Interventi specifici:
|
||||
|
||||
- Workspace: tipografia e stati uniformati, mantenute quattro schede e preprocessing.
|
||||
- Database: riepilogo più compatto, Catalog summary al posto di Fleet summary, intestazioni delle griglie senza monospace maiuscolo, selezioni compatibili con il tema scuro.
|
||||
- Memory: titoli e form sans-serif; messaggio distinto per libreria vuota e nessun risultato filtrato; paginazione mostrata quando serve.
|
||||
- Evidence: istruzioni tecniche espandibili, percorso host sempre disponibile e copiabile nella relativa sezione, filtri responsive con ricerca di larghezza utile.
|
||||
- Pi: guida leggibile a 16 px, codice correttamente su più righe, tab OS coerenti, testo che distingue default d'installazione e modello selezionato, Available models.
|
||||
- Core/sessioni: uniformazione estetica senza modifiche al workflow; otto fasi, gate, log, input e gestione sessioni conservati. Nei layout compatti il lettore documenti usa lo spazio disponibile anziché una colonna illeggibile.
|
||||
|
||||
Il modello globale, i default, le autorizzazioni e i blocchi di navigazione non sono stati ridisegnati. Non sono state eseguite elaborazioni LLM o mutate informazioni applicative per questa verifica.
|
||||
|
||||
## Follow-up: marchio applicativo (2026-09-13)
|
||||
|
||||
Su richiesta dell'utente, ripristinato ThothII a 48 px nel Core e ingrandito a 32 px nella
|
||||
sidebar destra. Manrope semibold e suffisso II rosso restano coerenti con Impeccable;
|
||||
il marchio è un'eccezione esplicita alla scala dei titoli ordinari in DESIGN.md.
|
||||
Nessuna modifica funzionale. Build e 7 scenari Playwright superati, con controlli delle
|
||||
dimensioni del marchio alle cinque larghezze. Pubblicato nuovamente il solo frontend
|
||||
Docker usando il medesimo tag di revisione; immagine di ripristino invariata, nessun merge.
|
||||
La nuova immagine sostituisce quella identificata nella prima consegna sotto:
|
||||
`sha256:f576ee4882456b4d2d338dbb2228d301f9ffd8d0c7de541842c767bbe8d02dad`.
|
||||
|
||||
## Follow-up: indicatori del catalogo (2026-09-13)
|
||||
|
||||
Spostati i pallini dopo le etichette Catalog, Operations e Metadata updated, mantenendo
|
||||
il rientro del testo e l'allineamento con i valori sottostanti. Spaziatura uniforme e
|
||||
centratura verticale secondo Impeccable. I 7 scenari Playwright passano, inclusi nuovi
|
||||
controlli geometrici per tutte e tre le etichette alle cinque larghezze responsive.
|
||||
Il successivo aggiornamento del medesimo tag frontend include questa correzione;
|
||||
il digest del follow-up precedente identifica quindi una versione superata.
|
||||
|
||||
## Follow-up: altezza del prompt Core (2026-09-13)
|
||||
|
||||
Riprodotto con Playwright il campo schiacciato: altezza inline 0 px, spazio utile 8 px,
|
||||
a fronte di 31 px necessari per la prima riga. L'autosizing misurava il Core ancora
|
||||
nascosto durante il caricamento del contesto e non reagiva alla successiva apertura.
|
||||
Il reset isolato dell'altezza ad auto riportava immediatamente il campo a 31 px.
|
||||
|
||||
Il composer ora evita misure nascoste, ricalcola al cambio di larghezza/visibilità e
|
||||
al caricamento dei font, conserva un minimo di 32 px e cresce fino a 160 px con scroll.
|
||||
Il focus usa il contorno esterno esistente, senza il secondo bordo interno.
|
||||
Nessuna modifica all'invio o al workflow. Passati 9 scenari Playwright e 17 test Vitest
|
||||
mirati; coperti ingresso dal Core e dall'Admin, Shift+Enter, testo lungo, mobile,
|
||||
navigazione con bozza conservata e svuotamento. Build Docker frontend aggiornata;
|
||||
branch di revisione isolato, immagine di ripristino invariata.
|
||||
|
||||
## Follow-up: griglia di allineamento del catalogo (2026-09-13)
|
||||
|
||||
Catalog condivide il margine di Catalog summary; sui layout ampi Metadata updated
|
||||
condivide l'asse di Description coverage e Operations occupa il punto medio fra i due.
|
||||
Usati margini condivisi e gli stessi cinque riferimenti di colonna dei KPI, senza offset
|
||||
assoluti. Sulle larghezze ridotte gli stati continuano a disporsi senza sovrapposizioni.
|
||||
Passati 9 scenari Playwright, con assert geometrici sugli allineamenti richiesti.
|
||||
Pubblicazione limitata al frontend Docker del worktree di revisione, nessun merge.
|
||||
|
||||
## Follow-up: margini esterni e assi interni (2026-09-13)
|
||||
|
||||
La verifica geometrica iniziale rilevava 20 px di differenza fra il bordo sinistro
|
||||
del riepilogo e quello della tabella. In accordo con Impeccable, introdotti due token:
|
||||
margine esterno condiviso di 20 px e padding interno di 16 px. Catalog summary,
|
||||
navigazione Databases e tabella hanno bordi sinistro/destro coincidenti; riepilogo,
|
||||
Tables, Databases e Workspace databases condividono l'asse testuale a 36 px.
|
||||
Header, toolbar azioni e stati seguono lo stesso asse. Conservato l'allineamento
|
||||
Metadata updated/Description coverage e Operations al punto medio sui layout ampi.
|
||||
Passati 9 scenari Playwright con assert geometrici a 390, 649, 768, 1280 e 1600 px.
|
||||
Nessuna modifica funzionale; aggiornamento Docker limitato al frontend di revisione.
|
||||
|
||||
## Follow-up: pannello del contesto globale (2026-09-13)
|
||||
|
||||
Il pannello espanso Workspace/Model ora usa gli stessi token di margine esterno (20 px)
|
||||
e padding interno (16 px) del catalogo, con superficie rientrata e controlli sull'asse
|
||||
testuale a 36 px. Allineamento curato con Impeccable e verificato alle cinque larghezze.
|
||||
Rimosse la frase sulle ultime scelte e le indicazioni di provenienza sotto i selettori
|
||||
(Installation default / Last choice). Logica di selezione, persistenza, default,
|
||||
blocchi operativi e messaggi di errore invariati. Passati 9 scenari Playwright con
|
||||
controlli sul pannello aperto, sui margini e sull'assenza delle diciture rimosse.
|
||||
|
||||
## Verifica
|
||||
|
||||
| Verifica | Esito |
|
||||
| --- | --- |
|
||||
| Frontend, suite Vitest | 679 test passati |
|
||||
| TypeScript + build produzione | Passati |
|
||||
| Playwright dedicato | 7 scenari passati, nessun retry |
|
||||
| Larghezze | 390, 649, 768, 1280, 1600 CSS px |
|
||||
| Superfici Playwright | Cinque Admin, guida Pi, Core iniziale, My/All sessions e lettore archivio |
|
||||
| Interazioni | Tab OS da tastiera, modifica Memory trattenuta durante tentativo di navigazione, ritorno al Core dopo annullamento |
|
||||
| Tema | Chiaro indipendente dal tema OS; scuro tramite attributo del portale |
|
||||
| Spazio host | Simulazione di 240 px riservati al menu sinistro e 72 px all'header, con utility concorrente |
|
||||
| Docker reale | Accesso e navigazione fra le cinque pagine Admin; titolo sans-serif 24 px e nessun overflow dell'intera pagina nel viewport verificato |
|
||||
|
||||
I test visuali usano fixture e intercettano tutte le richieste API prima che possano raggiungere un'installazione. Nessuna scrittura è ammessa: ogni scenario controlla che non ne siano state tentate. Gli screenshot sono negli output locali ignorati da Git, `frontend/test-results/visual-review/`.
|
||||
|
||||
Comandi di verifica nel worktree:
|
||||
|
||||
```sh
|
||||
cd /Users/mp/projects/ThothII-visual-review/frontend
|
||||
npm test -- --maxWorkers=2 --minWorkers=1
|
||||
npm run build
|
||||
npx playwright test --config playwright.visual.config.ts
|
||||
```
|
||||
|
||||
Contrasti calcolati sulle coppie di token opachi di riferimento, non su ogni possibile composizione/hover:
|
||||
|
||||
| Coppia | Chiaro | Scuro |
|
||||
| --- | --- | --- |
|
||||
| Testo principale / card | 15,16:1 | 13,61:1 |
|
||||
| Testo secondario / card | 5,67:1 | 8,40:1 |
|
||||
| Successo / card | 6,76:1 | 8,69:1 |
|
||||
| Avvertimento / card | 6,64:1 | 9,44:1 |
|
||||
| Bordo input / card | 3,36:1 | 4,56:1 |
|
||||
| Testo pulsante primario / riempimento | 5,03:1 | 6,83:1 |
|
||||
|
||||
Limiti: non è una certificazione WCAG completa; non è stata eseguita una nuova sessione LLM sul Docker reale. La prova con geometria host non equivale all'integrazione nel vero Omics Portal. Lo zoom browser 200% e tutte le combinazioni di hover/trasparenza non sono stati verificati esaustivamente. I test funzionali già esistenti coprono fasi, sessioni e protezioni; le modifiche non cambiano la loro logica.
|
||||
|
||||
## Docker e ripristino
|
||||
|
||||
Progetto Compose: `thothii-18998cca7b0a`. È stato ricreato **solo frontend** con `--no-deps --no-build` dopo una build esplicita dal nuovo worktree.
|
||||
|
||||
| Elemento | Valore verificato |
|
||||
| --- | --- |
|
||||
| Immagine nuova | `thothii-frontend:visual-review-20260912` |
|
||||
| Digest immagine nuova | `sha256:eeccb28f5c5cd78da08fd007e259e3fb88f26b4f1415a825a3b5829061f79ed3` |
|
||||
| Immagine precedente conservata | `thothii-frontend:before-visual-review-20260912` |
|
||||
| Digest precedente | `sha256:635f25477f11a476034052e87b2129ae26a9a049c4bedde0ada3a1d65e4ee924` |
|
||||
| Container frontend nuovo | `50dd012cd400`, healthy |
|
||||
| Core invariato | `0dcce917be0c`, healthy |
|
||||
| Catalog DB invariato | `89755e439dc7`, healthy |
|
||||
| Qdrant invariato | `52ebd5c47955`, healthy |
|
||||
| Embedding invariato | `7f690e534eda`, healthy |
|
||||
| Bundle servito | `/index-Cd-Ma0ye.js`, `/index-CptC2K6s.css` |
|
||||
|
||||
Per tornare al look precedente, senza rebuild e senza modificare dati o Core:
|
||||
|
||||
```sh
|
||||
bash /private/tmp/thothii-memory-preview.sh \
|
||||
-f /Users/mp/projects/ThothII-visual-review/deploy/compose.visual-review-rollback.yaml \
|
||||
up -d --no-deps --no-build frontend
|
||||
```
|
||||
|
||||
Per ripubblicare il look di revisione già costruito:
|
||||
|
||||
```sh
|
||||
bash /private/tmp/thothii-memory-preview.sh \
|
||||
-f /Users/mp/projects/ThothII-visual-review/deploy/compose.visual-review.yaml \
|
||||
up -d --no-deps --no-build frontend
|
||||
```
|
||||
|
||||
Questi override sono deliberatamente locali e usano il launcher dell'installazione già presente; non vanno applicati sul server PSD. Il launcher in `/private/tmp` dipende dai profili locali esistenti: se rimosso, ricostruire l'invocazione dai profili dell'installazione, senza sostituire configurazioni o volumi. Le immagini di review e rollback restano taggate: non eseguire una pulizia delle immagini prima della decisione.
|
||||
|
||||
## Decisione successiva
|
||||
|
||||
Il proprietario valuta il look su Docker. Solo dopo approvazione si procederà con l'integrazione su `main`, verificando anche l'eventuale differenza fra `main` e il ramo di partenza. Un rifiuto della revisione richiede soltanto il ripristino frontend sopra indicato; codice e prototipi precedenti sono preservati.
|
||||
@@ -3,6 +3,11 @@
|
||||
Data: 2026-09-13. Specifica: [Full shell, integrazione Omics e interfaccia bilingue](../plans/2026-09-13-full-shell-spec.md).
|
||||
I cinque ticket e le loro dipendenze sono elencati nel [piano](../plans/2026-09-13-full-shell-and-portal-integration.md).
|
||||
|
||||
Correzione della base grafica: la prima build descritta qui non includeva i sette
|
||||
commit del ramo `codex/ui-visual-review` già installati sul Mac. Il
|
||||
[rapporto di integrazione](2026-09-13-visual-shell-integration.md) documenta il
|
||||
recupero di font, allineamenti e layout, mantenendo le funzionalità di questa consegna.
|
||||
|
||||
## Risultato
|
||||
|
||||
- Configurazione pubblica generata dal descrittore installato; full ed embedded
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Integrazione della revisione grafica con full/embedded
|
||||
|
||||
Data: 2026-09-13. Ambito autorizzato: integrazione locale e aggiornamento del Mac,
|
||||
senza push, merge in `main` o deploy sul server PSD.
|
||||
|
||||
## Causa della regressione
|
||||
|
||||
La revisione full/embedded `d8a29bfb` e il ramo `codex/ui-visual-review` partivano
|
||||
entrambi da `2d1b714e`. La prima build full è stata eseguita dal primo ramo senza
|
||||
integrare i sette commit grafici già presenti nell'immagine installata sul Mac.
|
||||
La corrispondenza è verificata anche sulle immagini: il backup
|
||||
`thothii-frontend:before-full-shell-20260913` e
|
||||
`thothii-frontend:visual-review-20260912` identificano entrambi `7dceaafcd9ee`.
|
||||
|
||||
Commit recuperati, in ordine:
|
||||
|
||||
| Commit | Modifica |
|
||||
| --- | --- |
|
||||
| `c8d276dd` | Manrope locale, scala tipografica condivisa, leggibilità delle pagine operative |
|
||||
| `eed398e5` | Marchio ThothII a 48 px nel Core e 32 px nella sidebar |
|
||||
| `8c819968` | Indicatori del catalogo dopo le rispettive etichette |
|
||||
| `803e9e92` | Ricalcolo dell'altezza del campo domanda dopo la riapertura del Core |
|
||||
| `e088abd6` | Allineamento degli stati alla griglia del riepilogo |
|
||||
| `5af44081` | Margini e assi interni dei blocchi Database |
|
||||
| `a59624a6` | Allineamento del pannello del contesto e rimozione delle spiegazioni ridondanti |
|
||||
|
||||
## Criteri di riconciliazione
|
||||
|
||||
- Conservati traduzioni, protezione delle bozze, autenticazione, gestione delle
|
||||
sessioni e isolamento dell'adapter Omics introdotti dalla revisione full.
|
||||
- Recuperati font locale, classi e geometria approvati nel ramo grafico.
|
||||
Impeccable, registro product, ha guidato la verifica della coerenza; nessun
|
||||
ridisegno delle pagine o nuovo accorciamento dei loro titoli.
|
||||
- Il reset e i token CSS restano circoscritti al mount di ThothII e ai suoi popup:
|
||||
la revisione grafica non reintroduce un reset del documento del portale.
|
||||
- Conservati tema, traduzioni e stato di AG Grid; le colorazioni degli avvisi
|
||||
rimangono leggibili nei due temi.
|
||||
- Tradotte le nuove etichette e le spiegazioni introdotte dalla revisione grafica.
|
||||
- Il mock di ResizeObserver dei test ora emette le misure degli elementi osservati,
|
||||
come richiesto anche dal ridimensionamento del campo domanda recuperato.
|
||||
- I test visuali configurano esplicitamente full oppure embedded. La simulazione
|
||||
embedded fornisce le preferenze del portale e il suo reset del margine del body;
|
||||
ThothII non deve applicare quel reset al documento esterno.
|
||||
|
||||
## Verifiche
|
||||
|
||||
| Controllo | Esito |
|
||||
| --- | --- |
|
||||
| Suite frontend completa | 755 test passati, zero fallimenti |
|
||||
| Playwright visuale/interazione | 11 scenari passati, zero saltati o instabili |
|
||||
| Larghezze della revisione grafica | 390, 649, 768, 1280 e 1600 px |
|
||||
| Combinazione lingua/tema/full | EN/IT, chiaro/scuro, persistenza al reload e margini simmetrici a 390 e 1280 px |
|
||||
| Caricamento font | Verificata una font face Manrope Variable effettivamente caricata |
|
||||
| Embedded | Nessun header ThothII, contenimento sotto header e sidebar del portale simulato |
|
||||
| Cataloghi | 1681 messaggi italiani, 1706 riferimenti statici; nessuna chiave mancante o interpolazione incoerente |
|
||||
| Typecheck e build | Superati, inclusa la regressione sul CSS compilato prima della build |
|
||||
| Documentazione | Build MkDocs strict superata |
|
||||
|
||||
Le verifiche Playwright usano dati sintetici e bloccano tutte le richieste API;
|
||||
gli scenari non avviano modelli reali né modificano dati dell'installazione.
|
||||
Gli screenshot sono in `frontend/test-results/visual-review/`, ignorati da Git.
|
||||
Backend, harness e Omics non sono modificati da questa integrazione; le loro
|
||||
verifiche precedenti restano nel rapporto full-shell. Non viene rivendicata una
|
||||
nuova verifica end-to-end sul portale di produzione.
|
||||
|
||||
La verifica indipendente dell'integrazione di CSS, shell e reset non ha segnalato
|
||||
rilievi aperti. Il controllo della pagina reale ha inoltre rilevato l'etichetta
|
||||
predefinita «Administration» non tradotta: è stata corretta nel componente condiviso
|
||||
e coperta dallo scenario EN/IT. Nessun nuovo testo JSX o attributo di accessibilità
|
||||
non tradotto è stato introdotto dal merge, secondo il confronto statico con `d8a29bfb`.
|
||||
|
||||
## Aggiornamento e rollback locali
|
||||
|
||||
Contesto di build: `/Users/mp/projects/ThothII`; launcher dell'installazione:
|
||||
`/private/tmp/thothii-memory-preview.sh`. Il launcher termina con l'override
|
||||
`/private/tmp/thothii-context-a.compose.yaml`, che seleziona questo checkout.
|
||||
Non applicare l'override della precedente revisione isolata: selezionerebbe di nuovo
|
||||
il worktree senza l'integrazione full/embedded.
|
||||
|
||||
Comandi usati per costruire e sostituire il solo frontend:
|
||||
|
||||
```sh
|
||||
bash /private/tmp/thothii-memory-preview.sh build frontend
|
||||
bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend
|
||||
```
|
||||
|
||||
Configurazione Mac conservata: modalità `full`, lingua predefinita `en`.
|
||||
Aggiornamento completato alle 12:54 UTC: immagine frontend `605a6e6bba68`, container
|
||||
`9489a5765f7b`, stato healthy. Core (`3c3c0739b9af`), catalogo (`89755e439dc7`),
|
||||
Qdrant (`52ebd5c47955`) ed embedding (`7f690e534eda`) conservano gli stessi container,
|
||||
immagini e tempi di avvio rilevati prima dell'aggiornamento; sono tutti healthy.
|
||||
Il controllo dal browser usa l'accesso locale già presente, senza logout, modifica
|
||||
del catalogo o avvio di una sessione del modello. Sul viewport reale da 771 px:
|
||||
titolo Manrope Variable a 24 px, margini full sinistro/destro di 24 px, tre blocchi
|
||||
Database con identica posizione x=45 e larghezza 681 px; nessun overflow della pagina.
|
||||
Verificati italiano e tema scuro; ripristinati inglese e tema chiaro.
|
||||
|
||||
L'immagine prima del merge è conservata come
|
||||
`thothii-frontend:before-visual-shell-merge-20260913` (`781650644192`). Per ripristinare
|
||||
quella versione full precedente, senza toccare Core o i volumi:
|
||||
|
||||
```sh
|
||||
bash /private/tmp/thothii-memory-preview.sh \
|
||||
-f /Users/mp/projects/ThothII/deploy/compose.visual-shell-rollback.yaml \
|
||||
up -d --no-deps --no-build --wait frontend
|
||||
```
|
||||
|
||||
Quel rollback reintroduce volutamente l'aspetto precedente al recupero grafico.
|
||||
Per tornare alla versione integrata usare il launcher senza l'override di rollback.
|
||||
Le immagini della revisione grafica isolata rimangono disponibili, ma non includono
|
||||
le nuove funzionalità full/embedded: non sono il rollback equivalente di questa consegna.
|
||||
|
||||
## Conservazione del lavoro locale e prevenzione
|
||||
|
||||
Le tre modifiche documentali locali preesistenti sono state protette nello stash
|
||||
`436d59225869aa3d93ee04077c566d371bde4099`. I due rapporti erano identici alle versioni
|
||||
del ramo grafico; tutte le aggiunte di PROJECT_STATE erano già contenute nel merge.
|
||||
Non è stato necessario riapplicarle o sovrascrivere le versioni integrate. Lo stash
|
||||
è conservato come ulteriore copia recuperabile.
|
||||
|
||||
Prima di un successivo rebuild, confrontare il ramo corrente con gli altri worktree
|
||||
attivi e controllare il contesto di build effettivo del launcher. Un'immagine locale
|
||||
può provenire da un ramo laterale più recente del checkout principale. Il passaggio
|
||||
delle sole suite funzionali non certifica che la build contenga la revisione grafica
|
||||
già accettata: verificare anche l'ascendenza Git e i test visuali recuperati qui.
|
||||
@@ -0,0 +1,337 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../src/test/workspace-fixtures";
|
||||
|
||||
// These tests never forward API requests to an installation or invoke a real model.
|
||||
const workspace = "visual-fixture";
|
||||
const title = "Clinical research workspace";
|
||||
const model = { provider: "test", id: "review-model", name: "Review model", reasoning: true };
|
||||
const revision = workspaceRevisionFixture(workspace);
|
||||
const evidence = {
|
||||
id: "evidence:cohort", title: "Cohort definition", kind: "domain", language: "en",
|
||||
purposes: ["sql_generation"], applies_to: { concepts: ["cohort"], tables: [], columns: [] },
|
||||
payload: { rule: "# Approved cohort\n\nInclude eligible patients. Review the inclusion criteria before generating SQL." },
|
||||
provenance: { source_file: "research-guide.md", supporting_excerpts: ["Eligibility is evaluated at admission."] },
|
||||
review_items: [], file: "curated/domain/cohort.md", status: "active", revision: "1",
|
||||
};
|
||||
const evidencePage = {
|
||||
items: [evidence], total: 1, page: 1, page_size: 25, errors: [], initialized: true,
|
||||
active_revision: "1", pending_revision: null, source_reviews: [],
|
||||
location: { repository: "/installation/workspaces", workspace: "/installation/workspaces/clinical-research",
|
||||
runtime_workspace: "/data/workspaces/clinical-research", host: "installation", command: "tht workspace consolidate",
|
||||
git_commands: ["git status", "git diff"] },
|
||||
};
|
||||
|
||||
async function fixtures(page: Page, mode: "full" | "embedded" = "full") {
|
||||
const writes: string[] = [];
|
||||
await page.route("**/config.js", route => route.fulfill({
|
||||
contentType: "application/javascript",
|
||||
body: `window.__THOTHII_CONFIG__ = ${JSON.stringify({ backendBaseUrl: "/api", shell: { mode, defaultLocale: "en" } })};
|
||||
${mode === "embedded" ? `
|
||||
document.documentElement.setAttribute("data-bs-theme", "light");
|
||||
const language = document.createElement("select");
|
||||
language.className = "omics-language-select";
|
||||
language.dataset.lang = "en";
|
||||
language.hidden = true;
|
||||
document.body.prepend(language);
|
||||
` : ""}`,
|
||||
}));
|
||||
await page.route("http://127.0.0.1:5183/api/**", async route => {
|
||||
const path = new URL(route.request().url()).pathname.replace(/^\/api/, "");
|
||||
if (route.request().method() !== "GET") {
|
||||
writes.push(path);
|
||||
return route.fulfill({ status: 503, json: { message: "Visual fixture: no writes" } });
|
||||
}
|
||||
let body: unknown;
|
||||
if (path === "/auth/config") body = { mode: "mock", localLogin: false, oidcLogin: false };
|
||||
else if (path === "/me") body = { issuer: "test", subject: "admin", displayName: "Review administrator", isAdmin: true,
|
||||
roles: ["admin"], permissions: ["session.use", "session.read_all", "session.manage_all", "database.manage", "workspace.manage", "workspace.secrets.manage", "memory.manage", "evidence.manage", "pi.manage"], csrfToken: null, session: null };
|
||||
else if (path === "/settings") body = { workspace, provider: model.provider, model: model.id, thinking: "high" };
|
||||
else if (path === "/models") body = { models: [model] };
|
||||
else if (path === "/workspaces") body = [workspaceSummaryFixture(workspace, { displayName: title })];
|
||||
else if (path === `/workspaces/${workspace}`) body = { workspace: canonicalWorkspaceFixture(workspace), revision };
|
||||
else if (path.endsWith("/runtime-configuration")) body = { workspaceId: workspace, revision, configurationState: "ready", requirements: [] };
|
||||
else if (path.endsWith("/preprocessing")) body = { schemaVersion: 1, workspaceId: workspace, state: "ready", actionable: true, clearable: true,
|
||||
detail: "Catalog revision 1 is indexed.", metadataRevision: 1, preprocessedMetadataRevision: 1 };
|
||||
else if (path === "/workspace-registry/status") body = { branch: "main", ahead: 0, behind: 0, degraded: false };
|
||||
else if (path.endsWith("/memory/pending")) body = [];
|
||||
else if (path.endsWith("/memory")) body = { items: [], total: 0, page: 1, page_size: 25 };
|
||||
else if (path.endsWith("/evidence")) body = evidencePage;
|
||||
else if (path.includes("/evidence/")) body = { ...evidencePage, item: evidence };
|
||||
else if (path === "/pi-management/status") body = { ready: true, credentials: "present", hostPlatform: "macos", version: "0.80.3",
|
||||
config: { provider: model.provider, model: model.id, reasoning: "high" } };
|
||||
else if (path === "/sessions") body = [{ id: "archived-fixture", status: "finalized", question: "How many eligible patients?", name: "Cohort review",
|
||||
archived: true, active: false, workspace, created_at: "2026-09-01T12:00:00Z", updated_at: null, author: null, group: null, summary: null }];
|
||||
else if (path.endsWith("/documents")) body = [{ key: "question", title: "Original question", phase: "F1", format: "markdown", content: "# Cohort review\n\nHow many eligible patients were admitted in 2025?" }];
|
||||
else if (path === "/health/dwh") body = { ok: true, detail: "Available" };
|
||||
else if (path === "/catalog/databases") body = [];
|
||||
else if (path === "/catalog/metrics") body = { scope: "global", databaseId: null, tables: 0, columns: 0, sensitiveColumns: 0, relationships: 0,
|
||||
descriptionTargets: 0, describedTargets: 0, descriptionCoverage: 0, updatedAt: null };
|
||||
else if (path === "/catalog/metadata-generation/models") body = { models: [], default: null };
|
||||
else if (path.endsWith("-runs")) body = [];
|
||||
else return route.fulfill({ status: 404, json: { message: `Missing visual fixture: ${path}` } });
|
||||
return route.fulfill({ json: body });
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
async function navigation(page: Page) {
|
||||
const trigger = page.getByRole("button", { name: "Navigation", exact: true });
|
||||
if (await trigger.isVisible()) await trigger.click();
|
||||
}
|
||||
|
||||
async function admin(page: Page, name: string) {
|
||||
await navigation(page);
|
||||
const toggle = page.getByRole("button", { name: "Administration", exact: true });
|
||||
if (await toggle.getAttribute("aria-expanded") !== "true") await toggle.click();
|
||||
await page.getByRole("button", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function inspect(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const visible = (e: Element) => {
|
||||
const box = e.getBoundingClientRect();
|
||||
return box.width > 0 && box.height > 0 && getComputedStyle(e).visibility !== "hidden" && !e.closest("[hidden]");
|
||||
};
|
||||
return {
|
||||
overflow: document.documentElement.scrollWidth - innerWidth,
|
||||
serif: [...document.querySelectorAll("h1,h2,h3,h4,button,label")].filter(visible)
|
||||
.filter(e => /Fraunces|Georgia|Times|ui-serif/.test(getComputedStyle(e).fontFamily)).map(e => e.textContent?.slice(0,60)),
|
||||
tiny: [...document.querySelectorAll("h1,h2,h3,h4,button,label,small")].filter(visible)
|
||||
.filter(e => parseFloat(getComputedStyle(e).fontSize) < 12).map(e => e.textContent?.slice(0,60)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const writes = await fixtures(page);
|
||||
await page.goto("/");
|
||||
const header = page.locator(".thot-full-header");
|
||||
await expect(header).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
expect(await page.evaluate(() => [...document.fonts].some(font =>
|
||||
font.family.includes("Manrope Variable") && font.status === "loaded",
|
||||
))).toBe(true);
|
||||
const layout = await page.locator(".thot-context-layout").boundingBox();
|
||||
expect(layout!.x).toBeGreaterThanOrEqual(20);
|
||||
expect(Math.abs(layout!.x - (width - layout!.x - layout!.width))).toBeLessThan(1);
|
||||
await admin(page, "Database");
|
||||
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toBeVisible();
|
||||
await header.getByRole("combobox").selectOption("it");
|
||||
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
|
||||
await expect(page.locator(".thot-administration-eyebrow")).toHaveText("Amministrazione");
|
||||
await expect(page.locator(".thot-context-summary small")).toHaveText("Contesto di lavoro");
|
||||
await page.getByRole("button", { name: "Usa il tema scuro", exact: true }).click();
|
||||
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
|
||||
await expect(header.getByRole("combobox")).toHaveValue("it");
|
||||
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
|
||||
await page.getByRole("button", { name: "Usa il tema chiaro", exact: true }).click();
|
||||
await header.getByRole("combobox").selectOption("en");
|
||||
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toHaveCSS("font-size", "24px");
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const route of ["/", "/?thoth_route=administration%2Fdatabase"]) {
|
||||
test(`Core prompt remains readable after loading ${route}`, async ({ page }, testInfo) => {
|
||||
const writes = await fixtures(page);
|
||||
await page.goto(route);
|
||||
if (route !== "/") {
|
||||
await navigation(page);
|
||||
await page.getByRole("button", { name: "Return to session", exact: true }).click();
|
||||
}
|
||||
const prompt = page.getByRole("textbox", { name: "New question", exact: true });
|
||||
await expect(prompt).toBeVisible();
|
||||
await prompt.focus();
|
||||
const readable = async () => {
|
||||
await expect.poll(() => prompt.evaluate(el => {
|
||||
const style = getComputedStyle(el);
|
||||
return el.clientHeight >= parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
|
||||
})).toBe(true);
|
||||
};
|
||||
await readable();
|
||||
await expect(prompt).toHaveCSS("outline-style", "none");
|
||||
await page.screenshot({ path: testInfo.outputPath("Prompt-focused.png") });
|
||||
// Full-mode gutters can wrap the empty placeholder on a narrow viewport.
|
||||
// Compare clearing with that same viewport's initial height, not a single-line constant.
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
await readable();
|
||||
const emptyHeight = await prompt.evaluate(el => el.clientHeight);
|
||||
await prompt.fill("First line");
|
||||
await prompt.press("Shift+Enter");
|
||||
await prompt.press("a");
|
||||
await expect(prompt).toHaveValue("First line\na");
|
||||
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBeGreaterThan(40);
|
||||
const question = "Compare eligible patients across the available cohorts. ".repeat(30);
|
||||
await prompt.fill(question);
|
||||
await page.setViewportSize({ width: 390, height: 900 });
|
||||
await readable();
|
||||
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(160);
|
||||
expect(await prompt.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
|
||||
await admin(page, "Database");
|
||||
await navigation(page);
|
||||
await page.getByRole("button", { name: "Return to session", exact: true }).click();
|
||||
await expect(prompt).toHaveValue(question);
|
||||
await readable();
|
||||
await page.screenshot({ path: testInfo.outputPath("Prompt-multiline-mobile.png") });
|
||||
await prompt.fill("");
|
||||
await readable();
|
||||
await expect.poll(() => prompt.evaluate(el => el.clientHeight)).toBe(emptyHeight);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [390, 649, 768, 1280, 1600]) {
|
||||
test(`all surfaces retain shared typography at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const writes = await fixtures(page);
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Working context/ })).toContainText(title);
|
||||
for (const name of ["Workspace", "Database", "Memory", "Evidence", "Pi configuration"]) {
|
||||
await admin(page, name);
|
||||
const heading = page.getByRole("heading", { name: `${name === "Pi configuration" ? "Pi" : name} management`, exact: true });
|
||||
await expect(heading).toBeVisible();
|
||||
await expect(heading).toHaveCSS("font-size", "24px");
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
if (name === "Memory") await expect(page.getByText("No memory cards yet.", { exact: false })).toBeVisible();
|
||||
if (name === "Database") {
|
||||
const statuses = page.locator(".thot-fleet-ledger__header-status .thot-fleet-ledger__status-item");
|
||||
await expect(statuses).toHaveCount(3);
|
||||
const catalog = await statuses.nth(0).boundingBox();
|
||||
const operations = await statuses.nth(1).boundingBox();
|
||||
const metadata = await statuses.nth(2).boundingBox();
|
||||
const summary = await page.locator(".thot-fleet-ledger__kpi-section > h2").boundingBox();
|
||||
const summaryBlock = await page.locator(".thot-fleet-ledger__kpi-section").boundingBox();
|
||||
const navigationBlock = await page.locator(".thot-fleet-ledger__navigation").boundingBox();
|
||||
const gridBlock = await page.locator(".thot-fleet-ledger__content").boundingBox();
|
||||
const databasesLabel = await page.locator(".thot-fleet-ledger__navigation").getByText("Databases", { exact: true }).boundingBox();
|
||||
const tablesLabel = await page.locator(".thot-fleet-ledger__kpi-label").getByText("Tables", { exact: true }).boundingBox();
|
||||
expect(Math.abs(summaryBlock!.x - gridBlock!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(navigationBlock!.x - gridBlock!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(summary!.x - databasesLabel!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(summary!.x - tablesLabel!.x)).toBeLessThan(1);
|
||||
const gridLabel = await page.locator(".thot-fleet-grid-toolbar").getByText("Workspace databases", { exact: true }).boundingBox();
|
||||
expect(Math.abs(summary!.x - gridLabel!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(summaryBlock!.width - gridBlock!.width)).toBeLessThan(1);
|
||||
expect(Math.abs(navigationBlock!.width - gridBlock!.width)).toBeLessThan(1);
|
||||
const contextTrigger = page.getByRole("button", { name: /Working context/ });
|
||||
await contextTrigger.click();
|
||||
const contextBody = page.locator(".thot-context-body");
|
||||
await expect(contextBody).toBeVisible();
|
||||
await expect(contextBody).not.toContainText("Your last choices take precedence");
|
||||
await expect(contextBody).not.toContainText("Installation default");
|
||||
await expect(contextBody).not.toContainText("Last choice");
|
||||
const contextBlock = await contextBody.boundingBox();
|
||||
const workspaceSelect = await contextBody.getByRole("combobox", { name: "Workspace", exact: true }).boundingBox();
|
||||
expect(Math.abs(contextBlock!.x - summaryBlock!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(workspaceSelect!.x - summary!.x)).toBeLessThan(1);
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
await page.screenshot({ path: testInfo.outputPath("Context-expanded.png") });
|
||||
await contextBody.getByRole("button", { name: "Done", exact: true }).click();
|
||||
expect(Math.abs(catalog!.x - summary!.x)).toBeLessThan(1);
|
||||
const workArea = await page.locator(".thot-fleet-ledger__workspace").boundingBox();
|
||||
if (workArea!.width >= 768) {
|
||||
const coverage = await page.locator(".thot-fleet-ledger__kpi-label").filter({ hasText: "Description coverage" }).boundingBox();
|
||||
expect(Math.abs(metadata!.x - coverage!.x)).toBeLessThan(1);
|
||||
expect(Math.abs(operations!.x - (catalog!.x + metadata!.x) / 2)).toBeLessThan(1);
|
||||
}
|
||||
for (const status of await statuses.all()) {
|
||||
const label = await status.locator(".thot-fleet-ledger__status-label > span:first-child").boundingBox();
|
||||
const mark = await status.locator(".thot-fleet-ledger__status-mark").boundingBox();
|
||||
const value = await status.locator(".thot-fleet-ledger__status-value").boundingBox();
|
||||
expect(mark!.x - (label!.x + label!.width)).toBeGreaterThanOrEqual(7);
|
||||
expect(Math.abs(label!.x - value!.x)).toBeLessThan(1);
|
||||
}
|
||||
}
|
||||
if (name === "Evidence") {
|
||||
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
|
||||
expect(search!.width).toBeGreaterThan(180);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${name.replaceAll(" ", "-")}.png`) });
|
||||
}
|
||||
await page.getByRole("button", { name: "Host maintenance", exact: true }).click();
|
||||
await expect(page.getByRole("tab", { name: "macOS", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("region", { name: "Pi operator workflow" })).toHaveCSS("font-size", "16px");
|
||||
await page.screenshot({ path: testInfo.outputPath("Pi-guide.png") });
|
||||
await navigation(page);
|
||||
await page.getByRole("tab", { name: "All sessions", exact: true }).click();
|
||||
await expect(page.locator(".thot-wordmark--sidebar")).toHaveCSS("font-size", "32px");
|
||||
await page.getByRole("button", { name: /Archive \(1\)/ }).click();
|
||||
await page.getByRole("button", { name: "Cohort review", exact: true }).click();
|
||||
const documents = page.getByRole("complementary", { name: "Session summary" });
|
||||
await expect(documents).toBeVisible();
|
||||
await expect(documents.getByRole("heading", { name: "Original question" })).toBeVisible();
|
||||
const bounds = await documents.boundingBox();
|
||||
expect(bounds!.width).toBeGreaterThanOrEqual(Math.min(width, 300));
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
await page.screenshot({ path: testInfo.outputPath("Sessions.png") });
|
||||
await page.getByRole("button", { name: "Close panel", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
|
||||
await expect(page.locator(".thot-wordmark--core")).toHaveCSS("font-size", "48px");
|
||||
await page.screenshot({ path: testInfo.outputPath("Core.png") });
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("theme follows the app, not an unrelated OS preference; keyboard tabs and dirty guard remain", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const writes = await fixtures(page);
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(page.getByRole("banner", { name: "Application header" })).toBeVisible();
|
||||
await admin(page, "Pi configuration");
|
||||
const testButton = page.getByRole("button", { name: "Test catalog default", exact: true });
|
||||
await expect(testButton).toHaveCSS("background-color", "oklch(0.9985 0.0006 17.2)");
|
||||
await page.getByRole("button", { name: "Use dark theme", exact: true }).click();
|
||||
await expect(page.locator(".thot-context-trigger")).toHaveCSS("color", "oklch(0.931 0 90)");
|
||||
await expect(testButton).toHaveCSS("background-color", "oklch(0.62 0.008 23.2 / 0.3)");
|
||||
await page.screenshot({ path: testInfo.outputPath("Pi-dark.png") });
|
||||
const guideButton = page.getByRole("button", { name: "Host maintenance", exact: true });
|
||||
await guideButton.click();
|
||||
const mac = page.getByRole("tab", { name: "macOS", exact: true });
|
||||
await mac.focus(); await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByRole("tab", { name: "Windows", exact: true })).toBeFocused();
|
||||
await expect(page.getByRole("tab", { name: "Windows", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await admin(page, "Memory");
|
||||
await page.getByRole("button", { name: "New card", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Title", exact: true }).fill("Keep this draft");
|
||||
await page.getByRole("button", { name: "Return to session", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Title", exact: true })).toHaveValue("Keep this draft");
|
||||
await page.screenshot({ path: testInfo.outputPath("Memory-dark-unsaved.png") });
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Return to session", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
test("host header and left rail leave the application a bounded working area", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await fixtures(page, "embedded");
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-shell")).toBeVisible();
|
||||
await expect(page.locator(".thot-full-header")).toHaveCount(0);
|
||||
// Geometry fixture, not a replacement for acceptance inside the real Omics deployment.
|
||||
await page.addStyleTag({ content: `
|
||||
body { margin: 0; }
|
||||
#root { margin-left: 240px; margin-top: 72px; --thoth-app-height: calc(100dvh - 72px); }
|
||||
.px-4 { padding-left: 2.25rem !important; padding-right: 2.25rem !important; }
|
||||
` });
|
||||
await admin(page, "Workspace");
|
||||
const shell = await page.getByTestId("app-shell").boundingBox();
|
||||
expect(shell!.x).toBe(240);
|
||||
expect(shell!.x + shell!.width).toBeLessThanOrEqual(1280);
|
||||
expect(shell!.y + shell!.height).toBeLessThanOrEqual(1000);
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
await page.screenshot({ path: testInfo.outputPath("Host-bounded-Workspace.png") });
|
||||
await admin(page, "Evidence");
|
||||
const search = await page.getByRole("textbox", { name: "Search Evidence" }).boundingBox();
|
||||
expect(search!.width).toBeGreaterThan(180);
|
||||
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
|
||||
});
|
||||
@@ -4,12 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ThothII</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+10
@@ -9,6 +9,7 @@
|
||||
"@ag-grid-community/locale": "36.0.0",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@fontsource-variable/manrope": "5.3.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"ag-grid-community": "^36.0.0",
|
||||
"ag-grid-react": "^36.0.0",
|
||||
@@ -1491,6 +1492,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.3.0.tgz",
|
||||
"integrity": "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@ag-grid-community/locale": "36.0.0",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@fontsource-variable/manrope": "5.3.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"ag-grid-community": "^36.0.0",
|
||||
"ag-grid-react": "^36.0.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testMatch: "ui-visual-review.spec.ts",
|
||||
timeout: 45_000,
|
||||
workers: 2,
|
||||
outputDir: "test-results/visual-review",
|
||||
use: { ...devices["Desktop Chrome"], baseURL: "http://127.0.0.1:5183", trace: "retain-on-failure" },
|
||||
webServer: {
|
||||
command: "npm run dev -- --host 127.0.0.1 --port 5183 --strictPort",
|
||||
url: "http://127.0.0.1:5183",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: { VITE_BACKEND_URL: "/api" },
|
||||
},
|
||||
});
|
||||
@@ -89,8 +89,9 @@ function DefaultFallback({
|
||||
{error.message && (
|
||||
<details className="min-w-0">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
|
||||
{translate("Details")}</summary>
|
||||
<pre className="mt-1.5 max-w-full overflow-x-auto rounded-md bg-muted px-2.5 py-1.5 font-mono text-[0.7rem] text-muted-foreground">
|
||||
{translate("Details")}
|
||||
</summary>
|
||||
<pre className="mt-1.5 max-w-full overflow-x-auto rounded-md bg-muted px-2.5 py-1.5 font-mono text-xs text-muted-foreground">
|
||||
{error.message}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@@ -25,8 +25,8 @@ const buttonVariants = cva(
|
||||
default:
|
||||
"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",
|
||||
sm: "h-8 gap-1.5 px-3 text-sm 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-sm has-data-[icon=inline-end]:pr-3.5 has-data-[icon=inline-start]:pl-3.5",
|
||||
icon: "size-8",
|
||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-7",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
// English gettext message IDs. Workspace content, SQL and identifiers are never translated.
|
||||
export const itAdmin: Record<string, string> = {
|
||||
"Import sources, then review proposed changes before activation.": "Importa le fonti, poi rivedi le modifiche proposte prima dell'attivazione.",
|
||||
"Which sources are imported?": "Quali fonti vengono importate?",
|
||||
"Place new Markdown drafts in": "Inserisci le nuove bozze Markdown in",
|
||||
". Import also checks original local documents and configured remote sources. Review changes before they enter recall.": ". L'importazione controlla anche i documenti locali originali e le fonti remote configurate. Rivedi le modifiche prima che diventino disponibili per il recupero delle conoscenze.",
|
||||
"No memory cards yet. Create a card to add reusable knowledge.": "Non ci sono ancora schede di memoria. Crea una scheda per aggiungere conoscenze riutilizzabili.",
|
||||
"Run these commands on the installation host. Choose its operating system.": "Esegui questi comandi sul computer dell'installazione. Scegli il suo sistema operativo.",
|
||||
"Used for Core and Administration when no model has been chosen. The test below checks this default, not your current selection.": "Usato per Core e Amministrazione quando non è stato scelto un modello. Il test qui sotto verifica questo modello predefinito, non la selezione corrente.",
|
||||
"Change the installation default": "Cambia il modello predefinito dell'installazione",
|
||||
"Update": "Aggiorna",
|
||||
"in the installation YAML, then reload Pi. See Host maintenance for instructions.": "nel file YAML dell'installazione, poi ricarica Pi. Le istruzioni sono in Manutenzione host.",
|
||||
"Available models": "Modelli disponibili",
|
||||
"Choose your working model in the context bar. Each enabled model should be verified for both Core and Administration.": "Scegli il modello di lavoro nella barra del contesto. Ogni modello abilitato deve essere verificato sia per Core sia per Amministrazione.",
|
||||
"Catalog summary": "Riepilogo del catalogo",
|
||||
"{database}: {diagnostic}": "{database}: {diagnostic}",
|
||||
"{succeeded} succeeded. Failed: {databases}.": "{succeeded} riuscite. Non riuscite: {databases}.",
|
||||
"{count} of {total}": "{count} di {total}",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const itCore: Record<string, string> = {
|
||||
"Type your question below. Review each step before creating your datamart.": "Scrivi la domanda qui sotto. Rivedi ogni passaggio prima di creare il datamart.",
|
||||
"Working context": "Contesto di lavoro",
|
||||
"Session startup failed. Check configuration and connectivity, then Resume the session.": "Avvio della sessione non riuscito. Controlla configurazione e connessione, quindi riprendi la sessione.",
|
||||
"Session services are not ready. Check configuration and connectivity, then try again.": "I servizi della sessione non sono pronti. Controlla configurazione e connessione, quindi riprova.",
|
||||
"Session could not be resumed. Check configuration and connectivity, then try again.": "Impossibile riprendere la sessione. Controlla configurazione e connessione, quindi riprova.",
|
||||
|
||||
+57
-33
@@ -10,7 +10,7 @@
|
||||
body.thot-full-document { margin: 0; }
|
||||
|
||||
/*
|
||||
* GSD theme — mirrors the Omics Portal design system (Manrope + Fraunces, warm
|
||||
* Product theme — shares the Omics Portal identity (Manrope, 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) / <alpha-value>)` and
|
||||
* opacity utilities (bg-primary/80, …) keep working. Dark tokens follow either a
|
||||
@@ -20,9 +20,14 @@ body.thot-full-document { margin: 0; }
|
||||
@layer base {
|
||||
#root, [data-base-ui-portal] {
|
||||
color-scheme: light;
|
||||
--font-sans: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Arial, sans-serif;
|
||||
--font-heading: "Fraunces", "Source Serif Pro", Georgia, "Times New Roman", serif;
|
||||
--font-sans: "Manrope Variable", "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Arial, sans-serif;
|
||||
--font-heading: var(--font-sans);
|
||||
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
--text-page: 1.5rem;
|
||||
--text-section: 1.25rem;
|
||||
--text-body: 1rem;
|
||||
--text-control: 0.875rem;
|
||||
--text-meta: 0.75rem;
|
||||
|
||||
/* Layered elevation, tinted with the foreground hue so depth reads warm on
|
||||
warm surfaces. Borders mark structure; shadows mark elevation. */
|
||||
@@ -49,11 +54,11 @@ body.thot-full-document { margin: 0; }
|
||||
--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;
|
||||
--input: 0.64 0.008 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;
|
||||
--success: 0.46 0.095 160;
|
||||
--warning: 0.48 0.09 70;
|
||||
--info: var(--muted-foreground);
|
||||
/* Current navigation shares Instrument Red's hue, with lower chroma and
|
||||
higher lightness so selection reads as a muted location signal. */
|
||||
--nav-active: 0.9250 0.0520 23.2;
|
||||
@@ -80,8 +85,8 @@ body.thot-full-document { margin: 0; }
|
||||
--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: 0.73 0.15 23.2;
|
||||
--primary-foreground: 0.2178 0.005 23.2;
|
||||
--primary-hover: 0.6897 0.1779 16.9;
|
||||
--secondary: 0.2972 0 90;
|
||||
--secondary-foreground: 0.9310 0 90;
|
||||
@@ -89,11 +94,13 @@ body.thot-full-document { margin: 0; }
|
||||
--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: 0.73 0.15 23.2;
|
||||
--destructive-foreground: 0.9310 0 90;
|
||||
--border: 0.3715 0 90;
|
||||
--input: 0.3715 0 90;
|
||||
--input: 0.62 0.008 23.2;
|
||||
--ring: 0.6897 0.1779 16.9;
|
||||
--success: 0.78 0.10 160;
|
||||
--warning: 0.82 0.10 75;
|
||||
--nav-active: 0.3350 0.0550 20.3;
|
||||
--nav-active-hover: 0.3750 0.0650 20.3;
|
||||
--nav-active-foreground: 0.8900 0.0700 20.3;
|
||||
@@ -150,7 +157,22 @@ body.thot-full-document { margin: 0; }
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
}
|
||||
|
||||
:where(#root, [data-base-ui-portal]) :is(h1, h2, h3, h4) {
|
||||
:where(#root, [data-base-ui-portal]) :is(input, textarea)::placeholder {
|
||||
color: oklch(var(--muted-foreground));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:where(#root, [data-base-ui-portal]) :is(button, input, select, textarea, summary):focus-visible {
|
||||
outline: 2px solid oklch(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The composer already exposes keyboard focus on its enclosing focus-within frame. */
|
||||
#root textarea.thot-composer-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:where(#root, [data-base-ui-portal]) :where(h1, h2, h3, h4, h5, h6) {
|
||||
font-family: var(--font-heading);
|
||||
font-optical-sizing: auto;
|
||||
letter-spacing: -0.015em;
|
||||
@@ -221,8 +243,8 @@ body.thot-full-document { margin: 0; }
|
||||
}
|
||||
.thot-full-header__brand { font: 600 1.25rem/1.2 var(--font-heading); }
|
||||
.thot-full-header__controls { display: flex; align-items: center; flex-wrap: wrap; gap: 0.25rem; min-width: 0; }
|
||||
.thot-full-header__user { max-width: 15rem; min-width: 0; font-size: 0.8125rem; }
|
||||
.thot-full-header__error { flex-basis: 100%; font-size: 0.8125rem; color: oklch(var(--destructive)); }
|
||||
.thot-full-header__user { max-width: 15rem; min-width: 0; font-size: var(--text-control); }
|
||||
.thot-full-header__error { flex-basis: 100%; font-size: var(--text-control); color: oklch(var(--destructive)); }
|
||||
.thot-shell-select {
|
||||
min-height: 2rem;
|
||||
max-width: 9rem;
|
||||
@@ -231,7 +253,7 @@ body.thot-full-document { margin: 0; }
|
||||
border-radius: var(--radius);
|
||||
background: oklch(var(--background));
|
||||
color: oklch(var(--foreground));
|
||||
font: 600 0.8125rem/1.5 var(--font-sans);
|
||||
font: 600 var(--text-control)/1.5 var(--font-sans);
|
||||
}
|
||||
.thot-shell-select:focus-visible { outline: 3px solid oklch(var(--ring) / 0.5); outline-offset: 2px; }
|
||||
.thot-user-menu {
|
||||
@@ -269,22 +291,21 @@ body.thot-full-document { margin: 0; }
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Standard micro-label: panel headers, nav sections, meta rows. Set in the
|
||||
mono register so labels/meta read as a distinct typographic layer from the
|
||||
sans body prose (mono needs less tracking than the sans did). */
|
||||
/* UI labels use the same family as controls; monospace is for technical data. */
|
||||
.thot-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
color: oklch(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.thot-prose {
|
||||
color: oklch(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.65;
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.thot-prose > :first-child { margin-top: 0; }
|
||||
.thot-prose > :last-child { margin-bottom: 0; }
|
||||
@@ -296,9 +317,10 @@ body.thot-full-document { margin: 0; }
|
||||
margin: 1.1em 0 0.5em;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.thot-prose h1 { font-size: 1.45rem; }
|
||||
.thot-prose h2 { font-size: 1.2rem; }
|
||||
.thot-prose h3 { font-size: 1.02rem; }
|
||||
.thot-prose h1 { font-size: var(--text-page); }
|
||||
.thot-prose h2 { font-size: var(--text-section); }
|
||||
.thot-prose h3 { font-size: var(--text-body); }
|
||||
.thot-prose :where(p, ul, ol, blockquote) { max-width: 75ch; }
|
||||
.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; }
|
||||
@@ -320,8 +342,10 @@ body.thot-full-document { margin: 0; }
|
||||
}
|
||||
.thot-prose pre code { background: transparent; padding: 0; }
|
||||
.thot-prose blockquote {
|
||||
border-left: 2px solid oklch(var(--border));
|
||||
padding-left: 0.9em;
|
||||
border: 1px solid oklch(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: oklch(var(--muted) / 0.4);
|
||||
padding: 0.75em 1em;
|
||||
color: oklch(var(--muted-foreground));
|
||||
margin: 0.7em 0;
|
||||
}
|
||||
@@ -339,10 +363,10 @@ body.thot-full-document { margin: 0; }
|
||||
.thot-prose td { border-top: 1px solid oklch(var(--border) / 0.6); font-variant-numeric: tabular-nums; }
|
||||
.thot-prose th {
|
||||
background: oklch(var(--muted));
|
||||
font-size: 0.72rem;
|
||||
font-size: var(--text-control);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
color: oklch(var(--muted-foreground));
|
||||
}
|
||||
.thot-activity-prose,
|
||||
@@ -409,7 +433,7 @@ body.thot-full-document { margin: 0; }
|
||||
.thot-database-grid, .thot-preview-grid,
|
||||
:is(.thot-database-grid, .thot-preview-grid) .ag-theme-alpine {
|
||||
--ag-font-family: var(--font-sans);
|
||||
--ag-font-size: 0.8125rem;
|
||||
--ag-font-size: var(--text-control);
|
||||
--ag-background-color: oklch(var(--card));
|
||||
--ag-foreground-color: oklch(var(--foreground));
|
||||
--ag-header-background-color: oklch(var(--muted) / 0.72);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "@fontsource-variable/manrope";
|
||||
import "./index.css";
|
||||
import "./widgets";
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ test("option A has one collapsible global context and preserves the Core draft a
|
||||
renderShell();
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Keep my original Core question");
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
|
||||
expect(screen.getAllByRole("combobox", { name: "Workspace" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1);
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
|
||||
@@ -159,7 +159,7 @@ test("invalid remembered choices do not silently fall back and gate both Core an
|
||||
test("workspace and model stay locked during Core and unlock after it finishes", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
|
||||
act(() => useSessionStore.getState().setAgentActive(true));
|
||||
expect(screen.getByRole("combobox", { name: "Workspace" })).toBeDisabled();
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
|
||||
@@ -182,7 +182,7 @@ test("dirty Memory blocks workspace and model changes, including after a failed
|
||||
const page = await screen.findByRole("main", { name: "Memory management" });
|
||||
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
|
||||
await userEvent.type(within(page).getByLabelText("Title"), "Do not lose this");
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Model" }), "test/other");
|
||||
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "alpha", model: "test" });
|
||||
@@ -240,7 +240,7 @@ test("a successful Memory save releases the navigation guard and the context loc
|
||||
await userEvent.click(within(page).getByRole("button", { name: "New card" }));
|
||||
await userEvent.type(within(page).getByLabelText("Title"), "Saved card");
|
||||
await userEvent.type(within(page).getByLabelText("Scope"), "This workspace");
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
|
||||
await userEvent.click(within(page).getByRole("button", { name: "Save card" }));
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toBeDisabled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
||||
@@ -257,7 +257,7 @@ test("Admin generation locks the context and Core submission across page navigat
|
||||
renderShell(client);
|
||||
const composer = await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Wait for Admin to finish");
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
|
||||
await act(async () => {
|
||||
client.setQueryData(["description-generation-runs", 50], [{ id: "r1", status: "running" }]);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("authenticated shell permissions", () => {
|
||||
const memory = screen.getByRole("main", { name: "Memory management" });
|
||||
expect(memory).toBeVisible();
|
||||
expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
|
||||
expect(screen.getByRole("combobox", { name: "Workspace" })).toHaveValue("default");
|
||||
});
|
||||
test("does not use the legacy installation default as a preprocessing workspace", async () => {
|
||||
|
||||
@@ -177,7 +177,7 @@ test("model selector shows the three Pi-enabled models and stores the selected p
|
||||
renderShell();
|
||||
|
||||
await screen.findByRole("textbox", { name: /new question/i });
|
||||
await userEvent.click(screen.getByRole("button", { name: /WORKING CONTEXT/ }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /Working context/ }));
|
||||
const select = screen.getByRole("combobox", { name: "Model" });
|
||||
await waitFor(() => {
|
||||
expect(within(select).getAllByRole("option").map((option) => option.textContent)).toEqual([
|
||||
|
||||
@@ -36,13 +36,21 @@ const resumeResult = (id: string, alreadyActive = false) =>
|
||||
|
||||
class ControlledResizeObserver {
|
||||
static instances: ControlledResizeObserver[] = [];
|
||||
private targets = new Set<Element>();
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
ControlledResizeObserver.instances.push(this);
|
||||
}
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
observe = vi.fn((target: Element) => { this.targets.add(target); });
|
||||
disconnect = vi.fn(() => { this.targets.clear(); });
|
||||
trigger() {
|
||||
this.callback([], this as unknown as ResizeObserver);
|
||||
const entries: ResizeObserverEntry[] = [...this.targets].map(target => ({
|
||||
target,
|
||||
contentRect: new DOMRect(0, 0, target.clientWidth, target.clientHeight),
|
||||
borderBoxSize: [],
|
||||
contentBoxSize: [],
|
||||
devicePixelContentBoxSize: [],
|
||||
}));
|
||||
if (entries.length) this.callback(entries, this as unknown as ResizeObserver);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -884,7 +884,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
{activeSession?.question && (
|
||||
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<p className="thot-label mb-0.5 text-muted-foreground">{t("Question")}</p>
|
||||
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
|
||||
<h2 className="font-heading text-base font-semibold leading-snug text-foreground break-words">
|
||||
{activeSession.question}
|
||||
</h2>
|
||||
</header>
|
||||
@@ -951,7 +951,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
|
||||
<aside aria-label={t("Session navigation")} className="flex w-64 shrink-0 flex-col bg-sidebar">
|
||||
<div className="thot-session-navigation__header relative text-center">
|
||||
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||
<h1 className="thot-wordmark--sidebar font-heading text-[2rem] font-semibold leading-none tracking-tight text-foreground">
|
||||
Thoth<span className="text-primary">II</span>
|
||||
</h1>
|
||||
<p className="thot-label mt-1.5">{t("Datamart Builder with")}<br />{t("Human-in-the-loop review")}</p>
|
||||
@@ -1114,12 +1114,16 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{canReadAllSessions && showingAllSessions && (
|
||||
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">{t("Administrator view: all sessions")}</p>
|
||||
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-muted-foreground">
|
||||
{t("Administrator view: all sessions")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* L1 — rail title */}
|
||||
<div className="px-4 pb-1.5 pt-1">
|
||||
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">{t("Sessions")}</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{t("Sessions")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
@@ -1291,10 +1295,12 @@ function EmptyState() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex min-h-[72vh] flex-col items-center justify-center text-center">
|
||||
<span className="font-heading text-5xl font-semibold tracking-tight text-foreground">
|
||||
<span className="thot-wordmark--core font-heading text-5xl font-semibold tracking-tight text-foreground">
|
||||
Thoth<span className="text-primary">II</span>
|
||||
</span>
|
||||
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">{t("Build datamarts from your database through a guided, human-in-the-loop workflow. Type your question in the box below to begin a session.")}</p>
|
||||
<p className="mt-4 max-w-md text-balance text-base leading-relaxed text-muted-foreground">
|
||||
{t("Type your question below. Review each step before creating your datamart.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function CentralStatus({ working }: { working: boolean }) {
|
||||
<li
|
||||
key={index}
|
||||
className={[
|
||||
"flex items-baseline gap-2 font-mono text-[0.72rem] leading-relaxed",
|
||||
"flex items-baseline gap-2 font-mono text-xs leading-relaxed",
|
||||
isLast ? "text-foreground/80" : "text-muted-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
|
||||
@@ -281,7 +281,7 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persi
|
||||
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /Catalog status/ })).toBeVisible();
|
||||
const summary = screen.getByRole("region", { name: "Fleet summary" });
|
||||
const summary = screen.getByRole("region", { name: "Catalog summary" });
|
||||
expect(await within(summary).findByText("2,275")).toBeVisible();
|
||||
expect(within(summary).getByText("75%")).toBeVisible();
|
||||
const modelSelector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
||||
|
||||
@@ -1083,7 +1083,7 @@ export function DatabaseManagementPage({
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={connectionTestResult?.outcome === "success"
|
||||
? "grid size-9 shrink-0 place-items-center rounded-full bg-[oklch(var(--success)/0.14)] text-[oklch(0.47_0.12_165)]"
|
||||
? "grid size-9 shrink-0 place-items-center rounded-full bg-[oklch(var(--success)/0.14)] text-success"
|
||||
: "grid size-9 shrink-0 place-items-center rounded-full bg-destructive/10 text-destructive"
|
||||
}
|
||||
>
|
||||
@@ -1343,7 +1343,7 @@ export function DatabaseManagementPage({
|
||||
)}
|
||||
kpis={(
|
||||
<FleetLedgerKpiStrip
|
||||
title={metricsDatabaseId ? t("Database summary") : t("Fleet summary")}
|
||||
title={metricsDatabaseId ? t("Database summary") : t("Catalog summary")}
|
||||
items={[
|
||||
{ id: "tables", label: t("Tables"), value: metricsValue(metrics?.tables) },
|
||||
{ id: "columns", label: t("Columns"), value: metricsValue(metrics?.columns) },
|
||||
|
||||
@@ -68,8 +68,8 @@ function WorkspaceEvidence({ workspace, generation }: { workspace: string; gener
|
||||
return <div className="flex min-h-0 flex-1 flex-col gap-5 p-6">
|
||||
{list.data && <Instructions data={list.data} />}
|
||||
<EvidenceSources workspace={workspace} sources={list.data?.source_reviews ?? []} />
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid flex-1 gap-1 text-sm">{t("Search Evidence")}<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder={t("Title, identifier or content")} /></label>
|
||||
<div className="thot-evidence-filters">
|
||||
<label className="thot-evidence-search grid gap-1 text-sm">{t("Search Evidence")}<input className={control} value={filters.q} onChange={e => filter("q", e.target.value)} placeholder={t("Title, identifier or content")} /></label>
|
||||
<Select label={t("Kind")} value={filters.kind} values={kinds} onChange={v => filter("kind", v)} />
|
||||
<Select label={t("Purpose")} value={filters.purpose} values={["disambiguation", "rewriting", "schema_linking", "sql_generation"]} onChange={v => filter("purpose", v)} />
|
||||
<Select label={t("Status")} value={filters.status} values={Object.keys(states)} labels={states} onChange={v => filter("status", v)} />
|
||||
@@ -111,8 +111,8 @@ function Instructions({ data }: { data: EvidencePage }) {
|
||||
const example = `---\nschema_version: 4\nid: evidence:my-unit\nkind: ${kind}\nlanguage: en\npurposes: [sql_generation]\n---\n\n# My Evidence\n\n${templates[kind]}\n`;
|
||||
return <div className="border-b pb-5">
|
||||
<p className="mb-2 text-sm font-medium">{data.pending_revision ? t("Consolidation is incomplete. The previous active content remains available.") : data.active_revision ? t("The core uses the last successfully consolidated version.") : t("Run the first consolidation to convert and activate the local archive.")}</p>
|
||||
{data.location.workspace ? <><p className="mb-1 text-xs text-muted-foreground">{t("Workspace on the installation host")}</p><CopyText text={data.location.workspace} label={t("workspace path")} /></> : <p role="alert" className="text-sm">{t("A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.")}</p>}
|
||||
<details className="mt-3"><summary className="cursor-pointer text-sm font-medium">{t("Edit files, consolidate and commit")}</summary>
|
||||
{data.location.workspace ? <div className="mt-3"><p className="mb-1 text-xs text-muted-foreground">{t("Workspace on the installation host")}</p><CopyText text={data.location.workspace} label={t("workspace path")} /></div> : <p role="alert" className="mt-3 text-sm">{t("A host file path is unavailable for this installation. Configure a persistent workspace-registry bind mount before editing files from the host.")}</p>}
|
||||
<ol className="mt-3 max-w-[75ch] list-decimal space-y-3 pl-5 text-sm">
|
||||
<li>{t("Edit the files under")} <code>evidence/curated/<kind>/</code> {t("with your preferred editor. Keep existing IDs. Remove a unit's file to delete it; keep the curated directory.")}</li>
|
||||
<li>{t("Check Git status and the diff. Saving a file does not update recall.")}{data.location.git_commands.slice(0, 2).map(command => <div className="mt-2" key={command}><CopyText text={command} label={t("Git inspection command")} /></div>)}</li>
|
||||
@@ -131,7 +131,7 @@ function UnitDetail({ item, location }: { item: EvidenceUnit; location: Evidence
|
||||
const source = item.provenance.kind === "manual" ? item.provenance.original : item.provenance;
|
||||
const file = location.workspace ? `${location.workspace.replace(/\\/g, "/")}/evidence/${item.file}` : null;
|
||||
return <article className="max-w-[75ch] space-y-5 pb-8">
|
||||
<header><p className="text-xs text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="mt-1 font-serif text-xl font-semibold">{item.title}</h2></header>
|
||||
<header><p className="text-xs text-muted-foreground">{t(states[item.status])} · {item.purposes.map(purpose => t(purpose.replaceAll("_", " "))).join(", ")}</p><h2 className="mt-1 font-sans text-xl font-semibold">{item.title}</h2></header>
|
||||
{file && <CopyText text={file} label={t("Evidence file path")} />}
|
||||
{item.status !== "active" && <p className="text-sm text-muted-foreground">{["invalid", "removed"].includes(item.status) ? t("The file is invalid or removed. Its last active content is shown here.") : t("This is the file version awaiting consolidation.")} {t("The core continues to use its last active version.")}</p>}
|
||||
{Object.entries(item.applies_to).some(([, values]) => values.length > 0) && <section><h3 className="font-medium">{t("Scope")}</h3>{Object.entries(item.applies_to).map(([name, values]) => values.length > 0 && <p className="mt-2 break-words text-sm" key={name}><span className="capitalize">{t(name)}</span>: {values.join(", ")}</p>)}</section>}
|
||||
|
||||
@@ -18,10 +18,10 @@ export function EvidenceSources({ workspace, sources }: {workspace: string; sour
|
||||
const pending = sources.filter(s => s.status === "review" || s.status === "applying");
|
||||
const settled = sources.filter(s => s.status !== "review" && s.status !== "applying");
|
||||
const visible = showAll ? [...pending, ...settled] : pending;
|
||||
return <section aria-label={t("Evidence sources")} className="rounded-md border p-4">
|
||||
return <section aria-label={t("Evidence sources")} className="border-b pb-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><h2 className="font-serif text-lg font-semibold">{t("Sources and imports")}</h2>
|
||||
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">{t("Place new Markdown drafts in the workspace’s")} <code>evidence/incoming/</code> {t("folder. Import or refresh to acquire those drafts, original local documents and configured remote sources. Review changes before they enter recall.")}</p></div>
|
||||
<div><h2 className="font-sans text-lg font-semibold">{t("Sources and imports")}</h2>
|
||||
<p className="mt-1 max-w-[75ch] text-sm text-muted-foreground">{t("Import sources, then review proposed changes before activation.")}</p><details className="mt-2 text-sm"><summary className="cursor-pointer font-medium">{t("Which sources are imported?")}</summary><p className="mt-2 max-w-[75ch] text-muted-foreground">{t("Place new Markdown drafts in")} <code>evidence/incoming/</code>{t(". Import also checks original local documents and configured remote sources. Review changes before they enter recall.")}</p></details></div>
|
||||
<Button variant="outline" disabled={action.isPending || sources.some(s => s.status === "applying")}
|
||||
onClick={() => {setNotice(""); action.mutate({action: "refresh"});}}>{t("Import or refresh sources")}</Button>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@ function SourceUnit({ unit }: {unit: EvidenceUnit}) {
|
||||
{Object.entries(unit.payload).map(([key, value]) => <section key={key} className="mt-3"><h5 className="text-sm font-medium capitalize">{t(key.replaceAll("_", " "))}</h5>
|
||||
{typeof value === "string" ? key === "sql" ? <pre className="overflow-auto rounded bg-muted p-2 text-sm">{value}</pre> : <MarkdownView source={value} /> : Array.isArray(value) ? <ul className="list-inside list-disc text-sm">{value.map(v => <li key={v}>{v}</li>)}</ul> : <dl className="text-sm">{Object.entries(value).map(([k,v]) => <div key={k}><dt className="font-mono">{k || t("(empty value)")}</dt><dd>{v}</dd></div>)}</dl>}</section>)}
|
||||
<p className="mt-3 text-xs text-muted-foreground">{unit.provenance.kind === "manual" ? t("Manual declaration by {author}. Original excerpts:", { author: unit.provenance.declared_by ?? "" }) : t("Source excerpts:")}</p>
|
||||
{origin?.supporting_excerpts?.map((excerpt, i) => <blockquote key={i} className="mt-2 border-l-2 pl-3 text-sm text-muted-foreground">{excerpt}</blockquote>)}
|
||||
{origin?.supporting_excerpts?.map((excerpt, i) => <blockquote key={i} className="mt-2 border-l pl-3 text-sm text-muted-foreground">{excerpt}</blockquote>)}
|
||||
{unit.review_items.map((r,i) => <p key={i} className="mt-2 text-sm text-destructive">{t(r.message)}</p>)}
|
||||
</article>;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
|
||||
const list = useQuery({ queryKey: ["memory", workspace, filters, page],
|
||||
queryFn: () => listMemory(workspace, { ...filters, page, page_size: 25 }) });
|
||||
const pending = useQuery({ queryKey: ["memory-pending", workspace], queryFn: () => pendingMemory(workspace) });
|
||||
const hasFilters = Object.entries(filters).some(([key, value]) => !["sort", "direction"].includes(key) && Boolean(value));
|
||||
const linkOptions = useQuery({ queryKey: ["memory-links", workspace, linkSearch],
|
||||
queryFn: () => listMemory(workspace, { q: linkSearch, page_size: 15 }),
|
||||
enabled: draft !== null && linkSearch.trim().length > 0 });
|
||||
@@ -148,7 +149,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
|
||||
{list.isError && <div role="alert" className="p-6"><p>{t(apiErrorMessage(list.error))}</p><Button variant="outline" onClick={() => void list.refetch()}>{t("Retry loading")}</Button></div>}
|
||||
{list.data && <>
|
||||
<p className="px-6 py-3 text-xs text-muted-foreground">{list.data.total === 1 ? t("{count} card", { count: list.data.total }) : t("{count} cards", { count: list.data.total })}</p>
|
||||
{list.data.items.length === 0 && <p className="px-6 py-8 text-sm text-muted-foreground">{t("No cards match these filters. Adjust the filters or create a card.")}</p>}
|
||||
{list.data.items.length === 0 && <p className="px-6 py-8 text-sm text-muted-foreground">{hasFilters ? t("No cards match these filters. Adjust the filters or create a card.") : t("No memory cards yet. Create a card to add reusable knowledge.")}</p>}
|
||||
<ul>{list.data.items.map(card => <li key={card.id} className="border-b">
|
||||
<button disabled={busy} aria-current={selected?.id === card.id ? "true" : undefined} onClick={() => void open(card.id)}
|
||||
className={`w-full px-6 py-4 text-left outline-none hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 ${selected?.id === card.id ? "bg-muted" : ""}`}>
|
||||
@@ -156,17 +157,17 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{t(families[card.family])} · {card.origin === "manual" ? t("Manual") : t("Workflow")}</span>
|
||||
<span className="mt-2 block text-xs">{card.indexed ? t("Index up to date") : t("Index update pending")}</span>
|
||||
</button></li>)}</ul>
|
||||
<nav aria-label={t("Memory pagination")} className="flex items-center justify-between gap-2 p-4">
|
||||
{list.data.total > 25 && <nav aria-label={t("Memory pagination")} className="flex items-center justify-between gap-2 p-4">
|
||||
<Button variant="outline" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>{t("Previous")}</Button>
|
||||
<span className="text-xs">{t("Page")} {page} {t("of")} {Math.max(1, Math.ceil(list.data.total / 25))}</span>
|
||||
<Button variant="outline" disabled={page * 25 >= list.data.total} onClick={() => setPage(p => p + 1)}>{t("Next")}</Button>
|
||||
</nav>
|
||||
</nav>}
|
||||
</>}
|
||||
</section>
|
||||
<section aria-label={t("Memory detail")} className="thot-administration-detail">
|
||||
{!draft && !selected && <p className="py-8 text-sm text-muted-foreground">{t("Select a card to inspect its content, scope and provenance.")}</p>}
|
||||
{selected && !draft && <div className="space-y-5">
|
||||
<div><p className="text-xs text-muted-foreground">{t(families[selected.family])}</p><h2 className="mt-1 break-words font-serif text-xl font-semibold">{selected.subject}</h2></div>
|
||||
<div><p className="text-xs text-muted-foreground">{t(families[selected.family])}</p><h2 className="mt-1 break-words font-sans text-xl font-semibold">{selected.subject}</h2></div>
|
||||
<dl className="space-y-4 text-sm">
|
||||
{(["detail", "scope", "rationale", "question", "sql"] as const).filter(k => selected[k]).map(key => <div key={key}>
|
||||
<dt className="mb-1 font-semibold capitalize">{key === "detail" ? t("Content") : key === "sql" ? t("Approved SQL") : t(key)}</dt>
|
||||
@@ -187,7 +188,7 @@ function MemoryWorkspace({ workspace, onDirtyChange, onBusyChange }: {
|
||||
<Button variant="outline" disabled={busy} onClick={() => setDeleteConfirm(false)}>{t("Keep card")}</Button></div></div>}
|
||||
</div>}
|
||||
{draft && <form onSubmit={save} className="space-y-5">
|
||||
<h2 className="font-serif text-xl font-semibold">{selected ? t("Edit card") : t("New Memory card")}</h2>
|
||||
<h2 className="font-sans text-xl font-semibold">{selected ? t("Edit card") : t("New Memory card")}</h2>
|
||||
<fieldset disabled={busy} className="space-y-4">
|
||||
<label className="grid gap-1 text-sm font-medium">{t("Family")}<select aria-label={t("Family")} className={control} value={draft.family} onChange={e => {
|
||||
update("family", e.target.value as MemoryFamily); if (e.target.value !== "solved_question") update("sql", "");
|
||||
|
||||
@@ -50,7 +50,7 @@ function ActivityRow({ entry }: { entry: ActivityEntry }) {
|
||||
const { t: translate } = useI18n();
|
||||
return (
|
||||
<article className="w-full min-w-0 border-b border-border/50 py-2 last:border-b-0">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1 text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1 text-xs font-semibold text-muted-foreground">
|
||||
<span className="text-foreground/80">{translate(ACTIVITY_LABELS[entry.kind] ?? entry.kind)}</span>
|
||||
{entry.phase && <span aria-label={translate("Phase {phase}", { phase: entry.phase })}>{entry.phase}</span>}
|
||||
</div>
|
||||
|
||||
@@ -76,14 +76,14 @@ export function NavSessions({
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={[
|
||||
"block truncate text-[0.8rem] leading-snug",
|
||||
"block truncate text-sm leading-snug",
|
||||
active ? "font-semibold text-foreground" : "font-medium text-foreground/90",
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{showOwner && (
|
||||
<span className="block truncate text-[0.65rem] text-muted-foreground">
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{translate("Owner: {name}", { name: s.author ?? translate("Unknown") })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -90,7 +90,7 @@ test("shows every session model exposed by Core", async () => {
|
||||
] })));
|
||||
renderManagement();
|
||||
|
||||
const catalog = await screen.findByRole("region", { name: "Session models" });
|
||||
const catalog = await screen.findByRole("region", { name: "Available models" });
|
||||
expect(catalog).toHaveTextContent("GLM 5.3");
|
||||
expect(catalog).toHaveTextContent("DeepSeek V4 Pro");
|
||||
expect(catalog).toHaveTextContent("DeepSeek V4 Flash");
|
||||
|
||||
@@ -52,7 +52,7 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
|
||||
|
||||
function RailItem({ label, value, state }: { label: string; value: string; state: "ready" | "attention" | "idle" }) {
|
||||
const { t } = useI18n();
|
||||
const iconClass = state === "ready" ? "text-[oklch(var(--success))]" : state === "attention" ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground";
|
||||
const iconClass = state === "ready" ? "text-[oklch(var(--success))]" : state === "attention" ? "text-warning" : "text-muted-foreground";
|
||||
return <div className="min-w-0">
|
||||
<p className="thot-label">{t(label)}</p>
|
||||
<p className={`mt-1 flex items-start gap-1.5 text-sm font-semibold ${iconClass}`}>
|
||||
@@ -145,12 +145,12 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
|
||||
<p className="mt-1 text-muted-foreground">{t("Using")} {t(details.terminal)}{t(", before editing files or running any lifecycle command, enter the exact checkout or worktree root. Run the platform command below, then run every command in this list from that directory:")} <code>tht</code> {t("resolves the installation descriptor and configuration from the selected project. A worktree is an independent checkout, so this prevents changing a different copy by mistake.")}</p>
|
||||
<PiCodeBlock className="mt-2">{details.changeDirectoryCommand}</PiCodeBlock>
|
||||
<p className="mt-2 text-muted-foreground">{t("The relevant layout is:")}</p>
|
||||
<PiCodeBlock className="mt-1">project-root/
|
||||
<PiCodeBlock className="mt-1">{`project-root/
|
||||
├── compose.yaml
|
||||
├── deploy/
|
||||
│ └── <installation-id>/
|
||||
│ └── <installation-id>/
|
||||
│ └── thothii-installation.yaml
|
||||
└── docker/</PiCodeBlock>
|
||||
└── docker/`}</PiCodeBlock>
|
||||
<p className="mt-2 text-muted-foreground">{t("The")} <code>deploy/</code> {t("directory contains the selected installation descriptor and profile files. You do not need to create or manage a")} <code>bin/</code> {t("directory:")} <code>tht</code> {t("is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with")} <code>--installation <absolute-path>/thothii-installation.yaml</code>{t(". If")} <code>tht</code> {t("is not on your")} <code>PATH</code>{t(", install it using the installation guide.")}</p>
|
||||
</li>
|
||||
<li>
|
||||
@@ -185,13 +185,13 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
|
||||
<h4 className="font-semibold text-foreground">{t("Update the Pi version")}</h4>
|
||||
<p className="mt-1 text-muted-foreground">{t("Use")} <code>tht pi update</code> {t("for the routine build: it resolves the latest stable Pi release from the registry, builds it locally, drains active sessions, and recreates only")} <code>core</code>{t(". Add")} <code>--version <VERSION></code> {t("when an explicitly reviewed version is required. The digest-pinned")} <code>--source pull</code> {t("form is for an already-built, reviewed registry image; its declared version must match the digest-pinned image. Configuration changes use Reload above, not update.")}</p>
|
||||
<PiCodeBlock className="mt-2">{details.updateCommand}</PiCodeBlock>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">{t("Advanced: pull an immutable, digest-pinned image.")}</p>
|
||||
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.pullCommand}</PiCodeBlock>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("Advanced: pull an immutable, digest-pinned image.")}</p>
|
||||
<PiCodeBlock className="mt-1 text-xs text-muted-foreground">{details.pullCommand}</PiCodeBlock>
|
||||
</li>
|
||||
<li>
|
||||
<h4 className="font-semibold text-foreground">{t("Diagnose and recover a failed operation")}</h4>
|
||||
<p className="mt-1 text-muted-foreground">{t("Start with these diagnostics, in order:")}</p>
|
||||
<PiCodeBlock className="mt-2 text-[11px] text-muted-foreground">{details.diagnosticCommands}</PiCodeBlock>
|
||||
<PiCodeBlock className="mt-2 text-xs text-muted-foreground">{details.diagnosticCommands}</PiCodeBlock>
|
||||
<dl className="mt-2 grid gap-2 text-muted-foreground">
|
||||
<dt className="font-semibold text-foreground">{t("Configuration")}</dt><dd>{t("Invalid YAML, an unknown model default, or an incompatible use/adapter combination: fix the reported")} <code>modelCatalog</code> {t("field, then reload. Do not repair generated JSON directly.")}</dd>
|
||||
<dt className="font-semibold text-foreground">{t("Credentials")}</dt><dd>{t("Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.")}</dd>
|
||||
@@ -200,11 +200,11 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
|
||||
</dl>
|
||||
<p className="mt-2 text-muted-foreground">{t("If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.")}</p>
|
||||
<p className="mt-2 text-muted-foreground">{t("If maintenance is active after an update, rollback the previous image:")}</p>
|
||||
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.rollbackCommand}</PiCodeBlock>
|
||||
<PiCodeBlock className="mt-1 text-xs text-muted-foreground">{details.rollbackCommand}</PiCodeBlock>
|
||||
<p className="mt-2 text-muted-foreground">{t("If maintenance is active after a restart, fix the cause first, then recover the captured lifecycle state:")}</p>
|
||||
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.recoverCommand}</PiCodeBlock>
|
||||
<PiCodeBlock className="mt-1 text-xs text-muted-foreground">{details.recoverCommand}</PiCodeBlock>
|
||||
<p className="mt-2 text-muted-foreground">{t("After the selected remedy, verify the installation:")}</p>
|
||||
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.verificationCommands}</PiCodeBlock>
|
||||
<PiCodeBlock className="mt-1 text-xs text-muted-foreground">{details.verificationCommands}</PiCodeBlock>
|
||||
</li>
|
||||
</ol>;
|
||||
}
|
||||
@@ -229,9 +229,9 @@ function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform })
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={t("Pi operator workflow")} className="mt-2 grid gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
||||
<p className="text-muted-foreground">{t("Using the host terminal:")}</p>
|
||||
<div role="tablist" aria-label={t("Pi host platform")} className="grid grid-cols-3 gap-1 rounded-md bg-muted p-1">
|
||||
<section aria-label={t("Pi operator workflow")} className="thot-pi-guide grid gap-5 text-base leading-relaxed">
|
||||
<p className="text-muted-foreground">{t("Run these commands on the installation host. Choose its operating system.")}</p>
|
||||
<div role="tablist" aria-label={t("Pi host platform")} className="thot-workspace-area-tabs">
|
||||
{piPlatforms.map((platform) => (
|
||||
<button
|
||||
key={platform.id}
|
||||
@@ -242,7 +242,7 @@ function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform })
|
||||
aria-selected={activePlatform === platform.id}
|
||||
aria-controls={`pi-${platform.id}-panel`}
|
||||
tabIndex={activePlatform === platform.id ? 0 : -1}
|
||||
className={activePlatform === platform.id ? "rounded bg-background px-2 py-1.5 font-semibold text-foreground shadow-sm" : "rounded px-2 py-1.5 font-semibold text-muted-foreground hover:bg-background/70 hover:text-foreground"}
|
||||
className="hover:bg-muted/50"
|
||||
onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
|
||||
onClick={() => {
|
||||
setActivePlatform(platform.id);
|
||||
@@ -367,7 +367,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
|
||||
<p className="thot-label">{t("Bundled runtime")}</p>
|
||||
<p className="mt-1 font-mono text-sm font-semibold">Pi {statusQuery.data.version ?? t("version unavailable")}</p>
|
||||
</div>
|
||||
<p role="status" aria-label={t("Pi readiness")} className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${statusQuery.data.ready ? "border-[oklch(var(--success)/0.4)] bg-[oklch(var(--success)/0.1)] text-foreground" : "border-amber-500/35 bg-amber-500/10 text-amber-800 dark:text-amber-300"}`}>
|
||||
<p role="status" aria-label={t("Pi readiness")} className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${statusQuery.data.ready ? "border-[oklch(var(--success)/0.4)] bg-[oklch(var(--success)/0.1)] text-foreground" : "border-warning/35 bg-warning/10 text-warning"}`}>
|
||||
{statusQuery.data.ready ? t("Ready") : t("Needs attention")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -379,7 +379,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
|
||||
smokeState={smokeState}
|
||||
/>
|
||||
|
||||
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{t(statusQuery.data.message)}</p>}
|
||||
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">{t(statusQuery.data.message)}</p>}
|
||||
{feedback && <p role="status" aria-label={t("Pi management feedback")} aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
|
||||
{feedback.tone === "error" ? <CircleAlert className="mt-0.5 size-4 shrink-0 text-destructive" /> : <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-[oklch(var(--success))]" />}
|
||||
{renderAdminMessage(t, feedback.message)}
|
||||
@@ -387,14 +387,14 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
|
||||
|
||||
<section aria-label={t("Installation catalog default")} className="border-t border-border/70 pt-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div><h3 className="font-heading text-sm font-semibold">{t("Installation catalog default")}</h3><p className="mt-1 text-sm text-muted-foreground">{t("This value is read from")} <code>modelCatalog.defaults.interaction</code>{t(". Change the installation YAML and reload Pi; this page never creates a second model default.")}</p></div>
|
||||
<div><h3 className="font-heading text-sm font-semibold">{t("Installation catalog default")}</h3><p className="mt-1 text-sm text-muted-foreground">{t("Used for Core and Administration when no model has been chosen. The test below checks this default, not your current selection.")}</p><details className="mt-2 text-sm"><summary className="cursor-pointer font-medium">{t("Change the installation default")}</summary><p className="mt-2 text-muted-foreground">{t("Update")} <code>modelCatalog.defaults.interaction</code> {t("in the installation YAML, then reload Pi. See Host maintenance for instructions.")}</p></details></div>
|
||||
</div>
|
||||
{catalogReady ? (
|
||||
<>
|
||||
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div><p className="thot-label">{t("Provider")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.provider}</p></div>
|
||||
<div><p className="thot-label">{t("Model")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.model}</p></div>
|
||||
<div><p className="thot-label">{t("Reasoning")}</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.reasoning}</p></div>
|
||||
<div><p className="thot-label">{t("Reasoning")}</p><p className="mt-1 font-semibold">{statusQuery.data.config.reasoning}</p></div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={smokeMutation.isPending} onClick={testCatalogDefault}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}{t("Test catalog default")}</Button>
|
||||
@@ -402,7 +402,7 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<div role="alert" aria-label={t("Pi configuration incomplete")} className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||
<div role="alert" aria-label={t("Pi configuration incomplete")} className="rounded-md border border-warning/30 bg-warning/10 p-4 text-sm text-warning">
|
||||
<p className="font-semibold">{t("The catalog default is unavailable.")}</p>
|
||||
<p className="mt-1">{t("Fix")} <code>modelCatalog.defaults.interaction</code> {t("in the installation YAML, reload Pi, then retry this panel.")}</p>
|
||||
</div>
|
||||
@@ -414,14 +414,14 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-label={t("Session models")} className="border-t border-border/70 pt-5">
|
||||
<section aria-label={t("Available models")} className="border-t border-border/70 pt-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-heading text-sm font-semibold">{t("Session models")}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("Models currently available for interactive sessions in this installation.")}</p>
|
||||
<h3 className="font-heading text-sm font-semibold">{t("Available models")}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("Choose your working model in the context bar. Each enabled model should be verified for both Core and Administration.")}</p>
|
||||
</div>
|
||||
{modelsQuery.data && (
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">{modelsQuery.data.models.length} {t("available")}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{modelsQuery.data.models.length} {t("available")}</span>
|
||||
)}
|
||||
</div>
|
||||
{modelsQuery.isLoading ? (
|
||||
|
||||
@@ -80,9 +80,9 @@ function DocBody({ doc }: { doc: SessionDocument }) {
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item.status}:${item.subject}:${index}`} className="py-4 first:pt-0">
|
||||
<span className={[
|
||||
"inline-flex rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide",
|
||||
"inline-flex rounded-md px-2 py-0.5 text-xs font-semibold",
|
||||
item.status === "approved"
|
||||
? "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]"
|
||||
? "bg-[oklch(var(--success)/0.15)] text-success"
|
||||
: "bg-muted text-muted-foreground",
|
||||
].join(" ")}>
|
||||
{item.status === "approved" ? translate("Approved") : translate("Declined")}
|
||||
@@ -96,7 +96,7 @@ function DocBody({ doc }: { doc: SessionDocument }) {
|
||||
</div>
|
||||
)}
|
||||
{item.rationale && (
|
||||
<div className="mt-1 border-l-2 border-border pl-3 text-sm text-muted-foreground">
|
||||
<div className="mt-1 border-l border-border pl-3 text-sm text-muted-foreground">
|
||||
<MarkdownView source={item.rationale} />
|
||||
</div>
|
||||
)}
|
||||
@@ -121,7 +121,7 @@ function DocBody({ doc }: { doc: SessionDocument }) {
|
||||
<ul className="flex flex-col divide-y divide-border/50">
|
||||
{items.map((d, i) => (
|
||||
<li key={i} className="py-3 first:pt-0">
|
||||
<span className={`inline-block rounded-md px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide ${decisionChipClass(d.type ?? "")}`}>
|
||||
<span className={`inline-block rounded-md px-2 py-0.5 text-xs font-semibold ${decisionChipClass(d.type ?? "")}`}>
|
||||
{translate((d.type ?? "decision").replace(/_/g, " "))}
|
||||
</span>
|
||||
{d.subject && (
|
||||
@@ -147,7 +147,7 @@ function DocBody({ doc }: { doc: SessionDocument }) {
|
||||
function decisionChipClass(type: string): string {
|
||||
const t = type.toLowerCase();
|
||||
if (t.includes("approved") || t.includes("promoted"))
|
||||
return "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]";
|
||||
return "bg-[oklch(var(--success)/0.15)] text-success";
|
||||
if (t.includes("excluded") || t.includes("rejected") || t.includes("reopened"))
|
||||
return "bg-muted text-muted-foreground";
|
||||
return "bg-[oklch(var(--primary)/0.1)] text-primary";
|
||||
@@ -164,10 +164,11 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
|
||||
return (
|
||||
<aside
|
||||
aria-label={translate("Session summary")}
|
||||
data-layout={desktopSplit ? "split" : "compact"}
|
||||
aria-hidden={hidden || undefined}
|
||||
hidden={hidden}
|
||||
className={[
|
||||
"flex shrink-0 flex-col border-r border-border bg-sidebar",
|
||||
"thot-session-documents flex shrink-0 flex-col border-r border-border bg-sidebar",
|
||||
desktopSplit
|
||||
? "w-[var(--session-panel-width)] max-w-[50%]"
|
||||
: "w-[30vw] max-w-[30vw]",
|
||||
@@ -175,7 +176,7 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 border-b border-border/60 px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
|
||||
<h2 className="break-words font-heading text-sm font-semibold text-foreground">
|
||||
{session.name || session.question}
|
||||
</h2>
|
||||
<p className="thot-label mt-0.5">{statusLabel(session)}</p>
|
||||
@@ -212,7 +213,7 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
|
||||
{translate(doc.title)}
|
||||
</h3>
|
||||
{doc.phase !== "—" && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.6rem] font-medium text-muted-foreground">
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs font-medium text-muted-foreground">
|
||||
{doc.phase}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -83,12 +83,32 @@ export function SteerInput({
|
||||
}
|
||||
|
||||
// Grow the textarea to fit its content (up to a cap, then it scrolls), and shrink
|
||||
// back when the text is cleared. Runs after every value change so height tracks text.
|
||||
// back when cleared. Core stays mounted while hidden behind context/Admin pages,
|
||||
// so a hidden scrollHeight must never become a persisted zero-pixel height.
|
||||
useEffect(() => {
|
||||
const el = taRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||
let disposed = false;
|
||||
const resize = () => {
|
||||
if (disposed || el.getClientRects().length === 0) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
||||
};
|
||||
resize();
|
||||
let lastWidth = -1;
|
||||
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(([entry]) => {
|
||||
const width = entry.contentRect.width;
|
||||
if (width === lastWidth) return;
|
||||
lastWidth = width;
|
||||
// Ignore height-only notifications from our own autosizing writes.
|
||||
if (width > 0) resize();
|
||||
});
|
||||
observer?.observe(el);
|
||||
void document.fonts?.ready.then(resize);
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
async function submit() {
|
||||
@@ -181,7 +201,7 @@ export function SteerInput({
|
||||
placeholder={sessionId ? translate("Free steering (!)…") : translate("Ask a question to start a new session…")}
|
||||
aria-label={sessionId ? translate("Steering") : translate("New question")}
|
||||
data-awaiting-input={awaitingInput ? "true" : undefined}
|
||||
className={["max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")}
|
||||
className={["thot-composer-input min-h-8 min-w-0 max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -297,7 +317,7 @@ export function ComposerFooter() {
|
||||
)}
|
||||
</FooterSelect>}
|
||||
<span
|
||||
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
|
||||
className="whitespace-nowrap font-mono text-xs tabular-nums text-muted-foreground"
|
||||
aria-label={translate("Input, cached, and output tokens")}
|
||||
>
|
||||
{formatTokenUsage(tokenUsage)}
|
||||
@@ -351,7 +371,7 @@ function FooterSelect({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...rest}
|
||||
className={[
|
||||
"max-w-[10rem] cursor-pointer truncate rounded-lg border border-border bg-card px-1.5 py-0.5 text-[0.7rem] text-foreground/80 outline-none transition-colors hover:bg-muted focus:border-primary/40 focus:ring-2 focus:ring-ring/15",
|
||||
"max-w-[10rem] cursor-pointer truncate rounded-lg border border-border bg-card px-1.5 py-0.5 text-xs text-foreground/80 outline-none transition-colors hover:bg-muted focus:border-primary/40 focus:ring-2 focus:ring-ring/15",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
|
||||
@@ -94,19 +94,19 @@
|
||||
.thot-work-area-panel__eyebrow {
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.thot-work-area-panel__title {
|
||||
margin: 0.25rem 0 0;
|
||||
color: inherit;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.35rem;
|
||||
font-size: var(--text-section);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.18;
|
||||
@@ -116,7 +116,7 @@
|
||||
max-width: 68ch;
|
||||
margin: 0.45rem 0 0;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--text-control);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ type DotState = "done" | "running" | "error" | "pending";
|
||||
// Each state is a tinted chip; red is reserved strictly for error (the GSD theme's
|
||||
// primary is already red), green = done, amber = the live phase, neutral = pending.
|
||||
const DOT: Record<DotState, string> = {
|
||||
done: "bg-[oklch(var(--success)/0.16)] text-[oklch(0.5_0.13_165)] ring-[oklch(var(--success)/0.32)]",
|
||||
done: "bg-[oklch(var(--success)/0.16)] text-success ring-[oklch(var(--success)/0.32)]",
|
||||
running:
|
||||
"bg-[oklch(var(--warning)/0.24)] text-[oklch(0.46_0.11_79)] ring-[oklch(var(--warning)/0.6)]",
|
||||
"bg-[oklch(var(--warning)/0.24)] text-warning ring-[oklch(var(--warning)/0.6)]",
|
||||
error: "bg-destructive/15 text-destructive ring-destructive/45",
|
||||
pending: "bg-transparent text-muted-foreground/70 ring-border",
|
||||
};
|
||||
@@ -107,7 +107,7 @@ export function WorkflowBar({
|
||||
aria-current={isActive ? "step" : undefined}
|
||||
className={[
|
||||
"relative inline-flex size-6 items-center justify-center rounded-full",
|
||||
"text-[0.625rem] font-semibold leading-none tracking-tight tabular-nums",
|
||||
"text-xs font-semibold leading-none tracking-tight tabular-nums",
|
||||
"ring-1 ring-inset transition-colors",
|
||||
DOT[state],
|
||||
].join(" ")}
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
.thot-context-layout { display: flex; flex-direction: column; min-width: 0; height: var(--thoth-app-height, 100dvh); overflow: hidden; }
|
||||
.thot-context-layout { --thoth-content-gutter: 1.25rem; --thoth-content-padding: 1rem; display: flex; flex-direction: column; min-width: 0; height: var(--thoth-app-height, 100dvh); overflow: hidden; }
|
||||
.thot-context-layout > .thot-app-shell { flex: 1; height: auto; min-height: 0; }
|
||||
.thot-context-shelf { flex: none; min-width: 0; max-height: 60%; overflow: auto; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--muted)/.5); container: working-context / inline-size; }
|
||||
.thot-context-trigger { display: flex; align-items: center; gap: 16px; width: 100%; min-height: 72px; padding: 14px 24px; text-align: left; }
|
||||
.thot-context-trigger { display: flex; align-items: center; gap: 16px; width: 100%; min-height: 72px; padding: 14px calc(var(--thoth-content-gutter) + var(--thoth-content-padding)); text-align: left; }
|
||||
.thot-context-summary { min-width: 0; flex: 1; }
|
||||
.thot-context-summary small { display: block; font: 600 .625rem/1.6 var(--font-mono); color: oklch(var(--muted-foreground)); letter-spacing: .065em; }
|
||||
.thot-context-summary small { display: block; font: 500 var(--text-meta)/1.5 var(--font-sans); color: oklch(var(--muted-foreground)); }
|
||||
.thot-context-summary strong { display: block; font: 600 .875rem/1.6 var(--font-sans); overflow-wrap: anywhere; }
|
||||
.thot-context-summary strong span { padding-inline: 12px; color: oklch(var(--muted-foreground)); }
|
||||
.thot-context-action { display: flex; align-items: center; gap: 8px; color: oklch(var(--primary)); font-size: .75rem; font-weight: 600; }
|
||||
.thot-context-trigger[aria-expanded=true] .thot-context-action > svg:last-child { transform: rotate(180deg); }
|
||||
.thot-context-body { padding: 0 24px 20px; }
|
||||
.thot-context-body { margin: 0 var(--thoth-content-gutter) var(--thoth-content-gutter); padding: var(--thoth-content-padding); background: oklch(var(--card)); }
|
||||
.thot-context-body[hidden] { display: none; }
|
||||
.thot-context-explanation { max-width: none; margin: 0 0 20px; font-size: .8125rem; line-height: 1.6; color: oklch(var(--muted-foreground)); }
|
||||
.thot-context-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 24px; }
|
||||
.thot-context-fields label { display: grid; min-width: 0; gap: 7px; font-size: .8125rem; }
|
||||
.thot-context-fields label { display: grid; min-width: 0; gap: 8px; font-size: var(--text-control); }
|
||||
.thot-context-fields label > span { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||||
.thot-context-fields select { min-width: 0; width: 100%; padding: 9px 12px; border: 1px solid oklch(var(--border)); border-radius: 6px; background: oklch(var(--card)); }
|
||||
.thot-context-fields small, .thot-context-body > p[role] { color: oklch(var(--muted-foreground)); font-size: .75rem; line-height: 1.6; }
|
||||
.thot-context-body > p[role] { color: oklch(var(--muted-foreground)); font-size: .75rem; line-height: 1.6; }
|
||||
.thot-context-body > p[role] { margin-top: 12px; }
|
||||
.thot-context-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.thot-context-shelf :is(button,select):focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
|
||||
.thot-context-shelf select:disabled { opacity: .65; }
|
||||
@container working-context (max-width: 38rem) {
|
||||
.thot-context-fields { grid-template-columns: minmax(0,1fr); gap: 14px; }
|
||||
.thot-context-trigger { padding: 12px 16px; gap: 10px; }
|
||||
.thot-context-body { padding: 0 16px 16px; }
|
||||
.thot-context-trigger { padding-block: 12px; gap: 10px; }
|
||||
.thot-context-action > span { display: none; }
|
||||
}
|
||||
|
||||
@@ -16,21 +16,20 @@ export function WorkingContextShelf({ context, locked, onWorkspace, onModel }: {
|
||||
<button className="thot-context-trigger" aria-expanded={open} aria-controls="thoth-working-context"
|
||||
onClick={() => setExpanded(!open)}>
|
||||
<SlidersHorizontal size={18} aria-hidden />
|
||||
<span className="thot-context-summary"><small>{translate("WORKING CONTEXT")}</small><strong>{context.workspace?.displayName ?? translate("Choose workspace")}<span aria-hidden> / </span>{context.model?.name ?? translate("Choose model")}</strong></span>
|
||||
<span className="thot-context-summary"><small>{translate("Working context")}</small><strong>{context.workspace?.displayName ?? translate("Choose workspace")}<span aria-hidden> / </span>{context.model?.name ?? translate("Choose model")}</strong></span>
|
||||
<span className="thot-context-action">{locked && <LockKeyhole size={15} aria-hidden />}<span>{locked ? translate("Locked") : open ? translate("Close") : translate("Change context")}</span><ChevronDown size={16} aria-hidden /></span>
|
||||
</button>
|
||||
<div id="thoth-working-context" hidden={!open} className="thot-context-body">
|
||||
<p className="thot-context-explanation">{translate("Your last choices take precedence over installation defaults. Workspace and model are remembered independently.")}</p>
|
||||
<div className="thot-context-fields">
|
||||
<label><span><Layers size={15} aria-hidden />{translate("Workspace")}</span>
|
||||
<select aria-label={translate("Workspace")} value={context.workspace ? context.workspaceId : ""} disabled={locked || context.loading} onChange={event => onWorkspace(event.target.value)}>
|
||||
<option value="" disabled>{translate("Choose a workspace")}</option>{context.workspaces.map(workspace => <option key={workspace.id} value={workspace.id}>{workspace.displayName}</option>)}
|
||||
</select><small>{translate(context.origins.workspace)}</small>
|
||||
</select>
|
||||
</label>
|
||||
<label><span><Cpu size={15} aria-hidden />{translate("AI interaction model")}</span>
|
||||
<select aria-label={translate("Model")} value={context.model ? context.modelId : ""} disabled={locked || context.loading} onChange={event => onModel(event.target.value)}>
|
||||
<option value="" disabled>{translate("Choose a model")}</option>{context.models.map(model => <option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>{model.name}</option>)}
|
||||
</select><small>{translate(context.origins.model)}</small>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{locked && <p role="status">{translate("Workspace and model are locked until the current operation finishes.")}</p>}
|
||||
|
||||
@@ -310,7 +310,7 @@ test("keeps validation and connection results inside their respective action car
|
||||
await user.click(within(validationCard).getByRole("button", { name: "Validate workspace source" }));
|
||||
const validationStatus = await within(validationCard).findByRole("status");
|
||||
expect(validationStatus).toHaveTextContent("Workspace source and authentication are valid.");
|
||||
expect(validationStatus).toHaveClass("text-emerald-700");
|
||||
expect(validationStatus).toHaveClass("text-success");
|
||||
expect(within(connectionCard).queryByText("Workspace source and authentication are valid.")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
|
||||
@@ -634,7 +634,7 @@ test("renders binding_ok as a green connection success", async () => {
|
||||
await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
|
||||
const connectionStatus = await within(connectionCard).findByRole("status");
|
||||
expect(connectionStatus).toHaveTextContent("binding_ok: Installation bindings and diagnostics succeeded.");
|
||||
expect(connectionStatus).toHaveClass("text-emerald-700");
|
||||
expect(connectionStatus).toHaveClass("text-success");
|
||||
expect(within(connectionCard).queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -448,7 +448,7 @@ export function WorkspaceManager({
|
||||
>
|
||||
<ArrowLeft />{page ? t("Repository overview") : t("Back to Level 1")}
|
||||
</Button>
|
||||
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t("Available workspaces")}</p>
|
||||
<p className="mb-2 px-2 text-xs font-semibold text-muted-foreground">{t("Available workspaces")}</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{workspacesQuery.isLoading && <p className="p-2 text-xs text-muted-foreground">{t("Loading workspaces…")}</p>}
|
||||
{workspacesQuery.isError && (
|
||||
@@ -465,7 +465,7 @@ export function WorkspaceManager({
|
||||
>
|
||||
<span className="block truncate">{workspace.displayName}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
|
||||
<span className="mt-1 block text-[0.7rem] text-muted-foreground">{t(stateLabel(workspace.configurationState))}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{t(stateLabel(workspace.configurationState))}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -481,10 +481,10 @@ export function WorkspaceManager({
|
||||
</p>
|
||||
)}
|
||||
{diagnostics.length > 0 && (
|
||||
<div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
<div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm">
|
||||
{diagnostics.map((diagnostic) => (
|
||||
<p key={diagnostic} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{renderAdminDiagnostic(t, diagnostic)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -603,15 +603,15 @@ export function WorkspaceManager({
|
||||
<h4 className="font-heading font-semibold">{t("Validate workspace source")}</h4>
|
||||
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">{t("Confirms that workspace.yaml and the directories belonging to this workspace follow the supported format. This identifies source problems before activation, without changing repository files.")}</p>
|
||||
{validationNotice && (
|
||||
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
|
||||
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{t(validationNotice)}
|
||||
<p role="status" className="mt-3 rounded-md border border-success/30 bg-success/10 px-3 py-2 text-sm text-success">
|
||||
<CheckCircle2 className="mr-1 inline size-4 text-success" />{t(validationNotice)}
|
||||
</p>
|
||||
)}
|
||||
{validationDiagnostics.length > 0 && (
|
||||
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm">
|
||||
{validationDiagnostics.map((diagnostic) => (
|
||||
<p key={diagnostic} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{renderAdminDiagnostic(t, diagnostic)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -623,15 +623,15 @@ export function WorkspaceManager({
|
||||
<h4 className="font-heading font-semibold">{t("Test workspace connections")}</h4>
|
||||
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">{t("Verifies the database configured in Database Management, plus this workspace's Evidence source and the installation semantic services. The result is informational.")}</p>
|
||||
{connectionNotice && (
|
||||
<p role="status" className="mt-3 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700">
|
||||
<CheckCircle2 className="mr-1 inline size-4 text-emerald-600" />{renderAdminDiagnostic(t, connectionNotice)}
|
||||
<p role="status" className="mt-3 rounded-md border border-success/30 bg-success/10 px-3 py-2 text-sm text-success">
|
||||
<CheckCircle2 className="mr-1 inline size-4 text-success" />{renderAdminDiagnostic(t, connectionNotice)}
|
||||
</p>
|
||||
)}
|
||||
{connectionDiagnostics.length > 0 && (
|
||||
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
<div role="alert" aria-live="polite" className="mt-3 grid gap-1 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm">
|
||||
{connectionDiagnostics.map((diagnostic) => (
|
||||
<p key={diagnostic} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{renderAdminDiagnostic(t, diagnostic)}
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />{renderAdminDiagnostic(t, diagnostic)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -648,14 +648,14 @@ export function WorkspaceManager({
|
||||
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="font-heading font-semibold">{t("Authentication")}</h4>
|
||||
<span className={authentication.ready ? "text-sm font-medium text-emerald-700" : "text-sm font-medium text-amber-700"}>
|
||||
<span className={authentication.ready ? "text-sm font-medium text-success" : "text-sm font-medium text-warning"}>
|
||||
{authentication.ready ? t("Passed") : t("Failed")}
|
||||
</span>
|
||||
</div>
|
||||
{authentication.checks.some(({ level }) => level === "error") && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
{authentication.checks.some(({ level }) => level === "error") && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm">
|
||||
{authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => (
|
||||
<p key={`${code}:${field ?? ""}`} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
{code}: {field === undefined ? t(message) : `${field} — ${t(message)}`}
|
||||
</p>
|
||||
))}
|
||||
@@ -678,7 +678,7 @@ export function WorkspaceManager({
|
||||
<div key={requirement.id} className="grid gap-2 rounded-md bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label htmlFor={`workspace-secret-${requirement.id}`} className="font-medium">{t(requirement.label)}</label>
|
||||
<span className={requirement.configured ? "text-xs font-medium text-emerald-700" : "text-xs font-medium text-amber-700"}>
|
||||
<span className={requirement.configured ? "text-xs font-medium text-success" : "text-xs font-medium text-warning"}>
|
||||
{requirement.configured ? t("Configured") : t("Not configured")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -51,11 +51,11 @@ function diagnosticMessage(message: string): string {
|
||||
|
||||
function stateClasses(state: WorkspacePreprocessingState | undefined): string {
|
||||
switch (state) {
|
||||
case "ready": return "text-emerald-700";
|
||||
case "ready": return "text-success";
|
||||
case "failed": return "text-destructive";
|
||||
case "required":
|
||||
case "running":
|
||||
case "blocked": return "text-amber-700";
|
||||
case "blocked": return "text-warning";
|
||||
default: return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
|
||||
const step = Math.max(1, Math.min(progress.step, progress.totalSteps));
|
||||
return (
|
||||
<div className="col-span-2 mt-1 grid min-w-0 max-w-full gap-1">
|
||||
<div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-[9px] leading-none">
|
||||
<div className="flex min-w-0 max-w-full items-center justify-between gap-[0.5rem] text-xs leading-none">
|
||||
<span className="min-w-0 truncate font-medium text-foreground">
|
||||
{translate(PROGRESS_LABELS[progress.stage])}
|
||||
</span>
|
||||
@@ -98,9 +98,9 @@ function PhaseProgress({ progress }: { progress: PreprocessingProgress }) {
|
||||
className={[
|
||||
"h-1 rounded-full",
|
||||
index < step - 1
|
||||
? "bg-emerald-600/70"
|
||||
? "bg-success/70"
|
||||
: index === step - 1
|
||||
? "bg-amber-500"
|
||||
? "bg-warning"
|
||||
: "bg-muted",
|
||||
].join(" ")}
|
||||
/>
|
||||
@@ -125,10 +125,10 @@ function Diagnostic({ status }: { status: WorkspacePreprocessingStatus }) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"mt-2 grid gap-2 rounded-md border p-2.5 text-[10px] leading-[1.45]",
|
||||
"mt-2 grid gap-2 rounded-md border p-2.5 text-xs leading-[1.45]",
|
||||
status.state === "failed"
|
||||
? "border-destructive/25 bg-destructive/[0.055]"
|
||||
: "border-amber-500/35 bg-amber-500/[0.07]",
|
||||
: "border-warning/35 bg-warning/[0.07]",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="grid gap-0.5">
|
||||
@@ -262,7 +262,7 @@ export function WorkspacePreprocessingControl({
|
||||
className="thot-preprocessing-control mt-0.5 min-w-0 max-w-full border-t border-border/90 px-0.5 pt-2"
|
||||
data-presentation={presentation}
|
||||
>
|
||||
<p className="mb-2 px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.08em] text-primary">
|
||||
<p className="mb-2 px-0.5 text-sm font-semibold text-foreground">
|
||||
{translate("Preprocessing")}</p>
|
||||
{confirmingClear ? (
|
||||
<div className="rounded-lg border border-destructive/25 bg-destructive/[0.045] p-2.5">
|
||||
@@ -271,12 +271,12 @@ export function WorkspacePreprocessingControl({
|
||||
<AlertTriangle aria-hidden="true" className="size-3.5" />
|
||||
</span>
|
||||
<span className="grid gap-0.5">
|
||||
<strong className="text-[10px] font-semibold text-foreground">{translate("Clear derived data?")}</strong>
|
||||
<small className="text-[8px] leading-tight text-muted-foreground">
|
||||
<strong className="text-xs font-semibold text-foreground">{translate("Clear derived data?")}</strong>
|
||||
<small className="text-xs leading-tight text-muted-foreground">
|
||||
{translate("The workspace will require preprocessing.")}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-2 grid gap-1.5 border-y border-destructive/15 py-2 text-[8px]">
|
||||
<div className="my-2 grid gap-1.5 border-y border-destructive/15 py-2 text-xs">
|
||||
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
|
||||
<span className="grid size-[18px] place-items-center rounded bg-destructive/10 text-destructive">
|
||||
<Trash2 aria-hidden="true" className="size-3" />
|
||||
@@ -284,16 +284,16 @@ export function WorkspacePreprocessingControl({
|
||||
<span><strong className="block text-foreground">{translate("Clear")}</strong><small className="text-muted-foreground">{translate("Schema/Evidence vectors, LSH and derived checkpoints")}</small></span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1.2rem_minmax(0,1fr)] items-center gap-1.5">
|
||||
<span className="grid size-[18px] place-items-center rounded bg-emerald-600/10 text-emerald-700">
|
||||
<span className="grid size-[18px] place-items-center rounded bg-success/10 text-success">
|
||||
<ShieldCheck aria-hidden="true" className="size-3" />
|
||||
</span>
|
||||
<span><strong className="block text-foreground">{translate("Keep")}</strong><small className="text-muted-foreground">{translate("Memory and solved questions")}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
|
||||
<Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
|
||||
<Button type="button" variant="outline" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-xs" disabled={clearMutation.isPending} onClick={() => setConfirmingClear(false)}>
|
||||
{translate("Cancel")}</Button>
|
||||
<Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-[9px]" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
|
||||
<Button type="button" variant="destructive" size="xs" className="h-7 min-w-0 max-w-full px-[0.625rem] text-xs" disabled={clearMutation.isPending} onClick={() => clearMutation.mutate()}>
|
||||
{clearMutation.isPending ? <LoaderCircle aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : <Trash2 aria-hidden="true" />}
|
||||
{clearMutation.isPending ? translate("Clearing") : translate("Clear derived data")}
|
||||
</Button>
|
||||
@@ -306,8 +306,8 @@ export function WorkspacePreprocessingControl({
|
||||
<StatusIcon state={state} />
|
||||
</span>
|
||||
<div className="grid min-w-0 gap-0.5" role="status" aria-live="polite">
|
||||
<strong className="text-[11px] font-semibold leading-tight text-foreground">{translate(title)}</strong>
|
||||
{state !== "running" && <span className="text-[9px] leading-tight text-muted-foreground" title={diagnosticMessage(detail)}>{diagnosticMessage(detail)}</span>}
|
||||
<strong className="text-xs font-semibold leading-tight text-foreground">{translate(title)}</strong>
|
||||
{state !== "running" && <span className="text-xs leading-tight text-muted-foreground" title={diagnosticMessage(detail)}>{diagnosticMessage(detail)}</span>}
|
||||
</div>
|
||||
{progress && <PhaseProgress progress={progress} />}
|
||||
<div className="col-span-2 mt-0.5 grid min-w-0 max-w-full grid-cols-[minmax(0,0.78fr)_minmax(0,1.22fr)] gap-1.5">
|
||||
@@ -315,7 +315,7 @@ export function WorkspacePreprocessingControl({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px] hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
|
||||
className="h-7 min-w-0 max-w-full px-[0.5rem] text-xs hover:border-destructive/35 hover:bg-destructive/5 hover:text-destructive"
|
||||
disabled={!clearable}
|
||||
title={!canManage ? translate("Database management permission is required") : undefined}
|
||||
onClick={() => setConfirmingClear(true)}
|
||||
@@ -325,7 +325,7 @@ export function WorkspacePreprocessingControl({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="h-7 min-w-0 max-w-full px-[0.5rem] text-[10px]"
|
||||
className="h-7 min-w-0 max-w-full px-[0.5rem] text-xs"
|
||||
disabled={!actionable}
|
||||
title={!canManage ? translate("Database management permission is required") : undefined}
|
||||
onClick={() => mutation.mutate()}
|
||||
|
||||
@@ -5,8 +5,39 @@
|
||||
min-height: 0;
|
||||
height: var(--thoth-app-height, 100dvh);
|
||||
overflow: hidden;
|
||||
container: application / inline-size;
|
||||
}
|
||||
|
||||
.thot-evidence-filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 2fr) repeat(3, minmax(8rem, 1fr)) auto;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.thot-evidence-filters > label { min-width: 0; }
|
||||
.thot-pi-guide { max-width: 75ch; }
|
||||
.thot-pi-guide ol { list-style: decimal; gap: 2rem; }
|
||||
.thot-pi-guide h4 { font-size: var(--text-body); }
|
||||
.thot-pi-guide pre { font-size: var(--text-control); line-height: 1.6; padding: 0.75rem 1rem; }
|
||||
.thot-pi-guide p > code { font-size: var(--text-control); }
|
||||
|
||||
@container administration (max-width: 68rem) {
|
||||
.thot-evidence-filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.thot-evidence-search { grid-column: 1 / -1; }
|
||||
}
|
||||
@container administration (max-width: 30rem) {
|
||||
.thot-evidence-filters { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.thot-session-documents[data-layout="compact"]:not([hidden]) {
|
||||
position: absolute;
|
||||
z-index: 35;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
border-inline-end: 0;
|
||||
}
|
||||
.thot-app-shell[data-administration-narrow="true"] .thot-session-documents[data-layout="compact"] { inset-block-start: 3rem; }
|
||||
|
||||
.thot-administration-page,
|
||||
.thot-work-area-page {
|
||||
display: flex;
|
||||
@@ -27,26 +58,26 @@
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
padding: 1.65rem 1.75rem 1.35rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--muted) / 0.35);
|
||||
}
|
||||
.thot-administration-header__copy { min-width: 0; flex: 1 1 20rem; }
|
||||
.thot-administration-header h1 {
|
||||
margin: 0.35rem 0 0;
|
||||
margin: 0.25rem 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.15;
|
||||
font-size: var(--text-page);
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.thot-administration-eyebrow {
|
||||
margin: 0;
|
||||
font: 600 0.6875rem/1.4 var(--font-mono);
|
||||
font: 500 var(--text-meta)/1.5 var(--font-sans);
|
||||
color: oklch(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.065em;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.thot-administration-description {
|
||||
max-width: 68ch;
|
||||
@@ -75,7 +106,7 @@
|
||||
.thot-administration-detail { min-width: 0; padding: 1.35rem; }
|
||||
.thot-administration-index :where(button[aria-current]) { background: oklch(var(--nav-active)); color: oklch(var(--nav-active-foreground)); }
|
||||
.thot-administration-index button { overflow-wrap: anywhere; }
|
||||
.thot-administration-section-label { padding: 1rem 1.25rem 0; font: 600 0.6875rem/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: oklch(var(--muted-foreground)); }
|
||||
.thot-administration-section-label { padding: 1rem 1.25rem 0; font: 600 var(--text-control)/1.5 var(--font-sans); color: oklch(var(--foreground)); }
|
||||
.thot-administration-link { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 0.2em; font-size: 0.875rem; }
|
||||
.thot-administration-link:focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
|
||||
.thot-administration-dependency { padding: 1rem 1.25rem; border-block: 1px solid oklch(var(--border)); background: oklch(var(--muted) / 0.35); font-size: 0.875rem; }
|
||||
@@ -97,9 +128,9 @@
|
||||
.thot-workspace-preparation { border-block-start: 1px solid oklch(var(--border)); padding-top: 1.25rem; }
|
||||
.thot-workspace-preparation .thot-preprocessing-control { max-width: 40rem; }
|
||||
.thot-preprocessing-control[data-presentation="page"] { padding: 1rem; margin-top: 0.75rem; }
|
||||
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: 0.8125rem; line-height: 1.5; }
|
||||
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: var(--text-control); line-height: 1.5; }
|
||||
.thot-preprocessing-control[data-presentation="page"] button { height: auto; min-height: 2.25rem; white-space: normal; }
|
||||
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: 1.125rem; }
|
||||
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: var(--text-body); }
|
||||
.thot-administration-database .thot-administration-dependency { flex: none; }
|
||||
.thot-database-workbench { grid-template-columns: minmax(10rem, 0.55fr) minmax(0, 1.5fr); }
|
||||
.thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useI18n } from "../../i18n";
|
||||
import "./AdministrationPage.css";
|
||||
|
||||
export function AdministrationHeader({ title, description, actions, eyebrow = "Administration / Workbench" }: {
|
||||
export function AdministrationHeader({ title, description, actions, eyebrow }: {
|
||||
title: ReactNode; description?: ReactNode; actions?: ReactNode; eyebrow?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return <header className="thot-administration-header">
|
||||
<div className="thot-administration-header__copy">
|
||||
<p className="thot-administration-eyebrow">{eyebrow}</p>
|
||||
<p className="thot-administration-eyebrow">{eyebrow ?? t("Administration")}</p>
|
||||
<h1>{title}</h1>
|
||||
{description && <p className="thot-administration-description">{description}</p>}
|
||||
</div>
|
||||
|
||||
@@ -369,20 +369,20 @@ export function CatalogSyncDrawer({
|
||||
key={phase}
|
||||
aria-current={current ? "step" : undefined}
|
||||
aria-label={`${t(label)}, ${t(status)}`}
|
||||
className={`flex items-center gap-2 text-xs ${failedOutcome ? "font-semibold text-destructive" : interruptedOutcome ? "font-semibold text-amber-600" : terminalOutcome || current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
|
||||
className={`flex items-center gap-2 text-xs ${failedOutcome ? "font-semibold text-destructive" : interruptedOutcome ? "font-semibold text-warning" : terminalOutcome || current ? "font-semibold text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{confirmationSkipped ? (
|
||||
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||
) : failedOutcome ? (
|
||||
<X aria-hidden="true" className="size-3.5 text-destructive" />
|
||||
) : interruptedOutcome ? (
|
||||
<AlertTriangle aria-hidden="true" className="size-3.5 text-amber-600" />
|
||||
<AlertTriangle aria-hidden="true" className="size-3.5 text-warning" />
|
||||
) : terminalOutcome ? (
|
||||
<Minus aria-hidden="true" className="size-3.5 text-muted-foreground" />
|
||||
) : complete ? (
|
||||
<CheckCircle2 aria-hidden="true" className="size-3.5 text-[oklch(var(--success))]" />
|
||||
) : current ? (
|
||||
<LoaderCircle aria-hidden="true" className="size-3.5 animate-spin text-amber-600" />
|
||||
<LoaderCircle aria-hidden="true" className="size-3.5 animate-spin text-warning" />
|
||||
) : (
|
||||
<Circle aria-hidden="true" className="size-3.5" />
|
||||
)}
|
||||
@@ -396,16 +396,16 @@ export function CatalogSyncDrawer({
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
{(["tables", "columns", "relationships"] as const).map((name) => (
|
||||
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{name}</p>
|
||||
<p className="text-xs font-semibold text-muted-foreground">{name}</p>
|
||||
<p className="mt-1 text-lg font-semibold tabular-nums">{run.counts[name] ?? ""}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{run.state === "awaiting_confirmation" && run.plannedDiff ? (
|
||||
<section className="mt-5 rounded-md border border-amber-500/50 bg-amber-500/8 p-4" aria-label={t("Destructive changes")}>
|
||||
<section className="mt-5 rounded-md border border-warning/50 bg-warning/8 p-4" aria-label={t("Destructive changes")}>
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600" />
|
||||
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-warning" />
|
||||
<div>
|
||||
<h3 className="font-semibold">{t("Confirm")} {deletionCount} {t("removals")}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("The database will be scanned again before anything is removed.")}</p>
|
||||
|
||||
@@ -520,8 +520,8 @@ export function DatabaseColumns({
|
||||
<h3 className="mt-1 font-heading text-2xl font-semibold">{active.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("Physical schema fields are read-only. Review fields can be edited.")}</p>
|
||||
{stale ? (
|
||||
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
|
||||
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
|
||||
staleBannerOpen ? <div className="mt-4 rounded-md border border-warning/50 bg-warning/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
|
||||
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-warning/40 bg-warning/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
|
||||
) : null}
|
||||
<ColumnMetadataFieldMatrix
|
||||
column={active}
|
||||
@@ -656,8 +656,8 @@ export function DatabaseColumns({
|
||||
<p className="thot-label">{table.name} {t("· column")}</p>
|
||||
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
|
||||
{stale ? (
|
||||
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
|
||||
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
|
||||
staleBannerOpen ? <div className="mt-4 rounded-md border border-warning/50 bg-warning/8 p-4 text-sm"><p className="font-semibold">{t("Newer column metadata is available.")}</p><p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload.")}</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div></div>
|
||||
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-warning/40 bg-warning/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this metadata can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>{t("Reload latest")}</Button></div>
|
||||
) : null}
|
||||
<ColumnMetadataFieldMatrix
|
||||
column={active}
|
||||
|
||||
@@ -104,9 +104,9 @@ type StateTone = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
const stateToneClass: Record<StateTone, string> = {
|
||||
neutral: "bg-muted-foreground/55",
|
||||
info: "bg-sky-500",
|
||||
success: "bg-emerald-500",
|
||||
warning: "bg-amber-500",
|
||||
info: "bg-muted-foreground/55",
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
danger: "bg-destructive",
|
||||
};
|
||||
|
||||
@@ -128,7 +128,7 @@ function StateCell({
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${stateToneClass[tone]}`} aria-hidden="true" />
|
||||
<span className="truncate">{t(label)}</span>
|
||||
</span>
|
||||
<span className={`truncate pl-3 text-[11px] ${detailClassName}`}>{t(detail)}</span>
|
||||
<span className={`truncate pl-3 text-xs ${detailClassName}`}>{t(detail)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -588,7 +588,7 @@ export function DatabaseRelationships({
|
||||
<dt className="thot-label">{t("Column mapping")}</dt>
|
||||
<dd className="mt-2 overflow-hidden rounded-md border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">{t("Source")}</th><th className="px-3 py-2">{t("Target")}</th></tr></thead>
|
||||
<thead className="bg-muted/60 font-sans text-muted-foreground"><tr><th className="px-3 py-2">{t("Source")}</th><th className="px-3 py-2">{t("Target")}</th></tr></thead>
|
||||
<tbody>{activeRelationship.columns.map((pair) => <tr key={`${pair.sourceColumnName}:${pair.targetColumnName}`} className="border-t border-border"><td className="px-3 py-2 font-mono">{pair.sourceColumnName}</td><td className="px-3 py-2 font-mono">{pair.targetColumnName}</td></tr>)}</tbody>
|
||||
</table>
|
||||
</dd>
|
||||
|
||||
@@ -603,12 +603,12 @@ export function DatabaseTables({
|
||||
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{activeTable.name}</h3>
|
||||
{stale ? (
|
||||
staleBannerOpen ? (
|
||||
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
|
||||
<div className="mt-4 rounded-md border border-warning/50 bg-warning/8 p-4 text-sm">
|
||||
<p className="font-semibold">{t("A newer table description is available.")}</p>
|
||||
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
|
||||
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
|
||||
</div>
|
||||
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
|
||||
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-warning/40 bg-warning/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
|
||||
) : null}
|
||||
<div className="mt-5 grid gap-4">
|
||||
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
|
||||
@@ -654,12 +654,12 @@ export function DatabaseTables({
|
||||
<p className="text-sm text-muted-foreground">{t("Only review metadata can be changed.")}</p>
|
||||
{stale ? (
|
||||
staleBannerOpen ? (
|
||||
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
|
||||
<div className="mt-4 rounded-md border border-warning/50 bg-warning/8 p-4 text-sm">
|
||||
<p className="font-semibold">{t("A newer table description is available.")}</p>
|
||||
<p className="mt-1 text-muted-foreground">{t("Your draft is preserved until you reload the current metadata.")}</p>
|
||||
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>{t("Keep editing")}</Button></div>
|
||||
</div>
|
||||
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
|
||||
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-warning/40 bg-warning/8 px-4 py-3 text-sm"><span>{t("Reload the latest value before this description can be saved.")}</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>{t("Reload latest")}</Button></div>
|
||||
) : null}
|
||||
<div className="mt-5 grid gap-4">
|
||||
<label className="grid gap-1.5 text-sm font-semibold">{t("Physical table name")}<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
|
||||
|
||||
@@ -328,7 +328,7 @@ export function DescriptionGenerationDrawer({
|
||||
{apparentlyStale ? (
|
||||
<p
|
||||
id="description-generation-unlock-help"
|
||||
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||
className="mb-3 rounded-md border border-warning/35 bg-warning/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{t("Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.")}</p>
|
||||
) : null}
|
||||
@@ -411,7 +411,7 @@ export function DescriptionGenerationDrawer({
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/45">
|
||||
{progressCounters.map(([name, label]) => (
|
||||
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
|
||||
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground first:border-l-0">
|
||||
{t(label)}
|
||||
</th>
|
||||
))}
|
||||
|
||||
@@ -25,7 +25,7 @@ test("uses operational body text for every available-command list", () => {
|
||||
}
|
||||
|
||||
expect(styles).toMatch(
|
||||
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*0\.9375rem;/s,
|
||||
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*var\(--text-control\);/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.thot-fleet-action-selector\s*\{[^}]*flex:\s*0 0 auto;/s,
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__workspace {
|
||||
--fleet-block-gutter: var(--thoth-content-gutter, 1.25rem);
|
||||
--fleet-block-padding: var(--thoth-content-padding, 1rem);
|
||||
--fleet-summary-inset: calc(var(--fleet-block-gutter) + var(--fleet-block-padding));
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
@@ -54,12 +57,12 @@
|
||||
.thot-fleet-ledger__kpi-label {
|
||||
margin: 0;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__title,
|
||||
@@ -75,7 +78,7 @@
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.0125em;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
@@ -85,7 +88,7 @@
|
||||
max-width: 68ch;
|
||||
margin: 0.45rem 0 0;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--text-control);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -101,6 +104,12 @@
|
||||
.thot-fleet-ledger__header-status {
|
||||
grid-area: status;
|
||||
min-width: 0;
|
||||
padding-inline: var(--fleet-summary-inset);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger .thot-administration-header,
|
||||
.thot-fleet-ledger .thot-administration-toolbar {
|
||||
padding-inline: var(--fleet-summary-inset);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-status > .thot-fleet-ledger__status {
|
||||
@@ -175,7 +184,6 @@
|
||||
.thot-fleet-ledger__status-mark {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
margin-top: 0.33rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--fleet-status-color);
|
||||
@@ -191,15 +199,18 @@
|
||||
|
||||
.thot-fleet-ledger__status-label {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.625rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: var(--text-meta);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__status-value {
|
||||
overflow: hidden;
|
||||
color: oklch(var(--foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -208,7 +219,7 @@
|
||||
.thot-fleet-ledger__status-detail {
|
||||
overflow: hidden;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--text-meta);
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -217,25 +228,25 @@
|
||||
.thot-fleet-ledger__kpi-section {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1.25rem 0;
|
||||
margin-inline: var(--fleet-block-gutter);
|
||||
padding: 0.75rem var(--fleet-block-padding) 0;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--card));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8.5rem), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 6rem), 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi {
|
||||
min-width: 0;
|
||||
padding: 0 1rem 0.75rem 0;
|
||||
padding: 0 0.75rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi + .thot-fleet-ledger__kpi {
|
||||
padding-left: 1rem;
|
||||
border-left: 1px solid oklch(var(--border));
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-value,
|
||||
@@ -246,18 +257,18 @@
|
||||
.thot-fleet-ledger__kpi-value {
|
||||
margin-top: 0.2rem;
|
||||
color: oklch(var(--foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(1.25rem, 2vw, 1.625rem);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-detail {
|
||||
margin-top: 0.15rem;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--text-meta);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -269,7 +280,8 @@
|
||||
min-height: 3rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-inline: var(--fleet-block-gutter);
|
||||
padding: 0.5rem var(--fleet-block-padding);
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--card));
|
||||
}
|
||||
@@ -280,7 +292,7 @@
|
||||
color: oklch(var(--primary));
|
||||
background: oklch(var(--primary) / 0.08);
|
||||
box-shadow: 0 1px 2px oklch(var(--primary) / 0.1);
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.thot-fleet-back-action:not(:disabled):hover {
|
||||
@@ -326,7 +338,7 @@
|
||||
color: oklch(var(--muted-foreground));
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-size: var(--text-control);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
@@ -346,8 +358,8 @@
|
||||
max-width: 18rem;
|
||||
overflow: hidden;
|
||||
color: oklch(var(--foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
font-size: var(--text-control);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -392,14 +404,14 @@
|
||||
display: grid;
|
||||
min-width: 7.5rem;
|
||||
color: oklch(var(--foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-meta);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__scope span {
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.625rem;
|
||||
font-size: var(--text-meta);
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__select {
|
||||
@@ -413,7 +425,7 @@
|
||||
border-radius: var(--radius);
|
||||
color: oklch(var(--foreground));
|
||||
background: oklch(var(--background));
|
||||
font-size: 0.9375rem;
|
||||
font-size: var(--text-control);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -430,7 +442,7 @@
|
||||
.thot-fleet-action-selector__reason {
|
||||
flex-basis: 100%;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-size: var(--text-meta);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -438,9 +450,9 @@
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: oklch(var(--muted-foreground));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.025em;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-meta);
|
||||
letter-spacing: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -453,7 +465,7 @@
|
||||
min-width: 0;
|
||||
min-height: 18rem;
|
||||
margin-block-end: 30px;
|
||||
margin-inline-start: 20px;
|
||||
margin-inline: var(--fleet-block-gutter);
|
||||
overflow: hidden;
|
||||
border-inline-end: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--card));
|
||||
@@ -613,6 +625,10 @@
|
||||
background: oklch(var(--muted) / 0.32);
|
||||
}
|
||||
|
||||
.thot-fleet-ledger .thot-fleet-grid-toolbar {
|
||||
padding-inline: var(--fleet-block-padding);
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.thot-fleet-grid-toolbar > .thot-fleet-action-selector {
|
||||
flex: 0 0 auto;
|
||||
@@ -628,10 +644,10 @@
|
||||
}
|
||||
|
||||
.thot-fleet-ledger-grid .ag-header-cell-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.055em;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-meta);
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger-grid .ag-row {
|
||||
@@ -647,7 +663,8 @@
|
||||
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-cell,
|
||||
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-grid-pinned-left-cells,
|
||||
.thot-fleet-ledger-grid .ag-row.ag-row-selected .ag-grid-pinned-right-cells {
|
||||
background-color: oklch(93% 0.002 17.2) !important;
|
||||
background-color: oklch(var(--nav-active)) !important;
|
||||
color: oklch(var(--nav-active-foreground));
|
||||
}
|
||||
|
||||
.thot-fleet-icon-action {
|
||||
@@ -672,7 +689,7 @@
|
||||
background: oklch(24% 0.014 258);
|
||||
box-shadow: 0 8px 20px -10px oklch(12% 0.02 258 / 0.72);
|
||||
content: attr(data-tooltip);
|
||||
font-size: 0.6875rem;
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
@@ -701,8 +718,8 @@
|
||||
background: oklch(var(--popover));
|
||||
box-shadow: var(--shadow-sm);
|
||||
content: attr(data-tooltip);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 650;
|
||||
font-size: var(--text-meta);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -765,7 +782,7 @@
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__drawer-title {
|
||||
font-size: 1.35rem;
|
||||
font-size: var(--text-section);
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
@@ -819,17 +836,24 @@
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-section {
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__navigation,
|
||||
.thot-fleet-ledger__command-bar {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Share the KPI grid's first, middle and last column anchors on wide work areas. */
|
||||
@container fleet-workspace (min-width: 48rem) {
|
||||
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-list {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
column-gap: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-item:nth-child(-n + 2) {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@container fleet-surface (max-width: 47.999rem) {
|
||||
.thot-fleet-ledger__drawer,
|
||||
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
|
||||
@@ -949,19 +973,6 @@
|
||||
min-height: 28rem;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi:nth-child(odd) {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__kpi:nth-child(n + 3) {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid oklch(var(--border));
|
||||
}
|
||||
|
||||
.thot-fleet-ledger__navigation {
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -163,9 +163,11 @@ export function FleetLedgerRealStatus({
|
||||
className="thot-fleet-ledger__status-item"
|
||||
data-tone={item.tone ?? "neutral"}
|
||||
>
|
||||
<span className="thot-fleet-ledger__status-mark" aria-hidden="true" />
|
||||
<span className="thot-fleet-ledger__status-copy">
|
||||
<span className="thot-fleet-ledger__status-label">{item.label}</span>
|
||||
<span className="thot-fleet-ledger__status-label">
|
||||
<span>{item.label}</span>
|
||||
<span className="thot-fleet-ledger__status-mark" aria-hidden="true" />
|
||||
</span>
|
||||
<strong className="thot-fleet-ledger__status-value">{item.value}</strong>
|
||||
{item.detail ? (
|
||||
<span className="thot-fleet-ledger__status-detail">{item.detail}</span>
|
||||
@@ -197,7 +199,7 @@ export function FleetLedgerKpiStrip({
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<h2 id={titleId} className="thot-fleet-ledger__section-label">
|
||||
{title ?? t("Fleet summary")}
|
||||
{title ?? t("Catalog summary")}
|
||||
</h2>
|
||||
<dl className="thot-fleet-ledger__kpi-strip">
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -212,7 +212,7 @@ export function SensitiveDataReviewDrawer({
|
||||
: t("no sensitive match")}{t(". Coverage:")} {t(suggestion.coverage.replace("_", " "))}{t(". Observed values:")} {suggestion.observedValues}.
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded px-2 py-0.5 text-[11px] font-semibold ${changedFromCurrent ? "bg-amber-500/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${changedFromCurrent ? "bg-warning/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
|
||||
{changedFromCurrent ? t("Change") : t("No change")}
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -268,7 +268,7 @@ export function SensitivityAnalysisHistoryDrawer({
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/45">
|
||||
{progressCounters.map(([name, label]) => (
|
||||
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground first:border-l-0">
|
||||
<th key={name} scope="col" className="border-l border-border px-2 py-1.5 text-left text-xs font-semibold text-muted-foreground first:border-l-0">
|
||||
{t(label)}
|
||||
</th>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CtePlanCte, CtePlanFilter, CtePlanV2 } from "./artifactV2";
|
||||
|
||||
const CHIP =
|
||||
"min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere] rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
|
||||
const FIELD_LABEL = "text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground";
|
||||
const FIELD_LABEL = "text-xs font-semibold text-muted-foreground";
|
||||
|
||||
function Chip({ children }: { children: ReactNode }) {
|
||||
return <span className={CHIP}>{children}</span>;
|
||||
|
||||
@@ -150,7 +150,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
</div>
|
||||
|
||||
{oversized && (
|
||||
<p className="text-sm text-amber-600">
|
||||
<p className="text-sm text-warning">
|
||||
{t("Too many nodes to display in the graph. Use the table instead.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@ function Block({ block }: { block: SqlBlock }) {
|
||||
className="flex shrink-0 items-center gap-1.5 border-l border-border/70 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
|
||||
>
|
||||
{copyState === "copied" ? (
|
||||
<Check className="size-3.5 text-[oklch(0.47_0.12_165)]" aria-hidden="true" />
|
||||
<Check className="size-3.5 text-success" aria-hidden="true" />
|
||||
) : copyState === "failed" ? (
|
||||
<TriangleAlert className="size-3.5 text-destructive" aria-hidden="true" />
|
||||
) : (
|
||||
|
||||
@@ -9,7 +9,7 @@ export function FallbackWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
useUnsavedDraft(text);
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-card p-3 shadow-sm space-y-2">
|
||||
<p className="text-sm text-amber-600">{translate("Unsupported widget (kind: {kind}) — respond manually", { kind: descriptor.widget })}</p>
|
||||
<p className="text-sm text-warning">{translate("Unsupported widget (kind: {kind}) — respond manually", { kind: descriptor.widget })}</p>
|
||||
<pre className="text-xs font-mono overflow-auto max-h-48 rounded-lg bg-muted p-2">{JSON.stringify(descriptor, null, 2)}</pre>
|
||||
<textarea aria-label={translate("Manual response")} className="w-full rounded-lg border border-input bg-card p-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
<button className="rounded-md border border-border/70 px-2 py-1 text-sm shadow-xs transition-colors hover:bg-accent" onClick={() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
{descriptor.title && (
|
||||
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
|
||||
<p className="text-base font-semibold leading-snug text-foreground">
|
||||
{descriptor.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WidgetProps } from "./types";
|
||||
export function InfoWidget({ descriptor }: WidgetProps) {
|
||||
const color = descriptor.level === "error" ? "text-red-600" : descriptor.level === "warning" ? "text-amber-600" : "text-foreground";
|
||||
const color = descriptor.level === "error" ? "text-red-600" : descriptor.level === "warning" ? "text-warning" : "text-foreground";
|
||||
return <p className={`text-sm leading-relaxed ${color}`}>{descriptor.text ?? descriptor.title}</p>;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
<div className="space-y-1.5">
|
||||
{descriptor.title && (
|
||||
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
|
||||
<p className="text-base font-semibold leading-snug text-foreground">
|
||||
{descriptor.title}
|
||||
</p>
|
||||
)}
|
||||
@@ -45,8 +45,9 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
)}
|
||||
{option.rationale && (
|
||||
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{translate("Rationale")}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{translate("Rationale")}
|
||||
</span>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{option.rationale}
|
||||
</p>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
{descriptor.title && (
|
||||
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
|
||||
<p className="text-base font-semibold leading-snug text-foreground">
|
||||
{descriptor.title}
|
||||
</p>
|
||||
)}
|
||||
@@ -70,18 +70,20 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
</div>
|
||||
)}
|
||||
{o.rationale && (
|
||||
<div className="mt-3 border-l-2 border-border/70 pl-3 text-xs text-foreground/80">
|
||||
<div className="mb-1 font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{translate("Reason")}</div>
|
||||
<div className="mt-3 border-l border-border/70 pl-3 text-xs text-foreground/80">
|
||||
<div className="mb-1 font-semibold text-muted-foreground">
|
||||
{translate("Reason")}
|
||||
</div>
|
||||
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{o.rationale}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{typeof o.meta?.question_context === "string" && o.meta.question_context && (
|
||||
<div className="mt-3 border-l-2 border-border/50 pl-3 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold uppercase tracking-wide">
|
||||
{translate("Context question")}</div>
|
||||
<div className="mt-3 border-l border-border/50 pl-3 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold">
|
||||
{translate("Context question")}
|
||||
</div>
|
||||
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{o.meta.question_context}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
{descriptor.title && (
|
||||
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">{descriptor.title}</p>
|
||||
<p className="text-base font-semibold leading-snug text-foreground">{descriptor.title}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -10,7 +10,7 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
{(descriptor.title || descriptor.intro) && (
|
||||
<div className="space-y-1.5">
|
||||
{descriptor.title && (
|
||||
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
|
||||
<p className="text-base font-semibold leading-snug text-foreground">
|
||||
{descriptor.title}
|
||||
</p>
|
||||
)}
|
||||
@@ -26,8 +26,9 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
>
|
||||
<span>{o.label}</span>
|
||||
{o.recommended && (
|
||||
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-[0.65rem] font-medium text-[oklch(0.5_0.13_165)]">
|
||||
{translate("Recommended")}</span>
|
||||
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-xs font-medium text-success">
|
||||
{translate("Recommended")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
// Match the token theme, including the portal, rather than the OS preference alone.
|
||||
darkMode: ["selector", ':is(.dark, [data-bs-theme="dark"])'],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
@@ -37,6 +39,8 @@ export default {
|
||||
border: "oklch(var(--border) / <alpha-value>)",
|
||||
input: "oklch(var(--input) / <alpha-value>)",
|
||||
ring: "oklch(var(--ring) / <alpha-value>)",
|
||||
success: "oklch(var(--success) / <alpha-value>)",
|
||||
warning: "oklch(var(--warning) / <alpha-value>)",
|
||||
sidebar: {
|
||||
DEFAULT: "oklch(var(--sidebar) / <alpha-value>)",
|
||||
foreground: "oklch(var(--sidebar-foreground) / <alpha-value>)",
|
||||
@@ -53,6 +57,7 @@ export default {
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
ringWidth: { 3: "3px" },
|
||||
boxShadow: {
|
||||
xs: "var(--shadow-xs)",
|
||||
sm: "var(--shadow-sm)",
|
||||
@@ -64,6 +69,14 @@ export default {
|
||||
heading: ["var(--font-heading)", "sans-serif"],
|
||||
mono: ["var(--font-mono)", "monospace"],
|
||||
},
|
||||
fontSize: {
|
||||
xs: ["var(--text-meta)", "1.5"],
|
||||
sm: ["var(--text-control)", "1.5"],
|
||||
base: ["var(--text-body)", "1.5"],
|
||||
lg: ["var(--text-section)", "1.4"],
|
||||
xl: ["var(--text-section)", "1.4"],
|
||||
"2xl": ["var(--text-page)", "1.3"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
Reference in New Issue
Block a user