fix(ui): match Omics header and compact workspace layout

This commit is contained in:
Codex
2026-09-13 15:36:08 +02:00
parent 648434a32e
commit 7d826e46c0
12 changed files with 134 additions and 12 deletions
+9 -2
View File
@@ -160,6 +160,13 @@ users can scan structure without adding nested containers.
## Colors ## Colors
The full-mode application header matches Omics Portal's `--gsd-red-primary`
(`#CB333B`) in both themes. Its complete wordmark, including `II`, and controls
use a near-white foreground. This header is absent in embedded mode. The sidebar
and welcome wordmarks retain their red suffix. Context editing places workspace,
model and Done in one desktop row, stacking on narrow containers. Session-scope
tabs retain their selected fill and accessible keyboard state without a bottom border.
The palette combines warm porcelain surfaces, warm graphite text, and an instrument red used only The palette combines warm porcelain surfaces, warm graphite text, and an instrument red used only
for action, focus, and important state. OKLCH values in the frontmatter are normative because the for action, focus, and important state. OKLCH values in the frontmatter are normative because the
frontend uses OKLCH tokens directly. frontend uses OKLCH tokens directly.
@@ -311,13 +318,13 @@ default, hover, focus, active, disabled, loading, and error behavior where those
width; a Navigation button opens the shared accessible dialog. Selecting another archive width; a Navigation button opens the shared accessible dialog. Selecting another archive
page or pressing Escape closes it. Desktop retains the right session sidebar and its My sessions / 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. 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 In embedded mode 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. actual application container. Narrow session document panels may use the available width.
### Tabs ### Tabs
- **Shape:** compact label tabs sit on a shared baseline with rounded top corners and a two-pixel - **Shape:** compact label tabs sit on a shared baseline with rounded top corners and a two-pixel
lower edge. Inactive labels retain a complete Quiet Border and Porcelain Card surface, so every lower edge, except session-scope tabs which have no bottom border. Inactive labels retain a Quiet Border and Porcelain Card surface, so every
label reads as a tab before interaction; hover feedback reinforces clickability. label reads as a tab before interaction; hover feedback reinforces clickability.
- **Current:** the selected tab uses the muted Navigation Active red for its fill, text, and defined border. - **Current:** the selected tab uses the muted Navigation Active red for its fill, text, and defined border.
It must expose `aria-selected`, participate in a labelled `tablist`/`tabpanel`, and be the only It must expose `aria-selected`, participate in a labelled `tablist`/`tabpanel`, and be the only
+12
View File
@@ -48,6 +48,18 @@ PSD Gitea push and production deployment are still pending.
## Current product shape ## Current product shape
### Full-header and layout refinements — 2026-09-13
The owner's four visual adjustments are implemented: full header uses Omics
`#CB333B` in both themes with a light complete wordmark/controls; Database status
has 16px clearance below its top divider; workspace/model/Done share a compact
desktop row; session-scope tabs have no bottom border. Embedded has no extra header.
Verified with 71 targeted frontend tests, 11 browser scenarios, typecheck and
Docker production build. The Mac frontend was recreated alone and is healthy
(`8a1608ad7fc6`, image `a2f489ebe9de`); Core/data-service containers are unchanged.
Full/en is retained. Rollback image: `thothii-frontend:before-header-layout-20260913`.
See `docs/reports/2026-09-13-header-layout-refinements.md` for validation and rollback.
### Visual review integrated with the full/embedded shell ### Visual review integrated with the full/embedded shell
At the owner's request, the seven commits through `a59624a6` from At the owner's request, the seven commits through `a59624a6` from
@@ -0,0 +1,51 @@
# Ritocchi header e layout
Data: 13 settembre 2026. Ambito: frontend ThothII e installazione locale del Mac.
Nessuna modifica al repository Omics o deploy sul server PSD.
## Modifiche
- Header full `#CB333B`, valore di `--gsd-red-primary` in Omics
`static/css/gsd-theme.css`. Marchio completo e controlli chiari in entrambi i
temi; opzioni lingua, hover, focus ed errori rimangono leggibili. Gli altri
marchi conservano il suffisso rosso. Embedded non aggiunge alcun header.
- Catalog, Operations e Metadata updated separati di 16px dal divisore superiore.
- Due selettori e Done allineati in un'unica riga desktop; controlli alti 32px.
Il contenitore stretto impila i controlli. Retry, blocchi operativi e messaggi
di errore rimangono disponibili; le scelte mantengono lo stesso comportamento.
- Bordo inferiore rimosso dai tab My sessions e All sessions; stato selezionato,
focus e navigazione da tastiera conservati.
Impeccable, registro product, ha guidato il mantenimento della gerarchia esistente
e dell'adattamento agli schermi stretti, senza introdurre nuovi componenti.
## Verifiche
Typecheck superato; 71 test nelle suite AppShell.host, AppShell.administration e
AppShell.session-mgmt; 11 scenari Playwright della revisione visuale superati.
Le nuove asserzioni controllano colore header light/dark, colore del suffisso,
spazio degli stati, posizione di Done, assenza del bordo e overflow. Verificati
gli screenshot desktop/mobile e scuro; le transizioni sono completate prima della
cattura. Fixture sintetiche, nessun modello o dato reale modificato dai test.
Build Docker frontend superata, inclusi test del CSS compilato. Rimane l'avviso
preesistente Vite sui bundle maggiori di 500 kB. Il CSS servito dal Mac include
il rosso Omics, le azioni inline e la rimozione del bordo; configurazione full/en
confermata. Container frontend `8a1608ad7fc6`, immagine `a2f489ebe9de`, healthy.
Core `3c3c0739b9af`, catalogo `89755e439dc7`, Qdrant `52ebd5c47955` ed embedding
`7f690e534eda` sono invariati e healthy.
## Aggiornamento e rollback
È stato ricostruito e ricreato soltanto il frontend attraverso il launcher
`/private/tmp/thothii-memory-preview.sh`, con `up -d --no-deps --no-build --wait frontend`.
L'immagine precedente è conservata come
`thothii-frontend:before-header-layout-20260913`. Per ripristinarla sul Mac:
```bash
docker image tag thothii-frontend:before-header-layout-20260913 thothii-frontend:local
bash /private/tmp/thothii-memory-preview.sh up -d --no-deps --no-build --wait frontend
```
Per tornare alla revisione nuova, ricostruire il frontend dal branch aggiornato
e ripetere il comando di avvio. Non toccare volumi, Core o configurazione Omics.
+26 -1
View File
@@ -103,12 +103,16 @@ async function inspect(page: Page) {
} }
for (const width of [390, 1280]) { for (const width of [390, 1280]) {
test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }) => { test(`full shell keeps the visual revision across language and theme changes at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 }); await page.setViewportSize({ width, height: 1000 });
const writes = await fixtures(page); const writes = await fixtures(page);
await page.goto("/"); await page.goto("/");
const header = page.locator(".thot-full-header"); const header = page.locator(".thot-full-header");
await expect(header).toBeVisible(); await expect(header).toBeVisible();
await expect(header).toHaveCSS("background-color", "rgb(203, 51, 59)");
expect(await header.locator(".thot-full-header__brand span").evaluate(el =>
getComputedStyle(el).color === getComputedStyle(el.closest("header")!).color,
)).toBe(true);
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
expect(await page.evaluate(() => [...document.fonts].some(font => expect(await page.evaluate(() => [...document.fonts].some(font =>
font.family.includes("Manrope Variable") && font.status === "loaded", font.family.includes("Manrope Variable") && font.status === "loaded",
@@ -118,12 +122,33 @@ for (const width of [390, 1280]) {
expect(Math.abs(layout!.x - (width - layout!.x - layout!.width))).toBeLessThan(1); expect(Math.abs(layout!.x - (width - layout!.x - layout!.width))).toBeLessThan(1);
await admin(page, "Database"); await admin(page, "Database");
await expect(page.getByRole("heading", { name: "Database management", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "Database management", exact: true })).toBeVisible();
await expect(page.locator(".thot-fleet-ledger__header-status")).toHaveCSS("padding-top", "16px");
await page.getByRole("button", { name: /Working context/ }).click();
const fields = page.locator(".thot-context-fields");
const done = fields.getByRole("button", { name: "Done", exact: true });
await expect(done).toBeVisible();
if (width >= 1280) {
const workspaceBox = await fields.getByRole("combobox", { name: "Workspace", exact: true }).boundingBox();
const modelBox = await fields.getByRole("combobox", { name: "Model", exact: true }).boundingBox();
const doneBox = await done.boundingBox();
expect(Math.abs(workspaceBox!.y - modelBox!.y)).toBeLessThan(1);
expect(Math.abs(modelBox!.y - doneBox!.y)).toBeLessThan(1);
expect(doneBox!.x).toBeGreaterThan(modelBox!.x + modelBox!.width);
for (const name of ["My sessions", "All sessions"]) {
await expect(page.getByRole("tab", { name, exact: true })).toHaveCSS("border-bottom-width", "0px");
}
}
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.screenshot({ path: testInfo.outputPath("full-context-light.png"), animations: "disabled" });
await done.click();
await header.getByRole("combobox").selectOption("it"); await header.getByRole("combobox").selectOption("it");
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible(); 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-administration-eyebrow")).toHaveText("Amministrazione");
await expect(page.locator(".thot-context-summary small")).toHaveText("Contesto di lavoro"); 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 page.getByRole("button", { name: "Usa il tema scuro", exact: true }).click();
await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark"); await expect(page.locator(".thot-host")).toHaveAttribute("data-theme", "dark");
await expect(header).toHaveCSS("background-color", "rgb(203, 51, 59)");
await page.screenshot({ path: testInfo.outputPath("full-header-dark.png"), animations: "disabled" });
expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] }); expect(await inspect(page)).toEqual({ overflow: 0, serif: [], tiny: [] });
await page.reload(); await page.reload();
await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "Gestione database", exact: true })).toBeVisible();
+25 -3
View File
@@ -230,6 +230,9 @@ body.thot-full-document { margin: 0; }
} }
.thot-host > .thot-context-layout { flex: 1; min-height: 0; height: auto; } .thot-host > .thot-context-layout { flex: 1; min-height: 0; height: auto; }
.thot-full-header { .thot-full-header {
/* Match Omics static/css/gsd-theme.css --gsd-red-primary in both themes. */
--thot-header-background: #cb333b;
--thot-header-foreground: oklch(99.85% 0.0006 17.2);
display: flex; display: flex;
flex: none; flex: none;
flex-wrap: wrap; flex-wrap: wrap;
@@ -238,13 +241,32 @@ body.thot-full-document { margin: 0; }
gap: 0.5rem 1rem; gap: 0.5rem 1rem;
min-height: 3.75rem; min-height: 3.75rem;
padding: 0.625rem var(--thot-shell-gutter); padding: 0.625rem var(--thot-shell-gutter);
border-bottom: 1px solid oklch(var(--border)); border-bottom: 1px solid var(--thot-header-background);
background: oklch(var(--card)); background: var(--thot-header-background);
color: var(--thot-header-foreground);
} }
.thot-full-header__brand { font: 600 1.25rem/1.2 var(--font-heading); } .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__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: var(--text-control); } .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-full-header__error { flex-basis: 100%; font-size: var(--text-control); color: inherit; }
.thot-full-header__controls button { color: var(--thot-header-foreground); }
.thot-full-header__controls button:is(:hover, [aria-expanded="true"]) {
color: var(--thot-header-foreground);
background: color-mix(in srgb, var(--thot-header-foreground) 14%, transparent);
}
.thot-full-header :is(button, select):focus-visible {
outline: 2px solid var(--thot-header-foreground);
outline-offset: 2px;
}
.thot-full-header .thot-shell-select {
color: var(--thot-header-foreground);
background: transparent;
border-color: color-mix(in srgb, var(--thot-header-foreground) 65%, transparent);
}
.thot-full-header .thot-shell-select option {
color: oklch(var(--foreground));
background: oklch(var(--card));
}
.thot-shell-select { .thot-shell-select {
min-height: 2rem; min-height: 2rem;
max-width: 9rem; max-width: 9rem;
@@ -128,6 +128,8 @@ test("option A has one collapsible global context and preserves the Core draft a
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: "Workspace" })).toHaveLength(1);
expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1); expect(screen.getAllByRole("combobox", { name: "Model" })).toHaveLength(1);
expect(screen.getByRole("button", { name: "Done" }).closest(".thot-context-fields"))
.toBe(screen.getByRole("combobox", { name: "Model" }).closest(".thot-context-fields"));
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Workspace" }), "beta");
expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" }); expect(workspacePreferences.load()).toMatchObject({ workspaceId: "beta", provider: "test", model: "test" });
await userEvent.click(screen.getByRole("button", { name: "Done" })); await userEvent.click(screen.getByRole("button", { name: "Done" }));
@@ -190,6 +190,8 @@ test("administrators can explicitly switch to all sessions and see owners", asyn
expect(mySessions).toHaveAttribute("tabindex", "0"); expect(mySessions).toHaveAttribute("tabindex", "0");
expect(mySessions).toHaveAttribute("data-tab-state", "active"); expect(mySessions).toHaveAttribute("data-tab-state", "active");
expect(mySessions).toHaveClass("bg-[oklch(var(--nav-active))]"); expect(mySessions).toHaveClass("bg-[oklch(var(--nav-active))]");
expect(mySessions).toHaveClass("border-b-0");
expect(allSessions).toHaveClass("border-b-0");
expect(allSessions).toHaveAttribute("aria-selected", "false"); expect(allSessions).toHaveAttribute("aria-selected", "false");
expect(allSessions).toHaveAttribute("aria-controls", "session-scope-panel"); expect(allSessions).toHaveAttribute("aria-controls", "session-scope-panel");
expect(allSessions).toHaveAttribute("tabindex", "-1"); expect(allSessions).toHaveAttribute("tabindex", "-1");
+1 -1
View File
@@ -61,7 +61,7 @@ interface AppShellProps {
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"]; const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
function sessionScopeTabClass(selected: boolean): string { function sessionScopeTabClass(selected: boolean): string {
const base = "relative -mb-px flex h-8 w-full cursor-pointer items-center justify-center rounded-t-md border border-b-2 px-2 text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]"; const base = "relative -mb-px flex h-8 w-full cursor-pointer items-center justify-center rounded-t-md border border-b-0 px-2 text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]";
return selected return selected
? `${base} border-[oklch(var(--nav-active-border))] bg-[oklch(var(--nav-active))] text-[oklch(var(--nav-active-foreground))] hover:bg-[oklch(var(--nav-active-hover))]` ? `${base} border-[oklch(var(--nav-active-border))] bg-[oklch(var(--nav-active))] text-[oklch(var(--nav-active-foreground))] hover:bg-[oklch(var(--nav-active-hover))]`
: `${base} border-border bg-card text-muted-foreground hover:border-muted-foreground/45 hover:bg-muted/55 hover:text-foreground`; : `${base} border-border bg-card text-muted-foreground hover:border-muted-foreground/45 hover:bg-muted/55 hover:text-foreground`;
+3 -3
View File
@@ -10,13 +10,13 @@
.thot-context-trigger[aria-expanded=true] .thot-context-action > svg:last-child { transform: rotate(180deg); } .thot-context-trigger[aria-expanded=true] .thot-context-action > svg:last-child { transform: rotate(180deg); }
.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 { 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-body[hidden] { display: none; }
.thot-context-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 24px; } .thot-context-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)) auto; align-items: end; gap: 16px; }
.thot-context-fields label { display: grid; min-width: 0; gap: 8px; font-size: var(--text-control); } .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 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 select { min-width: 0; width: 100%; height: 32px; padding: 4px 12px; border: 1px solid oklch(var(--border)); border-radius: 6px; background: oklch(var(--card)); }
.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-body > p[role] { margin-top: 12px; }
.thot-context-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } .thot-context-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.thot-context-shelf :is(button,select):focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; } .thot-context-shelf :is(button,select):focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
.thot-context-shelf select:disabled { opacity: .65; } .thot-context-shelf select:disabled { opacity: .65; }
@container working-context (max-width: 38rem) { @container working-context (max-width: 38rem) {
+1 -1
View File
@@ -31,11 +31,11 @@ export function WorkingContextShelf({ context, locked, onWorkspace, onModel }: {
<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>)} <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> </select>
</label> </label>
<div className="thot-context-actions">{context.error && <Button variant="outline" onClick={context.retry}>{translate("Retry context")}</Button>}{context.ready && <Button variant="outline" onClick={() => setExpanded(false)}>{translate("Done")}</Button>}</div>
</div> </div>
{locked && <p role="status">{translate("Workspace and model are locked until the current operation finishes.")}</p>} {locked && <p role="status">{translate("Workspace and model are locked until the current operation finishes.")}</p>}
{context.ready && context.error && <p role="status">{translate("Context refresh failed. Your current choices and unsaved work have been retained.")}</p>} {context.ready && context.error && <p role="status">{translate("Context refresh failed. Your current choices and unsaved work have been retained.")}</p>}
{!context.ready && <p role="status">{context.loading ? translate("Loading working context…") : context.error ? translate("Working context is unavailable. Retry or ask the installation operator to check configuration.") : translate("Select an available workspace and model to enable Core and Administration. Unavailable saved choices are not replaced automatically.")}</p>} {!context.ready && <p role="status">{context.loading ? translate("Loading working context…") : context.error ? translate("Working context is unavailable. Retry or ask the installation operator to check configuration.") : translate("Select an available workspace and model to enable Core and Administration. Unavailable saved choices are not replaced automatically.")}</p>}
<div className="thot-context-footer">{context.error && <Button variant="outline" onClick={context.retry}>{translate("Retry context")}</Button>}{context.ready && <Button variant="outline" onClick={() => setExpanded(false)}>{translate("Done")}</Button>}</div>
</div> </div>
</section>; </section>;
} }
@@ -105,6 +105,7 @@
grid-area: status; grid-area: status;
min-width: 0; min-width: 0;
padding-inline: var(--fleet-summary-inset); padding-inline: var(--fleet-summary-inset);
padding-top: 1rem;
} }
.thot-fleet-ledger .thot-administration-header, .thot-fleet-ledger .thot-administration-header,
+1 -1
View File
@@ -38,7 +38,7 @@ export function FullHeader({ onLogout }: { onLogout?: () => Promise<void> }) {
return ( return (
<header className="thot-full-header" aria-label={t("Application header")}> <header className="thot-full-header" aria-label={t("Application header")}>
<span className="thot-full-header__brand">Thoth<span className="text-primary">II</span></span> <span className="thot-full-header__brand">Thoth<span>II</span></span>
<div className="thot-full-header__controls"> <div className="thot-full-header__controls">
<label className="sr-only" htmlFor="thot-ui-language">{t("Interface language")}</label> <label className="sr-only" htmlFor="thot-ui-language">{t("Interface language")}</label>
<select id="thot-ui-language" className="thot-shell-select" value={shell.locale} onChange={event => shell.changeLocale(event.target.value)}> <select id="thot-ui-language" className="thot-shell-select" value={shell.locale} onChange={event => shell.changeLocale(event.target.value)}>